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 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 00:55:31 +03:00
parent b32c38019e
commit 43fb51396c
4 changed files with 208 additions and 0 deletions

69
src/domain/format.test.ts Normal file
View File

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

34
src/domain/format.ts Normal file
View File

@@ -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<string> = 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(" + ");
}

74
src/domain/search.test.ts Normal file
View File

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

31
src/domain/search.ts Normal file
View File

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