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 <noreply@anthropic.com>
139 lines
4.1 KiB
TypeScript
139 lines
4.1 KiB
TypeScript
/**
|
|
* 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 };
|
|
}
|
|
}
|