bucket_catch: make the done phase carry its result

Removes the last fixable non-null assertion. GamePhase goes from a string
union to a tagged one whose "done" arm carries `result: GameResult`, so
narrowing on phase.kind yields a non-null result and `fileResult!`
disappears. GameResult - previously an untagged union nothing consumed -
is now tagged, so the render picks a screen from the discriminant instead
of inferring it from which of two nullable states was set.

Three earlier attempts at this site were reverted: a null guard returning
null, a widened ReactElement | null return, and a tagged GameResult with
phase left as a string. Each either added a branch no test could reach -
dropping the 100% gate - or left the value nullable in the fall-through.
The invariant lived in the phase, so the fix had to as well.

Coverage stays 100%. Statements 585 -> 579 and lines 536 -> 530, exactly
the two useState declarations and their setters that the collapse
removed; branches unchanged at 217, so nothing new went untested. All 10
App tests pass unmodified - they drive the app through mocked onDone
callbacks, so both done arms are exercised through the new union.

eslint 5 -> 4 (45 -> 4 for the session). The remaining four are reported
in the previous commit's evidence. usePuzzleGameLoop's Map.get assertion
is not among the fixable ones: `?? Infinity` was tried and measured at
219 branches / 99.54%, since the default side is unreachable.

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 11:55:05 +02:00
parent a4b879b19d
commit 031bff898e
2 changed files with 39 additions and 29 deletions

View File

@ -13,55 +13,52 @@ import { ScoreScreen } from "./components/ScoreScreen";
import { PuzzleResult } from "./components/PuzzleResult";
export default function App(): React.ReactElement {
const [phase, setPhase] = useState<GamePhase>("drop");
const [phase, setPhase] = useState<GamePhase>({ kind: "drop" });
const [files, setFiles] = useState<File[]>([]);
const [mode, setMode] = useState<TransferMode>("download");
const [puzzleGridSize, setPuzzleGridSize] = useState(4);
const [fileResult, setFileResult] = useState<FileGameResult | null>(null);
const [puzzleResult, setPuzzleResult] = useState<PuzzleGameResult | null>(null);
const handleFiles = useCallback((incoming: File[]) => {
setFiles(incoming);
setPhase("mode");
setPhase({ kind: "mode" });
}, []);
const handlePuzzleDirect = useCallback((imageFile: File, gridSize: number) => {
const handlePuzzleDirect = useCallback(
(imageFile: File, gridSize: number) => {
setFiles([imageFile]);
setMode("puzzle");
setPuzzleGridSize(gridSize);
setPhase("playing");
}, []);
setPhase({ kind: "playing" });
},
[],
);
const handleStart = useCallback((selected: TransferMode, gridSize = 4) => {
setMode(selected);
if (selected === "puzzle") setPuzzleGridSize(gridSize);
setPhase("playing");
setPhase({ kind: "playing" });
}, []);
const handleFileDone = useCallback((result: FileGameResult) => {
setFileResult(result);
setPhase("done");
const handleFileDone = useCallback((value: FileGameResult) => {
setPhase({ kind: "done", result: { kind: "file", value } });
}, []);
const handlePuzzleDone = useCallback((result: PuzzleGameResult) => {
setPuzzleResult(result);
setPhase("done");
const handlePuzzleDone = useCallback((value: PuzzleGameResult) => {
setPhase({ kind: "done", result: { kind: "puzzle", value } });
}, []);
const handleRestart = useCallback(() => {
setFiles([]);
setFileResult(null);
setPuzzleResult(null);
setPhase("drop");
setPhase({ kind: "drop" });
}, []);
if (phase === "drop") {
if (phase.kind === "drop") {
return <DropZone onFiles={handleFiles} onPuzzle={handlePuzzleDirect} />;
}
if (phase === "mode") {
if (phase.kind === "mode") {
return <ModeSelect files={files} onStart={handleStart} />;
}
if (phase === "playing") {
if (phase.kind === "playing") {
if (mode === "puzzle") {
return (
<PuzzleCanvas
@ -73,13 +70,15 @@ export default function App(): React.ReactElement {
}
return <GameCanvas files={files} onDone={handleFileDone} />;
}
// done phase — puzzleResult is non-null iff mode was "puzzle"
if (puzzleResult !== null) {
return <PuzzleResult result={puzzleResult} onRestart={handleRestart} />;
// done phase: the phase carries its result, so both arms are non-null.
if (phase.result.kind === "puzzle") {
return (
<PuzzleResult result={phase.result.value} onRestart={handleRestart} />
);
}
return (
<ScoreScreen
result={fileResult!}
result={phase.result.value}
mode={mode}
onRestart={handleRestart}
/>

View File

@ -1,4 +1,12 @@
export type GamePhase = "drop" | "mode" | "playing" | "done";
/**
* Where the app is. The done phase carries its result, so "finished with no
* result" cannot be represented and the render never has to assert one away.
*/
export type GamePhase =
| { kind: "drop" }
| { kind: "mode" }
| { kind: "playing" }
| { kind: "done"; result: GameResult };
export type TransferMode = "download" | "upload" | "puzzle";
@ -52,4 +60,7 @@ export interface PuzzleGameResult {
gridSize: number;
}
export type GameResult = FileGameResult | PuzzleGameResult;
/** The outcome of a finished game, tagged by which mode produced it. */
export type GameResult =
| { kind: "file"; value: FileGameResult }
| { kind: "puzzle"; value: PuzzleGameResult };