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:
69
src/domain/format.test.ts
Normal file
69
src/domain/format.test.ts
Normal 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
34
src/domain/format.ts
Normal 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
74
src/domain/search.test.ts
Normal 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
31
src/domain/search.ts
Normal 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));
|
||||
}
|
||||
Reference in New Issue
Block a user