// @vitest-environment jsdom import { beforeEach, describe, expect, test, vi } from "vitest"; import { createFavoritesDrawer, type FavoritesDrawerHandlers } from "../src/content/market/favorites-drawer"; import { promptForFavoriteFolderName } from "../src/content/market/favorite-folder-dialog"; 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(); } function createDeferred(): { promise: Promise; resolve: () => void } { let resolve!: () => void; const promise = new Promise((nextResolve) => { resolve = nextResolve; }); return { 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().style.writingMode).toBe("vertical-rl"); expect(readTab().getAttribute("aria-controls")).toBe(readDrawer().id); expect(readTab().getAttribute("aria-expanded")).toBe("false"); expect(readTab().querySelector("[data-sces-favorites-unread]")).toBeNull(); expect(readTab().querySelector("[data-sces-favorites-badge]")).toBeNull(); expect(readTab().dataset.scesFavoritesUnread).toBeUndefined(); expect(readTab().dataset.scesFavoritesCount).toBeUndefined(); expect(readTab().textContent).not.toMatch(/\d/); expect(readTab().style.color).not.toMatch(/b42318|dc2626|ef4444|red/i); 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"); expect(readDrawer().getAttribute("aria-label")).toBe("收藏夹"); }); 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("uses an accessible icon close control and a wrapping footer for folder imports", () => { const { controller } = createHarness(); controller.render(createState()); readTab().click(); click(`[data-sces-favorites-folder-id="${firstFolder.id}"]`); const close = document.querySelector( '[data-sces-favorites-drawer="close"]' ) as HTMLButtonElement; const footer = readDrawer().querySelector("footer") as HTMLElement; const importFolder = document.querySelector( '[data-sces-favorites-drawer="import-folder"]' ) as HTMLButtonElement; const importSelected = document.querySelector( '[data-sces-favorites-drawer="import-selected"]' ) as HTMLButtonElement; const actionRow = importFolder.parentElement as HTMLElement; expect(close.textContent).toBe("x"); expect(close.getAttribute("aria-label")).toBe("关闭收藏夹"); expect(close.title).toBe("关闭收藏夹"); expect(footer.style.boxSizing).toBe("border-box"); expect(footer.style.minWidth).toBe("0px"); expect(footer.style.flexWrap).toBe("wrap"); expect(actionRow.style.boxSizing).toBe("border-box"); expect(actionRow.style.minWidth).toBe("0px"); expect(actionRow.style.flexWrap).toBe("wrap"); expect(actionRow.style.width).toBe("100%"); expect(importFolder.style.boxSizing).toBe("border-box"); expect(importFolder.style.minWidth).toBe("0px"); expect(importFolder.style.flex).toBe("1 1 100%"); expect(importSelected.style.boxSizing).toBe("border-box"); expect(importSelected.style.minWidth).toBe("0px"); expect(importSelected.style.flex).toBe("1 1 100%"); }); 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.each([ { folderId: "all", name: "全部的具体收藏夹" }, { folderId: "", name: "空 ID 收藏夹" } ])("treats folder id $folderId as a concrete folder", ({ folderId, name }) => { const folder = createFolder(folderId, name); const { controller } = createHarness(); controller.render( createState({ creators: [createCreator("111", "Alpha"), createCreator("222", "Beta")], folders: [folder], memberships: [createMembership("111", folderId)] }) ); readTab().click(); click(`[data-sces-favorites-folder-id="${folderId}"]`); expect(document.querySelectorAll('[data-sces-favorites-author-id]')).toHaveLength(1); expect(readDrawer().textContent).toContain("Alpha"); expect(readDrawer().textContent).not.toContain("Beta"); expect( document.querySelector('[data-sces-favorites-drawer="import-folder"]') ).not.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("prunes selected creators that are outside the active folder before importing", async () => { const onImportSelected = vi.fn(async (_authorIds: string[]) => {}); const { controller } = createHarness({ handlers: { onImportSelected } }); controller.render( createState({ creators: [createCreator("111", "Alpha"), createCreator("222", "Beta")], memberships: [ createMembership("111", firstFolder.id), createMembership("222", secondFolder.id) ] }) ); readTab().click(); const firstSelection = document.querySelector( '[data-sces-favorites-select-author-id="111"]' ) as HTMLInputElement; firstSelection.checked = true; firstSelection.dispatchEvent(new Event("change", { bubbles: true })); const secondSelection = document.querySelector( '[data-sces-favorites-select-author-id="222"]' ) as HTMLInputElement; secondSelection.checked = true; secondSelection.dispatchEvent(new Event("change", { bubbles: true })); expect(readDrawer().textContent).toContain("导入已选 2 位达人"); click(`[data-sces-favorites-folder-id="${firstFolder.id}"]`); expect(readDrawer().textContent).toContain("导入已选 1 位达人"); click('[data-sces-favorites-drawer="import-selected"]'); await flush(); expect(onImportSelected).toHaveBeenCalledWith(["111"]); }); test("locks duplicate imports and overlapping folder mutations until the active handler settles", async () => { const selectedImport = createDeferred(); const deleteFolder = createDeferred(); const onImportSelected = vi.fn(() => selectedImport.promise); const onDeleteFolder = vi.fn(() => deleteFolder.promise); const onRenameFolder = vi.fn(async (_folderId: string) => {}); const { controller } = createHarness({ handlers: { onDeleteFolder, onImportSelected, onRenameFolder } }); controller.render(createState()); readTab().click(); const checkbox = document.querySelector( '[data-sces-favorites-select-author-id="author-1"]' ) as HTMLInputElement; checkbox.checked = true; checkbox.dispatchEvent(new Event("change", { bubbles: true })); click('[data-sces-favorites-drawer="import-selected"]'); const pendingImport = document.querySelector( '[data-sces-favorites-drawer="import-selected"]' ) as HTMLButtonElement; pendingImport.dispatchEvent(new MouseEvent("click", { bubbles: true })); await flush(); expect(onImportSelected).toHaveBeenCalledTimes(1); expect(pendingImport.disabled).toBe(true); selectedImport.resolve(); await flush(); click(`[data-sces-favorites-folder-id="${firstFolder.id}"]`); click(`[data-sces-favorites-folder-delete="${firstFolder.id}"]`); const pendingRename = document.querySelector( `[data-sces-favorites-folder-rename="${firstFolder.id}"]` ) as HTMLButtonElement; pendingRename.dispatchEvent(new MouseEvent("click", { bubbles: true })); await flush(); expect(onDeleteFolder).toHaveBeenCalledWith(firstFolder.id); expect(onRenameFolder).not.toHaveBeenCalled(); expect(pendingRename.disabled).toBe(true); deleteFolder.resolve(); await flush(); }); test("preserves focus for a folder switch and a creator checkbox render", () => { const { controller } = createHarness(); controller.render(createState()); readTab().click(); const folder = document.querySelector( `[data-sces-favorites-folder-id="${firstFolder.id}"]` ) as HTMLButtonElement; folder.focus(); folder.click(); const activeFolder = document.querySelector( `[data-sces-favorites-folder-id="${firstFolder.id}"]` ) as HTMLButtonElement; expect(document.activeElement).toBe(activeFolder); const checkbox = document.querySelector( '[data-sces-favorites-select-author-id="author-1"]' ) as HTMLInputElement; checkbox.focus(); checkbox.checked = true; checkbox.dispatchEvent(new Event("change", { bubbles: true })); const activeCheckbox = document.querySelector( '[data-sces-favorites-select-author-id="author-1"]' ) as HTMLInputElement; expect(document.activeElement).toBe(activeCheckbox); }); test("leaves the drawer open when an active folder-name dialog handles Escape", async () => { let dialogResult: Promise | undefined; const onCreateFolder = vi.fn(async () => { dialogResult = promptForFavoriteFolderName(document, { title: "新建收藏夹" }); await dialogResult; }); const { controller } = createHarness({ handlers: { onCreateFolder } }); controller.render(createState()); readTab().click(); const search = document.querySelector( '[data-sces-favorites-drawer="search"]' ) as HTMLInputElement; search.value = "Alpha"; search.dispatchEvent(new Event("input", { bubbles: true })); click('[data-sces-favorites-drawer="create-folder"]'); await flush(); expect( document.querySelector('[data-sces-favorite-folder-dialog="root"]') ).not.toBeNull(); document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); await expect(dialogResult).resolves.toBeNull(); await flush(); const restoredSearch = document.querySelector( '[data-sces-favorites-drawer="search"]' ) as HTMLInputElement; expect(readDrawer().hidden).toBe(false); expect(readTab().getAttribute("aria-expanded")).toBe("true"); expect(restoredSearch.value).toBe("Alpha"); expect(document.activeElement).toBe(restoredSearch); }); test("keeps a remounted drawer open after an earlier dialog handles Escape", async () => { const first = createHarness(); first.controller.render(createState()); readTab().click(); const dialogResult = promptForFavoriteFolderName(document, { title: "新建收藏夹" }); first.controller.dispose(); const remounted = createHarness(); remounted.controller.render(createState()); readTab().click(); document.dispatchEvent( new KeyboardEvent("keydown", { cancelable: true, key: "Escape" }) ); await expect(dialogResult).resolves.toBeNull(); expect(readDrawer().hidden).toBe(false); expect(readTab().getAttribute("aria-expanded")).toBe("true"); }); 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"]'); await flush(); click(`[data-sces-favorites-folder-rename="${secondFolder.id}"]`); await flush(); 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 DOM and event behavior on disposal", async () => { const onCreateFolder = vi.fn(async () => {}); const { controller } = createHarness({ handlers: { onCreateFolder } }); controller.render(createState()); readTab().click(); controller.setError("保存失败"); expect( document.querySelector('[data-sces-favorites-drawer="error"]')?.textContent ).toBe("保存失败"); const staleCreateButton = document.querySelector( '[data-sces-favorites-drawer="create-folder"]' ) as HTMLButtonElement; const removeEventListener = vi.spyOn(document, "removeEventListener"); controller.dispose(); controller.dispose(); expect(document.querySelector('[data-sces-favorites-tab="button"]')).toBeNull(); expect(document.querySelector('[data-sces-favorites-drawer="root"]')).toBeNull(); expect(removeEventListener).toHaveBeenCalledWith( "keydown", expect.any(Function), true ); const unrelatedButton = document.createElement("button"); unrelatedButton.type = "button"; document.body.append(unrelatedButton); unrelatedButton.focus(); const escape = new KeyboardEvent("keydown", { cancelable: true, key: "Escape" }); document.dispatchEvent(escape); staleCreateButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); await flush(); expect(escape.defaultPrevented).toBe(false); expect(document.activeElement).toBe(unrelatedButton); expect(onCreateFolder).not.toHaveBeenCalled(); removeEventListener.mockRestore(); }); });