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 }; + } +}