// @vitest-environment jsdom // // The login card's sub-views (Create an account / Forgot password / Didn't get the email) used to be // plain React state: clicking one changed nothing in history, so Back popped to whatever entry came // before the card and the address bar moved while the form on screen did not. They now ride // useHistorySubview, and this pins the wiring decisions that are specific to THIS card — which modes // are mirrored, and that Back walks the views. The hook's own mechanics live in // history.subview.dom.test.ts. import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { renderHook, act } from "@testing-library/react"; import { useHistorySubview } from "../lib/history"; import { MODE_URL, type Mode } from "./welcomeMode"; const render = (root: Mode = "signin") => renderHook(() => useHistorySubview(root, MODE_URL)); // jsdom's history.back() is asynchronous and does not rewind location, so — as in // history.subview.dom.test.ts — a Back is simulated by landing on the target entry and firing // popstate. `open()` pushing a REAL entry is asserted separately via history.length. const popTo = (search: string, state: unknown = null) => act(() => { window.history.replaceState(state, "", search || "/"); window.dispatchEvent(new PopStateEvent("popstate", { state })); }); // jsdom keeps one history across a file; land on a clean root entry before each test. beforeEach(() => window.history.replaceState(null, "", "/")); afterEach(() => window.history.replaceState(null, "", "/")); describe("login card mode ↔ URL", () => { it("mirrors the three link-reachable views and leaves sign-in as the bare root", () => { expect(MODE_URL.encode("signin")).toBeNull(); expect(MODE_URL.encode("register")).toBe("register"); expect(MODE_URL.encode("forgot")).toBe("forgot"); expect(MODE_URL.encode("resend")).toBe("resend"); }); it("never mirrors the reset view", () => { // Reset is only reachable with the one-time token from the #reset fragment. Mirroring it would // hand out a bookmark that opens a password form with no token behind it. expect(MODE_URL.encode("reset")).toBeNull(); expect(MODE_URL.decode("reset")).toBeNull(); }); it("decodes only real views, so a hand-typed ?m= falls back to sign-in", () => { expect(MODE_URL.decode("register")).toBe("register"); expect(MODE_URL.decode("")).toBeNull(); expect(MODE_URL.decode("nonsense")).toBeNull(); }); it("opening a view pushes a real history entry, and landing back on the root restores sign-in", () => { const { result } = render(); const entries = window.history.length; expect(result.current.view).toBe("signin"); act(() => result.current.open("forgot")); // The whole point: a Back has somewhere to go. Before this the click changed state only. expect(window.history.length).toBe(entries + 1); expect(result.current.view).toBe("forgot"); expect(new URLSearchParams(window.location.search).get("m")).toBe("forgot"); popTo("/"); expect(result.current.view).toBe("signin"); }); it("walks back one view at a time when the user hops between them", () => { const { result } = render(); const entries = window.history.length; act(() => result.current.open("register")); act(() => result.current.open("forgot")); expect(window.history.length).toBe(entries + 2); // one entry per view, not one for the lot popTo("/?m=register", { _sub: "register" }); expect(result.current.view).toBe("register"); popTo("/"); expect(result.current.view).toBe("signin"); }); it("opens on the reset view when a reset token seeded it, without putting it in the URL", () => { const { result } = render("reset"); expect(result.current.view).toBe("reset"); expect(window.location.search).toBe(""); }); it("restores a bookmarked view from the URL alone", () => { window.history.replaceState(null, "", "/?m=resend"); const { result } = render(); expect(result.current.view).toBe("resend"); }); });