From 6f1540635ef50d6bb57ea2d4475d2432add2a4b0 Mon Sep 17 00:00:00 2001 From: ncode Date: Thu, 17 Sep 2026 10:37:21 +0300 Subject: [PATCH] Phase 2: drill state as a pure reducer Drill transitions live in drillReducer.ts with no React, so they test like the domain does. Choosing the next card needs randomness, so that happens outside and arrives in the deal action; the reducer only ever transforms what it is given. Rules it encodes, each now pinned by a test: - Show me counts as a miss. You did not know it, so the card goes back to box 1 rather than being skipped. - Presses are inert once a card is graded, because the panel is showing the answer at that point. - Tapping toggles, so a mis-hit can be taken back; holding accumulates, because a second report from a held finger is just contact. - Changing deck or direction drops the current card, since it may not be in the new pool. Changing only the answering mode keeps it. - A card cannot be graded twice. 132 tests, typecheck clean. Co-Authored-By: Claude Opus 5 --- src/ui/drillReducer.test.ts | 178 ++++++++++++++++++++++++++++++++++++ src/ui/drillReducer.ts | 138 ++++++++++++++++++++++++++++ 2 files changed, 316 insertions(+) create mode 100644 src/ui/drillReducer.test.ts create mode 100644 src/ui/drillReducer.ts diff --git a/src/ui/drillReducer.test.ts b/src/ui/drillReducer.test.ts new file mode 100644 index 0000000..6473bc5 --- /dev/null +++ b/src/ui/drillReducer.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from "vitest"; +import { boxOf } from "../domain/drill"; +import { ENTRIES } from "../domain/entries"; +import { drillReducer, initialDrillState } from "./drillReducer"; +import type { DrillState } from "./drillReducer"; +import type { Entry } from "../domain/types"; + +const byName = (name: string): Entry => { + const found = ENTRIES.find((e) => e.name === name); + if (!found) throw new Error(`no entry named ${name}`); + return found; +}; + +const fixedVelocity = byName("Fixed velocity"); // SHIFT + PAD 1 + +const start = (over: Partial = {}): DrillState => ({ + ...initialDrillState("shiftpads", "play", "tap", {}), + card: fixedVelocity, + ...over, +}); + +describe("dealing", () => { + it("clears the previous answer", () => { + const answered = start({ chord: ["shift"], answered: true, lastCorrect: true }); + const next = drillReducer(answered, { type: "deal", card: fixedVelocity }); + expect(next.chord).toEqual([]); + expect(next.answered).toBe(false); + expect(next.lastCorrect).toBe(false); + }); + + it("carries the options for a combo-first card", () => { + const next = drillReducer(start(), { + type: "deal", + card: fixedVelocity, + options: ["Fixed velocity", "Cue mode"], + }); + expect(next.options).toHaveLength(2); + }); +}); + +describe("building a chord", () => { + it("adds controls as they are pressed", () => { + let s = start(); + s = drillReducer(s, { type: "press", id: "shift" }); + s = drillReducer(s, { type: "press", id: "p1" }); + expect(s.chord).toEqual(["shift", "p1"]); + }); + + it("lets a tap be taken back, since tapping is how you correct a mis-hit", () => { + let s = start({ input: "tap" }); + s = drillReducer(s, { type: "press", id: "p2" }); + s = drillReducer(s, { type: "press", id: "p2" }); + expect(s.chord).toEqual([]); + }); + + it("does not un-press while holding, where a second report is just contact", () => { + let s = start({ input: "hold" }); + s = drillReducer(s, { type: "press", id: "p1" }); + s = drillReducer(s, { type: "press", id: "p1" }); + expect(s.chord).toEqual(["p1"]); + }); + + it("ignores presses once the card is graded", () => { + const s = drillReducer(start({ answered: true }), { type: "press", id: "p1" }); + expect(s.chord).toEqual([]); + }); + + it("ignores presses when the card is asked combo-first", () => { + const s = drillReducer(start({ direction: "name" }), { type: "press", id: "p1" }); + expect(s.chord).toEqual([]); + }); + + it("clears only before grading", () => { + expect(drillReducer(start({ chord: ["p1"] }), { type: "clear" }).chord).toEqual([]); + const graded = start({ chord: ["p1"], answered: true }); + expect(drillReducer(graded, { type: "clear" }).chord).toEqual(["p1"]); + }); +}); + +describe("grading", () => { + it("promotes a right answer and parks on it", () => { + let s = start({ chord: ["shift", "p1"] }); + s = drillReducer(s, { type: "check" }); + expect(s.answered).toBe(true); + expect(s.lastCorrect).toBe(true); + expect(boxOf(s.boxes, fixedVelocity)).toBe(2); + }); + + it("sends a wrong answer back to box 1", () => { + let s = start({ chord: ["shift", "p2"], boxes: { [fixedVelocity.id]: 4 } }); + s = drillReducer(s, { type: "check" }); + expect(s.lastCorrect).toBe(false); + expect(boxOf(s.boxes, fixedVelocity)).toBe(1); + }); + + it("does nothing when nothing has been pressed", () => { + const s = drillReducer(start(), { type: "check" }); + expect(s.answered).toBe(false); + }); + + it("will not grade the same card twice", () => { + const once = drillReducer(start({ chord: ["shift", "p1"] }), { type: "check" }); + const twice = drillReducer(once, { type: "check" }); + expect(twice).toBe(once); + }); + + it("grades a hold on release, using the chord as it stood", () => { + const s = drillReducer(start({ input: "hold" }), { + type: "release", + chord: ["p1", "shift"], + }); + expect(s.lastCorrect).toBe(true); + expect(s.chord).toEqual(["p1", "shift"]); + }); + + it("ignores a release with nothing held", () => { + const s = drillReducer(start({ input: "hold" }), { type: "release", chord: [] }); + expect(s.answered).toBe(false); + }); + + it("counts Show me as a miss, because you did not know it", () => { + const s = drillReducer(start({ boxes: { [fixedVelocity.id]: 3 } }), { + type: "reveal", + }); + expect(s.answered).toBe(true); + expect(s.lastCorrect).toBe(false); + expect(boxOf(s.boxes, fixedVelocity)).toBe(1); + }); + + it("grades a picked name against the card", () => { + const right = drillReducer(start({ direction: "name" }), { + type: "pickOption", + name: "Fixed velocity", + }); + expect(right.lastCorrect).toBe(true); + + const wrong = drillReducer(start({ direction: "name" }), { + type: "pickOption", + name: "Cue mode", + }); + expect(wrong.lastCorrect).toBe(false); + }); +}); + +describe("settings", () => { + it("drops the current card when the deck changes", () => { + const s = drillReducer(start({ chord: ["p1"] }), { + type: "setDeck", + deck: "dj", + }); + expect(s.deck).toBe("dj"); + expect(s.card).toBeNull(); + expect(s.chord).toEqual([]); + }); + + it("drops the current card when the direction changes", () => { + const s = drillReducer(start(), { type: "setDirection", direction: "name" }); + expect(s.direction).toBe("name"); + expect(s.card).toBeNull(); + }); + + it("keeps the card when only the answering mode changes", () => { + const s = drillReducer(start({ chord: ["p1"] }), { + type: "setInput", + input: "hold", + }); + expect(s.card).toBe(fixedVelocity); + expect(s.chord).toEqual([]); + }); + + it("accepts boxes restored from storage", () => { + const s = drillReducer(start(), { + type: "setBoxes", + boxes: { [fixedVelocity.id]: 5 }, + }); + expect(boxOf(s.boxes, fixedVelocity)).toBe(5); + }); +}); diff --git a/src/ui/drillReducer.ts b/src/ui/drillReducer.ts new file mode 100644 index 0000000..eb82899 --- /dev/null +++ b/src/ui/drillReducer.ts @@ -0,0 +1,138 @@ +/** + * Drill state transitions. Pure, and free of React, so it tests like the domain. + * + * Choosing the next card needs randomness, so that happens outside and arrives + * in the `deal` action. The reducer only ever transforms what it is given. + * + * Angular: closest to a service's state plus its methods, except nothing here + * mutates — each action returns the next state. + */ +import { gradeBoxes, isCorrect } from "../domain/drill"; +import type { Boxes, DeckId, Direction, InputMode } from "../domain/drill"; +import type { ControlId, Entry } from "../domain/types"; + +export interface DrillState { + readonly deck: DeckId; + readonly direction: Direction; + readonly input: InputMode; + readonly boxes: Boxes; + readonly card: Entry | null; + /** Controls tapped or held so far for the current answer. */ + readonly chord: readonly ControlId[]; + readonly answered: boolean; + readonly lastCorrect: boolean; + /** Names offered when the card is asked combo-first. */ + readonly options: readonly string[]; +} + +export type DrillAction = + /** A new card, with its multiple-choice names when asked combo-first. */ + | { type: "deal"; card: Entry | null; options?: readonly string[] } + | { type: "press"; id: ControlId } + | { type: "clear" } + | { type: "check" } + /** Hold-to-answer: the chord as it stood when the last finger lifted. */ + | { type: "release"; chord: readonly ControlId[] } + | { type: "reveal" } + | { type: "pickOption"; name: string } + | { type: "setDeck"; deck: DeckId } + | { type: "setDirection"; direction: Direction } + | { type: "setInput"; input: InputMode } + | { type: "setBoxes"; boxes: Boxes }; + +export const initialDrillState = ( + deck: DeckId, + direction: Direction, + input: InputMode, + boxes: Boxes, +): DrillState => ({ + deck, + direction, + input, + boxes, + card: null, + chord: [], + answered: false, + lastCorrect: false, + options: [], +}); + +/** Grade the current card and park on the answer. */ +function grade(state: DrillState, correct: boolean): DrillState { + if (!state.card) return state; + return { + ...state, + boxes: gradeBoxes(state.boxes, state.card, correct), + answered: true, + lastCorrect: correct, + }; +} + +export function drillReducer(state: DrillState, action: DrillAction): DrillState { + switch (action.type) { + case "deal": + return { + ...state, + card: action.card, + chord: [], + answered: false, + lastCorrect: false, + options: action.options ?? [], + }; + + case "press": { + // Once graded, the panel is showing the answer; presses do nothing until + // the next card is dealt. + if (state.answered || state.direction !== "play") return state; + const held = state.chord.includes(action.id); + // Tapping toggles, so a mis-tap can be taken back. Holding accumulates. + const chord = + state.input === "tap" && held + ? state.chord.filter((c) => c !== action.id) + : held + ? state.chord + : [...state.chord, action.id]; + return { ...state, chord }; + } + + case "clear": + return state.answered ? state : { ...state, chord: [] }; + + case "check": + if (!state.card || state.answered || state.chord.length === 0) return state; + return grade(state, isCorrect(state.card, state.chord)); + + case "release": + if (!state.card || state.answered || action.chord.length === 0) return state; + return grade( + { ...state, chord: action.chord }, + isCorrect(state.card, action.chord), + ); + + case "reveal": + // "Show me" counts as a miss: you did not know it. + return state.answered ? state : grade(state, false); + + case "pickOption": + if (!state.card || state.answered) return state; + return grade(state, action.name === state.card.name); + + case "setDeck": + return { ...state, deck: action.deck, card: null, chord: [], answered: false }; + + case "setDirection": + return { + ...state, + direction: action.direction, + card: null, + chord: [], + answered: false, + }; + + case "setInput": + return { ...state, input: action.input, chord: [] }; + + case "setBoxes": + return { ...state, boxes: action.boxes }; + } +}