diff --git a/CLAUDE.md b/CLAUDE.md index b07a2ff..3bea162 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -176,8 +176,17 @@ src/domain/ entries.ts derives Entry[] from RAW layers.ts which combos exist for a context + held modifiers drill.ts decks, Leitner boxes, answer checking, card selection + format.ts ids to printed words, and the "SHIFT + PAD 1" lines + search.ts the Find tab's matcher + storage.ts the persisted blob, backup parsing, defaults ``` +**Two strings are load-bearing for migration** and must not be changed casually: +`Entry.id` (below) and `STORAGE_KEY`, still `sp404mk2-layers-v1`. Together they +mean the boxes already on the phone are picked up by the React app rather than +orphaned. `storage.test.ts` parses a payload copied verbatim out of +`CapacitorStorage.xml` on the device, so a change that would break that fails. + **The combo table is never transcribed by hand.** `scripts/extract-reference-data.mjs` lifts the literals out of `reference/index.html`, evaluating only pure-data declarations in a `vm` with no DOM, and writes `raw.ts` and `labels.ts`. Re-run it diff --git a/src/domain/drill.ts b/src/domain/drill.ts index 9a2bf2d..712600c 100644 --- a/src/domain/drill.ts +++ b/src/domain/drill.ts @@ -19,6 +19,9 @@ export type DeckId = /** Which way round a card is asked. */ export type Direction = "play" | "name"; +/** How a "press it" card is answered: hold the chord, or tap then check. */ +export type InputMode = "hold" | "tap"; + export type Box = 1 | 2 | 3 | 4 | 5; /** Card id to box. Absent means box 1. */ diff --git a/src/domain/storage.test.ts b/src/domain/storage.test.ts new file mode 100644 index 0000000..63c8187 --- /dev/null +++ b/src/domain/storage.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULTS, + STORAGE_KEY, + parseBackup, + parsePersisted, + parseStored, + sanitizeBoxes, + serialize, +} from "./storage"; + +describe("the storage key", () => { + it("is the one Phase 1 already writes on the phone", () => { + // Read out of CapacitorStorage.xml on the device during Phase 1. Changing + // this orphans everyone's drill progress. + expect(STORAGE_KEY).toBe("sp404mk2-layers-v1"); + }); +}); + +describe("parseStored", () => { + it("falls back to defaults when nothing is stored", () => { + expect(parseStored(null)).toEqual(DEFAULTS); + }); + + it("falls back to defaults on damaged JSON", () => { + expect(parseStored("{not json")).toEqual(DEFAULTS); + expect(parseStored("")).toEqual(DEFAULTS); + }); + + it("reads a real payload written by the Phase 1 app", () => { + // Exactly as it appeared in CapacitorStorage.xml after drilling. + const onDevice = '{"boxes":{"shift>p6@S#INIT":1,"shift>p8@S#MUTE GRP":2}}'; + const state = parseStored(onDevice); + expect(state.boxes["shift>p6@S#INIT"]).toBe(1); + expect(state.boxes["shift>p8@S#MUTE GRP"]).toBe(2); + expect(state.deck).toBe(DEFAULTS.deck); + }); +}); + +describe("parsePersisted", () => { + it("returns defaults for anything that is not an object", () => { + expect(parsePersisted(null)).toEqual(DEFAULTS); + expect(parsePersisted("nope")).toEqual(DEFAULTS); + expect(parsePersisted([])).toEqual(DEFAULTS); + expect(parsePersisted(7)).toEqual(DEFAULTS); + }); + + it("keeps values it recognises", () => { + expect( + parsePersisted({ ctx: "P", deck: "dj", dir: "name", input: "tap" }), + ).toEqual({ boxes: {}, ctx: "P", deck: "dj", dir: "name", input: "tap" }); + }); + + it("replaces values it does not recognise", () => { + const state = parsePersisted({ ctx: "Z", deck: "nope", dir: "sideways", input: 3 }); + expect(state.ctx).toBe("S"); + expect(state.deck).toBe("shiftpads"); + expect(state.dir).toBe("play"); + expect(state.input).toBe("hold"); + }); +}); + +describe("sanitizeBoxes", () => { + it("clamps boxes into 1-5", () => { + expect(sanitizeBoxes({ a: 0, b: 99, c: -4 })).toEqual({ a: 1, b: 5, c: 1 }); + }); + + it("rounds fractions", () => { + expect(sanitizeBoxes({ a: 3.4 })).toEqual({ a: 3 }); + }); + + it("drops anything that is not a finite number", () => { + expect(sanitizeBoxes({ a: "2", b: null, c: NaN, d: Infinity, e: 4 })).toEqual({ + e: 4, + }); + }); + + it("returns an empty set for a non-object", () => { + expect(sanitizeBoxes(null)).toEqual({}); + expect(sanitizeBoxes([1, 2])).toEqual({}); + }); +}); + +describe("parseBackup", () => { + it("accepts a backup carrying boxes", () => { + const state = parseBackup('{"boxes":{"x":3},"deck":"dj"}'); + expect(state).not.toBeNull(); + expect(state!.boxes["x"]).toBe(3); + expect(state!.deck).toBe("dj"); + }); + + it("accepts an empty but well-formed backup", () => { + expect(parseBackup('{"boxes":{}}')).not.toBeNull(); + }); + + it("rejects text that is not JSON", () => { + expect(parseBackup("hello")).toBeNull(); + }); + + it("rejects JSON without a boxes object", () => { + expect(parseBackup('{"deck":"dj"}')).toBeNull(); + expect(parseBackup('{"boxes":"nope"}')).toBeNull(); + expect(parseBackup("[]")).toBeNull(); + expect(parseBackup('"a string"')).toBeNull(); + expect(parseBackup("null")).toBeNull(); + }); + + it("round-trips whatever serialize produced", () => { + const state = { ...DEFAULTS, boxes: { "shift>p1@S#FIX VEL": 4 }, ctx: "D" as const }; + expect(parseBackup(serialize(state))).toEqual(state); + }); +}); diff --git a/src/domain/storage.ts b/src/domain/storage.ts new file mode 100644 index 0000000..da09466 --- /dev/null +++ b/src/domain/storage.ts @@ -0,0 +1,99 @@ +/** + * The small blob that survives a restart, and validation for pasted backups. + * + * Pure: parsing and serialising only. Where it is kept — localStorage, mirrored + * into Capacitor Preferences — is the shell's problem. + */ +import { DECKS } from "./drill"; +import { CTX } from "./labels"; +import type { Boxes, DeckId, Direction, InputMode } from "./drill"; +import type { ContextCode } from "./types"; + +/** Unchanged from Phase 1, so progress already on the phone is picked up. */ +export const STORAGE_KEY = "sp404mk2-layers-v1"; + +export interface PersistedState { + readonly boxes: Boxes; + readonly ctx: ContextCode; + readonly deck: DeckId; + readonly dir: Direction; + readonly input: InputMode; +} + +/** + * `input` defaults to "hold" rather than sniffing for a coarse pointer as the + * reference did; that is a DOM question, so the shell overrides it on first run. + */ +export const DEFAULTS: PersistedState = { + boxes: {}, + ctx: "S", + deck: "shiftpads", + dir: "play", + input: "hold", +}; + +const CONTEXTS: ReadonlySet = new Set(Object.keys(CTX)); +const DECK_IDS: ReadonlySet = new Set(Object.keys(DECKS)); +const DIRECTIONS: ReadonlySet = new Set(["play", "name"]); +const INPUTS: ReadonlySet = new Set(["hold", "tap"]); + +const isRecord = (v: unknown): v is Record => + typeof v === "object" && v !== null && !Array.isArray(v); + +/** Keeps entries that look like a box number, clamped to 1-5. */ +export function sanitizeBoxes(value: unknown): Boxes { + if (!isRecord(value)) return {}; + const clean: Record = {}; + for (const [id, box] of Object.entries(value)) { + if (typeof box !== "number" || !Number.isFinite(box)) continue; + clean[id] = Math.min(5, Math.max(1, Math.round(box))); + } + return clean; +} + +const pick = ( + value: unknown, + allowed: ReadonlySet, + fallback: T, +): T => (typeof value === "string" && allowed.has(value) ? (value as T) : fallback); + +/** Lenient: anything missing or unrecognised falls back to its default. */ +export function parsePersisted(value: unknown): PersistedState { + if (!isRecord(value)) return DEFAULTS; + return { + boxes: sanitizeBoxes(value["boxes"]), + ctx: pick(value["ctx"], CONTEXTS, DEFAULTS.ctx), + deck: pick(value["deck"], DECK_IDS, DEFAULTS.deck), + dir: pick(value["dir"], DIRECTIONS, DEFAULTS.dir), + input: pick(value["input"], INPUTS, DEFAULTS.input), + }; +} + +/** Whatever was in storage, however damaged. */ +export function parseStored(json: string | null): PersistedState { + if (json === null) return DEFAULTS; + try { + return parsePersisted(JSON.parse(json)); + } catch { + return DEFAULTS; + } +} + +/** + * Strict, for text a person pasted: it must parse to an object carrying a boxes + * object, or it is not a backup. Returns null so the UI can say so. + */ +export function parseBackup(text: string): PersistedState | null { + let value: unknown; + try { + value = JSON.parse(text); + } catch { + return null; + } + if (!isRecord(value) || !isRecord(value["boxes"])) return null; + return parsePersisted(value); +} + +export function serialize(state: PersistedState): string { + return JSON.stringify(state); +}