Gates the folder move had narrowed or left open: the MenuCheck contract scans the whole app again (it saw only components/); local/layers refuses an import of an entry (main.tsx, the /watch page) from any layer, and reads a backtick import() as the path it is; the /watch contract forbids the shell, the modules and the features by folder (its one path to App had gone stale); chunkGuard's own test gets the vitest focus/skip rules. The root tests' exemption from local/layers is in the gate register, with its three imports. Placement: Plex's chip builder moves into lib beside the feed's, the folder names into features/folders, and three files only one page uses into that page. ui/FilterRail takes its reset from useRail instead of a page id. The channel page reads the feed's view and builds "Show in feed" itself, and the feed's scroll key is the feed's hook, so the shell holds no feed logic. The floating header re-measures when one page's pills replace another's in the same commit (the claim count stayed equal, so only the observer caught it). A Plex view applied and edited now stays across a visit to another page, as the feed's does: pinned by a test. docs/development.md says what a page with a rail adds in lib.
108 lines
3.8 KiB
TypeScript
108 lines
3.8 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The owner's call: a freshly opened channel page shows what is still unwatched, and a watch state the
|
|
// viewer picks there sticks — a reload (a fresh mount, seeded from the stored preference) keeps it.
|
|
import type { ReactNode } from "react";
|
|
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
|
import { render, cleanup, act } from "@testing-library/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import type { FeedFilters } from "../../lib/api";
|
|
|
|
vi.mock("react-i18next", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("react-i18next")>()),
|
|
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
|
}));
|
|
vi.mock("../../features/navigation/navigationContext", () => ({
|
|
useNavigationActions: () => ({ closeChannel: vi.fn(), openChannel: vi.fn() }),
|
|
}));
|
|
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
|
vi.mock("./feedFiltersContext", () => ({
|
|
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
|
|
useFeedFiltersActions: () => ({ setFilters: () => {} }),
|
|
}));
|
|
const feed = vi.hoisted(() => ({
|
|
last: null as null | { filters: FeedFilters; setFilters: (f: FeedFilters) => void },
|
|
}));
|
|
vi.mock("./Feed", () => ({
|
|
default: (p: { filters: FeedFilters; setFilters: (f: FeedFilters) => void }) => {
|
|
feed.last = p;
|
|
return null;
|
|
},
|
|
}));
|
|
vi.mock("../../features/channel/ChannelAbout", () => ({ default: () => null }));
|
|
vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("../../components/ui/PageShell")>()),
|
|
PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
|
}));
|
|
|
|
import ChannelPage from "./ChannelPage";
|
|
import { api, type ChannelDetail, type Me } from "../../lib/api";
|
|
import { qk } from "../../lib/queryKeys";
|
|
|
|
beforeAll(() => {
|
|
class Stub {
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
takeRecords() {
|
|
return [];
|
|
}
|
|
}
|
|
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
|
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
|
});
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.restoreAllMocks();
|
|
feed.last = null;
|
|
});
|
|
|
|
const CH = "UCqoAEDirJPjEUFcF2FklnBA";
|
|
|
|
function mount(preferences: Record<string, unknown> = {}) {
|
|
vi.spyOn(api, "channelDetail").mockResolvedValue({
|
|
id: CH,
|
|
title: "StarTalk",
|
|
subscribed: true,
|
|
stored_videos: 10,
|
|
} as unknown as ChannelDetail);
|
|
vi.spyOn(api, "tags").mockResolvedValue([] as never);
|
|
const save = vi.spyOn(api, "savePrefs").mockResolvedValue({} as never);
|
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
qc.setQueryData(qk.me(), { id: 1, preferences } as unknown as Me);
|
|
render(
|
|
<QueryClientProvider client={qc}>
|
|
<ChannelPage
|
|
channelId={CH}
|
|
initialName="StarTalk"
|
|
me={{ id: 1, can_write: true, can_read: true } as unknown as Me}
|
|
/>
|
|
</QueryClientProvider>,
|
|
);
|
|
return save;
|
|
}
|
|
|
|
describe("ChannelPage — the watch state", () => {
|
|
it("shows only the unwatched videos on a freshly opened page", () => {
|
|
mount();
|
|
expect(feed.last?.filters.show).toBe("unwatched");
|
|
});
|
|
|
|
it("stores a picked watch state", () => {
|
|
const save = mount();
|
|
act(() => feed.last!.setFilters({ ...feed.last!.filters, show: "all" }));
|
|
expect(feed.last?.filters.show).toBe("all");
|
|
expect(save).toHaveBeenCalledWith({ channelShow: "all" });
|
|
});
|
|
|
|
it("keeps the stored watch state after a reload", () => {
|
|
mount({ channelShow: "watched" });
|
|
expect(feed.last?.filters.show).toBe("watched");
|
|
});
|
|
|
|
it("falls back to unwatched for a stored value it does not know", () => {
|
|
mount({ channelShow: "bogus" });
|
|
expect(feed.last?.filters.show).toBe("unwatched");
|
|
});
|
|
});
|