From cc36f46f1096f6b95558a5cc6e6d995029263dc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 13:00:19 +0200 Subject: [PATCH] test(filters): drive the URL filter rail in a real browser A FilterBar over useUrlFilterSync now proves the contract end to end: a facet picked from the open menu pushes a history entry, typed input replaces the current one, a pill's clear and Reset leave a bare URL, two writes in a row both land, Back and an edited address re-seed the pills, and the open menu rests inside the viewport under its pill. The markup tests for FilterBar and useUrlFilterSync are deleted; the popper-placement source pin is replaced by the measured menu position. The browser project re-bundles its dependencies on every run so a cache warmed before a new file existed cannot load a second React. --- .../baselines/test-source-reads.json | 5 +- .../baselines/version-stamp-comments.json | 5 +- .../ui/__tests__/filter-bar.test.tsx | 166 ----------- .../use-url-filter-sync.browser.test.tsx | 268 ++++++++++++++++++ .../__tests__/use-url-filter-sync.test.tsx | 117 -------- vitest.config.mts | 7 +- 6 files changed, 278 insertions(+), 290 deletions(-) delete mode 100644 src/components/ui/__tests__/filter-bar.test.tsx create mode 100644 src/hooks/__tests__/use-url-filter-sync.browser.test.tsx delete mode 100644 src/hooks/__tests__/use-url-filter-sync.test.tsx diff --git a/src/__tests__/baselines/test-source-reads.json b/src/__tests__/baselines/test-source-reads.json index bad51e959..4cafc7881 100644 --- a/src/__tests__/baselines/test-source-reads.json +++ b/src/__tests__/baselines/test-source-reads.json @@ -1,7 +1,7 @@ { "$comment": "tests that read a source file by path, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.", - "count": 359, - "total": 359, + "count": 358, + "total": 358, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 1, "__tests__/admin-data-wipe-completeness.test.ts": 1, @@ -263,7 +263,6 @@ "components/timeline/__tests__/no-template-key-guard.test.ts": 1, "components/ui/__tests__/card-density.test.tsx": 1, "components/ui/__tests__/card-header-rows.test.ts": 1, - "components/ui/__tests__/filter-bar.test.tsx": 1, "hooks/__tests__/use-insight-status.test.ts": 1, "lib/__tests__/geo-offline-detection.test.ts": 1, "lib/__tests__/query-keys.test.ts": 1, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index e546e423d..8e942a370 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", - "count": 2950, - "total": 10294, + "count": 2949, + "total": 10293, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -1448,7 +1448,6 @@ "components/timeline/__tests__/timeline-view-keys.test.tsx": 1, "components/timeline/__tests__/value-options.test.ts": 1, "components/timeline/life-event-form.lazy.tsx": 1, - "components/ui/__tests__/filter-bar.test.tsx": 1, "components/ui/back-link.tsx": 1, "components/ui/button.tsx": 1, "components/ui/card.tsx": 1, diff --git a/src/components/ui/__tests__/filter-bar.test.tsx b/src/components/ui/__tests__/filter-bar.test.tsx deleted file mode 100644 index 8ab5fd593..000000000 --- a/src/components/ui/__tests__/filter-bar.test.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { I18nProvider } from "@/lib/i18n/context"; -import { FilterBar, FilterBarDateRange, FilterBarSelect } from "../filter-bar"; -import { primeLocales } from "@/__tests__/helpers/prime-locales"; - -primeLocales("de"); - -/** - * v1.16.1 — unified filter rail. SSR-markup guards for the shared - * grammar: pill = trigger + active chip in one element, reset only - * while filtered, count slot, per-pill clear button with a translated - * aria-label. - */ - -const options = [ - { value: "WEIGHT", label: "Weight" }, - { value: "PULSE", label: "Pulse" }, -]; - -function render(node: React.ReactElement, locale: "en" | "de" = "en") { - return renderToStaticMarkup( - {node}, - ); -} - -describe("FilterBarSelect", () => { - it("inactive: shows only the dimension name, no clear button", () => { - const html = render( - {}}> - {}} - allLabel="All types" - options={options} - /> - , - ); - expect(html).toContain('data-slot="filter-bar-pill"'); - expect(html).toContain('data-active="false"'); - expect(html).toContain("Type"); - expect(html).not.toContain("Clear filter"); - }); - - it("active: shows `label: value`, active styling and a labelled clear button", () => { - const html = render( - {}}> - {}} - allLabel="All types" - options={options} - /> - , - ); - expect(html).toContain('data-active="true"'); - expect(html).toContain("Weight"); - expect(html).toContain('aria-label="Clear filter: Type"'); - }); -}); - -describe("FilterBarDateRange", () => { - it("inactive: pill shows the dimension name only", () => { - const html = render( - {}}> - {}} - onToChange={() => {}} - idPrefix="probe" - /> - , - ); - expect(html).toContain('data-active="false"'); - expect(html).toContain("Date range"); - }); - - it("active: pill shows the formatted bounds and a clear button", () => { - const html = render( - {}}> - {}} - onToChange={() => {}} - idPrefix="probe" - /> - , - ); - expect(html).toContain('data-active="true"'); - // en dateShort: "05/12" / "06/10" — assert both day fragments are in. - expect(html).toMatch(/05\/12/); - expect(html).toMatch(/06\/10/); - expect(html).toContain('aria-label="Clear filter: Date range"'); - }); -}); - -describe("FilterBar shell", () => { - it("shows the reset action only while filtered", () => { - const inactive = render( - {}}> - - , - ); - const active = render( - {}}> - - , - ); - expect(inactive).not.toContain("Reset"); - expect(active).toContain("Reset"); - }); - - it("renders the result count slot", () => { - const html = render( - {}} count="42 readings"> - - , - ); - expect(html).toContain("42 readings"); - expect(html).toContain("tabular-nums"); - }); - - it("localises reset + clear labels in German", () => { - const html = render( - {}}> - {}} - allLabel="Alle Quellen" - options={[{ value: "MANUAL", label: "Manuell" }]} - /> - , - "de", - ); - expect(html).toContain("Zurücksetzen"); - expect(html).toContain('aria-label="Filter entfernen: Quelle"'); - }); -}); - -describe("FilterBarSelect — menu positioning (source pin)", () => { - // The pill trigger paints its own label spans and mounts no - // ``. Radix's default item-aligned placement silently - // skips ALL positioning when the value node is absent — the open - // menu then lays out below the height-locked app shell, fully - // off-viewport, which users report as "clicking does nothing". SSR - // markup cannot exercise an open portal, so this pins the source: - // the content must request popper placement, which has no - // value-node dependency. - it("requests popper placement on the select content", () => { - const src = readFileSync( - join(process.cwd(), "src/components/ui/filter-bar.tsx"), - "utf8", - ); - expect(src).toMatch(//); - }); -}); diff --git a/src/hooks/__tests__/use-url-filter-sync.browser.test.tsx b/src/hooks/__tests__/use-url-filter-sync.browser.test.tsx new file mode 100644 index 000000000..6897b0b2e --- /dev/null +++ b/src/hooks/__tests__/use-url-filter-sync.browser.test.tsx @@ -0,0 +1,268 @@ +/** + * The URL-owned filter contract, driven the way a list page uses it: a + * `FilterBar` of pills over `useUrlFilterSync`, in a real browser with a real + * address bar. What these pin and the markup tests could not: a facet picked + * from the open menu lands in the query as a new history entry, typed input + * rewrites the current one, the per-pill clear and Reset leave a bare URL, two + * writes in a row both land, and Back or an edited address re-seeds the pills. + */ +import { describe, expect, it } from "vitest"; +import { userEvent } from "vitest/browser"; + +import { + FilterBar, + FilterBarDateRange, + FilterBarSelect, +} from "@/components/ui/filter-bar"; +import { Input } from "@/components/ui/input"; + +import { renderWithApp } from "../../../tests/browser/render"; +import { + currentSearch, + navigateExternally, + recordHistory, + setSearch, +} from "../../../tests/browser/url"; +import { useUrlFilterSync } from "../use-url-filter-sync"; + +interface Filters { + type?: string; + source?: string; + q?: string; + from?: string; + to?: string; +} + +const KEYS = ["type", "source", "q", "from", "to"] as const; + +function parse(params: URLSearchParams): Filters { + const filters: Filters = {}; + for (const key of KEYS) { + const value = params.get(key); + if (value) filters[key] = value; + } + return filters; +} + +function serialise(filters: Filters): string { + const sp = new URLSearchParams(); + for (const key of KEYS) { + const value = filters[key]; + if (value) sp.set(key, value); + } + return sp.toString(); +} + +const TYPES = [ + { value: "WEIGHT", label: "Weight" }, + { value: "PULSE", label: "Pulse" }, +]; +const SOURCES = [ + { value: "MANUAL", label: "Manual" }, + { value: "WITHINGS", label: "Withings" }, +]; + +/** A list page's filter rail, as the measurements and mood lists build it. */ +function FilterRail({ syncToUrl = true }: { syncToUrl?: boolean }) { + const { filters, applyFilters, patchFilters } = useUrlFilterSync({ + parse, + serialise, + syncToUrl, + }); + const isFiltered = Object.keys(filters).length > 0; + return ( + <> + patchFilters({ q: e.target.value }, "replace")} + /> + applyFilters({})} + count={`${Object.keys(filters).length} active`} + > + patchFilters({ from })} + onToChange={(to) => patchFilters({ to })} + idPrefix="probe" + /> + + patchFilters({ type: v === "ALL" ? undefined : v }) + } + allLabel="All types" + options={TYPES} + /> + + patchFilters({ source: v === "ALL" ? undefined : v }) + } + allLabel="All sources" + options={SOURCES} + /> + + + ); +} + +async function mount(search = "", props: { syncToUrl?: boolean } = {}) { + setSearch(search); + const history = recordHistory(); + const { screen } = await renderWithApp(); + const pill = (name: string | RegExp) => + screen.getByRole("combobox", { name }); + const reset = () => screen.getByRole("button", { name: "Reset" }); + const pick = async (pillName: string | RegExp, option: string) => { + await pill(pillName).click(); + await screen.getByRole("option", { name: option }).click(); + }; + return { screen, history, pill, reset, pick }; +} + +/** The URL settles a task after the write; wait for the expected query. */ +async function expectSearch(search: string) { + await expect.poll(currentSearch).toBe(search); +} + +describe("useUrlFilterSync with FilterBar", () => { + it("seeds the pills from the address it mounts on", async () => { + const { screen, pill, reset } = await mount("?type=WEIGHT&source=WITHINGS"); + await expect + .element(pill("Type: Weight")) + .toHaveAttribute("data-active", "true"); + await expect.element(pill("Source: Withings")).toBeInTheDocument(); + await expect.element(reset()).toBeVisible(); + await expect.element(screen.getByText("2 active")).toBeVisible(); + }); + + it("shows no Reset and no clear button while unfiltered", async () => { + const { screen, pill } = await mount(); + await expect.element(pill("Type")).toHaveAttribute("data-active", "false"); + await expect + .element(screen.getByRole("button", { name: "Reset" })) + .not.toBeInTheDocument(); + await expect + .element(screen.getByRole("button", { name: /^Clear filter/ })) + .not.toBeInTheDocument(); + }); + + it("writes a facet picked from the open menu as a new history entry", async () => { + const { pill, history, pick } = await mount(); + const entries = window.history.length; + await pick("Type", "Pulse"); + await expectSearch("?type=PULSE"); + expect(history.pushes()).toEqual(["?type=PULSE"]); + expect(history.replaces()).toEqual([]); + expect(window.history.length).toBe(entries + 1); + await expect.element(pill("Type: Pulse")).toBeInTheDocument(); + }); + + it("opens the menu inside the viewport, under its pill", async () => { + const { screen, pill } = await mount(); + // Read before opening: the open menu hides the rest of the page from + // the accessibility tree, the pill included. + const trigger = pill("Source").element().getBoundingClientRect(); + await pill("Source").click(); + const listbox = screen.getByRole("listbox"); + await expect.element(listbox).toBeVisible(); + // The menu slides in from above its resting place; measure where it rests. + await Promise.all(document.getAnimations().map((a) => a.finished)); + const menu = listbox.element().getBoundingClientRect(); + expect(menu.top).toBeGreaterThanOrEqual(trigger.bottom - 1); + expect(menu.bottom).toBeLessThanOrEqual(window.innerHeight); + expect(menu.left).toBeGreaterThanOrEqual(0); + await userEvent.keyboard("{Escape}"); + await expect.element(listbox).not.toBeInTheDocument(); + }); + + it("rewrites the current entry for typed input instead of stacking one per key", async () => { + const { screen, history } = await mount("?type=WEIGHT"); + const entries = window.history.length; + await screen.getByRole("textbox", { name: "Search notes" }).fill("knee"); + await expectSearch("?type=WEIGHT&q=knee"); + expect(history.pushes()).toEqual([]); + expect(history.replaces().at(-1)).toBe("?type=WEIGHT&q=knee"); + expect(window.history.length).toBe(entries); + }); + + it("drops one facet with its clear button and keeps the others", async () => { + const { screen, pill } = await mount("?type=WEIGHT&source=MANUAL"); + await screen.getByRole("button", { name: "Clear filter: Type" }).click(); + await expectSearch("?source=MANUAL"); + await expect.element(pill("Type")).toHaveAttribute("data-active", "false"); + await expect.element(pill("Source: Manual")).toBeInTheDocument(); + }); + + it("clears both date bounds in one press, two writes in a row", async () => { + const { screen } = await mount("?from=2026-05-12&to=2026-06-10&type=PULSE"); + const range = screen.getByRole("button", { name: /^Date range: / }); + await expect.element(range).toHaveTextContent("Date range: 05/12–06/10"); + await screen + .getByRole("button", { name: "Clear filter: Date range" }) + .click(); + await expectSearch("?type=PULSE"); + await expect + .element(screen.getByRole("button", { name: "Date range" })) + .toHaveAttribute("data-active", "false"); + }); + + it("resets every facet to a bare URL and hides Reset", async () => { + const { reset, pill, history } = await mount("?type=WEIGHT&source=MANUAL"); + await reset().click(); + await expectSearch(""); + expect(history.pushes()).toEqual([""]); + await expect.element(pill("Type")).toHaveAttribute("data-active", "false"); + await expect + .element(pill("Source")) + .toHaveAttribute("data-active", "false"); + await expect.element(reset()).not.toBeInTheDocument(); + }); + + it("re-seeds from an address changed outside the component", async () => { + const { pill, reset } = await mount("?type=WEIGHT"); + navigateExternally("?source=WITHINGS"); + await expect.element(pill("Source: Withings")).toBeInTheDocument(); + await expect.element(pill("Type")).toHaveAttribute("data-active", "false"); + navigateExternally(""); + await expect.element(reset()).not.toBeInTheDocument(); + }); + + it("steps back through filter states with the browser's Back", async () => { + const { pill, pick } = await mount("?type=WEIGHT"); + await pick("Type: Weight", "Pulse"); + await expectSearch("?type=PULSE"); + window.history.back(); + await expectSearch("?type=WEIGHT"); + await expect.element(pill("Type: Weight")).toBeInTheDocument(); + }); + + it("keeps the state in memory and the address untouched with syncToUrl off", async () => { + const { pill, pick, history } = await mount("?type=WEIGHT", { + syncToUrl: false, + }); + await expect.element(pill("Type")).toHaveAttribute("data-active", "false"); + await pick("Type", "Pulse"); + await expect.element(pill("Type: Pulse")).toBeInTheDocument(); + expect(currentSearch()).toBe("?type=WEIGHT"); + expect(history.writes).toEqual([]); + }); + + it("names Reset and the clear buttons in the reader's language", async () => { + setSearch("?source=MANUAL"); + const { screen } = await renderWithApp(, { locale: "de" }); + await expect + .element(screen.getByRole("button", { name: "Zurücksetzen" })) + .toBeVisible(); + await expect + .element(screen.getByRole("button", { name: "Filter entfernen: Source" })) + .toBeInTheDocument(); + }); +}); diff --git a/src/hooks/__tests__/use-url-filter-sync.test.tsx b/src/hooks/__tests__/use-url-filter-sync.test.tsx deleted file mode 100644 index 81e5a00bd..000000000 --- a/src/hooks/__tests__/use-url-filter-sync.test.tsx +++ /dev/null @@ -1,117 +0,0 @@ -/** - * The shared URL-owned-filter hook (`use-url-filter-sync.ts`) — the - * documents-vault pattern extracted for the measurements and mood lists. - * Pinned here: mount parses the live query string, a filter change writes - * the serialised form back through the router (push by default, replace on - * request), an empty filter set restores the bare pathname, and - * `syncToUrl: false` detaches the state from the URL entirely. - */ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -let mockSearch = ""; -const pushMock = vi.fn(); -const replaceMock = vi.fn(); -vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: pushMock, replace: replaceMock }), - usePathname: () => "/measurements", - useSearchParams: () => new URLSearchParams(mockSearch), -})); - -import { useUrlFilterSync } from "../use-url-filter-sync"; -import { - installUrlStateBrowser, - type UrlStateBrowser, -} from "@/__tests__/helpers/url-state-browser"; - -let browser: UrlStateBrowser = installUrlStateBrowser("/"); -afterEach(() => browser.uninstall()); - -interface ProbeFilters { - type?: string; - source?: string; -} - -function parse(params: URLSearchParams): ProbeFilters { - const filters: ProbeFilters = {}; - const type = params.get("type"); - if (type) filters.type = type; - const source = params.get("source"); - if (source) filters.source = source; - return filters; -} - -function serialise(filters: ProbeFilters): string { - const sp = new URLSearchParams(); - if (filters.type) sp.set("type", filters.type); - if (filters.source) sp.set("source", filters.source); - return sp.toString(); -} - -interface ProbeResult { - filters: ProbeFilters; - applyFilters: (next: ProbeFilters, mode?: "push" | "replace") => void; -} - -function mount(search: string, syncToUrl?: boolean): ProbeResult { - mockSearch = search; - // Ref-shaped capture cell: the render-scoped assignment happens inside a - // closure the control-flow analysis cannot see through. - const captured: { current: ProbeResult | null } = { current: null }; - function Probe() { - captured.current = useUrlFilterSync({ - parse, - serialise, - syncToUrl, - }); - return null; - } - renderToStaticMarkup(); - if (!captured.current) throw new Error("probe did not render"); - // The live address the writes start from (`@/lib/url-state`). - browser.uninstall(); - browser = installUrlStateBrowser( - search ? `/measurements?${search}` : "/measurements", - ); - return captured.current; -} - -beforeEach(() => { - pushMock.mockClear(); - replaceMock.mockClear(); -}); - -describe("useUrlFilterSync", () => { - it("parses the mounted query string into the filter object", () => { - const { filters } = mount("type=WEIGHT&source=WITHINGS"); - expect(filters).toEqual({ type: "WEIGHT", source: "WITHINGS" }); - }); - - it("writes a filter change to the URL via push by default", () => { - const { applyFilters } = mount(""); - applyFilters({ type: "WEIGHT" }); - expect(browser.push).toHaveBeenCalledWith("/measurements?type=WEIGHT"); - expect(browser.replace).not.toHaveBeenCalled(); - }); - - it("writes via replace when asked (high-frequency input)", () => { - const { applyFilters } = mount(""); - applyFilters({ source: "MANUAL" }, "replace"); - expect(browser.replace).toHaveBeenCalledWith("/measurements?source=MANUAL"); - expect(browser.push).not.toHaveBeenCalled(); - }); - - it("restores the bare pathname when every facet clears", () => { - const { applyFilters } = mount("type=WEIGHT"); - applyFilters({}); - expect(browser.push).toHaveBeenCalledWith("/measurements"); - }); - - it("syncToUrl: false ignores the URL and never navigates", () => { - const { filters, applyFilters } = mount("type=WEIGHT", false); - expect(filters).toEqual({}); - applyFilters({ type: "PULSE" }); - expect(browser.push).not.toHaveBeenCalled(); - expect(browser.replace).not.toHaveBeenCalled(); - }); -}); diff --git a/vitest.config.mts b/vitest.config.mts index 996edbdec..51fa3430b 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -132,8 +132,13 @@ export default defineConfig({ // Pre-bundle every dependency the browser files reach before the // first one loads. Discovered late, a dependency makes Vite re-bundle // and reload the page mid-run, which fails the file that was - // importing it; CI starts every run on an empty cache. + // importing it; CI starts every run on an empty cache. `force` + // makes a local run start there too: a cache warmed before a new + // browser file existed does not know that file's dependencies, and + // the late re-bundle loads a second React ("Invalid hook call"). + // A cold bundle costs about half a second. optimizeDeps: { + force: true, entries: ["src/**/*.browser.test.tsx", "tests/browser/**/*.{ts,tsx}"], }, test: {