From e8a1c252a9ae1bf0689c40a5277b15533e004a30 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 20 Aug 2026 05:14:12 +0200 Subject: [PATCH] test(e4): useHistorySubview seed + popstate URL-fallback (S2 fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backfills the E4 deep-review's deferred coverage for the subview history primitive. The keeper case is the S2 bookmark fix: a popstate that lands on a URL-only entry (no history.state._sub) must restore the sub-view from the ?v param via the SAME fallback the mount seed uses, not collapse to root. Covers: seed from URL vs state._sub vs root, the no-SubviewUrl state-only path, open() push+mirror + encode→null key-clear, and popstate restore from state / from URL (the fix) / collapse-to-root. 9 tests, gate green. --- frontend/src/lib/history.subview.dom.test.ts | 104 +++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 frontend/src/lib/history.subview.dom.test.ts 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"); + }); +});