Files
siftlode/frontend/src/modules/channels/ChannelsProvider.dom.test.tsx
T
peter 63a0fecc8b refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/
A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform
layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each
page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in
features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/
(ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows).

Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the
contract tests that scan sources now scan the whole app source, not only components/ — each glob
was counted before and after so none went green over fewer files. The locale-format rule matched
`/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton
check; it now covers every folder that holds components, with a test per folder.
2026-10-22 23:12:45 +02:00

237 lines
9.8 KiB
TypeScript

// @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, so it's exercised here rather than only by hand E2E.
//
// Uses REAL storage (jsdom localStorage/sessionStorage) with account 1's local state claimed, so
// useAccountPersistedState / useSortState / useFilterState all resolve their account-scoped keys; only
// navigationContext is mocked, pinned to the channels page. The layout is an account preference
// (usePref), read from the `me` cache BootGate fills before the app mounts.
import { describe, it, expect, beforeEach, vi } from "vitest";
import { renderHook, act, render as renderRoot } from "@testing-library/react";
import { useEffect, useEffectEvent, type ReactNode } from "react";
vi.mock("../../features/navigation/navigationContext", () => ({
useNavigation: () => ({ page: "channels" }),
useNavigationActions: () => ({ setPage: vi.fn() }),
}));
import { ChannelsProvider } from "./ChannelsProvider";
import { useChannelLayout, useChannels, useChannelsActions } from "./channelsContext";
import { claimLocalData } from "../../lib/storage";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { qk } from "../../lib/queryKeys";
let qc = new QueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>
<ChannelsProvider>{children}</ChannelsProvider>
</QueryClientProvider>
);
const withClient = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
const render = () => renderHook(() => ({ ...useChannels(), ...useChannelsActions() }), { wrapper });
const VIEW_KEY = "siftlode.channelsView"; // the account's (claimed in beforeEach)
beforeEach(() => {
localStorage.clear();
claimLocalData(1); // the browser holds account 1's state
qc = new QueryClient();
qc.setQueryData(qk.me(), { id: 1, preferences: {} });
// Reset BOTH the history state and the query string: setView only owns an entry on the channels page,
// and the 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 — 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",
JSON.stringify({ key: "subscribers", dir: "desc" }),
);
localStorage.setItem("siftlode.channelsFilterMap", 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")!)).toEqual({
key: "subscribers",
dir: "desc",
});
expect(JSON.parse(localStorage.getItem("siftlode.channelsFilterMap")!)).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's `applyFocus` — the same effect event, the same consume call. */
function Manager() {
const { focusName, focusToken } = useChannels();
const { setSearch, consumeFocus } = useChannelsActions();
const applyFocus = useEffectEvent(() => {
if (!focusName) return;
setSearch(focusName);
consumeFocus();
});
useEffect(() => applyFocus(), [focusToken]);
return null;
}
function Harness({ mounted }: { mounted: boolean }) {
return <>{mounted && <Manager />}</>;
}
it("seeds the search box once, and a later visit does not re-seed it", () => {
let api!: ReturnType<typeof useChannels> & ReturnType<typeof useChannelsActions>;
function Probe() {
api = { ...useChannels(), ...useChannelsActions() };
return null;
}
const { rerender } = renderRoot(
<ChannelsProvider>
<Probe />
<Harness mounted />
</ChannelsProvider>,
{ wrapper: withClient },
);
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(
<ChannelsProvider>
<Probe />
<Harness mounted={false} />
</ChannelsProvider>,
);
rerender(
<ChannelsProvider>
<Probe />
<Harness mounted />
</ChannelsProvider>,
);
expect(api.search).toBe("veritasium");
});
it("re-seeds when the channel is focused again — the token is the intent", () => {
let api!: ReturnType<typeof useChannels> & ReturnType<typeof useChannelsActions>;
function Probe() {
api = { ...useChannels(), ...useChannelsActions() };
return null;
}
renderRoot(
<ChannelsProvider>
<Probe />
<Harness mounted />
</ChannelsProvider>,
{ wrapper: withClient },
);
act(() => api.focusChannel("Kurzgesagt"));
act(() => api.setSearch("veritasium"));
act(() => api.focusChannel("Kurzgesagt")); // the same channel, a new intent
expect(api.search).toBe("Kurzgesagt");
});
});
describe("ChannelsProvider — the layout", () => {
// The table or the cards is a choice about the account, so it follows it to every device: the
// server's word wins over what this browser last showed.
it("is the account's preference, over this browser's copy", () => {
localStorage.setItem("siftlode.channelLayout", "table");
qc.setQueryData(qk.me(), { id: 1, preferences: { channelLayout: "cards" } });
const { result } = renderHook(useChannelLayout, { wrapper });
expect(result.current).toBe("cards");
});
});