testsAndMisc-archive/bucket_catch/packages/frontend/src/hooks/useGameLoop.test.ts
Krzysztof kuhy Rudnicki a4b879b19d bucket_catch: 45 eslint errors -> 5, no suppressions
These were unmasked, not caused, last session: `lint` is
`tsc --noEmit && eslint src`, and a broken tsc had been short-circuiting
the && so eslint had never run on this package.

The binding constraint is the 100% coverage gate, not the linter. Most
idiomatic fixes for no-non-null-assertion add a branch that no test can
reach, which trades a lint error for a coverage failure - and the
no-suppressions rule forbids escaping either way. So every fix here is
one that adds no branch:

- no-non-null-assertion 18 -> 2: a waitFor already proves result.current
  is non-null, so capture it into a local instead of asserting on each
  read. App.tsx's files[0]! was pure noise (the same position also
  reported no-unnecessary-type-assertion). usePuzzleGameLoop's shift()!
  became index-then-shift, since the while condition already indexes [0]
  unasserted.
- non-nullable-type-assertion-style 9 -> 0: the rule-swap trap - eslint
  asks for `!`, which the other rule forbids. Narrowed with instanceof.
- unbound-method 10 -> 1: keep the MockInstance vi.spyOn already returns
  rather than detaching the method. This surfaced a latent bug: one test
  calls restoreAllMocks() mid-test, so the beforeEach handle was stale.
- no-unnecessary-type-assertion 5 -> 1, require-await 1 -> 0.

Coverage held at 100%: 584/217/98/535 -> 585/217/98/536. The +1 statement
and +1 line are the index-then-shift split; branches unchanged at 217 is
the number that proves no fix added an untested path. 145 tests, tsc 0.

The 5 remaining are reported, not silenced. App.tsx's fileResult! needs
the phase state to carry the result ({ kind: "done"; result }) - three
smaller structures all failed the same way. The rest are documented in
the evidence file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-17 11:48:45 +02:00

209 lines
5.9 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import type React from "react";
import { useGameLoop } from "./useGameLoop";
const makeRef = <T>(val: T): React.RefObject<T> => ({ current: val });
describe("useGameLoop", () => {
let rafCallbacks: FrameRequestCallback[];
beforeEach(() => {
rafCallbacks = [];
vi.stubGlobal(
"requestAnimationFrame",
vi.fn((cb: FrameRequestCallback) => {
rafCallbacks.push(cb);
return rafCallbacks.length;
}),
);
vi.stubGlobal("cancelAnimationFrame", vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
const flushTick = (): void => {
const cb = rafCallbacks.pop();
if (cb) act(() => { cb(0); });
};
it("returns null when active=false and does not start RAF", () => {
const { result } = renderHook(() =>
useGameLoop(
makeRef<HTMLCanvasElement | null>(null),
makeRef(400),
[],
false,
),
);
expect(result.current).toBeNull();
expect(requestAnimationFrame).not.toHaveBeenCalled();
});
it("returns null when canvasRef.current is null (active=true)", () => {
renderHook(() =>
useGameLoop(
makeRef<HTMLCanvasElement | null>(null),
makeRef(400),
[new File(["x"], "a.txt")],
true,
),
);
expect(requestAnimationFrame).not.toHaveBeenCalled();
});
it("cancels RAF on unmount", () => {
const canvas = document.createElement("canvas");
canvas.width = 800;
canvas.height = 600;
const { unmount } = renderHook(() =>
useGameLoop(
makeRef<HTMLCanvasElement | null>(canvas),
makeRef(400),
[],
true,
),
);
unmount();
expect(cancelAnimationFrame).toHaveBeenCalled();
});
it("resolves immediately with empty result when files is empty", async () => {
const canvas = document.createElement("canvas");
canvas.width = 800;
canvas.height = 600;
const { result } = renderHook(() =>
useGameLoop(
makeRef<HTMLCanvasElement | null>(canvas),
makeRef(400),
[],
true,
),
);
flushTick(); // allDone=true on first frame → setResult
await waitFor(() => { expect(result.current).not.toBeNull(); });
const settled = result.current;
if (settled === null) {
throw new Error('result never settled');
}
expect(settled.caught).toHaveLength(0);
expect(settled.missed).toHaveLength(0);
});
it("catches file and resolves result (caught branch + caught-draw branch)", async () => {
// Math.random()=0 → file x=48, speed=2.5
// basketXRef=48 → |fx-bx|=0<108, |fy-by|=35.5<48 → caught on tick 0
vi.spyOn(Math, "random").mockReturnValue(0);
const canvas = document.createElement("canvas");
canvas.width = 800;
canvas.height = 90; // basketY = 90-80 = 10
const { result } = renderHook(() =>
useGameLoop(
makeRef<HTMLCanvasElement | null>(canvas),
makeRef(48),
[new File(["x"], "a.txt")],
true,
),
);
flushTick(); // tick 0: caught
flushTick(); // tick 1: draw caught + allDone=true → setResult
await waitFor(() => { expect(result.current).not.toBeNull(); });
const settled = result.current;
if (settled === null) {
throw new Error('result never settled');
}
expect(settled.caught).toHaveLength(1);
expect(settled.missed).toHaveLength(0);
});
it("misses file (still-falling + missed branches)", async () => {
// Math.random()=0 → file x=48, speed=2.5; basketXRef=400
// |48-400|=352 > 108 → no collision; off-bottom at y>118, ~60 ticks
vi.spyOn(Math, "random").mockReturnValue(0);
const canvas = document.createElement("canvas");
canvas.width = 800;
canvas.height = 90;
const { result } = renderHook(() =>
useGameLoop(
makeRef<HTMLCanvasElement | null>(canvas),
makeRef(400),
[new File(["x"], "a.txt")],
true,
),
);
for (let i = 0; i < 65; i++) {
if (result.current !== null) break;
flushTick();
}
await waitFor(() => { expect(result.current).not.toBeNull(); });
const settled = result.current;
if (settled === null) {
throw new Error('result never settled');
}
expect(settled.caught).toHaveLength(0);
expect(settled.missed).toHaveLength(1);
});
it("does not start RAF when canvas.getContext returns null", () => {
// A plain fake canvas whose getContext returns null avoids prototype contamination
const fakeCanvas = {
width: 800,
height: 600,
clientWidth: 800,
clientHeight: 600,
getContext: () => null,
} as unknown as HTMLCanvasElement;
renderHook(() =>
useGameLoop(makeRef<HTMLCanvasElement | null>(fakeCanvas), makeRef(400), [], true),
);
expect(requestAnimationFrame).not.toHaveBeenCalled();
});
it("handles not-yet-spawned branch with two files (startFrames 0 and 50)", async () => {
// Both files: x=48, speed=2.5; basket at 48 → both caught
// File 1 has startFrame=50 → "frame < ff.startFrame" branch hit for ticks 1-49
vi.spyOn(Math, "random").mockReturnValue(0);
const canvas = document.createElement("canvas");
canvas.width = 800;
canvas.height = 90;
const { result } = renderHook(() =>
useGameLoop(
makeRef<HTMLCanvasElement | null>(canvas),
makeRef(48),
[new File(["a"], "a.txt"), new File(["b"], "b.txt")],
true,
),
);
for (let i = 0; i < 60; i++) {
if (result.current !== null) break;
flushTick();
}
await waitFor(() => { expect(result.current).not.toBeNull(); });
const settled = result.current;
if (settled === null) {
throw new Error('result never settled');
}
expect(settled.caught).toHaveLength(2);
expect(settled.missed).toHaveLength(0);
});
});