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:
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
112
src/domain/storage.test.ts
Normal file
112
src/domain/storage.test.ts
Normal 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
99
src/domain/storage.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user