// @vitest-environment jsdom // // ChannelsProvider's tab-history behaviour: setView pushes a `_chTab` history entry + persists the tab, // and a genuine browser Back (popstate carrying `_chTab`) restores + re-persists it. This wiring was // re-routed through useAccountPersistedState in R13 S2b, so it's exercised here rather than only by hand E2E. // // Uses REAL storage (jsdom localStorage/sessionStorage) with the tab's account pinned to 1, so // useAccountPersistedState / useSortState / useFilterState all resolve their account-scoped keys; only // navigationContext (the sole runtime dependency) is mocked, pinned to the channels page. import { describe, it, expect, beforeEach, vi } from "vitest"; import { renderHook, act, render as renderRoot } from "@testing-library/react"; import { useEffect, useRef, type ReactNode } from "react"; vi.mock("./navigationContext", () => ({ useNavigation: () => ({ page: "channels" }), useNavigationActions: () => ({ setPage: vi.fn() }), })); import { ChannelsProvider } from "./ChannelsProvider"; import { useChannels, useChannelsActions } from "./channelsContext"; const wrapper = ({ children }: { children: ReactNode }) => ( {children} ); const render = () => renderHook(() => ({ ...useChannels(), ...useChannelsActions() }), { wrapper }); const VIEW_KEY = "siftlode.channelsView.1"; // account-scoped (accountKey suffixes the pinned id) beforeEach(() => { localStorage.clear(); sessionStorage.setItem("siftlode.activeAccount", "1"); // pin the tab's account // Reset BOTH the history state and the query string: setView only owns an entry on the channels page, // and the S8 URL mirror writes ctab/csort/cf — a stale key left by a prior test would be read back by // this provider's mount-time URL seed and change the starting tab. window.history.replaceState({ sfPage: "channels" }, "", "/"); }); describe("ChannelsProvider tab history", () => { it("setView pushes a _chTab history entry and persists the tab", () => { const { result } = render(); expect(result.current.view).toBe("subscribed"); // default act(() => result.current.setView("hidden")); expect(result.current.view).toBe("hidden"); expect(window.history.state).toMatchObject({ sfPage: "channels", _chTab: "hidden" }); expect(localStorage.getItem(VIEW_KEY)).toBe("hidden"); }); it("does NOT push a new entry when re-selecting the current tab", () => { const { result } = render(); const spy = vi.spyOn(window.history, "pushState"); act(() => result.current.setView("subscribed")); // already the current tab expect(spy).not.toHaveBeenCalled(); spy.mockRestore(); }); it("a genuine Back (popstate carrying _chTab) restores and re-persists that tab", () => { const { result } = render(); act(() => result.current.setView("blocked")); expect(result.current.view).toBe("blocked"); // Browser Back to an entry stamped with a different tab. act(() => window.dispatchEvent(new PopStateEvent("popstate", { state: { _chTab: "discovery" } })), ); expect(result.current.view).toBe("discovery"); expect(localStorage.getItem(VIEW_KEY)).toBe("discovery"); }); it("ignores a popstate for an entry outside the manager (no _chTab)", () => { const { result } = render(); act(() => result.current.setView("hidden")); act(() => window.dispatchEvent(new PopStateEvent("popstate", { state: { sfPage: "feed" } }))); expect(result.current.view).toBe("hidden"); // unchanged }); }); describe("ChannelsProvider — S8 shareable URL", () => { it("seeds tab + sort + filters from a shared manager link at mount (URL wins over stored)", () => { // Stored view says "blocked"; the shared link says "hidden" + a sort + a filter → the URL wins. localStorage.setItem(VIEW_KEY, "blocked"); const cf = encodeURIComponent(JSON.stringify({ sync: "needsFull" })); window.history.replaceState( { sfPage: "channels" }, "", `/?ctab=hidden&csort=priority:desc&cf=${cf}`, ); const { result } = render(); expect(result.current.view).toBe("hidden"); expect(result.current.sort).toEqual({ key: "priority", dir: "desc" }); expect(result.current.filters).toEqual({ sync: "needsFull" }); }); it("mirrors the tab into the address bar and clears it back to the default", () => { const { result } = render(); act(() => result.current.setView("blocked")); expect(new URLSearchParams(window.location.search).get("ctab")).toBe("blocked"); // Back to the default tab clears the key (only non-defaults are emitted). act(() => result.current.setView("subscribed")); expect(new URLSearchParams(window.location.search).has("ctab")).toBe(false); }); it("a PARTIAL shared link does not clobber the account's stored sort/filters", () => { // Regression: the seed's base for the OMITTED dimensions used to be read as defaults, and // persisting the seed overwrote the account's real stored sort + filter map. localStorage.setItem( "siftlode.channelSort.1", JSON.stringify({ key: "subscribers", dir: "desc" }), ); localStorage.setItem("siftlode.channelsFilterMap.1", JSON.stringify({ sync: "needsFull" })); window.history.replaceState({ sfPage: "channels" }, "", "/?ctab=hidden"); // only the tab in the URL const { result } = render(); // The present dim (tab) is adopted from the link... expect(result.current.view).toBe("hidden"); // ...but the omitted sort + filters keep the account's real stored values, in state AND storage. expect(result.current.sort).toEqual({ key: "subscribers", dir: "desc" }); expect(result.current.filters).toEqual({ sync: "needsFull" }); expect(JSON.parse(localStorage.getItem("siftlode.channelSort.1")!)).toEqual({ key: "subscribers", dir: "desc", }); expect(JSON.parse(localStorage.getItem("siftlode.channelsFilterMap.1")!)).toEqual({ sync: "needsFull", }); }); }); // --- the focus-a-channel intent is consumed, not masked ------------------------------------------- // `focusChannel` sends the user to the manager with its name filter seeded. The manager is a page // chunk: it unmounts on every navigation away, and on opening a channel page (which does not even // change `page`), and its seeding effect runs again on each mount. So the "already applied" marker // has to live in the PROVIDER — inside the page it would reset with the page and re-apply an intent // from earlier in the session over whatever the user has typed into the search box since. describe("ChannelsProvider — the focus-a-channel intent", () => { /** Stands in for Channels.tsx:287-297 — the same effect, the same consume call. */ function Manager() { const { focusName, focusToken } = useChannels(); const { setSearch, consumeFocus } = useChannelsActions(); const nameRef = useRef(focusName); nameRef.current = focusName; useEffect(() => { const name = nameRef.current; if (!name) return; setSearch(name); consumeFocus(); }, [focusToken, setSearch, consumeFocus]); return null; } function Harness({ mounted }: { mounted: boolean }) { return <>{mounted && }; } it("seeds the search box once, and a later visit does not re-seed it", () => { let api!: ReturnType & ReturnType; function Probe() { api = { ...useChannels(), ...useChannelsActions() }; return null; } const { rerender } = renderRoot( , ); act(() => api.focusChannel("Kurzgesagt")); expect(api.search).toBe("Kurzgesagt"); // The user looks for something else instead. act(() => api.setSearch("veritasium")); // Leave the manager (the page chunk unmounts) and come back. rerender( , ); rerender( , ); expect(api.search).toBe("veritasium"); }); it("re-seeds when the channel is focused again — the token is the intent", () => { let api!: ReturnType & ReturnType; function Probe() { api = { ...useChannels(), ...useChannelsActions() }; return null; } renderRoot( , ); act(() => api.focusChannel("Kurzgesagt")); act(() => api.setSearch("veritasium")); act(() => api.focusChannel("Kurzgesagt")); // the same channel, a new intent expect(api.search).toBe("Kurzgesagt"); }); });