492 lines
16 KiB
TypeScript
492 lines
16 KiB
TypeScript
// @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<void>;
|
|
resolve: () => void;
|
|
} {
|
|
let resolve!: () => void;
|
|
const promise = new Promise<void>((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<FavoriteFolder | null>;
|
|
onSetCreatorFolderIds?: (
|
|
creator: FavoriteCreatorInput,
|
|
folderIds: string[]
|
|
) => Promise<void>;
|
|
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<void> {
|
|
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();
|
|
});
|
|
});
|