diff --git a/src/components/game/CodSkeleton/CodSkeleton.accessibility.test.tsx b/src/components/game/CodSkeleton/CodSkeleton.accessibility.test.tsx index c3fb833e..dc0170b4 100644 --- a/src/components/game/CodSkeleton/CodSkeleton.accessibility.test.tsx +++ b/src/components/game/CodSkeleton/CodSkeleton.accessibility.test.tsx @@ -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'; @@ -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 { + const utils = render(); + 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(); 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(); + 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(); + const start = getByRole('button', { name: /start the scene/i }); + + expect(start.className).toContain('min-h-11'); + expect(start.className).toContain('min-w-11'); + }); }); diff --git a/src/components/game/CodSkeleton/CodSkeleton.stories.tsx b/src/components/game/CodSkeleton/CodSkeleton.stories.tsx index b46e907a..51ccca21 100644 --- a/src/components/game/CodSkeleton/CodSkeleton.stories.tsx +++ b/src/components/game/CodSkeleton/CodSkeleton.stories.tsx @@ -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".', }, }, }, diff --git a/src/components/game/CodSkeleton/CodSkeleton.test.tsx b/src/components/game/CodSkeleton/CodSkeleton.test.tsx index f4c9c398..0313c28f 100644 --- a/src/components/game/CodSkeleton/CodSkeleton.test.tsx +++ b/src/components/game/CodSkeleton/CodSkeleton.test.tsx @@ -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. @@ -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 { + const utils = render(); + 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(); + it('renders the canvas mock once started (physics world mounts without WebGL)', () => { + const { getByTestId } = renderStarted(); expect(getByTestId('canvas-mock')).toBeInTheDocument(); }); @@ -40,7 +53,7 @@ describe('CodSkeleton', () => { }); it('passes a quality-driven dpr (0 < dpr <= 2) to the canvas', () => { - const { getByTestId } = render(); + const { getByTestId } = renderStarted(); const props = JSON.parse( getByTestId('canvas-mock').getAttribute('data-props') ?? '{}' ); @@ -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(); + + 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(); + 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; @@ -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(); + const { container, getByTestId } = renderStarted(); expect(getByTestId('canvas-mock')).toBeInTheDocument(); expect( container.querySelector('[data-webgl-ok="true"]') diff --git a/src/components/game/CodSkeleton/CodSkeleton.tsx b/src/components/game/CodSkeleton/CodSkeleton.tsx index 5bb1abc6..8c0e893d 100644 --- a/src/components/game/CodSkeleton/CodSkeleton.tsx +++ b/src/components/game/CodSkeleton/CodSkeleton.tsx @@ -428,6 +428,7 @@ export default function CodSkeleton({ speed = 4.5, }: CodSkeletonProps = {}): React.ReactElement { const [webglOk, setWebglOk] = useState(() => isWebGLAvailable()); + const [started, setStarted] = useState(false); const [stance, setStance] = useState('stand'); const { tier, preset, setTier } = useQuality(); const handleRetry = useCallback(() => setWebglOk(isWebGLAvailable()), []); @@ -462,8 +463,58 @@ export default function CodSkeleton({ ); } + // THE SCENE DOES NOT MOUNT UNTIL THE VISITOR ASKS FOR IT (#757). + // + // Mounting 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 ( +
+
+

+ The 3D scene is not running yet. Starting it compiles shaders and + bakes textures, so it waits until you ask. +

+ +

+ Then: click to capture · WASD move · Shift sprint · C crouch · X + prone · Space jump +

+
+
+ ); + } + return ( -
+