Files
HealthLog/src/lib/__tests__/url-state.test.ts
T
Marc-André Bombeck d8e4cf6000 fix(url): write page state into the query string through one serialised helper
Next 16.4 adopts a native history write a render later. A second write
inside that gap was undone when the router adopted the first, and a
write computed from useSearchParams started from the URL before the
last one. Clearing the vault's filters could therefore leave
?encounter= behind: the debounced search wrote the filters it had
rendered back over the clear.

@/lib/url-state is now the one way a page writes its own query: each
write starts from the live address bar, at most one waits for the
router at a time, and later updates queue on top of it and go together
once the router has it (or after a short grace). useUrlSearch reads the
same live value and reports each adoption. The filter hook (documents,
measurements, mood), the open document, the day layer, the timeline's
zoom and range, the medication tabs, the AI provider choice, the
Coach's ?c= and the one-shot parameter strips all go through it; the
filter changes merge into the live filters. A guard refuses raw
history writes and router writes built from the rendered params
outside the helper.

The timeline spec now waits for the values menu to hand focus back
before scrolling to the last row; it measured mid-animation and the
focus return scrolled the page back.
2026-10-23 02:04:21 +02:00

126 lines
4.7 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import {
installUrlStateBrowser,
type UrlStateBrowser,
} from "@/__tests__/helpers/url-state-browser";
import {
ADOPTION_GRACE_MS,
currentUrlSearch,
reportRouterSearch,
setAllParams,
writeUrlHref,
writeUrlState,
} from "../url-state";
/**
* The URL-state helper's contract: every write starts from the live address
* (never a rendered copy), at most one write waits for the router at a time,
* and what queues behind it is applied on top, together, once the router
* has the first one (or the grace runs out).
*/
let browser: UrlStateBrowser;
afterEach(() => {
browser.uninstall();
vi.useRealTimers();
});
describe("writeUrlState", () => {
it("edits the live parameters and writes one entry", () => {
browser = installUrlStateBrowser("/documents?q=scan");
writeUrlState((params) => params.set("encounter", "e1"), {
mode: "push",
});
expect(browser.push).toHaveBeenCalledWith("/documents?q=scan&encounter=e1");
expect(browser.replace).not.toHaveBeenCalled();
});
it("does not write when nothing changes", () => {
browser = installUrlStateBrowser("/documents?q=scan");
writeUrlState((params) => params.set("q", "scan"));
expect(browser.replace).not.toHaveBeenCalled();
});
it("holds a second write until the router has the first, then applies both", () => {
browser = installUrlStateBrowser("/documents?q=scan", {
autoAdopt: false,
});
writeUrlState((params) => params.set("encounter", "e1"), {
mode: "push",
});
writeUrlState((params) => params.set("year", "2025"));
// Not yet written, but every reader already sees it.
expect(browser.current()).toBe("/documents?q=scan&encounter=e1");
expect(currentUrlSearch()).toBe("?q=scan&encounter=e1&year=2025");
reportRouterSearch("?q=scan&encounter=e1");
expect(browser.current()).toBe("/documents?q=scan&encounter=e1&year=2025");
});
it("never puts the cleared filters back (the vault's ?encounter= case)", () => {
browser = installUrlStateBrowser("/documents?q=scan&encounter=e1", {
autoAdopt: false,
});
// "Clear filters" empties the query …
writeUrlState(setAllParams(""), { mode: "push" });
// … and the debounced search, still holding the rendered filters,
// writes `q` again. Derived from the live query, it keeps the clear.
writeUrlState((params) => params.delete("q"), { mode: "replace" });
reportRouterSearch("");
expect(browser.current()).toBe("/documents");
expect(browser.current()).not.toMatch(/encounter=/);
});
it("lets the queue go after the grace when no reader reports", () => {
vi.useFakeTimers();
browser = installUrlStateBrowser("/timeline", { autoAdopt: false });
writeUrlState((params) => params.set("zoom", "year"), { mode: "push" });
writeUrlState((params) => params.set("day", "2026-01-03"));
expect(browser.current()).toBe("/timeline?zoom=year");
vi.advanceTimersByTime(ADOPTION_GRACE_MS);
expect(browser.current()).toBe("/timeline?zoom=year&day=2026-01-03");
});
it("coalesces a queued push with a queued replace into one new entry", () => {
browser = installUrlStateBrowser("/mood", { autoAdopt: false });
writeUrlState((params) => params.set("mood", "GUT"));
writeUrlState((params) => params.set("source", "MANUAL"), { mode: "push" });
writeUrlState((params) => params.set("from", "2026-05-01"));
reportRouterSearch("?mood=GUT");
expect(browser.push).toHaveBeenCalledTimes(1);
expect(browser.current()).toBe(
"/mood?mood=GUT&source=MANUAL&from=2026-05-01",
);
});
it("does not wait on a write that keeps the router's own entry state", () => {
browser = installUrlStateBrowser("/coach?settings=data", {
autoAdopt: false,
});
writeUrlState((params) => params.delete("settings"), { state: "keep" });
writeUrlState((params) => params.set("c", "abc"), { state: "keep" });
expect(browser.current()).toBe("/coach?c=abc");
});
it("writes a whole href through the same queue", () => {
browser = installUrlStateBrowser("/coach?c=old");
writeUrlHref("/coach?c=new");
expect(browser.replace).toHaveBeenCalledWith("/coach?c=new");
});
it("waits two frames for a write made while mounting", () => {
browser = installUrlStateBrowser("/measurements?add=WEIGHT&type=PULSE");
const raf = vi.fn((callback: (time: number) => void) => {
callback(0);
return 0;
});
(
window as unknown as { requestAnimationFrame: typeof raf }
).requestAnimationFrame = raf;
writeUrlState((params) => params.delete("add"), { afterPaint: true });
expect(raf).toHaveBeenCalledTimes(2);
expect(browser.current()).toBe("/measurements?type=PULSE");
});
});