// @vitest-environment jsdom // // S7 — the Personal-views manager gains an EDITABLE detail pane: captured filters are now removable // chips that write the pruned set back to the stored view (undoable), an explicit Save-current button // stores the live filters into the view, and rename has a visible commit affordance. This drives the // real modal against a mocked savedViewsContext so we can assert exactly which mutation fires; the // captured-filter row uses the REAL useActiveFilters (fed a tag from a seeded react-query cache), so // the prune snapshots are the genuine ones the live chip row computes. import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; const h = vi.hoisted(() => ({ updateFilters: vi.fn(async (_id: number, _filters: Record) => true), saveCurrent: vi.fn(async (_id: number) => ({ saved: true }) as Record | null), rename: vi.fn(async (_id: number, _name: string) => true), activeViewId: null as number | null, })); vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; }); // A full feed filter set that differs from the sidebar default ONLY in `tags`, so useActiveFilters // surfaces exactly one chip (the tag) — a partial object would read missing show/sort/content as // "active" and spray spurious chips. const VIEW_FILTERS = { tags: [1], tagModes: { language: "or", topic: "or", other: "or" }, untagged: false, channelIds: [], sort: "newest", includeNormal: true, includeShorts: false, includeLive: false, show: "unwatched", scope: "my", q: "", }; const CTX = { source: "feed" as const, views: [{ id: 5, name: "Docs", filters: VIEW_FILTERS, position: 0, is_default: false }], loading: false, displayView: null, isDirty: false, modalOpen: true, openModal: vi.fn(), closeModal: vi.fn(), apply: vi.fn(), clearActive: vi.fn(), create: vi.fn(async () => true), reorder: vi.fn(async () => {}), remove: vi.fn(async () => true), toggleDefault: vi.fn(async () => {}), }; vi.mock("./savedViewsContext", () => ({ useSavedViews: () => ({ ...CTX, activeViewId: h.activeViewId, updateFilters: h.updateFilters, saveCurrent: h.saveCurrent, rename: h.rename, }), // Passthrough coercion — the modal only needs tags/channelIds to be arrays before the chip builder. asFilters: (raw: Record) => ({ ...raw, tags: (raw.tags as unknown[]) ?? [], channelIds: (raw.channelIds as unknown[]) ?? [], }), })); import PersonalViewsModal from "./PersonalViewsModal"; import { api } from "../lib/api"; import { qk } from "../lib/queryKeys"; function mount() { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); qc.setQueryData(qk.tags(), [{ id: 1, name: "Docu" }]); vi.spyOn(api, "tags").mockResolvedValue([{ id: 1, name: "Docu" }] as never); const utils = render( , ); return { ...utils, qc }; } beforeEach(() => { h.updateFilters.mockClear(); h.saveCurrent.mockClear(); h.rename.mockClear(); h.activeViewId = null; // Fresh baseline each test — one test mutates CTX.views to simulate a refetch. CTX.views = [{ id: 5, name: "Docs", filters: VIEW_FILTERS, position: 0, is_default: false }]; }); afterEach(() => { cleanup(); vi.restoreAllMocks(); }); describe("PersonalViewsModal — S7 editable detail pane", () => { it("removing a captured-filter chip writes the pruned filters back to the stored view", async () => { mount(); const removeBtn = await screen.findByLabelText("feed.chips.remove"); fireEvent.click(removeBtn); await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(1)); expect(h.updateFilters.mock.calls[0]![0]).toBe(5); expect(h.updateFilters.mock.calls[0]![1]).toMatchObject({ tags: [] }); }); it("the prune is undoable — Undo re-persists the original filters", async () => { mount(); fireEvent.click(await screen.findByLabelText("feed.chips.remove")); await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(1)); // The undo control only appears once there is history. const undo = await screen.findByLabelText("common.undo"); fireEvent.click(undo); await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(2)); expect(h.updateFilters.mock.calls[1]![1]).toMatchObject({ tags: [1] }); }); it("a same-view refetch (new-identity filters) does not wipe the undo history", async () => { // Guards the [selectedId]-only re-seed effect (its exhaustive-deps disable): after a chip prune the // real provider refetches and hands back the SAME view id with a fresh-identity filters object. The // draft must NOT re-seed on that (it would destroy the undo history and the Undo control). A deps // regression that added selected.filters would make this fail. const { rerender, qc } = mount(); fireEvent.click(await screen.findByLabelText("feed.chips.remove")); await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(1)); expect(await screen.findByLabelText("common.undo")).toBeTruthy(); // Simulate the post-write refetch: same id 5, new object identity, carrying the pruned value. CTX.views = [ { id: 5, name: "Docs", filters: { ...VIEW_FILTERS, tags: [] }, position: 0, is_default: false, }, ]; rerender( , ); // Undo history survived the refetch — the control is still there and still re-persists on click. const undo = await screen.findByLabelText("common.undo"); fireEvent.click(undo); await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(2)); expect(h.updateFilters.mock.calls[1]![1]).toMatchObject({ tags: [1] }); }); it("Save-current stores the live filters into the view when it isn't already the active one", async () => { mount(); fireEvent.click(await screen.findByText("views.saveCurrent")); await waitFor(() => expect(h.saveCurrent).toHaveBeenCalledWith(5)); }); it("Save-current is disabled when the selected view is already the active one", async () => { h.activeViewId = 5; mount(); const btn = (await screen.findByText("views.saveCurrent")).closest( "button", ) as HTMLButtonElement; expect(btn.disabled).toBe(true); fireEvent.click(btn); expect(h.saveCurrent).not.toHaveBeenCalled(); }); it("rename commits via the visible check button once the name changes", async () => { mount(); const input = await screen.findByDisplayValue("Docs"); // No change yet → the commit button is disabled. const commit = screen.getByLabelText("views.rename").closest("button") as HTMLButtonElement; expect(commit.disabled).toBe(true); fireEvent.change(input, { target: { value: "Documentaries" } }); expect(commit.disabled).toBe(false); fireEvent.click(commit); await waitFor(() => expect(h.rename).toHaveBeenCalledWith(5, "Documentaries")); }); });