// @vitest-environment jsdom import { beforeEach, describe, expect, test, vi } from "vitest"; import { createFavoritesDrawer, type FavoritesDrawerHandlers } from "../src/content/market/favorites-drawer"; import type { FavoriteCreator, FavoriteFolder, FavoriteMembership, FavoritesStateV1 } from "../src/content/market/favorites-store"; const firstFolder = createFolder("folder-1", "合作"); const secondFolder = createFolder("folder-2", "待联系"); function createFolder(id: string, name: string): FavoriteFolder { return { createdAt: "2026-07-17T00:00:00.000Z", id, name, updatedAt: "2026-07-17T00:00:00.000Z" }; } function createCreator(authorId: string, authorName: string): FavoriteCreator { return { authorId, authorName, savedAt: "2026-07-17T00:00:00.000Z" }; } function createMembership(authorId: string, folderId: string): FavoriteMembership { return { addedAt: "2026-07-17T00:00:00.000Z", authorId, folderId }; } function createState(options: { creators?: FavoriteCreator[]; folders?: FavoriteFolder[]; memberships?: FavoriteMembership[]; } = {}): FavoritesStateV1 { return { creators: options.creators ?? [createCreator("author-1", "Alpha"), createCreator("author-2", "Beta")], folders: options.folders ?? [firstFolder, secondFolder], memberships: options.memberships ?? [ createMembership("author-1", firstFolder.id), createMembership("author-2", secondFolder.id) ], version: 1 }; } function createHarness(options: { confirmImport?: (message: string) => boolean; handlers?: Partial; } = {}) { const handlers: FavoritesDrawerHandlers = { onCreateFolder: async () => {}, onDeleteFolder: async () => {}, onImportFolder: async () => {}, onImportSelected: async () => {}, onRemoveCreatorFromFolder: async () => {}, onRenameFolder: async () => {}, ...options.handlers }; const confirmImport = options.confirmImport ?? vi.fn(() => true); const controller = createFavoritesDrawer(document, handlers, confirmImport); return { confirmImport, controller, handlers }; } function readTab(): HTMLButtonElement { const tab = document.querySelector('[data-sces-favorites-tab="button"]'); if (!(tab instanceof HTMLButtonElement)) { throw new Error("Expected favorites tab"); } return tab; } function readDrawer(): HTMLElement { const drawer = document.querySelector('[data-sces-favorites-drawer="root"]'); if (!(drawer instanceof HTMLElement)) { throw new Error("Expected favorites drawer"); } return drawer; } function click(selector: string): void { const target = document.querySelector(selector); if (!(target instanceof HTMLElement)) { throw new Error(`Expected ${selector}`); } target.click(); } async function flush(): Promise { await Promise.resolve(); await Promise.resolve(); } describe("favorites-drawer", () => { beforeEach(() => { document.body.innerHTML = ""; }); test("mounts one fixed, closed trigger and drawer left of the native service rail", () => { createHarness(); expect(document.querySelectorAll('[data-sces-favorites-tab="button"]')).toHaveLength(1); expect(document.querySelectorAll('[data-sces-favorites-drawer="root"]')).toHaveLength(1); expect(readTab().textContent).toContain("收藏夹"); expect(readTab().style.position).toBe("fixed"); expect(readTab().style.right).toBe("56px"); expect(readTab().getAttribute("aria-expanded")).toBe("false"); expect(readDrawer().hidden).toBe(true); expect(readDrawer().style.position).toBe("fixed"); expect(readDrawer().style.right).toBe("56px"); expect(readDrawer().style.width).toBe("380px"); expect(readDrawer().getAttribute("role")).toBe("dialog"); }); test("opens, closes, and restores focus through the trigger, close control, and Escape", () => { createHarness(); readTab().click(); expect(readDrawer().hidden).toBe(false); expect(readTab().getAttribute("aria-expanded")).toBe("true"); expect(document.activeElement).toBe( document.querySelector('[data-sces-favorites-drawer="close"]') ); click('[data-sces-favorites-drawer="close"]'); expect(readDrawer().hidden).toBe(true); expect(document.activeElement).toBe(readTab()); readTab().click(); document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); expect(readDrawer().hidden).toBe(true); expect(document.activeElement).toBe(readTab()); }); test("deduplicates creators in all view and shows their folder affiliations", () => { const { controller } = createHarness(); controller.render( createState({ creators: [ createCreator("author-1", "Alpha"), createCreator("author-1", "Alpha newer"), createCreator("author-2", "Beta") ], memberships: [ createMembership("author-1", firstFolder.id), createMembership("author-1", secondFolder.id), createMembership("author-2", secondFolder.id) ] }) ); readTab().click(); expect(document.querySelectorAll('[data-sces-favorites-author-id]')).toHaveLength(2); expect(readDrawer().textContent).toContain("Alpha"); expect(readDrawer().textContent).toContain("合作、待联系"); expect( document.querySelector('[data-sces-favorites-remove-author-id="author-1"]') ).toBeNull(); }); test("switches to a folder view and falls back to all when that folder is deleted", () => { const { controller } = createHarness(); controller.render(createState()); readTab().click(); click(`[data-sces-favorites-folder-id="${firstFolder.id}"]`); expect(document.querySelectorAll('[data-sces-favorites-author-id]')).toHaveLength(1); expect(readDrawer().textContent).toContain("Alpha"); expect(document.querySelector('[data-sces-favorites-remove-author-id="author-1"]')).not.toBeNull(); expect( document.querySelector('[data-sces-favorites-drawer="import-folder"]') ).not.toBeNull(); controller.render( createState({ creators: [createCreator("author-2", "Beta")], folders: [secondFolder], memberships: [createMembership("author-2", secondFolder.id)] }) ); expect(document.querySelectorAll('[data-sces-favorites-author-id]')).toHaveLength(1); expect(readDrawer().textContent).toContain("Beta"); expect( document.querySelector('[data-sces-favorites-drawer="import-folder"]') ).toBeNull(); }); test("filters the current view by a trimmed, case-insensitive name query and shows an empty state", () => { const { controller } = createHarness(); controller.render(createState()); readTab().click(); const search = document.querySelector( '[data-sces-favorites-drawer="search"]' ) as HTMLInputElement; search.value = " beta "; search.dispatchEvent(new Event("input", { bubbles: true })); const refocusedSearch = document.querySelector( '[data-sces-favorites-drawer="search"]' ) as HTMLInputElement; expect(document.activeElement).toBe(refocusedSearch); expect(refocusedSearch.selectionStart).toBe(refocusedSearch.value.length); expect(document.querySelectorAll('[data-sces-favorites-author-id]')).toHaveLength(1); expect(readDrawer().textContent).toContain("Beta"); const nextSearch = document.querySelector( '[data-sces-favorites-drawer="search"]' ) as HTMLInputElement; nextSearch.value = "没有"; nextSearch.dispatchEvent(new Event("input", { bubbles: true })); expect(readDrawer().textContent).toContain("暂无收藏达人"); }); test("imports only selected author ids and removes an author from the active folder", async () => { const onImportSelected = vi.fn(async (_authorIds: string[]) => {}); const onRemoveCreatorFromFolder = vi.fn( async (_authorId: string, _folderId: string) => {} ); const { controller } = createHarness({ handlers: { onImportSelected, onRemoveCreatorFromFolder } }); controller.render(createState()); readTab().click(); const alpha = document.querySelector( '[data-sces-favorites-select-author-id="author-1"]' ) as HTMLInputElement; const beta = document.querySelector( '[data-sces-favorites-select-author-id="author-2"]' ) as HTMLInputElement; alpha.checked = true; alpha.dispatchEvent(new Event("change", { bubbles: true })); const nextBeta = document.querySelector( '[data-sces-favorites-select-author-id="author-2"]' ) as HTMLInputElement; nextBeta.checked = true; nextBeta.dispatchEvent(new Event("change", { bubbles: true })); expect(readDrawer().textContent).toContain("导入已选 2 位达人"); click('[data-sces-favorites-drawer="import-selected"]'); await flush(); expect(onImportSelected).toHaveBeenCalledWith(["author-1", "author-2"]); click(`[data-sces-favorites-folder-id="${firstFolder.id}"]`); click('[data-sces-favorites-remove-author-id="author-1"]'); await flush(); expect(onRemoveCreatorFromFolder).toHaveBeenCalledWith("author-1", firstFolder.id); }); test("calls create, rename, and delete handlers with the matching folder id", async () => { const onCreateFolder = vi.fn(async () => {}); const onRenameFolder = vi.fn(async (_folderId: string) => {}); const onDeleteFolder = vi.fn(async (_folderId: string) => {}); const { controller } = createHarness({ handlers: { onCreateFolder, onDeleteFolder, onRenameFolder } }); controller.render(createState()); readTab().click(); click('[data-sces-favorites-drawer="create-folder"]'); click(`[data-sces-favorites-folder-rename="${secondFolder.id}"]`); click(`[data-sces-favorites-folder-delete="${secondFolder.id}"]`); await flush(); expect(onCreateFolder).toHaveBeenCalledTimes(1); expect(onRenameFolder).toHaveBeenCalledWith(secondFolder.id); expect(onDeleteFolder).toHaveBeenCalledWith(secondFolder.id); }); test("confirms a deduplicated full-folder import once before importing", async () => { const confirmImport = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true); const onImportFolder = vi.fn(async (_folderId: string, _authorIds: string[]) => {}); const { controller } = createHarness({ confirmImport, handlers: { onImportFolder } }); controller.render( createState({ memberships: [ createMembership("author-1", firstFolder.id), createMembership("author-1", firstFolder.id), createMembership("author-2", firstFolder.id) ] }) ); readTab().click(); expect( document.querySelector('[data-sces-favorites-drawer="import-folder"]') ).toBeNull(); click(`[data-sces-favorites-folder-id="${firstFolder.id}"]`); click('[data-sces-favorites-drawer="import-folder"]'); await flush(); expect(confirmImport).toHaveBeenLastCalledWith("将当前收藏夹的 2 位达人导入秒探吗?"); expect(onImportFolder).not.toHaveBeenCalled(); click('[data-sces-favorites-drawer="import-folder"]'); await flush(); expect(confirmImport).toHaveBeenCalledTimes(2); expect(onImportFolder).toHaveBeenCalledWith(firstFolder.id, ["author-1", "author-2"]); }); test("disables mutation and import controls while busy but keeps close usable", async () => { const onCreateFolder = vi.fn(async () => {}); const { controller } = createHarness({ handlers: { onCreateFolder } }); controller.render(createState()); readTab().click(); controller.setBusy(true); expect( (document.querySelector( '[data-sces-favorites-drawer="create-folder"]' ) as HTMLButtonElement).disabled ).toBe(true); expect( (document.querySelector( `[data-sces-favorites-folder-rename="${firstFolder.id}"]` ) as HTMLButtonElement).disabled ).toBe(true); expect( (document.querySelector( '[data-sces-favorites-drawer="import-selected"]' ) as HTMLButtonElement).disabled ).toBe(true); expect( (document.querySelector( '[data-sces-favorites-drawer="close"]' ) as HTMLButtonElement).disabled ).toBe(false); click('[data-sces-favorites-drawer="create-folder"]'); await flush(); expect(onCreateFolder).not.toHaveBeenCalled(); click('[data-sces-favorites-drawer="close"]'); expect(readDrawer().hidden).toBe(true); }); test("renders errors and removes its DOM and document behavior on disposal", () => { const { controller } = createHarness(); controller.render(createState()); readTab().click(); controller.setError("保存失败"); expect( document.querySelector('[data-sces-favorites-drawer="error"]')?.textContent ).toBe("保存失败"); controller.dispose(); controller.dispose(); expect(document.querySelector('[data-sces-favorites-tab="button"]')).toBeNull(); expect(document.querySelector('[data-sces-favorites-drawer="root"]')).toBeNull(); document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); }); });