mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
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
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user