24d13faca7
- npm run typecheck failed on the 0-arg vi.fn<() => void>() because mock.calls[0] became an empty tuple, so calls[0][2] (the URL arg) was typed as undefined. - Constrain the mock to the real (state, unused, url?) signature so mock.calls[0] is a 3-tuple and [2] is string-or-URL-or-null. Drops the as-string cast at every callsite.
244 lines
8.8 KiB
TypeScript
244 lines
8.8 KiB
TypeScript
// @vitest-environment happy-dom
|
|
// Mirror the IS_REACT_ACT_ENVIRONMENT setup from the other hook tests so
|
|
// React doesn't log act() warnings about the createRoot render/unmount
|
|
// and the popstate-driven state updates.
|
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
import React, { act } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
import { useDrawerUrlState } from "./useDrawerUrlState";
|
|
|
|
/**
|
|
* Minimal renderHook shim — same pattern as `useClaimDetail.test.ts`.
|
|
* The project doesn't ship `@testing-library/react`, so we wire a Probe
|
|
* component into a real `createRoot` and read its return value through
|
|
* a shared `result` object. `act` flushes React's state updates between
|
|
* micro-tasks so assertions after a popstate dispatch see the new value.
|
|
*/
|
|
function renderHook<TResult>(setup: () => TResult): {
|
|
result: { current: TResult | undefined };
|
|
unmount: () => void;
|
|
} {
|
|
const result: { current: TResult | undefined } = { current: undefined };
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
|
|
function Probe() {
|
|
result.current = setup();
|
|
return null;
|
|
}
|
|
|
|
const root: Root = createRoot(container);
|
|
act(() => {
|
|
root.render(React.createElement(Probe));
|
|
});
|
|
|
|
return {
|
|
result,
|
|
unmount: () => {
|
|
act(() => root.unmount());
|
|
container.remove();
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Point happy-dom's URL at a known value. happy-dom v20 doesn't expose a
|
|
* writable `window.location.search`, but `window.happyDOM.setURL` updates
|
|
* the URL the window reports without triggering a navigation — exactly
|
|
* what we want for mounting the hook at `/claims?claim=CLM-1` etc.
|
|
*/
|
|
function setLocation(url: string): void {
|
|
// happy-dom attaches the setURL helper under `window.happyDOM`. Cast
|
|
// through `unknown` to keep the test file free of happy-dom imports.
|
|
(window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(url);
|
|
}
|
|
|
|
describe("useDrawerUrlState", () => {
|
|
// `vi.fn()` with no args in vitest 4 is typed `Mock<Procedure |
|
|
// Constructable>`, which carries a constructor signature and won't
|
|
// assign to a real `pushState` slot. Constrain the generic to the
|
|
// `History.pushState` signature so the mock flows into `vi.stubGlobal`
|
|
// without complaint AND `mock.calls[0]` is typed as a 3-tuple, letting
|
|
// us read `calls[0][2]` (the URL) without an `as unknown` cast.
|
|
type PushState = (state: unknown, unused: string, url?: string | URL | null) => void;
|
|
let pushStateMock: ReturnType<typeof vi.fn<PushState>>;
|
|
let replaceStateMock: ReturnType<typeof vi.fn<PushState>>;
|
|
|
|
beforeEach(() => {
|
|
pushStateMock = vi.fn();
|
|
replaceStateMock = vi.fn();
|
|
|
|
// Replace `history` with a barebones stub. We only need pushState /
|
|
// replaceState (and a state slot for the History spec). `vi.stubGlobal`
|
|
// also affects `window.history` because happy-dom's window is the
|
|
// global object.
|
|
vi.stubGlobal("history", {
|
|
pushState: pushStateMock,
|
|
replaceState: replaceStateMock,
|
|
state: null,
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
// Reset happy-dom's URL between tests so one test's `?claim=…` doesn't
|
|
// leak into the next test's "no param" assertion.
|
|
setLocation("http://localhost/");
|
|
});
|
|
|
|
it("reads the ?claim= param from window.location.search on mount", () => {
|
|
setLocation("http://localhost/claims?claim=CLM-1");
|
|
|
|
const { result, unmount } = renderHook(() => useDrawerUrlState());
|
|
|
|
expect(result.current?.claimId).toBe("CLM-1");
|
|
expect(typeof result.current?.open).toBe("function");
|
|
expect(typeof result.current?.close).toBe("function");
|
|
expect(typeof result.current?.setClaimId).toBe("function");
|
|
|
|
unmount();
|
|
});
|
|
|
|
it("returns null claimId when no ?claim= param is set", () => {
|
|
// setLocation to a URL with no ?claim= (default afterLocation is
|
|
// bare, but afterEach resets it explicitly — be defensive).
|
|
setLocation("http://localhost/claims");
|
|
|
|
const { result, unmount } = renderHook(() => useDrawerUrlState());
|
|
|
|
expect(result.current?.claimId).toBeNull();
|
|
|
|
unmount();
|
|
});
|
|
|
|
it("returns null claimId when ?claim= is present but empty", () => {
|
|
// Regression: `URLSearchParams.get` returns `""` for `?claim=`, which
|
|
// a naive implementation would propagate as `claimId: ""`. We treat
|
|
// empty as absent so the contract is "present-or-null", not
|
|
// "present-or-empty-string".
|
|
setLocation("http://localhost/claims?claim=");
|
|
|
|
const { result, unmount } = renderHook(() => useDrawerUrlState());
|
|
|
|
expect(result.current?.claimId).toBeNull();
|
|
|
|
unmount();
|
|
});
|
|
|
|
it("open(id) pushes a new history entry containing ?claim=id", () => {
|
|
setLocation("http://localhost/claims");
|
|
|
|
const { result, unmount } = renderHook(() => useDrawerUrlState());
|
|
|
|
act(() => {
|
|
result.current?.open("CLM-2");
|
|
});
|
|
|
|
expect(pushStateMock).toHaveBeenCalledTimes(1);
|
|
expect(replaceStateMock).not.toHaveBeenCalled();
|
|
// The third arg to pushState is the URL. The plan calls for a relative
|
|
// URL built from pathname+search+hash, so it should contain the
|
|
// ?claim= param.
|
|
const urlArg = pushStateMock.mock.calls[0][2] as string;
|
|
expect(urlArg).toContain("?claim=CLM-2");
|
|
|
|
unmount();
|
|
});
|
|
|
|
it("setClaimId(id) replaces the current history entry (no new entry) and does NOT pushState", () => {
|
|
setLocation("http://localhost/claims?claim=CLM-1");
|
|
|
|
const { result, unmount } = renderHook(() => useDrawerUrlState());
|
|
|
|
act(() => {
|
|
result.current?.setClaimId("CLM-3");
|
|
});
|
|
|
|
expect(replaceStateMock).toHaveBeenCalledTimes(1);
|
|
expect(pushStateMock).not.toHaveBeenCalled();
|
|
const urlArg = replaceStateMock.mock.calls[0][2] as string;
|
|
expect(urlArg).toContain("?claim=CLM-3");
|
|
|
|
unmount();
|
|
});
|
|
|
|
it("open() and close() preserve other query params (only ?claim= is touched)", () => {
|
|
// Regression: `buildUrl` must only add/delete the `claim` key and
|
|
// leave sibling params like `?foo=bar` intact. A bug here would mean
|
|
// opening a claim nukes unrelated URL state — easy to miss without
|
|
// an explicit assertion.
|
|
setLocation("http://localhost/claims?foo=bar&claim=CLM-1");
|
|
|
|
const { result, unmount } = renderHook(() => useDrawerUrlState());
|
|
|
|
// open replaces the claim value but keeps foo.
|
|
act(() => {
|
|
result.current?.open("CLM-2");
|
|
});
|
|
const openUrl = pushStateMock.mock.calls[0][2] as string;
|
|
expect(openUrl).toContain("foo=bar");
|
|
// `claim=` may be preceded by `?` or `&` depending on param order,
|
|
// so just assert the param value appears.
|
|
expect(openUrl).toMatch(/[?&]claim=CLM-2/);
|
|
|
|
// close strips claim and keeps foo.
|
|
act(() => {
|
|
result.current?.close();
|
|
});
|
|
const closeUrl = pushStateMock.mock.calls[1][2] as string;
|
|
expect(closeUrl).toContain("foo=bar");
|
|
expect(closeUrl).not.toContain("claim=");
|
|
|
|
unmount();
|
|
});
|
|
|
|
it("close() pushes a new history entry with the ?claim= param stripped", () => {
|
|
setLocation("http://localhost/claims?claim=CLM-1");
|
|
|
|
const { result, unmount } = renderHook(() => useDrawerUrlState());
|
|
|
|
act(() => {
|
|
result.current?.close();
|
|
});
|
|
|
|
expect(pushStateMock).toHaveBeenCalledTimes(1);
|
|
expect(replaceStateMock).not.toHaveBeenCalled();
|
|
const urlArg = pushStateMock.mock.calls[0][2] as string;
|
|
// The plan's spec: close strips the param. After close, the URL should
|
|
// not contain ?claim= (or contain it as empty — either way the param
|
|
// is effectively gone). Our implementation deletes the param entirely.
|
|
expect(urlArg).not.toContain("?claim=");
|
|
// claimId must also flip back to null synchronously after close.
|
|
expect(result.current?.claimId).toBeNull();
|
|
|
|
unmount();
|
|
});
|
|
|
|
it("updates claimId in response to popstate (browser back/forward)", async () => {
|
|
// Start on CLM-1; simulate the user navigating forward to CLM-2 via
|
|
// browser history (back/forward fires popstate). The hook must pick up
|
|
// the new URL even though it didn't trigger the navigation itself.
|
|
setLocation("http://localhost/claims?claim=CLM-1");
|
|
|
|
const { result, unmount } = renderHook(() => useDrawerUrlState());
|
|
|
|
expect(result.current?.claimId).toBe("CLM-1");
|
|
|
|
// Simulate browser back/forward: the URL changes and popstate fires.
|
|
// happy-dom does not dispatch popstate automatically when setURL is
|
|
// called, so we change the URL and dispatch the event ourselves — the
|
|
// same shape a real browser would produce.
|
|
setLocation("http://localhost/claims?claim=CLM-2");
|
|
await act(async () => {
|
|
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
// Yield once so React's setState inside the listener flushes.
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(result.current?.claimId).toBe("CLM-2");
|
|
|
|
unmount();
|
|
});
|
|
}); |