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:
42
CLAUDE.md
42
CLAUDE.md
@@ -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.
|
||||
`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)
|
||||
|
||||
The look is **Direction A, "manual"**, after Teenage Engineering's EP-133 manual,
|
||||
|
||||
1561
package-lock.json
generated
1561
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@@ -2,6 +2,7 @@
|
||||
"name": "sp404-layers",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "SP-404MK2 combo trainer, wrapped as an Android app",
|
||||
"scripts": {
|
||||
"sync:www": "bash scripts/sync-www.sh",
|
||||
@@ -13,16 +14,28 @@
|
||||
"device:sleep-normal": "bash scripts/device.sh sleep-normal",
|
||||
"device:launch": "bash scripts/device.sh 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": {
|
||||
"@capacitor/android": "^8.5.2",
|
||||
"@capacitor/app": "^8.1.1",
|
||||
"@capacitor/core": "^8.5.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": {
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
|
||||
83
scripts/extract-reference-data.mjs
Normal file
83
scripts/extract-reference-data.mjs
Normal 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
207
src/domain/drill.test.ts
Normal 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
161
src/domain/drill.ts
Normal 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
130
src/domain/entries.test.ts
Normal 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
78
src/domain/entries.ts
Normal 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
186
src/domain/labels.ts
Normal 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": "RESAMPLE",
|
||||
"reverse": "REVERSE",
|
||||
"remain": "REMAIN",
|
||||
"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
127
src/domain/layers.test.ts
Normal 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
81
src/domain/layers.ts
Normal 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
1684
src/domain/raw.ts
Normal file
File diff suppressed because it is too large
Load Diff
67
src/domain/types.ts
Normal file
67
src/domain/types.ts
Normal 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
22
tsconfig.json
Normal 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
18
vitest.config.ts
Normal 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"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user