Ports main.ts's parseRoute/dispatch hash parsing into a React hook: same #/ and #/attach/:id shape, current route plus a navigate function. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
83 lines
2.9 KiB
TypeScript
83 lines
2.9 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { act, cleanup, render, waitFor } from "@testing-library/react";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
|
|
import { useHashRouter, type Route, type UseHashRouterResult } from "./useHashRouter.js";
|
|
|
|
let controls: UseHashRouterResult;
|
|
|
|
function Probe() {
|
|
controls = useHashRouter();
|
|
return <div>{controls.route.kind === "attach" ? `attach:${controls.route.sessionId}` : "catalog"}</div>;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
location.hash = "";
|
|
});
|
|
|
|
afterEach(cleanup);
|
|
|
|
describe("useHashRouter", () => {
|
|
it("starts on catalog for an empty hash", () => {
|
|
const view = render(<Probe />);
|
|
expect(view.container.textContent).toBe("catalog");
|
|
});
|
|
|
|
it("resolves #/attach/:id to the attach route", () => {
|
|
location.hash = "#/attach/s1";
|
|
const view = render(<Probe />);
|
|
expect(view.container.textContent).toBe("attach:s1");
|
|
});
|
|
|
|
it("decodes an encoded session id", () => {
|
|
location.hash = `#/attach/${encodeURIComponent("weird id/x")}`;
|
|
const view = render(<Probe />);
|
|
expect(view.container.textContent).toBe("attach:weird id/x");
|
|
});
|
|
|
|
it("falls back to catalog for an unknown route", () => {
|
|
location.hash = "#/nonsense";
|
|
const view = render(<Probe />);
|
|
expect(view.container.textContent).toBe("catalog");
|
|
});
|
|
|
|
it("follows a hashchange fired outside the hook", async () => {
|
|
const view = render(<Probe />);
|
|
// happy-dom dispatches `hashchange` on a real timer, like a browser — not
|
|
// synchronously with the assignment.
|
|
location.hash = "#/attach/s9";
|
|
await waitFor(() => expect(view.container.textContent).toBe("attach:s9"));
|
|
});
|
|
|
|
it("navigate updates the hash and the route", async () => {
|
|
const view = render(<Probe />);
|
|
act(() => controls.navigate({ kind: "attach", sessionId: "s2" } satisfies Route));
|
|
expect(location.hash).toBe("#/attach/s2");
|
|
await waitFor(() => expect(view.container.textContent).toBe("attach:s2"));
|
|
});
|
|
|
|
it("navigate back to catalog sets the root hash", async () => {
|
|
location.hash = "#/attach/s1";
|
|
const view = render(<Probe />);
|
|
act(() => controls.navigate({ kind: "catalog" }));
|
|
expect(location.hash).toBe("#/");
|
|
await waitFor(() => expect(view.container.textContent).toBe("catalog"));
|
|
});
|
|
|
|
it("navigating to the already-current route still updates state", () => {
|
|
location.hash = "#/attach/s1";
|
|
const view = render(<Probe />);
|
|
act(() => controls.navigate({ kind: "attach", sessionId: "s1" } satisfies Route));
|
|
expect(view.container.textContent).toBe("attach:s1");
|
|
});
|
|
|
|
it("stops listening after unmount", async () => {
|
|
const view = render(<Probe />);
|
|
view.unmount();
|
|
location.hash = "#/attach/s3";
|
|
// No assertion beyond "doesn't throw" — a leaked listener would only ever
|
|
// surface as noise in a later test's act() warnings.
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
});
|
|
});
|