bucket_catch: split usePuzzleGameLoop.test.ts 332 -> 125 + 210
Some checks failed
Pre-commit checks / pre-commit (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
reverse-survivors CI / check (push) Has been cancelled

Eleven tests split by theme: the five that assert the loop declines to start
or tears down cleanly stay put; the six that actually play the game move to
usePuzzleGameLoopPlay.test.ts.

Fixtures went to src/test/puzzleLoopFixtures.ts (62) -- that path is required,
not stylistic, since coverage excludes src/test/** and a fixture beside the
hook would be counted as production code and break the 100% gate.

flushTick could not move as-is: it closed over the describe block's
rafCallbacks. It is now installRafHarness(), which owns the queue and hands
back a bound flushTick, so the two suites cannot leak frames into each other.

The check that matters for a test split is the pair (15 files, 145 tests):
file count up by one, test count unchanged. A name outside vitest's glob would
have passed just as green with six tests never running.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
This commit is contained in:
Krzysztof Rudnicki 2026-08-17 10:07:38 +02:00
parent 3f4d8d4e90
commit 7ff048be1c
4 changed files with 341 additions and 219 deletions

View File

@ -1,44 +1,23 @@
/** Lifecycle and guard paths: when the loop declines to start, and teardown. */
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import type React from "react";
import { renderHook, act } from "@testing-library/react";
import type { PuzzlePiece } from "../types";
import { usePuzzleGameLoop } from "./usePuzzleGameLoop";
vi.mock("../lib/sliceImage");
import { sliceImage } from "../lib/sliceImage";
import {
flushPromises,
installRafHarness,
makeCanvas,
makePiece,
makeRef,
} from "../test/puzzleLoopFixtures";
const makeRef = <T>(val: T): React.RefObject<T> =>
({ current: val }) as React.RefObject<T>;
const makePiece = (row: number, col: number): PuzzlePiece => ({
row,
col,
gridSize: 1,
imageUrl: "data:image/png;base64,mock",
pieceWidth: 50,
pieceHeight: 50,
});
const makeCanvas = (w = 800, h = 90): HTMLCanvasElement => {
const c = document.createElement("canvas");
c.width = w;
c.height = h;
return c;
};
describe("usePuzzleGameLoop", () => {
let rafCallbacks: FrameRequestCallback[];
describe("usePuzzleGameLoop lifecycle", () => {
beforeEach(() => {
rafCallbacks = [];
vi.stubGlobal(
"requestAnimationFrame",
vi.fn((cb: FrameRequestCallback) => {
rafCallbacks.push(cb);
return rafCallbacks.length;
}),
);
vi.stubGlobal("cancelAnimationFrame", vi.fn());
installRafHarness();
vi.mocked(sliceImage).mockResolvedValue([makePiece(0, 0)]);
});
@ -47,15 +26,6 @@ describe("usePuzzleGameLoop", () => {
vi.restoreAllMocks();
});
const flushTick = (): void => {
const cb = rafCallbacks.pop();
if (cb) act(() => { cb(0); });
};
const flushPromises = async (): Promise<void> => {
await act(async () => { await Promise.resolve(); });
};
it("returns null when active=false", () => {
const { result } = renderHook(() =>
usePuzzleGameLoop(
@ -110,30 +80,6 @@ describe("usePuzzleGameLoop", () => {
expect(requestAnimationFrame).not.toHaveBeenCalled();
});
it("catches single piece and resolves result (solo x branch, drawPiece no-img branch)", async () => {
// Math.random()=0 → speed=3, x=48 (solo: minX + 0*(maxX-minX) = 48)
// basketXRef=48 → caught on tick 0; game ends on same tick
vi.spyOn(Math, "random").mockReturnValue(0);
vi.mocked(sliceImage).mockResolvedValue([makePiece(0, 0)]);
const { result } = renderHook(() =>
usePuzzleGameLoop(
makeRef<HTMLCanvasElement | null>(makeCanvas(800, 90)),
makeRef(48),
new File([""], "img.png"),
1,
true,
),
);
await flushPromises(); // let sliceImage resolve and startLoop run
flushTick(); // tick 0: spawn, move, caught, flash, allDone → setResult
await waitFor(() => { expect(result.current).not.toBeNull(); });
expect(result.current!.caughtPieces).toHaveLength(1);
expect(result.current!.missedPieces).toHaveLength(0);
});
it("cancels RAF on unmount after game started", async () => {
vi.spyOn(Math, "random").mockReturnValue(0);
@ -152,102 +98,6 @@ describe("usePuzzleGameLoop", () => {
expect(cancelAnimationFrame).toHaveBeenCalled();
});
it("misses piece (still-falling + missed branches)", async () => {
// Math.random()=0 → speed=3, x=48; basket at 500 → no collision
// piece off bottom at y>90+48=138 → ~62 ticks
vi.spyOn(Math, "random").mockReturnValue(0);
const { result } = renderHook(() =>
usePuzzleGameLoop(
makeRef<HTMLCanvasElement | null>(makeCanvas(800, 90)),
makeRef(500),
new File([""], "img.png"),
1,
true,
),
);
await flushPromises();
for (let i = 0; i < 70; i++) {
if (result.current !== null) break;
flushTick();
}
await waitFor(() => { expect(result.current).not.toBeNull(); });
expect(result.current!.caughtPieces).toHaveLength(0);
expect(result.current!.missedPieces).toHaveLength(1);
});
it("tests flash-during and flash-expired branches with 2 pieces", async () => {
// Piece 0: x=48, speed=3, spawnFrame=0 → caught by basket@48 on tick 0
// Piece 1: x=168, speed=3, spawnFrame=20 → |168-48|=120>108 → missed at tick~82
// Flash: piece 0 caught at frame 0; at frame 30+ flash expires
vi.spyOn(Math, "random")
.mockReturnValueOnce(0) // speed[0] = 3
.mockReturnValueOnce(0) // speed[1] = 3
.mockReturnValueOnce(0) // centerX = 108 (108 + 0*(692-108))
.mockReturnValueOnce(0) // x[0] = 108 + (0*2-1)*60 = 48
.mockReturnValueOnce(1); // x[1] = 108 + (1*2-1)*60 = 168
vi.mocked(sliceImage).mockResolvedValue([
makePiece(0, 0),
{ ...makePiece(0, 1), gridSize: 2 },
]);
const { result } = renderHook(() =>
usePuzzleGameLoop(
makeRef<HTMLCanvasElement | null>(makeCanvas(800, 90)),
makeRef(48),
new File([""], "img.png"),
2,
true,
),
);
await flushPromises();
for (let i = 0; i < 90; i++) {
if (result.current !== null) break;
flushTick();
}
await waitFor(() => { expect(result.current).not.toBeNull(); });
expect(result.current!.caughtPieces).toHaveLength(1);
expect(result.current!.missedPieces).toHaveLength(1);
});
it("uses drawImage when piece image is loaded (img.complete=true branch)", async () => {
// Class constructor — arrow functions can't be used with `new`
class FakeImage {
complete = true;
naturalWidth = 100;
src = "";
}
vi.stubGlobal("Image", FakeImage);
vi.spyOn(Math, "random").mockReturnValue(0);
// Use large canvas so piece doesn't collide on first tick
const canvas = makeCanvas(800, 600); // basketY=520; piece reaches it at tick~190
renderHook(() =>
usePuzzleGameLoop(
makeRef<HTMLCanvasElement | null>(canvas),
makeRef(500), // far from x=48 → piece will miss
new File([""], "img.png"),
1,
true,
),
);
await flushPromises();
// Run one tick — piece is still falling, drawPiece called with loaded img
flushTick();
// Verify drawImage was called (piece has complete+naturalWidth>0)
const { mockCtx } = await import("../test/canvasMock");
expect(mockCtx.drawImage).toHaveBeenCalled();
});
it("does not start RAF when startLoop canvas.getContext returns null", async () => {
// A plain fake canvas whose getContext returns null avoids prototype contamination
const fakeCanvas = {
@ -272,61 +122,4 @@ describe("usePuzzleGameLoop", () => {
expect(requestAnimationFrame).not.toHaveBeenCalled();
});
it("triggers Union-Find path compression with 4 overlapping pieces", async () => {
vi.spyOn(Math, "random").mockReturnValue(0);
vi.mocked(sliceImage).mockResolvedValue([
makePiece(0, 0),
makePiece(0, 1),
makePiece(1, 0),
makePiece(1, 1),
]);
// basketY = 300-80 = 220; all 4 pieces overlap (SPAWN_GAP=20 and speed=3)
const canvas = makeCanvas(800, 300);
const { unmount } = renderHook(() =>
usePuzzleGameLoop(
makeRef<HTMLCanvasElement | null>(canvas),
makeRef(48),
new File([""], "img.png"),
2,
true,
),
);
await flushPromises(); // sliceImage resolves → assignXPositions (path compression) runs
flushTick(); // one tick to confirm loop is running
unmount();
});
it("uses canvasW/2 for centerX when canvas is too narrow", async () => {
// canvas.width=200: centerMin=108 >= centerMax=92 → uses 200/2=100
vi.spyOn(Math, "random").mockReturnValue(0.5);
vi.mocked(sliceImage).mockResolvedValue([
makePiece(0, 0),
makePiece(0, 1),
]);
const canvas = makeCanvas(200, 90); // basketY=10
// With centerX=100, both pieces at x=100 (offset = 0); basket@100 → caught
const { result } = renderHook(() =>
usePuzzleGameLoop(
makeRef<HTMLCanvasElement | null>(canvas),
makeRef(100),
new File([""], "img.png"),
2,
true,
),
);
await flushPromises();
for (let i = 0; i < 30; i++) {
if (result.current !== null) break;
flushTick();
}
await waitFor(() => { expect(result.current).not.toBeNull(); });
});
});

View File

@ -0,0 +1,210 @@
/**
* Gameplay paths: catching, missing, the catch flash, piece rendering, and the
* two x-position clustering branches in assignXPositions.
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { usePuzzleGameLoop } from "./usePuzzleGameLoop";
vi.mock("../lib/sliceImage");
import { sliceImage } from "../lib/sliceImage";
import {
flushPromises,
installRafHarness,
makeCanvas,
makePiece,
makeRef,
type FlushTick,
} from "../test/puzzleLoopFixtures";
describe("usePuzzleGameLoop play", () => {
let flushTick: FlushTick;
beforeEach(() => {
flushTick = installRafHarness();
vi.mocked(sliceImage).mockResolvedValue([makePiece(0, 0)]);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("catches single piece and resolves result (solo x branch, drawPiece no-img branch)", async () => {
// Math.random()=0 → speed=3, x=48 (solo: minX + 0*(maxX-minX) = 48)
// basketXRef=48 → caught on tick 0; game ends on same tick
vi.spyOn(Math, "random").mockReturnValue(0);
vi.mocked(sliceImage).mockResolvedValue([makePiece(0, 0)]);
const { result } = renderHook(() =>
usePuzzleGameLoop(
makeRef<HTMLCanvasElement | null>(makeCanvas(800, 90)),
makeRef(48),
new File([""], "img.png"),
1,
true,
),
);
await flushPromises(); // let sliceImage resolve and startLoop run
flushTick(); // tick 0: spawn, move, caught, flash, allDone → setResult
await waitFor(() => { expect(result.current).not.toBeNull(); });
expect(result.current!.caughtPieces).toHaveLength(1);
expect(result.current!.missedPieces).toHaveLength(0);
});
it("misses piece (still-falling + missed branches)", async () => {
// Math.random()=0 → speed=3, x=48; basket at 500 → no collision
// piece off bottom at y>90+48=138 → ~62 ticks
vi.spyOn(Math, "random").mockReturnValue(0);
const { result } = renderHook(() =>
usePuzzleGameLoop(
makeRef<HTMLCanvasElement | null>(makeCanvas(800, 90)),
makeRef(500),
new File([""], "img.png"),
1,
true,
),
);
await flushPromises();
for (let i = 0; i < 70; i++) {
if (result.current !== null) break;
flushTick();
}
await waitFor(() => { expect(result.current).not.toBeNull(); });
expect(result.current!.caughtPieces).toHaveLength(0);
expect(result.current!.missedPieces).toHaveLength(1);
});
it("tests flash-during and flash-expired branches with 2 pieces", async () => {
// Piece 0: x=48, speed=3, spawnFrame=0 → caught by basket@48 on tick 0
// Piece 1: x=168, speed=3, spawnFrame=20 → |168-48|=120>108 → missed at tick~82
// Flash: piece 0 caught at frame 0; at frame 30+ flash expires
vi.spyOn(Math, "random")
.mockReturnValueOnce(0) // speed[0] = 3
.mockReturnValueOnce(0) // speed[1] = 3
.mockReturnValueOnce(0) // centerX = 108 (108 + 0*(692-108))
.mockReturnValueOnce(0) // x[0] = 108 + (0*2-1)*60 = 48
.mockReturnValueOnce(1); // x[1] = 108 + (1*2-1)*60 = 168
vi.mocked(sliceImage).mockResolvedValue([
makePiece(0, 0),
{ ...makePiece(0, 1), gridSize: 2 },
]);
const { result } = renderHook(() =>
usePuzzleGameLoop(
makeRef<HTMLCanvasElement | null>(makeCanvas(800, 90)),
makeRef(48),
new File([""], "img.png"),
2,
true,
),
);
await flushPromises();
for (let i = 0; i < 90; i++) {
if (result.current !== null) break;
flushTick();
}
await waitFor(() => { expect(result.current).not.toBeNull(); });
expect(result.current!.caughtPieces).toHaveLength(1);
expect(result.current!.missedPieces).toHaveLength(1);
});
it("uses drawImage when piece image is loaded (img.complete=true branch)", async () => {
// Class constructor — arrow functions can't be used with `new`
class FakeImage {
complete = true;
naturalWidth = 100;
src = "";
}
vi.stubGlobal("Image", FakeImage);
vi.spyOn(Math, "random").mockReturnValue(0);
// Use large canvas so piece doesn't collide on first tick
const canvas = makeCanvas(800, 600); // basketY=520; piece reaches it at tick~190
renderHook(() =>
usePuzzleGameLoop(
makeRef<HTMLCanvasElement | null>(canvas),
makeRef(500), // far from x=48 → piece will miss
new File([""], "img.png"),
1,
true,
),
);
await flushPromises();
// Run one tick — piece is still falling, drawPiece called with loaded img
flushTick();
// Verify drawImage was called (piece has complete+naturalWidth>0)
const { mockCtx } = await import("../test/canvasMock");
expect(mockCtx.drawImage).toHaveBeenCalled();
});
it("triggers Union-Find path compression with 4 overlapping pieces", async () => {
vi.spyOn(Math, "random").mockReturnValue(0);
vi.mocked(sliceImage).mockResolvedValue([
makePiece(0, 0),
makePiece(0, 1),
makePiece(1, 0),
makePiece(1, 1),
]);
// basketY = 300-80 = 220; all 4 pieces overlap (SPAWN_GAP=20 and speed=3)
const canvas = makeCanvas(800, 300);
const { unmount } = renderHook(() =>
usePuzzleGameLoop(
makeRef<HTMLCanvasElement | null>(canvas),
makeRef(48),
new File([""], "img.png"),
2,
true,
),
);
await flushPromises(); // sliceImage resolves → assignXPositions (path compression) runs
flushTick(); // one tick to confirm loop is running
unmount();
});
it("uses canvasW/2 for centerX when canvas is too narrow", async () => {
// canvas.width=200: centerMin=108 >= centerMax=92 → uses 200/2=100
vi.spyOn(Math, "random").mockReturnValue(0.5);
vi.mocked(sliceImage).mockResolvedValue([
makePiece(0, 0),
makePiece(0, 1),
]);
const canvas = makeCanvas(200, 90); // basketY=10
// With centerX=100, both pieces at x=100 (offset = 0); basket@100 → caught
const { result } = renderHook(() =>
usePuzzleGameLoop(
makeRef<HTMLCanvasElement | null>(canvas),
makeRef(100),
new File([""], "img.png"),
2,
true,
),
);
await flushPromises();
for (let i = 0; i < 30; i++) {
if (result.current !== null) break;
flushTick();
}
await waitFor(() => { expect(result.current).not.toBeNull(); });
});
});

View File

@ -0,0 +1,62 @@
import { act } from "@testing-library/react";
import type React from "react";
import type { PuzzlePiece } from "../types";
/**
* Shared fixtures for the usePuzzleGameLoop tests.
*
* Lives under src/test/ because the coverage config excludes that path — a
* fixture placed beside the hook would be counted as production code and break
* the 100% gate.
*/
export const makeRef = <T>(val: T): React.RefObject<T> =>
({ current: val }) as React.RefObject<T>;
export const makePiece = (row: number, col: number): PuzzlePiece => ({
row,
col,
gridSize: 1,
imageUrl: "data:image/png;base64,mock",
pieceWidth: 50,
pieceHeight: 50,
});
export const makeCanvas = (w = 800, h = 90): HTMLCanvasElement => {
const c = document.createElement("canvas");
c.width = w;
c.height = h;
return c;
};
/** Drives one queued animation frame, if any is pending. */
export type FlushTick = () => void;
/**
* Installs the requestAnimationFrame harness the loop tests drive by hand.
*
* Returns a `flushTick` bound to this suite's own queue, so each describe block
* gets an isolated set of callbacks rather than sharing module state.
*
* @returns A function that runs the most recently queued frame.
*/
export function installRafHarness(): FlushTick {
const rafCallbacks: FrameRequestCallback[] = [];
vi.stubGlobal(
"requestAnimationFrame",
vi.fn((cb: FrameRequestCallback) => {
rafCallbacks.push(cb);
return rafCallbacks.length;
}),
);
vi.stubGlobal("cancelAnimationFrame", vi.fn());
return () => {
const cb = rafCallbacks.pop();
if (cb) act(() => { cb(0); });
};
}
/** Lets pending promise callbacks settle inside act(). */
export const flushPromises = async (): Promise<void> => {
await act(async () => { await Promise.resolve(); });
};

View File

@ -0,0 +1,57 @@
{
"intent": "Bring bucket_catch/packages/frontend/src/hooks/usePuzzleGameLoop.test.ts (332 lines) under the repo-wide 250-line cap without losing or weakening a single test.",
"scope": [
"bucket_catch/packages/frontend/src/hooks/usePuzzleGameLoop.test.ts — reduced to the five lifecycle and guard tests",
"bucket_catch/packages/frontend/src/hooks/usePuzzleGameLoopPlay.test.ts — new: the six gameplay, flash, rendering and clustering tests",
"bucket_catch/packages/frontend/src/test/puzzleLoopFixtures.ts — new: makeRef, makePiece, makeCanvas, flushPromises, installRafHarness",
"Non-goal: adding, deleting or rewriting any test; every assertion is carried across verbatim",
"Non-goal: the 45 pre-existing eslint errors in this package"
],
"changes": [
"Split the 11 tests by theme: the five that assert the loop declines to start or tears down cleanly stayed in usePuzzleGameLoop.test.ts; the six that play the game — catching, missing, the catch flash, drawImage, and the two assignXPositions clustering branches — moved to usePuzzleGameLoopPlay.test.ts.",
"Moved the shared fixtures to src/test/puzzleLoopFixtures.ts. That path is required rather than stylistic: the coverage config excludes src/test/**, so a fixture placed beside the hook would be counted as production code and break the 100% gate.",
"flushTick could not move as-is because it closed over the describe block's rafCallbacks array. Replaced it with installRafHarness(), which owns the queue and returns a flushTick bound to it, so each of the two suites gets an isolated set of callbacks instead of sharing module state — a shared array would have let one suite's pending frames leak into the other.",
"Test bodies are otherwise byte-identical, including the comments that record the arithmetic behind each seeded Math.random sequence."
],
"verification": [
{
"command": "npm run coverage",
"result": "pass",
"evidence": "Test Files 15 passed (15) and Tests 145 passed (145). This pair is the discriminating check for a test-file split: the file count rose by exactly one while the test count held at 145, which proves the six moved tests are actually being collected. Had the new filename fallen outside vitest's test glob, the suite would still have passed — with six tests silently never run."
},
{
"command": "npm run coverage (thresholds)",
"result": "pass",
"evidence": "Statements 100% (584/584), Branches 100% (217/217), Functions 100% (98/98), Lines 100% (535/535) — identical to the totals after the source split. Unchanged coverage from an unchanged test count is the second half of the proof: if a moved test had stopped running, the branches it alone covers would have dropped below the enforced 100%."
},
{
"command": "npm run build (tsc -b && vite build)",
"result": "pass",
"evidence": "Exit 0."
},
{
"command": "npx eslint src, compared against the pre-split count",
"result": "fail",
"evidence": "45 errors, the same 45 as before this split and before the source split. The extraction introduced no new violation. These are pre-existing and out of scope, as recorded in the baseline commit."
},
{
"command": "wc -l on the three files",
"result": "pass",
"evidence": "usePuzzleGameLoop.test.ts 125, usePuzzleGameLoopPlay.test.ts 210, puzzleLoopFixtures.ts 62 — all three under the 250-line cap, down from a single 332-line file."
},
{
"command": "pre-commit run --files <changed files>",
"result": "pass",
"evidence": "All configured hooks pass on the three touched files."
}
],
"risks": [
"The two suites now install the RAF harness independently. That is stricter isolation than before, not weaker, but it does mean a future fixture change has to keep both suites in mind.",
"usePuzzleGameLoopPlay.test.ts at 210 lines has the least headroom of the three; adding two or three more gameplay tests would push it back over the cap and require a further split.",
"This package has no CI and is not covered by pre-commit, so the local coverage, build and eslint runs above are the only gate."
],
"rollback": [
"git revert the commit — one edited file and two added files, with no data, config or schema migration.",
"After rollback, re-run npm run coverage; expect 14 test files and 145 tests passing at 100% coverage."
]
}