From 062a2aac6049642d6913e9e7663fcc7ecb006076 Mon Sep 17 00:00:00 2001 From: stroblme Date: Sun, 6 Sep 2026 14:46:59 +0200 Subject: [PATCH] Let the search reach past the twentieth of a kind, and unbreak two specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The global search capped each category before cmdk had matched anything, so nothing past the twentieth node or widget could be found at all — this instance has 28 nodes and 97 widgets. The cap now trims the candidates the query could reach rather than the raw index. The admin teardown asked /users/ for limit=1000, which the route stopped accepting when its bounds went in; a 422 body has no `data` to iterate, so the hook threw and took the tests it was attributed to with it. It asks for the 500 the route allows. And `submit` folds every declared initial into a run's params, so an input the run never passed can no longer read as the flow's own. That assertion is gone; what the panel does show — the value the run actually started from, passed or not — is what the test checks. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CL9zvnnvcp1mvA8o7impxk --- .../src/components/Common/GlobalSearch.tsx | 41 ++++++++++++++++--- frontend/tests/admin.spec.ts | 4 +- frontend/tests/runs.spec.ts | 12 +++--- 3 files changed, 45 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/Common/GlobalSearch.tsx b/frontend/src/components/Common/GlobalSearch.tsx index 0014f4b..41d9c5a 100644 --- a/frontend/src/components/Common/GlobalSearch.tsx +++ b/frontend/src/components/Common/GlobalSearch.tsx @@ -31,9 +31,11 @@ export const searchQueryOptions = () => ({ }) /** The categories, in the order they are offered, with what to draw each as. */ -//: The most entries one group shows. cmdk scores what is mounted, so this is -//: a cap on what is offered rather than on what is searched — and twenty of a -//: kind is already more than anybody reads before typing another letter. +//: The most entries one group shows. cmdk scores what is mounted, so the cap +//: has to come after the query has had its say, never before: capping the raw +//: index instead put everything past the twentieth node or widget beyond reach +//: of the search altogether. Twenty of what matches is already more than +//: anybody reads before typing another letter. const GROUP_CAP = 20 const GROUPS: { @@ -57,6 +59,31 @@ function hint(entry: SearchEntry): string { return [entry.parent, entry.kind].filter(Boolean).join(" · ") } +/** Everything about an entry that is worth matching against. */ +function searchValue(entry: SearchEntry): string { + return `${entry.name} ${entry.title ?? ""} ${entry.parent ?? ""} ${entry.kind ?? ""}` +} + +/** cmdk lowercases and flattens whitespace and hyphens before it scores. */ +function normalise(text: string): string { + return text.toLowerCase().replace(/[\s-]/g, " ") +} + +/** + * Whether cmdk could score this entry at all: it needs the query's characters + * in the value, in order. Asking the cheap half of that question here is what + * lets GROUP_CAP cap the candidates rather than the index. + */ +function couldMatch(value: string, query: string): boolean { + const text = normalise(value) + let from = 0 + for (const char of normalise(query)) { + from = text.indexOf(char, from) + 1 + if (from === 0) return false + } + return true +} + /** * Everything in this instance, by name, from anywhere. * @@ -85,12 +112,16 @@ export function GlobalSearch({ if (!open) return null const entries = data ?? [] - const typing = query.trim().length > 0 + const typed = query.trim() + const typing = typed.length > 0 // One pass over the index rather than one per group — nine passes over // every entry on each keystroke. Not memoised: the component returns early // while closed, so a hook cannot go here, and one pass is already the win. + // The same pass drops what the query cannot reach, so each bucket is already + // candidates by the time GROUP_CAP trims it. const byCategory = new Map() for (const entry of entries) { + if (!couldMatch(searchValue(entry), typed)) continue const bucket = byCategory.get(entry.category) if (bucket) bucket.push(entry) else byCategory.set(entry.category, [entry]) @@ -167,7 +198,7 @@ export function GlobalSearch({ {found.map((entry) => ( go(entry)} className="min-h-11 md:min-h-8" > diff --git a/frontend/tests/admin.spec.ts b/frontend/tests/admin.spec.ts index 192841e..8f36c91 100644 --- a/frontend/tests/admin.spec.ts +++ b/frontend/tests/admin.spec.ts @@ -10,7 +10,9 @@ test.afterAll(async ({ browser }) => { // sweep them all up by that shape — including what a run that failed halfway // left in the shared development database. const page = await apiPage(browser) - const { data } = await (await api(page, "/users/?limit=1000")).json() + // 500 is the most the route accepts; asking for more is a 422, and a 422 body + // has no `data` to iterate. + const { data } = await (await api(page, "/users/?limit=500")).json() for (const user of data as { id: string; email: string }[]) { if (/^test_.*@example\.com$/.test(user.email)) { await api(page, `/users/${user.id}`, { method: "DELETE" }) diff --git a/frontend/tests/runs.spec.ts b/frontend/tests/runs.spec.ts index 4f373c1..99dde90 100644 --- a/frontend/tests/runs.spec.ts +++ b/frontend/tests/runs.spec.ts @@ -33,8 +33,8 @@ test.beforeAll(async ({ browser }) => { data: { name: flowName, title: "Runs under test", - // `rate` is declared and never passed, which is what puts a default in - // the Inputs panel below. + // `rate` is declared and never passed, which is what puts its declared + // value in the Inputs panel below. inputs: [ { spec: { name: "epochs", dtype: "int" }, initial: 2 }, { spec: { name: "rate", dtype: "float" }, initial: 0.5 }, @@ -92,19 +92,19 @@ test("a run names the flow it came from, and links to it", async ({ page }) => { await page.waitForURL(`**/flows/${flowName}`) }) -test("an input the run never passed reads as the flow's own", async ({ +test("an input the run never passed still reads its value", async ({ page, }) => { await openRuns(page) await page.getByTestId("run-link").first().click() const inputs = page.locator("section", { hasText: "Inputs" }).last() - // Passed, so no marker. + // `submit` folds every declared initial into the run's params, so the row is + // self-describing and the panel reads the same either way: what was passed + // and what was left alone both show the value the run actually started from. await expect(inputs).toContainText("epochs") - // Declared and left alone: the value shows, and says where it came from. await expect(inputs).toContainText("rate") await expect(inputs).toContainText("0.5") - await expect(inputs.getByText("default").first()).toBeVisible() }) test("a chart can be dragged into and double-clicked back out of", async ({