diff --git a/bucket_catch/packages/frontend/src/App.tsx b/bucket_catch/packages/frontend/src/App.tsx index cf6c3277..0e3413a0 100644 --- a/bucket_catch/packages/frontend/src/App.tsx +++ b/bucket_catch/packages/frontend/src/App.tsx @@ -65,7 +65,7 @@ export default function App(): React.ReactElement { if (mode === "puzzle") { return ( diff --git a/bucket_catch/packages/frontend/src/components/DropZone.test.tsx b/bucket_catch/packages/frontend/src/components/DropZone.test.tsx index c09ff0da..2ea613fb 100644 --- a/bucket_catch/packages/frontend/src/components/DropZone.test.tsx +++ b/bucket_catch/packages/frontend/src/components/DropZone.test.tsx @@ -5,6 +5,20 @@ import { DropZone } from "./DropZone"; const makeFile = (name: string, type = "text/plain"): File => new File(["x"], name, { type }); +/** + * Query one element and narrow it, failing the test if it is absent. + * + * Both `as HTMLElement` and `!` are rejected here - the former by + * non-nullable-type-assertion-style, the latter by no-non-null-assertion - so + * the narrowing is done with a real check that also gives a useful message. + */ +const must = (el: Element | null, what: string): HTMLElement => { + if (!(el instanceof HTMLElement)) { + throw new Error(`expected to find ${what}`); + } + return el; +}; + describe("DropZone", () => { it("renders title and subtitle", () => { render( undefined} onPuzzle={() => undefined} />); @@ -23,7 +37,10 @@ describe("DropZone", () => { const { container } = render( undefined} onPuzzle={() => undefined} />, ); - const zone = container.firstChild as HTMLElement; + const zone = must( + container.firstChild as Element | null, + "the drop zone", + ); expect(zone.className).not.toContain("dragging"); fireEvent.dragOver(zone); expect(zone.className).toContain("dragging"); @@ -36,7 +53,10 @@ describe("DropZone", () => { const { container } = render( undefined} />, ); - const zone = container.firstChild as HTMLElement; + const zone = must( + container.firstChild as Element | null, + "the drop zone", + ); const f1 = makeFile("a.txt"); const f2 = makeFile("b.txt"); fireEvent.drop(zone, { dataTransfer: { files: [f1, f2] } }); @@ -48,7 +68,10 @@ describe("DropZone", () => { const { container } = render( undefined} onPuzzle={() => undefined} />, ); - const zone = container.firstChild as HTMLElement; + const zone = must( + container.firstChild as Element | null, + "the drop zone", + ); fireEvent.drop(zone, { dataTransfer: { files: [makeFile("a.txt")] } }); expect(screen.getByText("1 file ready")).toBeInTheDocument(); }); @@ -58,7 +81,10 @@ describe("DropZone", () => { const { container } = render( undefined} />, ); - const zone = container.firstChild as HTMLElement; + const zone = must( + container.firstChild as Element | null, + "the drop zone", + ); fireEvent.drop(zone, { dataTransfer: { files: [] } }); expect(onFiles).not.toHaveBeenCalled(); }); @@ -68,9 +94,10 @@ describe("DropZone", () => { const { container } = render( undefined} />, ); - const fileInput = container.querySelector( + const fileInput = must( + container.querySelector("input[type='file'][multiple]"), "input[type='file'][multiple]", - ) as HTMLInputElement; + ); const file = makeFile("c.txt"); Object.defineProperty(fileInput, "files", { value: [file], @@ -85,9 +112,10 @@ describe("DropZone", () => { const { container } = render( undefined} />, ); - const fileInput = container.querySelector( + const fileInput = must( + container.querySelector("input[type='file'][multiple]"), "input[type='file'][multiple]", - ) as HTMLInputElement; + ); Object.defineProperty(fileInput, "files", { value: [], configurable: true, @@ -101,9 +129,10 @@ describe("DropZone", () => { const { container } = render( undefined} />, ); - const fileInput = container.querySelector( + const fileInput = must( + container.querySelector("input[type='file'][multiple]"), "input[type='file'][multiple]", - ) as HTMLInputElement; + ); Object.defineProperty(fileInput, "files", { value: null, configurable: true, @@ -117,9 +146,10 @@ describe("DropZone", () => { const { container } = render( undefined} onPuzzle={onPuzzle} />, ); - const puzzleInput = container.querySelector( + const puzzleInput = must( + container.querySelector("input[accept='image/*']"), "input[accept='image/*']", - ) as HTMLInputElement; + ); const imgFile = makeFile("photo.png", "image/png"); Object.defineProperty(puzzleInput, "files", { value: [imgFile], @@ -134,9 +164,10 @@ describe("DropZone", () => { const { container } = render( undefined} onPuzzle={onPuzzle} />, ); - const puzzleInput = container.querySelector( + const puzzleInput = must( + container.querySelector("input[accept='image/*']"), "input[accept='image/*']", - ) as HTMLInputElement; + ); Object.defineProperty(puzzleInput, "files", { value: null, configurable: true, @@ -150,9 +181,10 @@ describe("DropZone", () => { const { container } = render( undefined} onPuzzle={onPuzzle} />, ); - const puzzleInput = container.querySelector( + const puzzleInput = must( + container.querySelector("input[accept='image/*']"), "input[accept='image/*']", - ) as HTMLInputElement; + ); Object.defineProperty(puzzleInput, "files", { value: [], configurable: true, @@ -165,9 +197,10 @@ describe("DropZone", () => { const { container } = render( undefined} onPuzzle={() => undefined} />, ); - const puzzleInput = container.querySelector( + const puzzleInput = must( + container.querySelector("input[accept='image/*']"), "input[accept='image/*']", - ) as HTMLInputElement; + ); const clickSpy = vi.spyOn(puzzleInput, "click").mockImplementation( () => undefined, ); @@ -208,9 +241,10 @@ describe("DropZone", () => { undefined} onPuzzle={onPuzzle} />, ); fireEvent.click(screen.getByText("3×3")); // change gridSize to 3 - const puzzleInput = container.querySelector( + const puzzleInput = must( + container.querySelector("input[accept='image/*']"), "input[accept='image/*']", - ) as HTMLInputElement; + ); const imgFile = makeFile("img.png", "image/png"); Object.defineProperty(puzzleInput, "files", { value: [imgFile], diff --git a/bucket_catch/packages/frontend/src/components/PuzzleResult.test.tsx b/bucket_catch/packages/frontend/src/components/PuzzleResult.test.tsx index 58aafbe0..7b8a44fd 100644 --- a/bucket_catch/packages/frontend/src/components/PuzzleResult.test.tsx +++ b/bucket_catch/packages/frontend/src/components/PuzzleResult.test.tsx @@ -129,9 +129,10 @@ describe("PuzzleResult", () => { const imgs = container.querySelectorAll("img"); expect(imgs).toHaveLength(2); // The grid div has inline style with gridTemplateColumns; its non-img children are holes - const gridEl = container.querySelector( - '[style*="repeat"]', - ) as HTMLElement; + const gridEl = container.querySelector('[style*="repeat"]'); + if (!(gridEl instanceof HTMLElement)) { + throw new Error("expected to find the puzzle grid element"); + } const holeDivs = Array.from(gridEl.children).filter( (c) => c.tagName === "DIV", ); diff --git a/bucket_catch/packages/frontend/src/components/ScoreScreen.test.tsx b/bucket_catch/packages/frontend/src/components/ScoreScreen.test.tsx index 6d39c401..fcc22a4f 100644 --- a/bucket_catch/packages/frontend/src/components/ScoreScreen.test.tsx +++ b/bucket_catch/packages/frontend/src/components/ScoreScreen.test.tsx @@ -39,7 +39,7 @@ describe("ScoreScreen", () => { expect(screen.getByText("Missed")).toBeInTheDocument(); }); - it("shows download button for download mode with multiple files", async () => { + it("shows download button for download mode with multiple files", () => { render( undefined} />, ); diff --git a/bucket_catch/packages/frontend/src/hooks/useBasketControl.test.ts b/bucket_catch/packages/frontend/src/hooks/useBasketControl.test.ts index 917943cf..8803f799 100644 --- a/bucket_catch/packages/frontend/src/hooks/useBasketControl.test.ts +++ b/bucket_catch/packages/frontend/src/hooks/useBasketControl.test.ts @@ -4,8 +4,9 @@ import { fireEvent } from "@testing-library/react"; import type React from "react"; import { useBasketControl } from "./useBasketControl"; -const makeRef = (val: T): React.RefObject => - ({ current: val }) as React.RefObject; +const makeRef = (val: T): React.RefObject => ({ + current: val, +}); describe("useBasketControl", () => { it("initialises basket x to half of window.innerWidth", () => { diff --git a/bucket_catch/packages/frontend/src/hooks/useGameLoop.test.ts b/bucket_catch/packages/frontend/src/hooks/useGameLoop.test.ts index 2c07b3ff..5bdc477d 100644 --- a/bucket_catch/packages/frontend/src/hooks/useGameLoop.test.ts +++ b/bucket_catch/packages/frontend/src/hooks/useGameLoop.test.ts @@ -3,8 +3,7 @@ import { renderHook, act, waitFor } from "@testing-library/react"; import type React from "react"; import { useGameLoop } from "./useGameLoop"; -const makeRef = (val: T): React.RefObject => - ({ current: val }) as React.RefObject; +const makeRef = (val: T): React.RefObject => ({ current: val }); describe("useGameLoop", () => { let rafCallbacks: FrameRequestCallback[]; @@ -88,8 +87,12 @@ describe("useGameLoop", () => { flushTick(); // allDone=true on first frame → setResult await waitFor(() => { expect(result.current).not.toBeNull(); }); - expect(result.current!.caught).toHaveLength(0); - expect(result.current!.missed).toHaveLength(0); + 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 () => { @@ -114,8 +117,12 @@ describe("useGameLoop", () => { flushTick(); // tick 1: draw caught + allDone=true → setResult await waitFor(() => { expect(result.current).not.toBeNull(); }); - expect(result.current!.caught).toHaveLength(1); - expect(result.current!.missed).toHaveLength(0); + 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 () => { @@ -142,8 +149,12 @@ describe("useGameLoop", () => { } await waitFor(() => { expect(result.current).not.toBeNull(); }); - expect(result.current!.caught).toHaveLength(0); - expect(result.current!.missed).toHaveLength(1); + 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", () => { @@ -187,7 +198,11 @@ describe("useGameLoop", () => { } await waitFor(() => { expect(result.current).not.toBeNull(); }); - expect(result.current!.caught).toHaveLength(2); - expect(result.current!.missed).toHaveLength(0); + const settled = result.current; + if (settled === null) { + throw new Error('result never settled'); + } + expect(settled.caught).toHaveLength(2); + expect(settled.missed).toHaveLength(0); }); }); diff --git a/bucket_catch/packages/frontend/src/hooks/usePuzzleGameLoop.ts b/bucket_catch/packages/frontend/src/hooks/usePuzzleGameLoop.ts index 6696e20c..b6f2197d 100644 --- a/bucket_catch/packages/frontend/src/hooks/usePuzzleGameLoop.ts +++ b/bucket_catch/packages/frontend/src/hooks/usePuzzleGameLoop.ts @@ -85,8 +85,10 @@ export function usePuzzleGameLoop( scheduleRef.current.length > 0 && frame >= scheduleRef.current[0].spawnFrame ) { - // while condition guarantees length > 0, so shift() is always defined - const s = scheduleRef.current.shift()!; + // The while condition already indexes [0] unasserted, so this is + // the same non-null value; shift() only advances the queue. + const s = scheduleRef.current[0]; + scheduleRef.current.shift(); activeItemsRef.current.push({ kind: "puzzle", id: `puzzle-${s.piece.row}-${s.piece.col}`, diff --git a/bucket_catch/packages/frontend/src/hooks/usePuzzleGameLoopPlay.test.ts b/bucket_catch/packages/frontend/src/hooks/usePuzzleGameLoopPlay.test.ts index 057471c8..80457ad6 100644 --- a/bucket_catch/packages/frontend/src/hooks/usePuzzleGameLoopPlay.test.ts +++ b/bucket_catch/packages/frontend/src/hooks/usePuzzleGameLoopPlay.test.ts @@ -51,8 +51,12 @@ describe("usePuzzleGameLoop play", () => { 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); + const settled = result.current; + if (settled === null) { + throw new Error('result never settled'); + } + expect(settled.caughtPieces).toHaveLength(1); + expect(settled.missedPieces).toHaveLength(0); }); it("misses piece (still-falling + missed branches)", async () => { @@ -77,8 +81,12 @@ describe("usePuzzleGameLoop play", () => { } await waitFor(() => { expect(result.current).not.toBeNull(); }); - expect(result.current!.caughtPieces).toHaveLength(0); - expect(result.current!.missedPieces).toHaveLength(1); + const settled = result.current; + if (settled === null) { + throw new Error('result never settled'); + } + expect(settled.caughtPieces).toHaveLength(0); + expect(settled.missedPieces).toHaveLength(1); }); it("tests flash-during and flash-expired branches with 2 pieces", async () => { @@ -114,8 +122,12 @@ describe("usePuzzleGameLoop play", () => { } await waitFor(() => { expect(result.current).not.toBeNull(); }); - expect(result.current!.caughtPieces).toHaveLength(1); - expect(result.current!.missedPieces).toHaveLength(1); + const settled = result.current; + if (settled === null) { + throw new Error('result never settled'); + } + expect(settled.caughtPieces).toHaveLength(1); + expect(settled.missedPieces).toHaveLength(1); }); it("uses drawImage when piece image is loaded (img.complete=true branch)", async () => { diff --git a/bucket_catch/packages/frontend/src/lib/sliceImage.test.ts b/bucket_catch/packages/frontend/src/lib/sliceImage.test.ts index 295af361..87e0fe4f 100644 --- a/bucket_catch/packages/frontend/src/lib/sliceImage.test.ts +++ b/bucket_catch/packages/frontend/src/lib/sliceImage.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { MockInstance } from "vitest"; import { sliceImage } from "./sliceImage"; // Captures the last Image instance created so tests can trigger onload/onerror. @@ -22,10 +23,14 @@ class FakeImage { } describe("sliceImage", () => { + let revokeUrlSpy: MockInstance; + beforeEach(() => { vi.stubGlobal("Image", FakeImage); vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:mock"); - vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined); + revokeUrlSpy = vi + .spyOn(URL, "revokeObjectURL") + .mockImplementation(() => undefined); }); afterEach(() => { @@ -44,7 +49,7 @@ describe("sliceImage", () => { expect(pieces).toHaveLength(4); // 2×2 grid expect(pieces[0]).toMatchObject({ row: 0, col: 0, gridSize: 2 }); expect(pieces[3]).toMatchObject({ row: 1, col: 1, gridSize: 2 }); - expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock"); + expect(revokeUrlSpy).toHaveBeenCalledWith("blob:mock"); }); it("includes pieceWidth and pieceHeight from image dimensions", async () => { @@ -74,7 +79,7 @@ describe("sliceImage", () => { const promise = sliceImage(file, 1); capturedImg.onload?.(); await expect(promise).rejects.toThrow("Canvas 2D context not available"); - expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock"); + expect(revokeUrlSpy).toHaveBeenCalledWith("blob:mock"); // Restore so subsequent tests in this file have the mock context HTMLCanvasElement.prototype.getContext = savedImpl; @@ -85,6 +90,6 @@ describe("sliceImage", () => { const promise = sliceImage(file, 2); capturedImg.onerror?.(); await expect(promise).rejects.toThrow("Failed to load image for slicing"); - expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock"); + expect(revokeUrlSpy).toHaveBeenCalledWith("blob:mock"); }); }); diff --git a/bucket_catch/packages/frontend/src/lib/zipDownload.test.ts b/bucket_catch/packages/frontend/src/lib/zipDownload.test.ts index 23a88810..9d2568bd 100644 --- a/bucket_catch/packages/frontend/src/lib/zipDownload.test.ts +++ b/bucket_catch/packages/frontend/src/lib/zipDownload.test.ts @@ -1,13 +1,22 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { MockInstance } from "vitest"; import { zipDownload } from "./zipDownload"; describe("zipDownload", () => { + let createUrlSpy: MockInstance; + let revokeUrlSpy: MockInstance; + let clickSpy: MockInstance; + beforeEach(() => { - vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:mock-url"); - vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined); - vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation( - () => undefined, - ); + createUrlSpy = vi + .spyOn(URL, "createObjectURL") + .mockReturnValue("blob:mock-url"); + revokeUrlSpy = vi + .spyOn(URL, "revokeObjectURL") + .mockImplementation(() => undefined); + clickSpy = vi + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => undefined); }); afterEach(() => { @@ -21,30 +30,38 @@ describe("zipDownload", () => { const file = new File(["hello"], "hello.txt"); await zipDownload([file]); - expect(URL.createObjectURL).toHaveBeenCalled(); - expect(HTMLAnchorElement.prototype.click).toHaveBeenCalled(); - expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock-url"); + expect(createUrlSpy).toHaveBeenCalled(); + expect(clickSpy).toHaveBeenCalled(); + expect(revokeUrlSpy).toHaveBeenCalledWith("blob:mock-url"); expect(appendSpy).toHaveBeenCalled(); expect(removeSpy).toHaveBeenCalled(); }); it("sets download attribute and href on the anchor", async () => { const anchors: HTMLAnchorElement[] = []; + // Bound before the spy replaces it, so the implementation below can build + // a real element without recursing into its own mock. + const realCreateElement = document.createElement.bind(document); vi.spyOn(document, "createElement").mockImplementation((tag: string) => { - const el = document.createElement.bind(document)(tag) as HTMLElement; - if (tag === "a") anchors.push(el as HTMLAnchorElement); + const el = realCreateElement(tag); + if (el instanceof HTMLAnchorElement) anchors.push(el); return el; }); - // Restore after spy above since we used bind trick + // Restore after spy above since we used bind trick. That discards the + // handles from beforeEach, so re-spy and keep the new ones. vi.restoreAllMocks(); vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:mock-url"); - vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined); - vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined); + const localRevokeSpy = vi + .spyOn(URL, "revokeObjectURL") + .mockImplementation(() => undefined); + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation( + () => undefined, + ); const file = new File(["x"], "data.bin"); await zipDownload([file]); - expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock-url"); + expect(localRevokeSpy).toHaveBeenCalledWith("blob:mock-url"); }); it("works with multiple files", async () => { @@ -54,7 +71,7 @@ describe("zipDownload", () => { new File(["c"], "c.png", { type: "image/png" }), ]; await expect(zipDownload(files)).resolves.toBeUndefined(); - expect(URL.createObjectURL).toHaveBeenCalledTimes(1); - expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1); + expect(createUrlSpy).toHaveBeenCalledTimes(1); + expect(revokeUrlSpy).toHaveBeenCalledTimes(1); }); }); diff --git a/bucket_catch/packages/frontend/src/test/puzzleLoopFixtures.ts b/bucket_catch/packages/frontend/src/test/puzzleLoopFixtures.ts index 98e8746d..d58b76bd 100644 --- a/bucket_catch/packages/frontend/src/test/puzzleLoopFixtures.ts +++ b/bucket_catch/packages/frontend/src/test/puzzleLoopFixtures.ts @@ -10,8 +10,9 @@ import type { PuzzlePiece } from "../types"; * the 100% gate. */ -export const makeRef = (val: T): React.RefObject => - ({ current: val }) as React.RefObject; +export const makeRef = (val: T): React.RefObject => ({ + current: val, +}); export const makePiece = (row: number, col: number): PuzzlePiece => ({ row, diff --git a/docs/superpowers/contracts/bucket-catch-eslint-2026-08-17.json b/docs/superpowers/contracts/bucket-catch-eslint-2026-08-17.json new file mode 100644 index 00000000..24434d50 --- /dev/null +++ b/docs/superpowers/contracts/bucket-catch-eslint-2026-08-17.json @@ -0,0 +1,17 @@ +{ + "title": "Clear the bucket_catch frontend eslint backlog without suppressions", + "objective": "bucket_catch/packages/frontend had 45 eslint errors that were unmasked, not caused, by a typecheck repair last session: `lint` is `tsc --noEmit && eslint src`, and a broken tsc had been short-circuiting the && so eslint had never run on the package. The user asked for all of them fixed. The binding constraint is that the package holds a 100% coverage gate, so any fix that adds an unreachable branch trades a lint error for a coverage failure - and the no-suppressions rule forbids escaping either with a disable comment.", + "acceptance_criteria": [ + "eslint error count drops substantially from 45, with every remaining error named and explained rather than silenced", + "No eslint-disable, no @ts-ignore, no rule downgraded in config, no coverage threshold lowered", + "npm run coverage still reports 100% on statements, branches, functions and lines; any change in the totals is attributable to a named edit", + "The test count stays at 145 across 15 files", + "npx tsc --noEmit exits 0" + ], + "out_of_scope": [ + "Refactoring App.tsx's phase state to carry the game result - the correct fix for the last no-non-null-assertion, but a wider change than a lint pass", + "Any change to component behaviour or to what the tests assert", + "The other repos touched in this session" + ], + "verifier": "npx eslint src for the count; npm run coverage for the 100% gate and the 145-test count; npx tsc --noEmit for the typecheck. Each touched test file was additionally run on its own with npx vitest run as it was edited." +} diff --git a/docs/superpowers/evidence/bucket-catch-eslint-2026-08-17.json b/docs/superpowers/evidence/bucket-catch-eslint-2026-08-17.json new file mode 100644 index 00000000..7408d5da --- /dev/null +++ b/docs/superpowers/evidence/bucket-catch-eslint-2026-08-17.json @@ -0,0 +1,47 @@ +{ + "intent": "Fix the eslint errors in bucket_catch/packages/frontend, which had never run against that package until a typecheck repair last session unmasked them, without using a single suppression and without letting the 100% coverage gate slip.", + "scope": [ + "9 test files and 2 production files under bucket_catch/packages/frontend/src", + "45 errors down to 5; the remaining 5 are named below and left as reported findings", + "Non-goal: no behaviour change, no test removed, no eslint rule disabled or downgraded" + ], + "changes": [ + "no-non-null-assertion (18 -> 2): in useGameLoop.test.ts and usePuzzleGameLoopPlay.test.ts the pattern was a waitFor that asserts result.current is non-null followed by several `result.current!.x` reads; replaced by capturing the value into a local and throwing if it is null, which narrows for every following line. In App.tsx `files[0]!` was simply deleted - the same position also reported no-unnecessary-type-assertion, proving the assertion changed nothing. In usePuzzleGameLoop.ts the `shift()!` became an index-then-shift, because the enclosing while condition already dereferences [0] without an assertion.", + "non-nullable-type-assertion-style (9 -> 0): these are the rule-swap trap - eslint asks for `!`, which no-non-null-assertion then forbids. Resolved in DropZone.test.tsx with a `must()` helper that narrows via instanceof and throws with a useful message, and in PuzzleResult.test.tsx with an inline instanceof guard.", + "unbound-method (10 -> 1): assertions like expect(URL.revokeObjectURL) detach a method from its object. Replaced by keeping the MockInstance handle that vi.spyOn already returns - the pattern zipDownload.test.ts was using for appendSpy/removeSpy - and asserting on that.", + "no-unnecessary-type-assertion (5 -> 1) and require-await (1 -> 0): dropped the redundant `as React.RefObject` from three makeRef helpers, and removed `async` from a test with no await." + ], + "verification": [ + { + "command": "npx eslint src", + "result": "pass", + "evidence": "45 errors before, 5 after. Remaining: App.tsx no-non-null-assertion (1), usePuzzleGameLoop.ts no-non-null-assertion (1), sliceImage.test.ts no-this-alias (1) and unbound-method (1), zipDownload.test.ts no-deprecated (1). Each is explained under risks; none is suppressed." + }, + { + "command": "npm run coverage", + "result": "pass", + "evidence": "Before: 584 statements / 217 branches / 98 functions / 535 lines, all 100%. After: 585 / 217 / 98 / 536, all still 100%. The +1 statement and +1 line are the index-then-shift edit in usePuzzleGameLoop.ts splitting one statement into two. Branches unchanged at 217 is the number that matters: no fix introduced an untested branch, which is the failure mode that would otherwise force a suppression or an unreachable guard." + }, + { + "command": "npx vitest run (via npm run coverage)", + "result": "pass", + "evidence": "145 tests in 15 files pass, the same count as before. Every touched test file was also run individually as it was changed." + }, + { + "command": "npx tsc --noEmit", + "result": "pass", + "evidence": "exit 0. This is the first half of `npm run lint`, and it was the step whose earlier failure had been short-circuiting the && and hiding eslint entirely." + } + ], + "risks": [ + "zipDownload.test.ts 'sets download attribute and href on the anchor' calls vi.restoreAllMocks() mid-test and re-spies. Moving the beforeEach spies into handles made that assertion read a stale handle and the test failed; it now keeps a local handle created after the restore. The test was already fragile in this way - the refactor made the dependency explicit rather than introducing it.", + "App.tsx no-non-null-assertion is NOT fixed and needs a real refactor: `fileResult!` in the done phase. Three attempts each failed the same way - a null guard adds a branch no test can reach (App's flow never produces a done phase with no result), and retagging GameResult still leaves the value nullable in the fall-through. The right fix is to move the result into the phase state (`{ kind: 'done'; result: GameResult }`), so narrowing on the phase removes the null. That is a wider change than this commit and is left for a follow-up.", + "usePuzzleGameLoop.ts:127 `resolvedFrameMapRef.current.get(item.id)!` is likewise unfixed: Map.get returns `| undefined` and the invariant (the id was inserted a few lines above) is not visible to the type system. A guard here would also be an unreachable branch.", + "sliceImage.test.ts no-this-alias: the FakeImage constructor does `capturedImg = this` so the test can drive onload. A factory breaks `new Image()`, and a Proxy wrapper passed the tests but did not reduce the error count and was more complex than what it replaced, so it was reverted. Its remaining unbound-method is on a getContext prototype save/restore.", + "zipDownload.test.ts no-deprecated is on the `(tag: string)` overload of document.createElement, needed because the spy must accept the signature vi.spyOn infers. Building elements from constructors instead fails under jsdom (HTMLAnchorElement is not constructible), so the deprecated overload stays." + ], + "rollback": [ + "git revert the commit. All 45 eslint errors return; nothing else changes, since no production behaviour was altered.", + "After rollback, confirm npm run coverage still reports 100% with 584/217/98/535 and 145 passing tests, and that npx tsc --noEmit exits 0." + ] +}