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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 13:00:19 +02:00
1 parent ca1cceb3a9
commit cc36f46f10
6 files changed
+278 -290

No files matched your search

@@ -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,
@@ -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,
@@ -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(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("FilterBarSelect", () => {
it("inactive: shows only the dimension name, no clear button", () => {
const html = render(
<FilterBar isFiltered={false} onReset={() => {}}>
<FilterBarSelect
label="Type"
value="ALL"
onValueChange={() => {}}
allLabel="All types"
options={options}
/>
</FilterBar>,
);
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(
<FilterBar isFiltered onReset={() => {}}>
<FilterBarSelect
label="Type"
value="WEIGHT"
onValueChange={() => {}}
allLabel="All types"
options={options}
/>
</FilterBar>,
);
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(
<FilterBar isFiltered={false} onReset={() => {}}>
<FilterBarDateRange
label="Date range"
from=""
to=""
onFromChange={() => {}}
onToChange={() => {}}
idPrefix="probe"
/>
</FilterBar>,
);
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(
<FilterBar isFiltered onReset={() => {}}>
<FilterBarDateRange
label="Date range"
from="2026-05-12"
to="2026-06-10"
onFromChange={() => {}}
onToChange={() => {}}
idPrefix="probe"
/>
</FilterBar>,
);
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(
<FilterBar isFiltered={false} onReset={() => {}}>
<span />
</FilterBar>,
);
const active = render(
<FilterBar isFiltered onReset={() => {}}>
<span />
</FilterBar>,
);
expect(inactive).not.toContain("Reset");
expect(active).toContain("Reset");
});
it("renders the result count slot", () => {
const html = render(
<FilterBar isFiltered={false} onReset={() => {}} count="42 readings">
<span />
</FilterBar>,
);
expect(html).toContain("42 readings");
expect(html).toContain("tabular-nums");
});
it("localises reset + clear labels in German", () => {
const html = render(
<FilterBar isFiltered onReset={() => {}}>
<FilterBarSelect
label="Quelle"
value="MANUAL"
onValueChange={() => {}}
allLabel="Alle Quellen"
options={[{ value: "MANUAL", label: "Manuell" }]}
/>
</FilterBar>,
"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
// `<SelectValue>`. 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(/<SelectContent\s+position="popper"\s+align="start">/);
});
});
@@ -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<Filters>({
parse,
serialise,
syncToUrl,
});
const isFiltered = Object.keys(filters).length > 0;
return (
<>
<Input
aria-label="Search notes"
value={filters.q ?? ""}
onChange={(e) => patchFilters({ q: e.target.value }, "replace")}
/>
<FilterBar
isFiltered={isFiltered}
onReset={() => applyFilters({})}
count={`${Object.keys(filters).length} active`}
>
<FilterBarDateRange
label="Date range"
from={filters.from ?? ""}
to={filters.to ?? ""}
onFromChange={(from) => patchFilters({ from })}
onToChange={(to) => patchFilters({ to })}
idPrefix="probe"
/>
<FilterBarSelect
label="Type"
value={filters.type ?? "ALL"}
onValueChange={(v) =>
patchFilters({ type: v === "ALL" ? undefined : v })
}
allLabel="All types"
options={TYPES}
/>
<FilterBarSelect
label="Source"
value={filters.source ?? "ALL"}
onValueChange={(v) =>
patchFilters({ source: v === "ALL" ? undefined : v })
}
allLabel="All sources"
options={SOURCES}
/>
</FilterBar>
</>
);
}
async function mount(search = "", props: { syncToUrl?: boolean } = {}) {
setSearch(search);
const history = recordHistory();
const { screen } = await renderWithApp(<FilterRail {...props} />);
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(<FilterRail />, { locale: "de" });
await expect
.element(screen.getByRole("button", { name: "Zurücksetzen" }))
.toBeVisible();
await expect
.element(screen.getByRole("button", { name: "Filter entfernen: Source" }))
.toBeInTheDocument();
});
});
@@ -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<ProbeFilters>({
parse,
serialise,
syncToUrl,
});
return null;
}
renderToStaticMarkup(<Probe />);
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();
});
});
+6 -1
View File
@@ -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: {