220 lines
6.6 KiB
TypeScript
220 lines
6.6 KiB
TypeScript
// @vitest-environment happy-dom
|
|
// Mirror the IS_REACT_ACT_ENVIRONMENT setup from useProviderDrawerUrlState.test.ts
|
|
// 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 { useAckDrawerUrlState } from "./useAckDrawerUrlState";
|
|
|
|
/**
|
|
* Minimal renderHook shim — same pattern as the other hook tests.
|
|
*/
|
|
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 `/acks?ack=42` etc.
|
|
*/
|
|
function setLocation(url: string): void {
|
|
(window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(url);
|
|
}
|
|
|
|
describe("useAckDrawerUrlState", () => {
|
|
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();
|
|
|
|
vi.stubGlobal("history", {
|
|
pushState: pushStateMock,
|
|
replaceState: replaceStateMock,
|
|
state: null,
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
setLocation("http://localhost/");
|
|
});
|
|
|
|
it("reads the ?ack= param from window.location.search on mount", () => {
|
|
setLocation("http://localhost/acks?ack=42");
|
|
|
|
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
|
|
|
|
expect(result.current?.ackId).toBe("42");
|
|
expect(typeof result.current?.open).toBe("function");
|
|
expect(typeof result.current?.close).toBe("function");
|
|
expect(typeof result.current?.setAckId).toBe("function");
|
|
|
|
unmount();
|
|
});
|
|
|
|
it("returns null ackId when no ?ack= param is set", () => {
|
|
setLocation("http://localhost/acks");
|
|
|
|
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
|
|
|
|
expect(result.current?.ackId).toBeNull();
|
|
|
|
unmount();
|
|
});
|
|
|
|
it("returns null ackId when ?ack= is present but empty", () => {
|
|
setLocation("http://localhost/acks?ack=");
|
|
|
|
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
|
|
|
|
expect(result.current?.ackId).toBeNull();
|
|
|
|
unmount();
|
|
});
|
|
|
|
it("open(id) pushes a new history entry containing ?ack=ID", () => {
|
|
setLocation("http://localhost/acks");
|
|
|
|
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
|
|
|
|
act(() => {
|
|
result.current?.open("42");
|
|
});
|
|
|
|
expect(pushStateMock).toHaveBeenCalledTimes(1);
|
|
expect(replaceStateMock).not.toHaveBeenCalled();
|
|
const urlArg = pushStateMock.mock.calls[0][2] as string;
|
|
expect(urlArg).toContain("?ack=42");
|
|
|
|
unmount();
|
|
});
|
|
|
|
it("setAckId(id) replaces the current history entry (no new entry) and does NOT pushState", () => {
|
|
setLocation("http://localhost/acks?ack=42");
|
|
|
|
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
|
|
|
|
act(() => {
|
|
result.current?.setAckId("43");
|
|
});
|
|
|
|
expect(replaceStateMock).toHaveBeenCalledTimes(1);
|
|
expect(pushStateMock).not.toHaveBeenCalled();
|
|
const urlArg = replaceStateMock.mock.calls[0][2] as string;
|
|
expect(urlArg).toContain("?ack=43");
|
|
|
|
unmount();
|
|
});
|
|
|
|
it("open() and close() preserve other query params (only ?ack= is touched)", () => {
|
|
setLocation("http://localhost/acks?sort=date&ack=42");
|
|
|
|
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
|
|
|
|
act(() => {
|
|
result.current?.open("43");
|
|
});
|
|
const openUrl = pushStateMock.mock.calls[0][2] as string;
|
|
expect(openUrl).toContain("sort=date");
|
|
expect(openUrl).toMatch(/[?&]ack=43/);
|
|
|
|
act(() => {
|
|
result.current?.close();
|
|
});
|
|
const closeUrl = pushStateMock.mock.calls[1][2] as string;
|
|
expect(closeUrl).toContain("sort=date");
|
|
expect(closeUrl).not.toContain("ack=");
|
|
|
|
unmount();
|
|
});
|
|
|
|
it("close() pushes a new history entry with the ?ack= param stripped", () => {
|
|
setLocation("http://localhost/acks?ack=42");
|
|
|
|
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
|
|
|
|
act(() => {
|
|
result.current?.close();
|
|
});
|
|
|
|
expect(pushStateMock).toHaveBeenCalledTimes(1);
|
|
expect(replaceStateMock).not.toHaveBeenCalled();
|
|
const urlArg = pushStateMock.mock.calls[0][2] as string;
|
|
expect(urlArg).not.toContain("?ack=");
|
|
expect(result.current?.ackId).toBeNull();
|
|
|
|
unmount();
|
|
});
|
|
|
|
it("updates ackId in response to popstate (browser back/forward)", async () => {
|
|
setLocation("http://localhost/acks?ack=42");
|
|
|
|
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
|
|
|
|
expect(result.current?.ackId).toBe("42");
|
|
|
|
setLocation("http://localhost/acks?ack=43");
|
|
await act(async () => {
|
|
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(result.current?.ackId).toBe("43");
|
|
|
|
unmount();
|
|
});
|
|
|
|
it("does not collide with the existing ?claim=, ?remit=, or ?provider= params (orthogonal keys)", () => {
|
|
// The acks drawer is independent of the claim/remit/provider
|
|
// drawers — opening an ack must leave the others intact so a user
|
|
// can deep-link to multiple states simultaneously (though the UI
|
|
// currently only shows one drawer at a time, the URL params don't
|
|
// know that).
|
|
setLocation("http://localhost/?claim=CLM-1&remit=REM-1&provider=1881068062");
|
|
|
|
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
|
|
|
|
act(() => {
|
|
result.current?.open("42");
|
|
});
|
|
const openUrl = pushStateMock.mock.calls[0][2] as string;
|
|
expect(openUrl).toContain("claim=CLM-1");
|
|
expect(openUrl).toContain("remit=REM-1");
|
|
expect(openUrl).toContain("provider=1881068062");
|
|
expect(openUrl).toMatch(/[?&]ack=42/);
|
|
|
|
unmount();
|
|
});
|
|
});
|