mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 20:07:04 +01:00
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
This commit is contained in:
parent
efb471fc8b
commit
e165ae8987
@ -65,7 +65,7 @@ export default function App(): React.ReactElement {
|
||||
if (mode === "puzzle") {
|
||||
return (
|
||||
<PuzzleCanvas
|
||||
imageFile={files[0]!}
|
||||
imageFile={files[0]}
|
||||
gridSize={puzzleGridSize}
|
||||
onDone={handlePuzzleDone}
|
||||
/>
|
||||
|
||||
@ -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(<DropZone onFiles={() => undefined} onPuzzle={() => undefined} />);
|
||||
@ -23,7 +37,10 @@ describe("DropZone", () => {
|
||||
const { container } = render(
|
||||
<DropZone onFiles={() => 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(
|
||||
<DropZone onFiles={onFiles} onPuzzle={() => 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(
|
||||
<DropZone onFiles={() => 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(
|
||||
<DropZone onFiles={onFiles} onPuzzle={() => 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(
|
||||
<DropZone onFiles={onFiles} onPuzzle={() => 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(
|
||||
<DropZone onFiles={onFiles} onPuzzle={() => 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(
|
||||
<DropZone onFiles={onFiles} onPuzzle={() => 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(
|
||||
<DropZone onFiles={() => 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(
|
||||
<DropZone onFiles={() => 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(
|
||||
<DropZone onFiles={() => 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(
|
||||
<DropZone onFiles={() => 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", () => {
|
||||
<DropZone onFiles={() => 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],
|
||||
|
||||
@ -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",
|
||||
);
|
||||
|
||||
@ -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(
|
||||
<ScoreScreen result={result1} mode="download" onRestart={() => undefined} />,
|
||||
);
|
||||
|
||||
@ -4,8 +4,9 @@ import { fireEvent } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { useBasketControl } from "./useBasketControl";
|
||||
|
||||
const makeRef = <T>(val: T): React.RefObject<T> =>
|
||||
({ current: val }) as React.RefObject<T>;
|
||||
const makeRef = <T>(val: T): React.RefObject<T> => ({
|
||||
current: val,
|
||||
});
|
||||
|
||||
describe("useBasketControl", () => {
|
||||
it("initialises basket x to half of window.innerWidth", () => {
|
||||
|
||||
@ -3,8 +3,7 @@ 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 }) as React.RefObject<T>;
|
||||
const makeRef = <T>(val: T): React.RefObject<T> => ({ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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}`,
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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<typeof URL.revokeObjectURL>;
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<typeof URL.createObjectURL>;
|
||||
let revokeUrlSpy: MockInstance<typeof URL.revokeObjectURL>;
|
||||
let clickSpy: MockInstance<typeof HTMLAnchorElement.prototype.click>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -10,8 +10,9 @@ import type { PuzzlePiece } from "../types";
|
||||
* the 100% gate.
|
||||
*/
|
||||
|
||||
export const makeRef = <T>(val: T): React.RefObject<T> =>
|
||||
({ current: val }) as React.RefObject<T>;
|
||||
export const makeRef = <T>(val: T): React.RefObject<T> => ({
|
||||
current: val,
|
||||
});
|
||||
|
||||
export const makePiece = (row: number, col: number): PuzzlePiece => ({
|
||||
row,
|
||||
|
||||
@ -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."
|
||||
}
|
||||
@ -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<T>` 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."
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user