// @vitest-environment jsdom import { beforeEach, describe, expect, test, vi } from "vitest"; import { disposeFavoriteRowPickers, syncFavoriteRowPickers } from "../src/content/market/favorite-row-picker"; import type { FavoriteCreatorInput, FavoriteFolder } 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 = "author-1"): FavoriteCreatorInput { return { authorId, authorName: "Alice", coreUserId: "core-1" }; } function createActionCell(label = "下单"): HTMLElement { const cell = document.createElement("div"); cell.dataset.testid = "native-action-cell"; const nativeButton = document.createElement("button"); nativeButton.dataset.nativeAction = "order"; nativeButton.textContent = label; cell.appendChild(nativeButton); document.body.appendChild(cell); return cell; } function readFavoriteButton(actionCell: HTMLElement): HTMLButtonElement { const button = actionCell.querySelector( ':scope > button[data-sces-favorite-row-action="button"]' ); if (!(button instanceof HTMLButtonElement)) { throw new Error("Expected favorite action button"); } return button; } function readPopover(): HTMLElement | null { return document.querySelector('[data-sces-favorite-row-picker="root"]'); } function createDeferred(): { promise: Promise; resolve: () => void; } { let resolve!: () => void; const promise = new Promise((nextResolve) => { resolve = nextResolve; }); return { promise, resolve }; } function createRect(options: { bottom?: number; height?: number; left?: number; top?: number; width?: number; }): DOMRect { const bottom = options.bottom ?? (options.top ?? 0) + (options.height ?? 0); const height = options.height ?? bottom - (options.top ?? 0); const left = options.left ?? 0; const top = options.top ?? bottom - height; const width = options.width ?? 0; return { bottom, height, left, right: left + width, toJSON() { return {}; }, top, width, x: left, y: top } as DOMRect; } function sync(options: { actionCell?: HTMLElement; authorId?: string; folders?: FavoriteFolder[]; folderIds?: string[]; onCreateFolder?: () => Promise; onSetCreatorFolderIds?: ( creator: FavoriteCreatorInput, folderIds: string[] ) => Promise; rows?: Array<{ actionCell?: HTMLElement; authorId: string; authorName: string; coreUserId?: string; }>; } = {}): void { const creator = createCreator(options.authorId); syncFavoriteRowPickers({ document, folderIdsByAuthorId: new Map([ [creator.authorId, new Set(options.folderIds ?? [])] ]), folders: options.folders ?? [firstFolder, secondFolder], onCreateFolder: options.onCreateFolder ?? (async () => null), onSetCreatorFolderIds: options.onSetCreatorFolderIds ?? (async () => {}), rows: options.rows ?? (options.actionCell ? [{ ...creator, actionCell: options.actionCell }] : []) }); } async function flushAsyncEvents(): Promise { await Promise.resolve(); await Promise.resolve(); } describe("favorite-row-picker", () => { beforeEach(() => { disposeFavoriteRowPickers(document); document.body.innerHTML = ""; }); test("prepends one picker control without replacing the native action", () => { const actionCell = createActionCell(); sync({ actionCell }); const button = readFavoriteButton(actionCell); expect(actionCell.firstElementChild).toBe(button); expect(actionCell.querySelector('[data-native-action="order"]')?.textContent).toBe( "下单" ); expect(button.type).toBe("button"); expect(button.title).toBe("加入收藏夹"); expect(button.getAttribute("aria-label")).toBe("加入收藏夹"); expect(button.dataset.scesFavoriteCount).toBe("0"); expect(button.dataset.scesFavoriteIcon).toBe("bookmark-outline"); }); test("renders saved count and title from existing memberships", () => { const actionCell = createActionCell(); sync({ actionCell, folderIds: [firstFolder.id, secondFolder.id] }); const button = readFavoriteButton(actionCell); expect(button.title).toBe("已收藏至 2 个收藏夹"); expect(button.getAttribute("aria-label")).toBe("已收藏至 2 个收藏夹"); expect(button.dataset.scesFavoriteCount).toBe("2"); expect(button.dataset.scesFavoriteIcon).toBe("bookmark-filled"); }); test("disables a row with no author id and does not open a picker", () => { const actionCell = createActionCell(); sync({ actionCell, authorId: " " }); const button = readFavoriteButton(actionCell); expect(button.disabled).toBe(true); expect(button.title).toContain("达人 ID"); button.click(); expect(readPopover()).toBeNull(); }); test("keeps one document popover when different row buttons are clicked", () => { const firstCell = createActionCell("下单 A"); const secondCell = createActionCell("下单 B"); const firstCreator = createCreator("author-1"); const secondCreator = createCreator("author-2"); sync({ rows: [ { ...firstCreator, actionCell: firstCell }, { ...secondCreator, actionCell: secondCell } ] }); expect(readFavoriteButton(firstCell).getAttribute("aria-label")).toBe( "加入收藏夹" ); expect(readFavoriteButton(secondCell).getAttribute("aria-label")).toBe( "加入收藏夹" ); readFavoriteButton(firstCell).click(); expect(readPopover()).not.toBeNull(); readFavoriteButton(secondCell).click(); expect(document.querySelectorAll('[data-sces-favorite-row-picker="root"]')).toHaveLength(1); expect(readPopover()?.textContent).toContain("合作"); }); test("shows existing folder membership in the picker", () => { const actionCell = createActionCell(); sync({ actionCell, folderIds: [firstFolder.id] }); readFavoriteButton(actionCell).click(); expect( ( document.querySelector( `input[data-sces-favorite-folder-id="${firstFolder.id}"]` ) as HTMLInputElement ).checked ).toBe(true); expect( ( document.querySelector( `input[data-sces-favorite-folder-id="${secondFolder.id}"]` ) as HTMLInputElement ).checked ).toBe(false); }); test("saves the complete checked folder selection for the matching creator", async () => { const actionCell = createActionCell(); const onSetCreatorFolderIds = vi.fn(async () => {}); sync({ actionCell, folderIds: [firstFolder.id], onSetCreatorFolderIds }); readFavoriteButton(actionCell).click(); const folderCheckbox = document.querySelector( `input[data-sces-favorite-folder-id="${secondFolder.id}"]` ) as HTMLInputElement; folderCheckbox.checked = true; folderCheckbox.dispatchEvent(new Event("change", { bubbles: true })); await flushAsyncEvents(); expect(onSetCreatorFolderIds).toHaveBeenCalledWith(createCreator(), [ firstFolder.id, secondFolder.id ]); expect(readFavoriteButton(actionCell).dataset.scesFavoriteCount).toBe("2"); }); test("retains a confirmed save through a resync while it is pending", async () => { const actionCell = createActionCell(); const deferredSave = createDeferred(); const onSetCreatorFolderIds = vi.fn(() => deferredSave.promise); sync({ actionCell, onSetCreatorFolderIds }); readFavoriteButton(actionCell).click(); const firstCheckbox = document.querySelector( `input[data-sces-favorite-folder-id="${firstFolder.id}"]` ) as HTMLInputElement; firstCheckbox.checked = true; firstCheckbox.dispatchEvent(new Event("change", { bubbles: true })); sync({ actionCell, onSetCreatorFolderIds }); expect(readFavoriteButton(actionCell).disabled).toBe(true); expect( ( document.querySelector( `input[data-sces-favorite-folder-id="${firstFolder.id}"]` ) as HTMLInputElement ).disabled ).toBe(true); deferredSave.resolve(); await flushAsyncEvents(); expect(readFavoriteButton(actionCell).disabled).toBe(false); expect(readFavoriteButton(actionCell).dataset.scesFavoriteCount).toBe("1"); expect( ( document.querySelector( `input[data-sces-favorite-folder-id="${firstFolder.id}"]` ) as HTMLInputElement ).checked ).toBe(true); expect( ( document.querySelector( `input[data-sces-favorite-folder-id="${firstFolder.id}"]` ) as HTMLInputElement ).disabled ).toBe(false); }); test("adds a newly created folder to the creator selection", async () => { const actionCell = createActionCell(); const createdFolder = createFolder("folder-3", "重点"); const onCreateFolder = vi.fn(async () => createdFolder); const onSetCreatorFolderIds = vi.fn(async () => {}); sync({ actionCell, onCreateFolder, onSetCreatorFolderIds }); readFavoriteButton(actionCell).click(); const createButton = document.querySelector( '[data-sces-favorite-row-picker="create-folder"]' ) as HTMLButtonElement; createButton.click(); await flushAsyncEvents(); expect(onCreateFolder).toHaveBeenCalledTimes(1); expect(onSetCreatorFolderIds).toHaveBeenCalledWith(createCreator(), [createdFolder.id]); expect( ( document.querySelector( `input[data-sces-favorite-folder-id="${createdFolder.id}"]` ) as HTMLInputElement ).checked ).toBe(true); expect(readFavoriteButton(actionCell).dataset.scesFavoriteCount).toBe("1"); }); test("keeps the visible selection when saving a checkbox change fails", async () => { const actionCell = createActionCell(); const onSetCreatorFolderIds = vi.fn(async () => { throw new Error("save failed"); }); sync({ actionCell, folderIds: [firstFolder.id], onSetCreatorFolderIds }); readFavoriteButton(actionCell).click(); const firstCheckbox = document.querySelector( `input[data-sces-favorite-folder-id="${firstFolder.id}"]` ) as HTMLInputElement; firstCheckbox.checked = false; firstCheckbox.dispatchEvent(new Event("change", { bubbles: true })); await flushAsyncEvents(); expect(onSetCreatorFolderIds).toHaveBeenCalledWith(createCreator(), []); expect( ( document.querySelector( `input[data-sces-favorite-folder-id="${firstFolder.id}"]` ) as HTMLInputElement ).checked ).toBe(true); expect(readFavoriteButton(actionCell).dataset.scesFavoriteCount).toBe("1"); }); test("closes the popover on outside pointer-down and Escape", () => { const actionCell = createActionCell(); sync({ actionCell }); readFavoriteButton(actionCell).click(); document.body.dispatchEvent(new Event("pointerdown", { bubbles: true })); expect(readPopover()).toBeNull(); readFavoriteButton(actionCell).click(); document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); expect(readPopover()).toBeNull(); }); test("disposes an open picker and clears its detached action handler", () => { const actionCell = createActionCell(); const onSetCreatorFolderIds = vi.fn(async () => {}); sync({ actionCell, onSetCreatorFolderIds }); const button = readFavoriteButton(actionCell); button.click(); expect(readPopover()).not.toBeNull(); disposeFavoriteRowPickers(document); disposeFavoriteRowPickers(document); expect(readPopover()).toBeNull(); expect(button.onclick).toBeNull(); document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); document.body.dispatchEvent(new Event("pointerdown", { bubbles: true })); button.click(); expect(onSetCreatorFolderIds).not.toHaveBeenCalled(); }); test("clamps a bottom-right picker within the viewport", () => { const actionCell = createActionCell(); const originalWidth = Object.getOwnPropertyDescriptor( document.documentElement, "clientWidth" ); const originalHeight = Object.getOwnPropertyDescriptor( document.documentElement, "clientHeight" ); Object.defineProperty(document.documentElement, "clientWidth", { configurable: true, value: 300 }); Object.defineProperty(document.documentElement, "clientHeight", { configurable: true, value: 200 }); const rectSpy = vi .spyOn(HTMLElement.prototype, "getBoundingClientRect") .mockImplementation(function mockPickerRect(this: HTMLElement): DOMRect { if (this.dataset.scesFavoriteRowAction === "button") { return createRect({ bottom: 190, height: 20, left: 280, top: 170, width: 20 }); } if (this.dataset.scesFavoriteRowPicker === "root") { return createRect({ height: 60, width: 120 }); } return createRect({}); }); try { sync({ actionCell }); readFavoriteButton(actionCell).click(); const root = readPopover() as HTMLElement; expect(Number.parseFloat(root.style.left)).toBeGreaterThanOrEqual(8); expect(Number.parseFloat(root.style.left)).toBeLessThanOrEqual(172); expect(Number.parseFloat(root.style.top)).toBeGreaterThanOrEqual(8); expect(Number.parseFloat(root.style.top)).toBeLessThanOrEqual(132); expect(root.style.top).toBe("102px"); } finally { rectSpy.mockRestore(); if (originalWidth) { Object.defineProperty(document.documentElement, "clientWidth", originalWidth); } else { delete (document.documentElement as { clientWidth?: number }).clientWidth; } if (originalHeight) { Object.defineProperty(document.documentElement, "clientHeight", originalHeight); } else { delete (document.documentElement as { clientHeight?: number }).clientHeight; } } }); test("sets trigger dialog state, focuses the picker, and restores focus on Escape", () => { const actionCell = createActionCell(); sync({ actionCell }); const button = readFavoriteButton(actionCell); expect(button.getAttribute("aria-haspopup")).toBe("dialog"); expect(button.getAttribute("aria-expanded")).toBe("false"); const pickerId = button.getAttribute("aria-controls"); expect(pickerId).not.toBeNull(); button.focus(); button.click(); const root = readPopover() as HTMLElement; expect(root.id).toBe(pickerId); expect(root.getAttribute("role")).toBe("dialog"); expect(root.getAttribute("aria-label")).toBe("选择收藏夹"); expect(button.getAttribute("aria-expanded")).toBe("true"); expect(document.activeElement).toBe( document.querySelector( `input[data-sces-favorite-folder-id="${firstFolder.id}"]` ) ); document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); expect(readPopover()).toBeNull(); expect(button.getAttribute("aria-expanded")).toBe("false"); expect(document.activeElement).toBe(button); }); test("resyncs one control with fresh callbacks and closes an obsolete popover", async () => { const actionCell = createActionCell(); const staleCallback = vi.fn(async () => {}); const currentCallback = vi.fn(async () => {}); sync({ actionCell, onSetCreatorFolderIds: staleCallback }); readFavoriteButton(actionCell).click(); expect(readPopover()).not.toBeNull(); sync({ actionCell, onSetCreatorFolderIds: currentCallback }); expect( actionCell.querySelectorAll('[data-sces-favorite-row-action="button"]') ).toHaveLength(1); const folderCheckbox = document.querySelector( `input[data-sces-favorite-folder-id="${firstFolder.id}"]` ) as HTMLInputElement; folderCheckbox.checked = true; folderCheckbox.dispatchEvent(new Event("change", { bubbles: true })); await flushAsyncEvents(); expect(staleCallback).not.toHaveBeenCalled(); expect(currentCallback).toHaveBeenCalledWith(createCreator(), [firstFolder.id]); sync({ rows: [] }); expect(readPopover()).toBeNull(); }); });