bucket_catch: split usePuzzleGameLoop.test.ts 332 -> 125 + 210

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 e67dfb185d
commit 38f7718a49
3 changed files with 284 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(); });
};