Phase 2: domain layer with tests

Vite + React + TypeScript + Vitest. No router, state library or UI kit.

The 159-row combo table is extracted from reference/index.html by
scripts/extract-reference-data.mjs rather than retyped: it balances
brackets past string literals to slice out the pure-data declarations and
evaluates them in a vm with no DOM. raw.ts and labels.ts are generated.
The extracted count matches the 159 the Phase 1 app reported on device.

Entry.id keeps the reference's format verbatim (shift>p1@S#FIX VEL),
built from the unsplit context string, so drill progress already on the
phone migrates instead of being orphaned.

The domain is pure where the reference was not: boxes go in and new
boxes come out, and the random source is injected. That is what made
card selection testable.

Two bugs found by doing so:

- pickCard returned the card just answered when random() gave exactly 0.
  A zero-weight card does not move the accumulator, so "r <= 0" matched
  it. The reference has this; at one draw in 2^53 it is invisible in use,
  but a pinned random source hits it every time. Fixed to "r < 0".
- boxDistribution incremented through an index that
  noUncheckedIndexedAccess types as possibly undefined.

65 tests. Several assert numbers observed on the device: 159 entries,
sample-mode layers of 3 / 35 / 2, a 16-card SHIFT+pads deck.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 00:53:04 +03:00
parent 9e897a26ea
commit b32c38019e
15 changed files with 4461 additions and 5 deletions

View File

@@ -156,6 +156,48 @@ now empty and bar styling is configured in `capacitor.config.json`
installs over Phase 1's without an uninstall and drill progress survives. installs over Phase 1's without an uninstall and drill progress survives.
`appId` is `dev.ncode.sp404layers` and must not change for the same reason. `appId` is `dev.ncode.sp404layers` and must not change for the same reason.
## Phase 2: the React port
Stack: **Vite + React + TypeScript + Vitest**. No router, no state library, no UI
kit — the app is four tabs and a reducer, and none of those earn their keep.
| Command | What it does |
|---|---|
| `npm test` | Run the domain suite once |
| `npm run test:watch` | Watch mode |
| `npm run typecheck` | `tsc --noEmit` |
| `npm run extract:data` | Regenerate the combo table from `reference/` |
```
src/domain/
types.ts hand-written: RawEntry, Entry, ContextCode, GroupId
raw.ts GENERATED — the 159-row combo table
labels.ts GENERATED — control labels, groups, contexts
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
```
**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
after any reference change; never edit those two files. Retyping 159 rows is
precisely where silent errors would creep in, and the extraction cross-checks:
159 entries, matching the count the Phase 1 app reported on the device.
**`Entry.id` keeps the reference's format verbatim** (`shift>p1@S#FIX VEL`) — built
from the *unsplit* context string and the target as authored. That string is the
key drill progress is stored under, so preserving it byte-for-byte means existing
boxes on the phone migrate to the React app instead of being orphaned.
**The domain layer is pure.** The reference mutated a module-level `store` and
called `Math.random()` inline; here boxes go in and new boxes come out, and the
random source is injected. That is what makes card selection testable at all.
Tests assert against numbers **observed on the device**, not just against the
code: 159 entries, sample-mode layer counts of 3 / 35 / 2 for no-modifier /
SHIFT / SHIFT+REVERSE, and a 16-card SHIFT+pads deck.
## Visual spec (settled — build Phase 2 against this) ## Visual spec (settled — build Phase 2 against this)
The look is **Direction A, "manual"**, after Teenage Engineering's EP-133 manual, The look is **Direction A, "manual"**, after Teenage Engineering's EP-133 manual,

1561
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@
"name": "sp404-layers", "name": "sp404-layers",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"type": "module",
"description": "SP-404MK2 combo trainer, wrapped as an Android app", "description": "SP-404MK2 combo trainer, wrapped as an Android app",
"scripts": { "scripts": {
"sync:www": "bash scripts/sync-www.sh", "sync:www": "bash scripts/sync-www.sh",
@@ -13,16 +14,28 @@
"device:sleep-normal": "bash scripts/device.sh sleep-normal", "device:sleep-normal": "bash scripts/device.sh sleep-normal",
"device:launch": "bash scripts/device.sh launch", "device:launch": "bash scripts/device.sh launch",
"run:android": "npm run apk && npm run install:apk && npm run device:launch", "run:android": "npm run apk && npm run install:apk && npm run device:launch",
"logcat": "adb logcat -s Capacitor:V Capacitor/Console:V chromium:V" "logcat": "adb logcat -s Capacitor:V Capacitor/Console:V chromium:V",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"extract:data": "node scripts/extract-reference-data.mjs"
}, },
"dependencies": { "dependencies": {
"@capacitor/android": "^8.5.2", "@capacitor/android": "^8.5.2",
"@capacitor/app": "^8.1.1", "@capacitor/app": "^8.1.1",
"@capacitor/core": "^8.5.2", "@capacitor/core": "^8.5.2",
"@capacitor/haptics": "^8.0.2", "@capacitor/haptics": "^8.0.2",
"@capacitor/preferences": "^8.0.1" "@capacitor/preferences": "^8.0.1",
"react": "^19.3.0",
"react-dom": "^19.3.0"
}, },
"devDependencies": { "devDependencies": {
"@capacitor/cli": "^8.5.2" "@capacitor/cli": "^8.5.2",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1",
"typescript": "^7.0.2",
"vite": "^8.3.0",
"vitest": "^5.0.1"
} }
} }

View File

@@ -0,0 +1,83 @@
// Extracts the combo table and label maps from reference/index.html into TypeScript.
//
// The reference is the data spec, so the 159 entries are never retyped by hand:
// this lifts the literals straight out of it and re-runs whenever it changes.
// It reads only pure-data declarations, evaluated in a vm with no DOM.
import { readFileSync, writeFileSync } from "node:fs";
import vm from "node:vm";
const SRC = "reference/index.html";
const src = readFileSync(SRC, "utf8");
// Balance brackets from a declaration's first opener, skipping string literals so
// that braces or quotes inside descriptions cannot end the scan early.
function literalAfter(name) {
const at = src.indexOf(`const ${name}=`);
if (at < 0) throw new Error(`declaration not found: ${name}`);
let i = src.indexOf("=", at) + 1;
while (/\s/.test(src[i])) i++;
const start = i;
let depth = 0, quote = null;
for (; i < src.length; i++) {
const c = src[i];
if (quote) {
if (c === "\\") { i++; continue; }
if (c === quote) quote = null;
continue;
}
if (c === '"' || c === "'" || c === "`") { quote = c; continue; }
if (c === "{" || c === "[" || c === "(") depth++;
else if (c === "}" || c === "]" || c === ")") {
depth--;
if (depth === 0) return src.slice(start, i + 1);
}
}
throw new Error(`unbalanced literal: ${name}`);
}
const NAMES = ["L", "PL", "GROUPS", "GNAME", "NOLATCH", "MODORDER", "RAW", "CTX", "CTXHINT"];
const ctx = vm.createContext({});
for (const n of NAMES) vm.runInContext(`var ${n} = ${literalAfter(n)};`, ctx);
// The reference adds the 16 pad labels in a loop right after declaring L.
vm.runInContext(`for (let i = 1; i <= 16; i++) L["p" + i] = "PAD " + i;`, ctx);
const g = (n) => vm.runInContext(n, ctx);
const header = (what) =>
`// GENERATED from ${SRC} by scripts/extract-reference-data.mjs — do not edit by hand.\n` +
`// ${what}\n\n`;
const j = (v) => JSON.stringify(v, null, 2);
writeFileSync(
"src/domain/raw.ts",
header("The combo table, exactly as the reference declares it.") +
`import type { RawEntry } from "./types";\n\nexport const RAW: RawEntry[] = ${j(g("RAW"))};\n`
);
writeFileSync(
"src/domain/labels.ts",
header("Control labels, groupings and context names.") +
`import type { ControlId, ContextCode, GroupId } from "./types";\n\n` +
`/** Full name printed on each control. */\n` +
`export const L: Record<ControlId, string> = ${j(g("L"))};\n\n` +
`/** Shorter labels used on the panel where the full one will not fit. */\n` +
`export const PL: Partial<Record<ControlId, string>> = ${j(g("PL"))};\n\n` +
`/** A target may name a whole group of controls rather than one. */\n` +
`export const GROUPS: Record<GroupId, ControlId[]> = ${j(g("GROUPS"))};\n\n` +
`export const GNAME: Record<GroupId, string> = ${j(g("GNAME"))};\n\n` +
`/** Controls that never latch when held: the knobs and the pads. */\n` +
`export const NOLATCH: ControlId[] = ${j([...g("NOLATCH")])};\n\n` +
`/** Canonical modifier order, so a layer key is stable however it was pressed. */\n` +
`export const MODORDER: ControlId[] = ${j(g("MODORDER"))};\n\n` +
`export const CTX: Record<ContextCode, string> = ${j(g("CTX"))};\n\n` +
`export const CTXHINT: Record<ContextCode, string> = ${j(g("CTXHINT"))};\n`
);
const raw = g("RAW");
console.log(`RAW entries ${raw.length}`);
console.log(`control labels ${Object.keys(g("L")).length}`);
console.log(`contexts ${Object.keys(g("CTX")).join(", ")}`);
console.log(`groups ${Object.keys(g("GROUPS")).join(", ")}`);
console.log(`entries with how ${raw.filter((r) => r.length > 7).length}`);
console.log(`group targets ${raw.filter((r) => typeof r[1] === "string" && g("GROUPS")[r[1]]).length}`);
console.log(`array targets ${raw.filter((r) => Array.isArray(r[1])).length}`);

207
src/domain/drill.test.ts Normal file
View File

@@ -0,0 +1,207 @@
import { describe, expect, it } from "vitest";
import {
DECKS,
boxDistribution,
boxOf,
deckPool,
gradeBoxes,
isCorrect,
isPressable,
knownCount,
nextBox,
pickCard,
pickWeight,
} from "./drill";
import { ENTRIES } from "./entries";
import type { Boxes } from "./drill";
import type { Entry } from "./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");
const padMute = byName("Pad mute"); // shift + reverse, targets all 16 pads
describe("decks", () => {
it("puts all sixteen SHIFT + pad combos in the pads deck", () => {
// The device showed "0 of 16 known" for this deck.
expect(deckPool(ENTRIES, "shiftpads", "play")).toHaveLength(16);
});
it("keeps the two decks disjoint", () => {
const pads = new Set(deckPool(ENTRIES, "shiftpads", "name").map((e) => e.id));
const buttons = deckPool(ENTRIES, "shiftbtn", "name");
expect(buttons.some((e) => pads.has(e.id))).toBe(false);
});
it("includes everything in the everything deck", () => {
expect(deckPool(ENTRIES, "all", "name")).toHaveLength(ENTRIES.length);
});
it("names every deck", () => {
for (const deck of Object.values(DECKS)) expect(deck.name).not.toBe("");
});
});
describe("isPressable", () => {
it("rejects combos on the CTRL knobs, which are turned not pressed", () => {
const knobs = ENTRIES.filter((e) => e.target === "ctrls");
expect(knobs.length).toBeGreaterThan(0);
for (const e of knobs) expect(isPressable(e)).toBe(false);
});
it("rejects gestures the table describes in prose", () => {
expect(isPressable(byName("Panic stop"))).toBe(false); // "Tap EXIT four times fast"
expect(isPressable(byName("Fine CTRL 1"))).toBe(false); // "Hold SHIFT, turn CTRL 1"
});
it("rejects unmodified combos that light a whole group", () => {
const groupy = ENTRIES.filter((e) => e.mods.length === 0 && e.tset.length > 1);
for (const e of groupy) expect(isPressable(e)).toBe(false);
});
it("accepts an ordinary two-control combo", () => {
expect(isPressable(fixedVelocity)).toBe(true);
});
it("only narrows the pool when asking play-first", () => {
const play = deckPool(ENTRIES, "all", "play").length;
const name = deckPool(ENTRIES, "all", "name").length;
expect(play).toBeLessThan(name);
});
});
describe("isCorrect", () => {
it("accepts the modifier plus the right target", () => {
expect(isCorrect(fixedVelocity, ["shift", "p1"])).toBe(true);
});
it("does not care about the order fingers went down", () => {
expect(isCorrect(fixedVelocity, ["p1", "shift"])).toBe(true);
});
it("rejects a missing modifier", () => {
expect(isCorrect(fixedVelocity, ["p1"])).toBe(false);
});
it("rejects the wrong target", () => {
expect(isCorrect(fixedVelocity, ["shift", "p2"])).toBe(false);
});
it("rejects extra controls beyond the one target", () => {
expect(isCorrect(fixedVelocity, ["shift", "p1", "p2"])).toBe(false);
});
it("rejects the modifiers alone", () => {
expect(isCorrect(fixedVelocity, ["shift"])).toBe(false);
});
it("accepts any control in a group target", () => {
expect(isCorrect(padMute, ["shift", "reverse", "p5"])).toBe(true);
expect(isCorrect(padMute, ["shift", "reverse", "p16"])).toBe(true);
expect(isCorrect(padMute, ["shift", "reverse", "exit"])).toBe(false);
});
it("rejects an empty chord", () => {
expect(isCorrect(fixedVelocity, [])).toBe(false);
});
});
describe("boxes", () => {
it("treats a card never seen as box 1", () => {
expect(boxOf({}, fixedVelocity)).toBe(1);
});
it("promotes one box when right and resets to 1 when wrong", () => {
expect(nextBox(1, true)).toBe(2);
expect(nextBox(4, true)).toBe(5);
expect(nextBox(5, true)).toBe(5);
expect(nextBox(4, false)).toBe(1);
expect(nextBox(1, false)).toBe(1);
});
it("returns new boxes rather than mutating the ones given", () => {
const before: Boxes = { [fixedVelocity.id]: 2 };
const after = gradeBoxes(before, fixedVelocity, true);
expect(after[fixedVelocity.id]).toBe(3);
expect(before[fixedVelocity.id]).toBe(2);
expect(after).not.toBe(before);
});
it("clamps stored values that are out of range", () => {
expect(boxOf({ [fixedVelocity.id]: 99 }, fixedVelocity)).toBe(5);
expect(boxOf({ [fixedVelocity.id]: 0 }, fixedVelocity)).toBe(1);
expect(boxOf({ [fixedVelocity.id]: -3 }, fixedVelocity)).toBe(1);
});
});
describe("choosing the next card", () => {
const pool = deckPool(ENTRIES, "shiftpads", "play");
it("weighs a new card sixteen times a known one", () => {
const known: Boxes = { [pool[0]!.id]: 5 };
expect(pickWeight(pool[0]!, known, null, pool.length)).toBe(1);
expect(pickWeight(pool[1]!, {}, null, pool.length)).toBe(16);
});
it("never picks the card just answered", () => {
const last = pool[0]!;
// Lowest possible draw would otherwise land on the first card.
expect(pickCard(pool, {}, last, () => 0)).not.toBe(last);
});
it("does repeat when the deck holds a single card", () => {
const only = [pool[0]!];
expect(pickCard(only, {}, only[0]!, () => 0)).toBe(only[0]);
});
it("is deterministic given a fixed random source", () => {
expect(pickCard(pool, {}, null, () => 0)).toBe(pool[0]);
expect(pickCard(pool, {}, null, () => 0.999999)).toBe(pool[pool.length - 1]);
});
it("favours the box a card sits in", () => {
// Two cards: the first known, the second new. Weights are 1 and 16, so a
// draw just past the first card's share must land on the second.
const two = [pool[0]!, pool[1]!];
const boxes: Boxes = { [two[0]!.id]: 5 };
expect(pickCard(two, boxes, null, () => 0)).toBe(two[0]);
expect(pickCard(two, boxes, null, () => 0.5)).toBe(two[1]);
});
it("returns nothing for an empty deck", () => {
expect(pickCard([], {}, null, () => 0)).toBeNull();
});
});
describe("progress", () => {
const pool = deckPool(ENTRIES, "shiftpads", "play");
it("starts with everything in box 1", () => {
expect(boxDistribution(pool, {})).toEqual([16, 0, 0, 0, 0]);
expect(knownCount(pool, {})).toBe(0);
});
it("always sums to the size of the deck", () => {
const boxes: Boxes = { [pool[0]!.id]: 5, [pool[1]!.id]: 3 };
const dist = boxDistribution(pool, boxes);
expect(dist.reduce((a, b) => a + b, 0)).toBe(pool.length);
});
it("counts only the top box as known", () => {
const boxes: Boxes = { [pool[0]!.id]: 5, [pool[1]!.id]: 4 };
expect(knownCount(pool, boxes)).toBe(1);
});
it("moves a card up a box at a time as it is answered", () => {
let boxes: Boxes = {};
for (let i = 0; i < 4; i++) boxes = gradeBoxes(boxes, pool[0]!, true);
expect(knownCount(pool, boxes)).toBe(1);
boxes = gradeBoxes(boxes, pool[0]!, false);
expect(knownCount(pool, boxes)).toBe(0);
expect(boxOf(boxes, pool[0]!)).toBe(1);
});
});

161
src/domain/drill.ts Normal file
View File

@@ -0,0 +1,161 @@
/**
* Leitner drill: boxes 1 (new or just missed) to 5 (known). Right promotes one
* box, wrong resets to 1, and selection weight halves per box.
*
* All pure: boxes in, new boxes out, random injected. Angular: no stateful
* service — these are a reducer's helpers.
*/
import type { ControlId, Entry } from "./types";
export type DeckId =
| "shiftpads"
| "shiftbtn"
| "mods"
| "pattern"
| "dj"
| "edit"
| "all";
/** Which way round a card is asked. */
export type Direction = "play" | "name";
export type Box = 1 | 2 | 3 | 4 | 5;
/** Card id to box. Absent means box 1. */
export type Boxes = Readonly<Record<string, number>>;
export interface Deck {
readonly name: string;
readonly includes: (entry: Entry) => boolean;
}
const isSinglePad = (entry: Entry): boolean => /^p\d+$/.test(String(entry.target));
export const DECKS: Readonly<Record<DeckId, Deck>> = {
shiftpads: {
name: "SHIFT + pads",
includes: (e) => e.key === "shift" && isSinglePad(e) && e.ctx.includes("S"),
},
shiftbtn: {
name: "SHIFT + buttons",
includes: (e) => e.key.startsWith("shift") && !isSinglePad(e) && e.ctx.includes("S"),
},
mods: {
name: "REMAIN, VALUE, DEL, COPY, MFX",
includes: (e) =>
/^(remain|value|del|copy|mfx)/.test(e.key) &&
e.ctx.some((c) => c === "S" || c === "E"),
},
pattern: {
name: "Pattern + TR-REC",
includes: (e) => e.ctx.some((c) => c === "P" || c === "T"),
},
dj: { name: "DJ mode", includes: (e) => e.ctx.includes("D") },
edit: {
name: "Edit, looper, chromatic",
includes: (e) => e.ctx.some((c) => c === "E" || c === "L" || c === "X"),
},
all: { name: "Everything", includes: () => true },
};
/**
* Can this be answered by pressing it? Not the CTRL knobs (turned), not gestures
* described in prose (a turn, four taps), not unmodified whole-group targets
* where no single control is right. Those are asked name-first only.
*/
export function isPressable(entry: Entry): boolean {
if (entry.target === "ctrls") return false;
if (entry.how && /turn|four|tap pads/i.test(entry.how)) return false;
if (entry.mods.length === 0 && entry.tset.length > 1) return false;
return true;
}
export function deckPool(
entries: readonly Entry[],
deck: DeckId,
direction: Direction,
): Entry[] {
const inDeck = entries.filter(DECKS[deck].includes);
return direction === "name" ? inDeck : inDeck.filter(isPressable);
}
/** Unseen is box 1. Out-of-range stored values are clamped, not trusted. */
export function boxOf(boxes: Boxes, entry: Entry): Box {
const box = boxes[entry.id] ?? 1;
if (box <= 1) return 1;
if (box >= 5) return 5;
return Math.round(box) as Box;
}
export function nextBox(box: Box, correct: boolean): Box {
if (!correct) return 1;
return Math.min(5, box + 1) as Box;
}
/** Returns new boxes; never mutates the ones given. */
export function gradeBoxes(boxes: Boxes, entry: Entry, correct: boolean): Boxes {
return { ...boxes, [entry.id]: nextBox(boxOf(boxes, entry), correct) };
}
/**
* Every modifier held, plus exactly one other control that the entry lights up.
* Order is irrelevant: the chord is what was down when the last finger lifted.
*/
export function isCorrect(entry: Entry, chord: readonly ControlId[]): boolean {
const held = new Set(chord);
if (!entry.mods.every((m) => held.has(m))) return false;
const rest = [...held].filter((c) => !entry.mods.includes(c));
return rest.length === 1 && entry.tset.includes(rest[0]!);
}
/** Box 1 weighs 16, box 5 weighs 1. The card just answered weighs nothing. */
export function pickWeight(
entry: Entry,
boxes: Boxes,
last: Entry | null,
poolSize: number,
): number {
if (entry === last && poolSize > 1) return 0;
return 2 ** (5 - boxOf(boxes, entry));
}
/** Weighted random. `random` is injected so tests can pin it; returns [0, 1). */
export function pickCard(
pool: readonly Entry[],
boxes: Boxes,
last: Entry | null = null,
random: () => number = Math.random,
): Entry | null {
if (pool.length === 0) return null;
const weights = pool.map((e) => pickWeight(e, boxes, last, pool.length));
const total = weights.reduce((a, b) => a + b, 0);
if (total === 0) return pool[pool.length - 1]!;
let r = random() * total;
for (let i = 0; i < pool.length; i++) {
r -= weights[i]!;
// `< 0`, not `<= 0`: a zero-weight card doesn't move `r`, so `<= 0` returns
// the card just answered when random() gives exactly 0. The reference does.
if (r < 0) return pool[i]!;
}
return pool[pool.length - 1]!;
}
/** Cards per box, for the progress bars. */
export function boxDistribution(
pool: readonly Entry[],
boxes: Boxes,
): [number, number, number, number, number] {
const counts: [number, number, number, number, number] = [0, 0, 0, 0, 0];
for (const entry of pool) {
const slot = boxOf(boxes, entry) - 1;
counts[slot] = (counts[slot] ?? 0) + 1;
}
return counts;
}
/** Cards in the top box — what "12 of 40 known" counts. */
export function knownCount(pool: readonly Entry[], boxes: Boxes): number {
return boxDistribution(pool, boxes)[4];
}

130
src/domain/entries.test.ts Normal file
View File

@@ -0,0 +1,130 @@
import { describe, expect, it } from "vitest";
import { ENTRIES, deriveEntry, expandTarget, layerKeyOf, sortMods } from "./entries";
import { GROUPS } from "./labels";
import { RAW } from "./raw";
import type { RawEntry } from "./types";
/**
* These assert against the real table rather than fixtures. Several expected
* numbers were read off the running Phase 1 app on the device, so they check the
* port against observed behaviour, not just against itself.
*/
describe("the table", () => {
it("has every row the reference has", () => {
expect(RAW).toHaveLength(159);
expect(ENTRIES).toHaveLength(159);
});
it("gives every entry a unique id", () => {
const ids = new Set(ENTRIES.map((e) => e.id));
expect(ids.size).toBe(ENTRIES.length);
});
it("only uses known context codes", () => {
const known = new Set(["S", "E", "P", "T", "L", "D", "X"]);
for (const e of ENTRIES) {
expect(e.ctx.length).toBeGreaterThan(0);
for (const c of e.ctx) expect(known).toContain(c);
}
});
it("expands every target to at least one real control", () => {
for (const e of ENTRIES) expect(e.tset.length).toBeGreaterThan(0);
});
});
describe("sortMods", () => {
it("puts modifiers in canonical order however they were pressed", () => {
expect(sortMods(["reverse", "shift"])).toEqual(["shift", "reverse"]);
expect(sortMods(["shift", "reverse"])).toEqual(["shift", "reverse"]);
});
it("does not mutate its argument", () => {
const held = ["reverse", "shift"];
sortMods(held);
expect(held).toEqual(["reverse", "shift"]);
});
it("makes the layer key independent of press order", () => {
expect(layerKeyOf(["reverse", "shift"])).toBe("shift+reverse");
expect(layerKeyOf(["shift", "reverse"])).toBe("shift+reverse");
});
it("treats no modifiers as the empty key, which is a real layer", () => {
expect(layerKeyOf([])).toBe("");
});
});
describe("expandTarget", () => {
it("leaves a single control alone", () => {
expect(expandTarget("p1")).toEqual(["p1"]);
});
it("expands a group name to its controls", () => {
expect(expandTarget("pads")).toEqual(GROUPS.pads);
expect(expandTarget("pads")).toHaveLength(16);
expect(expandTarget("banks")).toHaveLength(5);
});
it("keeps an explicit list as given", () => {
expect(expandTarget(["p13", "p15"])).toEqual(["p13", "p15"]);
});
it("returns a copy, so callers cannot corrupt GROUPS", () => {
const expanded = expandTarget("pads");
expanded.push("nonsense");
expect(GROUPS.pads).toHaveLength(16);
});
});
describe("a derived entry", () => {
const fixedVelocity = ENTRIES.find((e) => e.name === "Fixed velocity");
it("carries the reference's fields through unchanged", () => {
expect(fixedVelocity).toBeDefined();
expect(fixedVelocity!.mods).toEqual(["shift"]);
expect(fixedVelocity!.key).toBe("shift");
expect(fixedVelocity!.tset).toEqual(["p1"]);
expect(fixedVelocity!.short).toBe("FIX VEL");
expect(fixedVelocity!.page).toBe(24);
expect(fixedVelocity!.ctx).toEqual(["S"]);
});
it("uses the stored-progress id format verbatim", () => {
// Drill boxes on the device are keyed by this exact string, e.g.
// "shift>p6@S#INIT" was read out of CapacitorStorage.xml during Phase 1.
expect(fixedVelocity!.id).toBe("shift>p1@S#FIX VEL");
const init = ENTRIES.find((e) => e.name === "Init sample params");
expect(init!.id).toBe("shift>p6@S#INIT");
});
it("builds the id from the unsplit context string", () => {
const stop = ENTRIES.find((e) => e.short === "STOP ×4");
expect(stop!.ctx).toEqual(["S", "P", "D", "L"]);
expect(stop!.id).toBe(">exit@S P D L#STOP ×4");
});
it("joins an explicit multi-control target with a slash in the id", () => {
const reset = ENTRIES.find((e) => e.short === "CH1 RST");
expect(reset!.tset).toEqual(["p2", "p6"]);
expect(reset!.id).toBe("del>p2/p6@D#CH1 RST");
});
it("omits how entirely when the reference gave none", () => {
const withHow = ENTRIES.find((e) => e.short === "SAVE FX");
expect(withHow!.how).toBe("Hold SHIFT, hold MARK 3 s");
expect("how" in fixedVelocity!).toBe(false);
});
it("sorts an entry's own modifiers", () => {
const entry = deriveEntry(
[["reverse", "shift"], "pads", "S P", "MUTE", "Pad mute", "", 26] as RawEntry,
0,
);
expect(entry.mods).toEqual(["shift", "reverse"]);
expect(entry.key).toBe("shift+reverse");
expect(entry.tset).toHaveLength(16);
});
});

78
src/domain/entries.ts Normal file
View File

@@ -0,0 +1,78 @@
/**
* Derives `Entry[]` from the raw table, mirroring the reference.
* The `id` format is load-bearing: it keys stored drill progress.
*/
import { GROUPS, MODORDER } from "./labels";
import { RAW } from "./raw";
import type { ContextCode, ControlId, Entry, GroupId, RawEntry } from "./types";
const GROUP_IDS: ReadonlySet<string> = new Set(Object.keys(GROUPS));
const isGroupId = (value: string): value is GroupId => GROUP_IDS.has(value);
/**
* Sorts modifiers into the canonical order.
*
* Holding SHIFT then REVERSE must produce the same layer as REVERSE then SHIFT,
* so every place that builds a layer key sorts first. Controls missing from
* MODORDER get `indexOf` of -1 and sort to the front, which is the reference's
* behaviour and is relied upon by nothing — no entry uses such a modifier.
*/
export function sortMods(mods: readonly ControlId[]): ControlId[] {
return [...mods].sort((a, b) => MODORDER.indexOf(a) - MODORDER.indexOf(b));
}
/**
* The identity of a layer: sorted modifiers joined with `+`.
* The empty string is the no-modifier layer, which is a real layer with entries
* on it, not an absence.
*/
export function layerKeyOf(mods: readonly ControlId[]): string {
return sortMods(mods).join("+");
}
/**
* Expands a target into the concrete controls it lights up.
*
* A target is one control (`"p1"`), the name of a group (`"pads"`, meaning all
* sixteen), or an explicit list (`["p13", "p15"]`).
*/
export function expandTarget(target: RawEntry[1]): ControlId[] {
if (Array.isArray(target)) return [...target];
return isGroupId(target) ? [...GROUPS[target]] : [target];
}
export function deriveEntry(raw: RawEntry, index: number): Entry {
const [mods, target, ctx, short, name, desc, page, how] = raw;
const sorted = sortMods(mods);
const key = sorted.join("+");
// Built from the *unsplit* ctx string and the target as authored, exactly as
// the reference does. Preserved verbatim so stored progress keeps matching.
const id = `${key}>${[target].flat().join("/")}@${ctx}#${short}`;
const entry = {
index,
mods: sorted,
key,
target,
tset: expandTarget(target),
ctx: ctx.split(" ") as ContextCode[],
short,
name,
desc,
page,
id,
};
// `exactOptionalPropertyTypes` means an absent `how` must be absent, not
// explicitly undefined.
return how === undefined ? entry : { ...entry, how };
}
export function deriveEntries(raw: readonly RawEntry[] = RAW): Entry[] {
return raw.map(deriveEntry);
}
/** Every combo in the table, derived once. */
export const ENTRIES: readonly Entry[] = deriveEntries();

186
src/domain/labels.ts Normal file
View File

@@ -0,0 +1,186 @@
// GENERATED from reference/index.html by scripts/extract-reference-data.mjs — do not edit by hand.
// Control labels, groupings and context names.
import type { ControlId, ContextCode, GroupId } from "./types";
/** Full name printed on each control. */
export const L: Record<ControlId, string> = {
"vol": "VOLUME",
"c1": "CTRL 1",
"c2": "CTRL 2",
"c3": "CTRL 3",
"fx_fd": "FILTER +DRIVE",
"fx_res": "RESONATOR",
"fx_dly": "DELAY",
"fx_iso": "ISOLATOR",
"fx_djl": "DJFX LOOPER",
"mfx": "MFX",
"pat_sel": "PATTERN SELECT",
"pat_edit": "PATTERN EDIT",
"rec_set": "RECORD SETTING",
"del": "DEL",
"rec": "REC",
"resample": "RESAMPLE",
"exit": "EXIT",
"copy": "COPY",
"remain": "REMAIN",
"start_end": "START/END",
"pitch": "PITCH/SPEED",
"mark": "MARK",
"value": "VALUE",
"bpm_sync": "BPM SYNC",
"gate": "GATE",
"loop": "LOOP",
"reverse": "REVERSE",
"roll": "ROLL",
"bank_a": "A/F",
"bank_b": "B/G",
"bank_c": "C/H",
"bank_d": "D/I",
"bank_e": "E/J",
"shift": "SHIFT",
"busfx": "BUS FX",
"hold": "HOLD",
"ext": "EXT SOURCE",
"subpad": "SUB PAD",
"p1": "PAD 1",
"p2": "PAD 2",
"p3": "PAD 3",
"p4": "PAD 4",
"p5": "PAD 5",
"p6": "PAD 6",
"p7": "PAD 7",
"p8": "PAD 8",
"p9": "PAD 9",
"p10": "PAD 10",
"p11": "PAD 11",
"p12": "PAD 12",
"p13": "PAD 13",
"p14": "PAD 14",
"p15": "PAD 15",
"p16": "PAD 16"
};
/** Shorter labels used on the panel where the full one will not fit. */
export const PL: Partial<Record<ControlId, string>> = {
"pat_sel": "PTN SEL",
"pat_edit": "PTN EDIT",
"rec_set": "REC SET",
"resample": "RE­SAMPLE",
"reverse": "REV­ERSE",
"remain": "RE­MAIN",
"start_end": "START /END",
"pitch": "PITCH /SPEED"
};
/** A target may name a whole group of controls rather than one. */
export const GROUPS: Record<GroupId, ControlId[]> = {
"pads": [
"p1",
"p2",
"p3",
"p4",
"p5",
"p6",
"p7",
"p8",
"p9",
"p10",
"p11",
"p12",
"p13",
"p14",
"p15",
"p16"
],
"ctrls": [
"c1",
"c2",
"c3"
],
"effects": [
"fx_fd",
"fx_res",
"fx_dly",
"fx_iso",
"fx_djl",
"mfx"
],
"banks": [
"bank_a",
"bank_b",
"bank_c",
"bank_d",
"bank_e"
]
};
export const GNAME: Record<GroupId, string> = {
"pads": "PAD 1–16",
"ctrls": "CTRL 1–3",
"effects": "FX button",
"banks": "BANK A–E"
};
/** Controls that never latch when held: the knobs and the pads. */
export const NOLATCH: ControlId[] = [
"vol",
"c1",
"c2",
"c3",
"p1",
"p2",
"p3",
"p4",
"p5",
"p6",
"p7",
"p8",
"p9",
"p10",
"p11",
"p12",
"p13",
"p14",
"p15",
"p16"
];
/** Canonical modifier order, so a layer key is stable however it was pressed. */
export const MODORDER: ControlId[] = [
"shift",
"reverse",
"remain",
"value",
"del",
"copy",
"mfx",
"roll",
"hold",
"subpad",
"pat_edit",
"mark",
"pitch",
"resample",
"bank_d"
];
export const CTX: Record<ContextCode, string> = {
"S": "Sample mode",
"E": "Edit screens",
"P": "Pattern",
"T": "TR-REC",
"L": "Looper",
"D": "DJ mode",
"X": "Chromatic / synth"
};
export const CTXHINT: Record<ContextCode, string> = {
"S": "Top screen, pads orange.",
"E": "START/END, marker, PITCH/SPEED and envelope screens.",
"P": "PATTERN SELECT lit, pads purple. Includes real-time recording and the chain editor.",
"T": "Step recording (REC in pattern mode, REMAIN until it blinks).",
"L": "MARK function set to Looper, then MARK.",
"D": "Press D/I and E/J together.",
"X": "SHIFT + pad 4, or SHIFT + RECORD SETTING."
};

127
src/domain/layers.test.ts Normal file
View File

@@ -0,0 +1,127 @@
import { describe, expect, it } from "vitest";
import { ENTRIES } from "./entries";
import {
contextsPresent,
entriesByTarget,
entriesForHeld,
entriesForLayer,
layerCounts,
orderLayerKeys,
} from "./layers";
/**
* The counts below were read off the running Phase 1 app on the phone — the
* layer picker showed "No modifier 3", "SHIFT 35", "SHIFT + REVERSE 2" in sample
* mode. If the port changes them, it has changed behaviour.
*/
describe("entriesForLayer", () => {
it("matches the counts the device shows in sample mode", () => {
expect(entriesForLayer(ENTRIES, "S", "")).toHaveLength(3);
expect(entriesForLayer(ENTRIES, "S", "shift")).toHaveLength(35);
expect(entriesForLayer(ENTRIES, "S", "shift+reverse")).toHaveLength(2);
});
it("finds the three functions on the bare sample-mode layer", () => {
const shorts = entriesForLayer(ENTRIES, "S", "").map((e) => e.short).sort();
expect(shorts).toEqual(["INFO", "SKIPBACK", "STOP ×4"]);
});
it("gives a different set for the same layer in another context", () => {
const inSample = entriesForLayer(ENTRIES, "S", "");
const inPattern = entriesForLayer(ENTRIES, "P", "");
expect(inPattern).not.toHaveLength(inSample.length);
expect(inPattern.length).toBeGreaterThan(0);
});
it("returns nothing for a layer that carries no functions", () => {
expect(entriesForLayer(ENTRIES, "S", "mfx+roll")).toEqual([]);
});
});
describe("entriesForHeld", () => {
it("does not care what order the modifiers went down in", () => {
const one = entriesForHeld(ENTRIES, "S", ["shift", "reverse"]);
const other = entriesForHeld(ENTRIES, "S", ["reverse", "shift"]);
expect(one).toHaveLength(2);
expect(other).toEqual(one);
});
it("treats holding nothing as the no-modifier layer", () => {
expect(entriesForHeld(ENTRIES, "S", [])).toHaveLength(3);
});
});
describe("layerCounts", () => {
const counts = layerCounts(ENTRIES, "S");
it("counts each layer in a context", () => {
expect(counts.get("")).toBe(3);
expect(counts.get("shift")).toBe(35);
expect(counts.get("shift+reverse")).toBe(2);
});
it("sums to the number of entries in that context", () => {
const total = [...counts.values()].reduce((a, b) => a + b, 0);
const inContext = ENTRIES.filter((e) => e.ctx.includes("S")).length;
expect(total).toBe(inContext);
});
it("omits layers belonging to other contexts", () => {
expect(layerCounts(ENTRIES, "S").has("pat_edit")).toBe(false);
expect(layerCounts(ENTRIES, "T").has("pat_edit")).toBe(true);
});
});
describe("orderLayerKeys", () => {
it("puts the no-modifier layer first however the input was ordered", () => {
expect(orderLayerKeys(["shift", "", "remain"])[0]).toBe("");
});
it("orders by MODORDER, then puts shorter combinations first", () => {
expect(orderLayerKeys(["shift+reverse", "shift"])).toEqual([
"shift",
"shift+reverse",
]);
// shift precedes remain in MODORDER
expect(orderLayerKeys(["remain", "shift"])).toEqual(["shift", "remain"]);
});
it("produces the order the picker showed on the device", () => {
const keys = orderLayerKeys(layerCounts(ENTRIES, "S").keys());
expect(keys.slice(0, 3)).toEqual(["", "shift", "shift+reverse"]);
});
});
describe("entriesByTarget", () => {
it("maps each lit control to the entries on it", () => {
const byTarget = entriesByTarget(entriesForLayer(ENTRIES, "S", ""));
expect([...byTarget.keys()].sort()).toEqual(["exit", "mark", "remain"]);
expect(byTarget.get("exit")!.map((e) => e.short)).toEqual(["STOP ×4"]);
});
it("spreads a group target across every control in the group", () => {
const byTarget = entriesByTarget(entriesForLayer(ENTRIES, "S", "shift+reverse"));
expect(byTarget.get("p1")).toHaveLength(1);
expect(byTarget.get("p16")).toHaveLength(1);
});
it("collects several entries when a control carries more than one", () => {
const stacked = [...entriesByTarget(ENTRIES).values()].filter((l) => l.length > 1);
expect(stacked.length).toBeGreaterThan(0);
});
});
describe("contextsPresent", () => {
it("finds every context the table uses", () => {
expect([...contextsPresent(ENTRIES)].sort()).toEqual([
"D",
"E",
"L",
"P",
"S",
"T",
"X",
]);
});
});

81
src/domain/layers.ts Normal file
View File

@@ -0,0 +1,81 @@
/**
* Which combos exist for a context and a set of held modifiers — the heart of
* Explore. Same modifiers, different context, different set.
*
* Angular: no pipes or computed signals here. Derived data is a plain function
* call during render; memoising it is an explicit `useMemo`, not automatic.
*/
import { layerKeyOf } from "./entries";
import { MODORDER } from "./labels";
import type { ContextCode, ControlId, Entry } from "./types";
/** Entries on one layer of one context. */
export function entriesForLayer(
entries: readonly Entry[],
ctx: ContextCode,
key: string,
): Entry[] {
return entries.filter((e) => e.ctx.includes(ctx) && e.key === key);
}
/** Entries on the layer currently being held. */
export function entriesForHeld(
entries: readonly Entry[],
ctx: ContextCode,
held: readonly ControlId[],
): Entry[] {
return entriesForLayer(entries, ctx, layerKeyOf(held));
}
/** How many functions sit on each layer of a context. */
export function layerCounts(
entries: readonly Entry[],
ctx: ContextCode,
): Map<string, number> {
const counts = new Map<string, number>();
for (const e of entries) {
if (!e.ctx.includes(ctx)) continue;
counts.set(e.key, (counts.get(e.key) ?? 0) + 1);
}
return counts;
}
/**
* Layer keys in the order the picker lists them: no-modifier first, then by the
* first modifier's position in MODORDER, then shortest combination first — so
* SHIFT comes before SHIFT + REVERSE.
*/
export function orderLayerKeys(keys: Iterable<string>): string[] {
return [...keys].sort((a, b) => {
if (a === "") return -1;
if (b === "") return 1;
const byModifier =
MODORDER.indexOf(a.split("+")[0]!) - MODORDER.indexOf(b.split("+")[0]!);
return byModifier || a.length - b.length;
});
}
/**
* Which entries light up each control on the current layer.
*
* A control can carry more than one function — the panel shows a count badge and
* tapping cycles through them — so this maps to a list, not a single entry.
*/
export function entriesByTarget(entries: readonly Entry[]): Map<ControlId, Entry[]> {
const byTarget = new Map<ControlId, Entry[]>();
for (const e of entries) {
for (const control of e.tset) {
const list = byTarget.get(control);
if (list) list.push(e);
else byTarget.set(control, [e]);
}
}
return byTarget;
}
/** Every context an entry list touches, for building the mode picker. */
export function contextsPresent(entries: readonly Entry[]): Set<ContextCode> {
const present = new Set<ContextCode>();
for (const e of entries) for (const c of e.ctx) present.add(c);
return present;
}

1684
src/domain/raw.ts Normal file

File diff suppressed because it is too large Load Diff

67
src/domain/types.ts Normal file
View File

@@ -0,0 +1,67 @@
/** Domain types, mirroring `reference/index.html` — still the data spec. */
/** A control on the panel: `"shift"`, `"p1"`…`"p16"`, `"bank_a"`, and so on. */
export type ControlId = string;
/** A named set of controls an entry can target instead of a single control. */
export type GroupId = "pads" | "ctrls" | "effects" | "banks";
/**
* Where you are on the device. One entry can apply in several at once.
*
* `S` sample mode · `E` edit screens · `P` pattern · `T` TR-REC ·
* `L` looper · `D` DJ mode · `X` chromatic / synth
*/
export type ContextCode = "S" | "E" | "P" | "T" | "L" | "D" | "X";
/**
* One row of the reference's `RAW` table, positional exactly as authored:
* `[mods, target, ctx, short, name, desc, page, how?]`.
*
* `target` is one control, a group name, or an explicit list of controls.
* `ctx` is a space-separated string of context codes.
* `how` overrides the generated "hold X, press Y" line when the gesture is
* unusual — a three-second hold, a knob turn, four quick taps.
*/
export type RawEntry = [
mods: ControlId[],
target: ControlId | GroupId | ControlId[],
ctx: string,
short: string,
name: string,
desc: string,
page: number,
how?: string,
];
/**
* A `RawEntry` with everything the UI needs precomputed.
*
* Derived once at module load, like the reference's `ENTRIES`. `key` identifies
* the layer an entry lives on; `tset` is the target expanded to concrete
* controls; `id` is stable across runs so drill progress survives a reload.
*/
export interface Entry {
/** Index in `RAW`, kept so an entry can be addressed compactly. */
readonly index: number;
/** Modifiers held, sorted into `MODORDER` so the key is canonical. */
readonly mods: ControlId[];
/** `mods` joined with `+`. The empty string is the no-modifier layer. */
readonly key: string;
/** The target as authored, before expansion. */
readonly target: ControlId | GroupId | ControlId[];
/** The target expanded to the concrete controls it lights up. */
readonly tset: ControlId[];
/** Context codes this entry applies in. */
readonly ctx: ContextCode[];
/** Panel-sized label, e.g. `"FIX VEL"`. */
readonly short: string;
/** Human name, e.g. `"Fixed velocity"`. */
readonly name: string;
readonly desc: string;
/** Page in the v5.50 reference manual. */
readonly page: number;
readonly how?: string;
/** Stable identity used as the drill progress key. */
readonly id: string;
}

22
tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}

18
vitest.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from "vitest/config";
/**
* Test-only config for now.
*
* The UI phase adds `vite.config.ts` for the build; until then nothing runs Vite
* itself, and `www/` is still produced by `scripts/sync-www.sh` for the Phase 1
* APK. Keeping the two configs separate means starting the React build cannot
* quietly break the wrapper that currently works.
*/
export default defineConfig({
test: {
// Domain code is plain TypeScript with no DOM, so the default node
// environment is enough. The UI phase adds jsdom if we test components.
environment: "node",
include: ["src/**/*.test.ts"],
},
});