From 43fb51396c9af2b6dba2bfc2ecd2e99baf64ae4d Mon Sep 17 00:00:00 2001 From: ncode Date: Thu, 17 Sep 2026 00:55:31 +0300 Subject: [PATCH] Phase 2: label formatting and the Find search format.ts turns ids into the words printed on the panel: controlName, targetName (a group reads "PAD 1-16" rather than sixteen names), and comboParts/comboText for the "SHIFT + PAD 1" lines. search.ts matches a query against everything printed about an entry - name, panel label, description, modifiers, target and contexts - so "metronome", "SHIFT" and "sub pad" all find something. An empty query returns nothing, not everything: Find opens on a prompt. One test was wrong rather than the code. Searching "dj mode" does not only match the context name: the entry named "Enter DJ mode" lives in sample mode, because that is where you press it. Asserting every result was in context D was too strong, and surfacing that combo is the useful behaviour. The assertion now documents it. 87 tests. Co-Authored-By: Claude Opus 5 --- src/domain/format.test.ts | 69 ++++++++++++++++++++++++++++++++++++ src/domain/format.ts | 34 ++++++++++++++++++ src/domain/search.test.ts | 74 +++++++++++++++++++++++++++++++++++++++ src/domain/search.ts | 31 ++++++++++++++++ 4 files changed, 208 insertions(+) create mode 100644 src/domain/format.test.ts create mode 100644 src/domain/format.ts create mode 100644 src/domain/search.test.ts create mode 100644 src/domain/search.ts diff --git a/src/domain/format.test.ts b/src/domain/format.test.ts new file mode 100644 index 0000000..521ba21 --- /dev/null +++ b/src/domain/format.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { ENTRIES } from "./entries"; +import { comboParts, comboText, contextNames, controlName, targetName } from "./format"; +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; +}; + +describe("controlName", () => { + it("names modifiers and pads as the panel prints them", () => { + expect(controlName("shift")).toBe("SHIFT"); + expect(controlName("p1")).toBe("PAD 1"); + expect(controlName("p16")).toBe("PAD 16"); + expect(controlName("subpad")).toBe("SUB PAD"); + }); + + it("falls back to the id for anything unknown", () => { + expect(controlName("nonsense")).toBe("nonsense"); + }); +}); + +describe("targetName", () => { + it("names a single control", () => { + expect(targetName("p1")).toBe("PAD 1"); + }); + + it("names a group rather than listing sixteen pads", () => { + expect(targetName("pads")).toBe("PAD 1–16"); + expect(targetName("banks")).toBe("BANK A–E"); + expect(targetName("ctrls")).toBe("CTRL 1–3"); + }); + + it("joins an explicit list with a slash", () => { + expect(targetName(["p13", "p15"])).toBe("PAD 13 / PAD 15"); + }); +}); + +describe("combo lines", () => { + it("reads modifiers first, then the target", () => { + expect(comboText(byName("Fixed velocity"))).toBe("SHIFT + PAD 1"); + }); + + it("keeps two modifiers in canonical order", () => { + expect(comboText(byName("Pad mute"))).toBe("SHIFT + REVERSE + PAD 1–16"); + }); + + it("is just the target when nothing is held", () => { + expect(comboText(byName("Skip-back sampling"))).toBe("MARK"); + }); + + it("exposes the parts so the UI can chip them separately", () => { + expect(comboParts(byName("Fixed velocity"))).toEqual(["SHIFT", "PAD 1"]); + }); +}); + +describe("contextNames", () => { + it("spells out the codes", () => { + expect(contextNames(["S"])).toEqual(["Sample mode"]); + expect(contextNames(["S", "P", "D", "L"])).toEqual([ + "Sample mode", + "Pattern", + "DJ mode", + "Looper", + ]); + }); +}); diff --git a/src/domain/format.ts b/src/domain/format.ts new file mode 100644 index 0000000..666b36a --- /dev/null +++ b/src/domain/format.ts @@ -0,0 +1,34 @@ +/** Turning ids into the words printed on the panel and in combo lines. */ +import { CTX, GNAME, GROUPS, L } from "./labels"; +import type { ContextCode, ControlId, Entry, GroupId, RawEntry } from "./types"; + +const GROUP_IDS: ReadonlySet = new Set(Object.keys(GROUPS)); + +/** Name of a single control: `"shift"` becomes `"SHIFT"`. */ +export function controlName(id: ControlId): string { + return L[id] ?? id; +} + +/** + * Name of whatever an entry targets: one control, a group ("PAD 1–16"), or an + * explicit list joined with a slash. + */ +export function targetName(target: RawEntry[1]): string { + if (Array.isArray(target)) return target.map(controlName).join(" / "); + if (GROUP_IDS.has(target)) return GNAME[target as GroupId]; + return controlName(target); +} + +export function contextNames(ctx: readonly ContextCode[]): string[] { + return ctx.map((c) => CTX[c]); +} + +/** The combo as its parts: modifiers first, then the target. */ +export function comboParts(entry: Entry): string[] { + return [...entry.mods.map(controlName), targetName(entry.target)]; +} + +/** The combo as one line: `"SHIFT + PAD 1"`. */ +export function comboText(entry: Entry): string { + return comboParts(entry).join(" + "); +} diff --git a/src/domain/search.test.ts b/src/domain/search.test.ts new file mode 100644 index 0000000..5bbb798 --- /dev/null +++ b/src/domain/search.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { ENTRIES } from "./entries"; +import { searchEntries, searchableText } from "./search"; + +const names = (query: string): string[] => + searchEntries(ENTRIES, query).map((e) => e.name); + +describe("searchEntries", () => { + it("finds an entry by its name", () => { + expect(names("undo")).toContain("Undo last take"); + }); + + it("finds an entry by its panel label", () => { + expect(names("fix vel")).toContain("Fixed velocity"); + }); + + it("finds an entry by words only in its description", () => { + // "chop" appears in the marker screen's description, not its name. + expect(names("chop").length).toBeGreaterThan(0); + }); + + it("finds entries by the modifier held", () => { + const shift = searchEntries(ENTRIES, "shift"); + expect(shift.length).toBeGreaterThan(10); + }); + + it("finds entries by the control they target", () => { + expect(names("sub pad").length).toBeGreaterThan(0); + }); + + it("finds entries by the mode they belong to", () => { + const dj = searchEntries(ENTRIES, "dj mode"); + expect(dj.some((e) => e.ctx.includes("D"))).toBe(true); + }); + + it("also finds the combo that enters a mode, which lives outside it", () => { + // "Enter DJ mode" is pressed from sample mode, so searching "dj mode" should + // turn it up alongside the DJ-mode entries rather than only those. + const enter = searchEntries(ENTRIES, "dj mode").find( + (e) => e.name === "Enter DJ mode", + ); + expect(enter).toBeDefined(); + expect(enter!.ctx).toEqual(["S"]); + }); + + it("ignores case", () => { + expect(names("METRONOME")).toEqual(names("metronome")); + expect(names("metronome").length).toBeGreaterThan(0); + }); + + it("returns nothing for an empty or blank query", () => { + expect(searchEntries(ENTRIES, "")).toEqual([]); + expect(searchEntries(ENTRIES, " ")).toEqual([]); + }); + + it("returns nothing when nothing matches", () => { + expect(searchEntries(ENTRIES, "theremin")).toEqual([]); + }); + + it("trims the query before matching", () => { + expect(names(" undo ")).toEqual(names("undo")); + }); +}); + +describe("searchableText", () => { + it("includes the spelled-out modifier, target and context", () => { + const fixedVelocity = ENTRIES.find((e) => e.name === "Fixed velocity")!; + const text = searchableText(fixedVelocity); + expect(text).toContain("shift"); + expect(text).toContain("pad 1"); + expect(text).toContain("sample mode"); + expect(text).toContain("127"); // from the description + }); +}); diff --git a/src/domain/search.ts b/src/domain/search.ts new file mode 100644 index 0000000..89ff1ca --- /dev/null +++ b/src/domain/search.ts @@ -0,0 +1,31 @@ +/** + * The Find tab. Matches a substring against everything printed about an entry: + * its name, panel label, description, modifiers, target and contexts — so + * "metronome", "SHIFT" and "DJ mode" all find something. + */ +import { contextNames, controlName, targetName } from "./format"; +import type { Entry } from "./types"; + +/** Everything about an entry a query can match, lowercased. */ +export function searchableText(entry: Entry): string { + return [ + entry.name, + entry.short, + entry.desc, + ...entry.mods.map(controlName), + targetName(entry.target), + ...contextNames(entry.ctx), + ] + .join(" ") + .toLowerCase(); +} + +/** + * An empty query returns nothing rather than everything: Find opens on a prompt, + * not on all 159 rows. + */ +export function searchEntries(entries: readonly Entry[], query: string): Entry[] { + const q = query.trim().toLowerCase(); + if (q === "") return []; + return entries.filter((e) => searchableText(e).includes(q)); +}