Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 31 additions & 3 deletions src/components/game/CodSkeleton/CodSkeleton.accessibility.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

import { describe, it, expect, vi } from 'vitest';
import { render } from '@testing-library/react';
import { render, fireEvent } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import React from 'react';

Expand Down Expand Up @@ -38,15 +38,43 @@ vi.mock('@react-three/fiber', () => ({

import CodSkeleton from './CodSkeleton';

/** Render, then press the start control — the scene is gated behind it (#757). */
function renderStarted(): ReturnType<typeof render> {
const utils = render(<CodSkeleton />);
fireEvent.click(utils.getByRole('button', { name: /start the scene/i }));
return utils;
}

describe('CodSkeleton Accessibility', () => {
it('should have no accessibility violations on the DOM chrome', async () => {
// The gate (#757) means there are now TWO states a visitor can be looking at,
// and the one they see FIRST is the placeholder. Auditing only the started
// scene would leave the default state unmeasured — the shape of #411.
it('should have no accessibility violations before the scene is started', async () => {
const { container } = render(<CodSkeleton />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});

it('should have no accessibility violations on the DOM chrome', async () => {
const { container } = renderStarted();
const results = await axe(container);
expect(results).toHaveNoViolations();
});

it('canvas mock has an aria-label for screen-reader users', () => {
const { getByTestId } = render(<CodSkeleton />);
const { getByTestId } = renderStarted();
expect(getByTestId('canvas-mock').getAttribute('aria-label')).toBeTruthy();
});

it('the start control is reachable by name and meets the 44px touch floor', () => {
// `min-h-11 min-w-11` is the repo's mobile-first touch target. It is asserted
// here because jsdom has no layout: the class IS the contract, and the
// mobile-touch-targets sweep cannot see a control that only exists after a
// click it never performs.
const { getByRole } = render(<CodSkeleton />);
const start = getByRole('button', { name: /start the scene/i });

expect(start.className).toContain('min-h-11');
expect(start.className).toContain('min-w-11');
});
});
2 changes: 1 addition & 1 deletion src/components/game/CodSkeleton/CodSkeleton.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const meta = {
docs: {
description: {
component:
'First-person "walking skeleton" for the Claude-of-Duty extraction spike. R3F owns the render loop; the vendored CoD BVH + swept-capsule character controller run a fixed 120 Hz tick in useFrame and drive the camera. WASD + pointer-lock mouselook, collide-and-slide against a small procedural level (floor, a 0.40 m step-up, a wall, a crate) skinned with zero-asset procedural DataTexture surfaces. Falls back to FallbackPanel when WebGL is unavailable.',
'First-person "walking skeleton" for the Claude-of-Duty extraction spike. R3F owns the render loop; the vendored CoD BVH + swept-capsule character controller run a fixed 120 Hz tick in useFrame and drive the camera. WASD + pointer-lock mouselook, collide-and-slide against a small procedural level (floor, a 0.40 m step-up, a wall, a crate) skinned with zero-asset procedural DataTexture surfaces. Falls back to FallbackPanel when WebGL is unavailable. **Every story opens on the start placeholder, not the scene** — mounting the canvas bakes 1K textures synchronously, which cost 22.6-30.2s on a GPU-less runner and timed the mobile-layout sweep out three times (#757), so it now waits to be asked. Press "Start the scene".',
},
},
},
Expand Down
75 changes: 69 additions & 6 deletions src/components/game/CodSkeleton/CodSkeleton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,16 @@
* under the mocked Canvas — these tests assert the DOM contract + the WebGL
* fallback path. Physics correctness is proven separately by the standalone
* r184 smoke test; canvas rendering is a Playwright concern.
*
* THE SCENE IS GATED BEHIND A START (#757), so every test that wants the canvas
* has to ask for it. That gate is the fix for a route which took 22.6-30.2s on a
* GPU-less chromium runner and 12.7-41.7s on webkit before its layout could be
* measured, timing out `mobile-horizontal-scroll` three times and blocking two
* merges. The guards below are what stop it coming back a fourth time.
*/

import { describe, it, expect, vi } from 'vitest';
import { render } from '@testing-library/react';
import { render, fireEvent } from '@testing-library/react';
import React from 'react';

// Mock @react-three/fiber: Canvas -> div (renders children), hooks -> no-ops.
Expand All @@ -28,9 +34,16 @@ vi.mock('@react-three/fiber', () => ({

import CodSkeleton from './CodSkeleton';

/** Render, then press the start control — for tests that need the live scene. */
function renderStarted(): ReturnType<typeof render> {
const utils = render(<CodSkeleton />);
fireEvent.click(utils.getByRole('button', { name: /start the scene/i }));
return utils;
}

describe('CodSkeleton', () => {
it('renders the canvas mock (physics world mounts without WebGL)', () => {
const { getByTestId } = render(<CodSkeleton />);
it('renders the canvas mock once started (physics world mounts without WebGL)', () => {
const { getByTestId } = renderStarted();
expect(getByTestId('canvas-mock')).toBeInTheDocument();
});

Expand All @@ -40,7 +53,7 @@ describe('CodSkeleton', () => {
});

it('passes a quality-driven dpr (0 < dpr <= 2) to the canvas', () => {
const { getByTestId } = render(<CodSkeleton />);
const { getByTestId } = renderStarted();
const props = JSON.parse(
getByTestId('canvas-mock').getAttribute('data-props') ?? '{}'
);
Expand All @@ -50,6 +63,56 @@ describe('CodSkeleton', () => {
});
});

describe('CodSkeleton — the scene is gated behind a start (#757)', () => {
it('mounts NO canvas until the visitor asks for one', () => {
// The whole point. If this regresses, `/game/cod-skeleton` goes back to
// spending ~28s of a 30s test budget on a mount nobody requested, and the
// mobile-layout sweep starts timing out again on branches that never touched
// this route.
const { queryByTestId, container } = render(<CodSkeleton />);

expect(queryByTestId('canvas-mock')).not.toBeInTheDocument();
expect(
container.querySelector('[data-scene-started="false"]')
).toBeInTheDocument();
});

it('mounts the canvas after the start control is pressed', () => {
// The other half: a gate that never opens would pass the test above while
// shipping a dead route.
const { getByTestId, container } = renderStarted();

expect(getByTestId('canvas-mock')).toBeInTheDocument();
expect(
container.querySelector('[data-scene-started="true"]')
).toBeInTheDocument();
});

it('gives the placeholder the SAME box as the started scene', () => {
// The coverage floor (#396). `mobile-horizontal-scroll` measures this route's
// geometry; if the placeholder sized itself differently from the canvas
// wrapper, the sweep would quietly start measuring something else and its
// green would stop meaning what it used to mean. jsdom has no layout, so the
// decidable invariant is that both branches carry the identical wrapper class
// — which is why the sizing lives on the wrapper and not on either branch.
// Unmount between the two renders: both mount into the same document, so
// leaving the first up makes `getByRole('button')` ambiguous rather than wrong
// — a failure that looks like a component bug and is not one.
const first = render(<CodSkeleton />);
const before = first.container
.querySelector('[data-scene-started]')
?.getAttribute('class');
first.unmount();

const after = renderStarted()
.container.querySelector('[data-scene-started]')
?.getAttribute('class');

expect(before).toBeTruthy();
expect(after).toBe(before);
});
});

describe('CodSkeleton — WebGL fallback', () => {
it('renders FallbackPanel instead of Canvas when WebGL is unavailable', () => {
const original = HTMLCanvasElement.prototype.getContext;
Expand All @@ -67,13 +130,13 @@ describe('CodSkeleton — WebGL fallback', () => {
HTMLCanvasElement.prototype.getContext = original;
});

it('renders Canvas when WebGL is available', () => {
it('renders Canvas when WebGL is available and the scene is started', () => {
const original = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = vi.fn(
() => ({}) as unknown as RenderingContext
) as unknown as typeof HTMLCanvasElement.prototype.getContext;

const { container, getByTestId } = render(<CodSkeleton />);
const { container, getByTestId } = renderStarted();
expect(getByTestId('canvas-mock')).toBeInTheDocument();
expect(
container.querySelector('[data-webgl-ok="true"]')
Expand Down
53 changes: 52 additions & 1 deletion src/components/game/CodSkeleton/CodSkeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,7 @@ export default function CodSkeleton({
speed = 4.5,
}: CodSkeletonProps = {}): React.ReactElement {
const [webglOk, setWebglOk] = useState<boolean>(() => isWebGLAvailable());
const [started, setStarted] = useState<boolean>(false);
const [stance, setStance] = useState<Stance>('stand');
const { tier, preset, setTier } = useQuality();
const handleRetry = useCallback(() => setWebglOk(isWebGLAvailable()), []);
Expand Down Expand Up @@ -462,8 +463,58 @@ export default function CodSkeleton({
);
}

// THE SCENE DOES NOT MOUNT UNTIL THE VISITOR ASKS FOR IT (#757).
//
// Mounting <Canvas> during page load builds a MaterialSystem and bakes its 1K
// texture sets synchronously before the page is usable. On a GPU-less runner that
// measured 22.6-30.2s on chromium and 12.7-41.7s on webkit, against the 30s
// per-test budget — so `mobile-horizontal-scroll` timed out on this route three
// times and blocked two merges. Firefox, which has no WebGL and never mounts the
// canvas, finished the identical layout measurement in 2.8-3.3s: the whole gap is
// this mount.
//
// That is a property of the PAGE, not of the test. A software rasteriser stands in
// for a low-end visitor, and they pay the same cost on a route that has not yet
// been asked to do anything. Games gate their scene behind a start for exactly this
// reason, and this one already tells you to click.
//
// The placeholder lives inside the SAME wrapper, so `aspect-video w-full` gives it
// the identical box and the layout sweep measures the geometry it always measured.
// `CodSkeleton.test.tsx` asserts that; do not move the sizing onto either branch.
if (!started) {
return (
<div
className={wrapperClass}
data-webgl-ok="true"
data-scene-started="false"
>
<div className="bg-base-200 border-base-300 absolute inset-0 flex flex-col items-center justify-center gap-3 rounded border p-4 text-center">
<p className="text-base-content max-w-md text-sm">
The 3D scene is not running yet. Starting it compiles shaders and
bakes textures, so it waits until you ask.
</p>
<button
type="button"
onClick={() => setStarted(true)}
className="btn btn-primary min-h-11 min-w-11"
>
Start the scene
</button>
<p className="text-base-content/85 text-xs">
Then: click to capture · WASD move · Shift sprint · C crouch · X
prone · Space jump
</p>
</div>
</div>
);
}

return (
<div className={wrapperClass} data-webgl-ok="true">
<div
className={wrapperClass}
data-webgl-ok="true"
data-scene-started="true"
>
<Canvas
dpr={Math.max(
0.5,
Expand Down
Loading