diff --git a/frontend/src/lib/history.subview.dom.test.ts b/frontend/src/lib/history.subview.dom.test.ts new file mode 100644 index 00000000..75603f4f --- /dev/null +++ b/frontend/src/lib/history.subview.dom.test.ts @@ -0,0 +1,104 @@ +// @vitest-environment jsdom +// +// useHistorySubview: a module's active sub-view rides in history.state._sub, with optional address-bar +// mirroring so a deep sub-view (Plex show/season/info/person/playlist) is bookmarkable. This backfills +// the E4 deep-review's deferred coverage for the S2 fix — the seed AND the popstate handler must share +// the SAME URL fallback, so a popstate that lands on a bookmark entry (URL-only, no _sub — e.g. after +// an overlay's programmatic history.back()) restores the sub-view from the URL instead of collapsing +// it to root. The overlay/back-ladder half of history.ts is covered in history.dom.test.ts. +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useHistorySubview, type SubviewUrl } from "./history"; + +// A minimal string-view mirror: "root" is the un-mirrored root (encode → null), anything else mirrors +// verbatim to ?v=. decode rejects the empty string (→ null, i.e. not a valid sub-view). +const url: SubviewUrl = { + key: "v", + encode: (v) => (v === "root" ? null : v), + decode: (raw) => raw || null, +}; + +const setUrl = (search: string, state: unknown = null) => + window.history.replaceState(state, "", search || "/"); +const popTo = (search: string, state: unknown = null) => + act(() => { + window.history.replaceState(state, "", search || "/"); + window.dispatchEvent(new PopStateEvent("popstate", { state })); + }); + +beforeEach(() => setUrl("/")); +afterEach(() => vi.clearAllMocks()); + +describe("useHistorySubview seed", () => { + it("decodes the ? URL param when the entry has no _sub (fresh bookmark)", () => { + setUrl("?v=a"); + const { result } = renderHook(() => useHistorySubview("root", url)); + expect(result.current.view).toBe("a"); + }); + + it("prefers history.state._sub over the URL param (in-app entry)", () => { + setUrl("?v=a", { _sub: "b" }); + const { result } = renderHook(() => useHistorySubview("root", url)); + expect(result.current.view).toBe("b"); + }); + + it("falls back to root when neither state nor a decodable URL param is present", () => { + setUrl("/"); + const { result } = renderHook(() => useHistorySubview("root", url)); + expect(result.current.view).toBe("root"); + }); + + it("ignores the URL entirely when no SubviewUrl is supplied (state-only mirror)", () => { + setUrl("?v=a", { _sub: "fromState" }); + const { result } = renderHook(() => useHistorySubview("root")); + expect(result.current.view).toBe("fromState"); + // A URL param with no url config must NOT leak in. + setUrl("?v=a", null); + const { result: r2 } = renderHook(() => useHistorySubview("root")); + expect(r2.current.view).toBe("root"); + }); +}); + +describe("useHistorySubview open", () => { + it("pushes state._sub and mirrors the URL via encode", () => { + const { result } = renderHook(() => useHistorySubview("root", url)); + act(() => result.current.open("a")); + expect(result.current.view).toBe("a"); + expect(window.history.state?._sub).toBe("a"); + expect(window.location.search).toBe("?v=a"); + }); + + it("clears the mirrored key when encode returns null (root/ephemeral view)", () => { + setUrl("?v=a"); + const { result } = renderHook(() => useHistorySubview("root", url)); + act(() => result.current.open("root")); + expect(result.current.view).toBe("root"); + expect(window.history.state?._sub).toBe("root"); + expect(window.location.search).toBe(""); // ?v dropped + }); +}); + +describe("useHistorySubview popstate", () => { + it("restores the sub-view from state._sub on Back/Forward", () => { + const { result } = renderHook(() => useHistorySubview("root", url)); + popTo("?v=a", { _sub: "a" }); + expect(result.current.view).toBe("a"); + }); + + it("restores from the ? URL when the landed entry has no _sub — the S2 bookmark fix", () => { + // Start in-app at root, then a popstate lands on a URL-only bookmark entry (no _sub). Reading + // history.state alone would wrongly collapse to root; the shared URL fallback must decode ?v=b. + const { result } = renderHook(() => useHistorySubview("root", url)); + expect(result.current.view).toBe("root"); + popTo("?v=b", null); + expect(result.current.view).toBe("b"); + }); + + it("collapses to root when a popstate lands on an entry with neither _sub nor a decodable URL", () => { + setUrl("?v=a", { _sub: "a" }); + const { result } = renderHook(() => useHistorySubview("root", url)); + expect(result.current.view).toBe("a"); + popTo("/", null); + expect(result.current.view).toBe("root"); + }); +});