Phase 2: persisted state and backup parsing

storage.ts owns the blob that survives a restart - boxes, ctx, deck,
dir, input - plus validation for text a person pastes into the backup
box. Pure: parsing and serialising only. Where it is kept, localStorage
mirrored into Capacitor Preferences, stays the shell's problem.

STORAGE_KEY is still sp404mk2-layers-v1, unchanged from Phase 1, so
progress already on the phone is read rather than orphaned. The test
parses a payload copied verbatim out of CapacitorStorage.xml on the
device, so a change that would break migration fails the suite.

Two levels of strictness, matching the reference: reading our own
storage is lenient and falls back per field, while a pasted backup must
be an object carrying a boxes object or it is refused outright.

input defaults to hold rather than sniffing for a coarse pointer as the
reference did - that is a DOM question, so the shell decides it.

The domain layer is now complete: entries, layers, drill, format,
search, storage. 103 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 00:57:38 +03:00
parent 43fb51396c
commit ad847faa2e
4 changed files with 223 additions and 0 deletions

View File

@@ -176,8 +176,17 @@ src/domain/
entries.ts derives Entry[] from RAW entries.ts derives Entry[] from RAW
layers.ts which combos exist for a context + held modifiers layers.ts which combos exist for a context + held modifiers
drill.ts decks, Leitner boxes, answer checking, card selection 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` **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 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 declarations in a `vm` with no DOM, and writes `raw.ts` and `labels.ts`. Re-run it

View File

@@ -19,6 +19,9 @@ export type DeckId =
/** Which way round a card is asked. */ /** Which way round a card is asked. */
export type Direction = "play" | "name"; 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; export type Box = 1 | 2 | 3 | 4 | 5;
/** Card id to box. Absent means box 1. */ /** Card id to box. Absent means box 1. */

112
src/domain/storage.test.ts Normal file
View File

@@ -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);
});
});

99
src/domain/storage.ts Normal file
View File

@@ -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<string> = new Set(Object.keys(CTX));
const DECK_IDS: ReadonlySet<string> = new Set(Object.keys(DECKS));
const DIRECTIONS: ReadonlySet<string> = new Set(["play", "name"]);
const INPUTS: ReadonlySet<string> = new Set(["hold", "tap"]);
const isRecord = (v: unknown): v is Record<string, unknown> =>
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<string, number> = {};
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 = <T extends string>(
value: unknown,
allowed: ReadonlySet<string>,
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<ContextCode>(value["ctx"], CONTEXTS, DEFAULTS.ctx),
deck: pick<DeckId>(value["deck"], DECK_IDS, DEFAULTS.deck),
dir: pick<Direction>(value["dir"], DIRECTIONS, DEFAULTS.dir),
input: pick<InputMode>(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);
}