test(e4): useHistorySubview seed + popstate URL-fallback (S2 fix)

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.
This commit is contained in:
2026-08-20 05:14:12 +02:00
parent fa8bdf83a8
commit e8a1c252a9
@@ -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=<view>. decode rejects the empty string (→ null, i.e. not a valid sub-view).
const url: SubviewUrl<string> = {
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 ?<key> 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 ?<key> 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");
});
});