diff --git a/bucket_catch/packages/frontend/src/components/DropZone.test.tsx b/bucket_catch/packages/frontend/src/components/DropZone.test.tsx index 2ea613fb..d6e31e6b 100644 --- a/bucket_catch/packages/frontend/src/components/DropZone.test.tsx +++ b/bucket_catch/packages/frontend/src/components/DropZone.test.tsx @@ -1,31 +1,16 @@ import { describe, it, expect, vi } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import { DropZone } from "./DropZone"; +import { must } from "../test/domQueries"; 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} />); expect(screen.getByText("Bucket Catch")).toBeInTheDocument(); - expect( - screen.getByText(/Drop your files here/), - ).toBeInTheDocument(); + expect(screen.getByText(/Drop your files here/)).toBeInTheDocument(); }); it("shows initial grid equation", () => { @@ -37,10 +22,7 @@ describe("DropZone", () => { const { container } = render( undefined} onPuzzle={() => undefined} />, ); - const zone = must( - container.firstChild as Element | null, - "the drop zone", - ); + 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"); @@ -53,10 +35,7 @@ describe("DropZone", () => { const { container } = render( undefined} />, ); - const zone = must( - container.firstChild as Element | null, - "the drop zone", - ); + 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] } }); @@ -68,10 +47,7 @@ describe("DropZone", () => { const { container } = render( undefined} onPuzzle={() => undefined} />, ); - const zone = must( - container.firstChild as Element | null, - "the drop zone", - ); + 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(); }); @@ -81,10 +57,7 @@ describe("DropZone", () => { const { container } = render( undefined} />, ); - const zone = must( - container.firstChild as Element | null, - "the drop zone", - ); + const zone = must(container.firstChild as Element | null, "the drop zone"); fireEvent.drop(zone, { dataTransfer: { files: [] } }); expect(onFiles).not.toHaveBeenCalled(); }); @@ -201,9 +174,9 @@ describe("DropZone", () => { container.querySelector("input[accept='image/*']"), "input[accept='image/*']", ); - const clickSpy = vi.spyOn(puzzleInput, "click").mockImplementation( - () => undefined, - ); + const clickSpy = vi + .spyOn(puzzleInput, "click") + .mockImplementation(() => undefined); fireEvent.click(screen.getByText(/Play Puzzle Mode/)); expect(clickSpy).toHaveBeenCalled(); }); diff --git a/bucket_catch/packages/frontend/src/components/PuzzleResult.test.tsx b/bucket_catch/packages/frontend/src/components/PuzzleResult.test.tsx index 7b8a44fd..7c24dfdd 100644 --- a/bucket_catch/packages/frontend/src/components/PuzzleResult.test.tsx +++ b/bucket_catch/packages/frontend/src/components/PuzzleResult.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import { PuzzleResult } from "./PuzzleResult"; +import { must } from "../test/domQueries"; import type { FallingPuzzleItem, PuzzlePiece } from "../types"; const makePiece = (row: number, col: number): PuzzlePiece => ({ @@ -129,10 +130,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"]'); - if (!(gridEl instanceof HTMLElement)) { - throw new Error("expected to find the puzzle grid element"); - } + const gridEl = must( + container.querySelector('[style*="repeat"]'), + "the puzzle grid element", + ); const holeDivs = Array.from(gridEl.children).filter( (c) => c.tagName === "DIV", ); diff --git a/bucket_catch/packages/frontend/src/test/domQueries.ts b/bucket_catch/packages/frontend/src/test/domQueries.ts new file mode 100644 index 00000000..ab2a96f8 --- /dev/null +++ b/bucket_catch/packages/frontend/src/test/domQueries.ts @@ -0,0 +1,21 @@ +/** + * DOM query helpers shared by the component tests. + * + * Lives under src/test/ because the coverage config excludes that path — a + * helper placed beside a component would be counted as production code and + * break the 100% gate. + */ + +/** + * 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. + */ +export const must = (el: Element | null, what: string): HTMLElement => { + if (!(el instanceof HTMLElement)) { + throw new Error(`expected to find ${what}`); + } + return el; +}; diff --git a/docs/superpowers/evidence/bucket-catch-shared-dom-helper-2026-08-17.json b/docs/superpowers/evidence/bucket-catch-shared-dom-helper-2026-08-17.json new file mode 100644 index 00000000..e6878158 --- /dev/null +++ b/docs/superpowers/evidence/bucket-catch-shared-dom-helper-2026-08-17.json @@ -0,0 +1,37 @@ +{ + "intent": "Move the must() DOM narrowing helper out of DropZone.test.tsx, which the eslint pass had pushed 6 lines over the 250-line cap, into the shared coverage-excluded test directory where both component tests can use it.", + "scope": [ + "New bucket_catch/packages/frontend/src/test/domQueries.ts", + "DropZone.test.tsx 256 -> 243 lines; PuzzleResult.test.tsx switched to the shared helper", + "Non-goal: no behaviour change, no assertion weakened" + ], + "changes": [ + "Extracted must() into src/test/domQueries.ts. That directory is excluded by the coverage config, which is why fixtures already live there - a helper beside a component would be counted as production code and break the 100% gate.", + "DropZone.test.tsx imports it instead of defining it, dropping the file back under the cap.", + "PuzzleResult.test.tsx replaced its inline instanceof guard with the same helper, so the two component tests narrow DOM queries identically rather than each rolling their own." + ], + "verification": [ + { + "command": "bash ~/utils/scripts/check_file_length.sh --all", + "result": "pass", + "evidence": "40 over-cap files before this change, 39 after; DropZone.test.tsx no longer appears. It had been 250 exactly and the must() helper pushed it to 256, so the eslint commit had silently added an over-cap file while removing others - this restores the intended 41 -> 39 for the session." + }, + { + "command": "npm run coverage", + "result": "pass", + "evidence": "100% on all four measures with totals unchanged at 579 statements / 217 branches / 98 functions / 530 lines, and 145 tests in 15 files. Identical totals are the check that moving the helper into a coverage-excluded directory neither removed code from test reach nor added any." + }, + { + "command": "npx tsc --noEmit && npx eslint src", + "result": "pass", + "evidence": "tsc exit 0; eslint still reports exactly the 4 known remaining errors, so the move introduced none." + } + ], + "risks": [ + "src/test/domQueries.ts is not coverage-measured, so a bug in must() would not be caught by the coverage gate. It is four lines with a single instanceof check, and every caller exercises it on the happy path in tests that assert on the returned element." + ], + "rollback": [ + "git revert the commit. must() returns to DropZone.test.tsx and that file goes back over the cap at 256 lines; PuzzleResult.test.tsx regains its inline guard.", + "After rollback, confirm npm run coverage still reports 100% with 579/217/98/530 and 145 passing tests." + ] +}