feat: import saved favorites to batches

This commit is contained in:
wxs
2026-07-17 14:18:35 +08:00
parent 0f1edcd181
commit ff1decd4a7
2 changed files with 647 additions and 2 deletions
+310 -2
View File
@@ -16,6 +16,21 @@ import { promptForAudienceProfileFields } from "./audience-profile-field-dialog"
import { promptForAuthorIds } from "./author-id-dialog"; import { promptForAuthorIds } from "./author-id-dialog";
import { promptForBatchName } from "./batch-name-dialog"; import { promptForBatchName } from "./batch-name-dialog";
import { createBatchPayload, type BatchPayload } from "./batch-payload"; import { createBatchPayload, type BatchPayload } from "./batch-payload";
import { promptForFavoriteFolderName } from "./favorite-folder-dialog";
import { syncFavoriteRowPickers } from "./favorite-row-picker";
import {
createFavoritesDrawer,
type FavoritesDrawerController
} from "./favorites-drawer";
import { toFavoriteMarketRecords } from "./favorites-import";
import {
createChromeFavoritesStorage,
createFavoritesRepository,
type FavoriteCreatorInput,
type FavoriteFolder,
type FavoritesRepository,
type FavoritesStateV1
} from "./favorites-store";
import { import {
applyRowOrder, applyRowOrder,
applyRowVisibility, applyRowVisibility,
@@ -79,7 +94,10 @@ export interface CreateMarketControllerOptions {
options?: AudienceProfileCsvOptions options?: AudienceProfileCsvOptions
) => string; ) => string;
buildCsv?: (records: MarketRecord[]) => string; buildCsv?: (records: MarketRecord[]) => string;
confirmFavoriteDelete?: (message: string) => boolean;
confirmFavoriteImport?: (message: string) => boolean;
document: Document; document: Document;
favoritesRepository?: FavoritesRepository;
getAuthState?: () => Promise<AuthStateValue>; getAuthState?: () => Promise<AuthStateValue>;
loadAuthorBaseInfo?: (authorId: string) => Promise<MarketRecord>; loadAuthorBaseInfo?: (authorId: string) => Promise<MarketRecord>;
loadBusinessAbility?: ( loadBusinessAbility?: (
@@ -104,6 +122,12 @@ export interface CreateMarketControllerOptions {
onCsvReady?: (csv: string, filename?: string) => void; onCsvReady?: (csv: string, filename?: string) => void;
promptAuthorIds?: () => Promise<string | null> | string | null; promptAuthorIds?: () => Promise<string | null> | string | null;
promptBatchName?: () => Promise<string | null> | string | null; promptBatchName?: () => Promise<string | null> | string | null;
promptFavoriteFolderName?: (
options: {
initialValue?: string;
title: "新建收藏夹" | "重命名收藏夹";
}
) => Promise<string | null> | string | null;
resultStore?: ReturnType<typeof createMarketResultStore>; resultStore?: ReturnType<typeof createMarketResultStore>;
submitBatch?: (payload: BatchPayload) => Promise<unknown>; submitBatch?: (payload: BatchPayload) => Promise<unknown>;
window: Window; window: Window;
@@ -139,6 +163,15 @@ export function createMarketController(options: CreateMarketControllerOptions) {
const loadBusinessAbility = const loadBusinessAbility =
options.loadBusinessAbility ?? businessAbilityClient.loadBusinessAbility; options.loadBusinessAbility ?? businessAbilityClient.loadBusinessAbility;
const getAuthState = options.getAuthState ?? (() => readAuthState(sendRuntimeMessage)); const getAuthState = options.getAuthState ?? (() => readAuthState(sendRuntimeMessage));
const favoritesRepository =
options.favoritesRepository ??
createFavoritesRepository({
storage: createChromeFavoritesStorage()
});
const confirmFavoriteImport =
options.confirmFavoriteImport ?? options.window.confirm.bind(options.window);
const confirmFavoriteDelete =
options.confirmFavoriteDelete ?? options.window.confirm.bind(options.window);
const mutationObserverFactory = const mutationObserverFactory =
options.mutationObserverFactory ?? options.mutationObserverFactory ??
((callback: MutationCallback) => new MutationObserver(callback)); ((callback: MutationCallback) => new MutationObserver(callback));
@@ -148,6 +181,12 @@ export function createMarketController(options: CreateMarketControllerOptions) {
const promptAuthorIds = const promptAuthorIds =
options.promptAuthorIds ?? options.promptAuthorIds ??
(() => promptForAuthorIds(options.document)); (() => promptForAuthorIds(options.document));
const promptFavoriteFolderName =
options.promptFavoriteFolderName ??
((dialogOptions: {
initialValue?: string;
title: "新建收藏夹" | "重命名收藏夹";
}) => promptForFavoriteFolderName(options.document, dialogOptions));
const submitBatch = const submitBatch =
options.submitBatch ?? options.submitBatch ??
((payload: BatchPayload) => ((payload: BatchPayload) =>
@@ -190,6 +229,25 @@ export function createMarketController(options: CreateMarketControllerOptions) {
let scheduledSyncTimeoutId: number | null = null; let scheduledSyncTimeoutId: number | null = null;
const selectedAuthorIds = new Set<string>(); const selectedAuthorIds = new Set<string>();
let toolbar: ReturnType<typeof ensurePluginToolbar> | undefined; let toolbar: ReturnType<typeof ensurePluginToolbar> | undefined;
let cachedFavoritesState: FavoritesStateV1 | undefined;
let favoritesRefreshVersion = 0;
let favoriteActionButtons: HTMLButtonElement[] = [];
const favoritesDrawer: FavoritesDrawerController = createFavoritesDrawer(
options.document,
{
onCreateFolder: async () => {
await createFavoriteFolder();
},
onDeleteFolder: deleteFavoriteFolder,
onImportFolder: async (_folderId, authorIds) => {
await submitFavoriteAuthors(authorIds);
},
onImportSelected: submitFavoriteAuthors,
onRemoveCreatorFromFolder: removeFavoriteCreatorFromFolder,
onRenameFolder: renameFavoriteFolder
},
confirmFavoriteImport
);
const observer = mutationObserverFactory(() => { const observer = mutationObserverFactory(() => {
if (isDisposed) { if (isDisposed) {
return; return;
@@ -207,10 +265,14 @@ export function createMarketController(options: CreateMarketControllerOptions) {
const selectionControlsMissing = const selectionControlsMissing =
!options.document.querySelector('[data-market-selection-checkbox="row"]') || !options.document.querySelector('[data-market-selection-checkbox="row"]') ||
!options.document.querySelector('[data-market-selection-checkbox="header"]'); !options.document.querySelector('[data-market-selection-checkbox="header"]');
const favoriteControlsMissing =
cachedFavoritesState !== undefined &&
favoriteActionButtons.some((button) => !button.isConnected);
if ( if (
nextPageSignature === lastKnownPageSignature && nextPageSignature === lastKnownPageSignature &&
!toolbarNeedsRemount && !toolbarNeedsRemount &&
!selectionControlsMissing !selectionControlsMissing &&
!favoriteControlsMissing
) { ) {
return; return;
} }
@@ -457,7 +519,11 @@ export function createMarketController(options: CreateMarketControllerOptions) {
}; };
toolbar = ensurePluginToolbar(options.document, toolbarHandlers); toolbar = ensurePluginToolbar(options.document, toolbarHandlers);
const ready = runSyncCycle(); const ready = (async () => {
await runSyncCycle();
await refreshFavorites();
await waitForDomSettled();
})();
return { return {
dispose() { dispose() {
@@ -467,6 +533,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
options.window.clearTimeout(scheduledSyncTimeoutId); options.window.clearTimeout(scheduledSyncTimeoutId);
scheduledSyncTimeoutId = null; scheduledSyncTimeoutId = null;
} }
favoritesDrawer.dispose();
}, },
ready ready
}; };
@@ -634,10 +701,199 @@ export function createMarketController(options: CreateMarketControllerOptions) {
applyRowOrder(table, records.map((record) => record.authorId)); applyRowOrder(table, records.map((record) => record.authorId));
bindSelectionControls(table); bindSelectionControls(table);
syncMarketSelectionState(table, selectedAuthorIds); syncMarketSelectionState(table, selectedAuthorIds);
syncFavoriteRowPickersForCurrentTable(table);
lastKnownPageSignature = readMarketPageSignature(options.document); lastKnownPageSignature = readMarketPageSignature(options.document);
}); });
} }
async function refreshFavorites(): Promise<void> {
const refreshVersion = ++favoritesRefreshVersion;
try {
const state = await favoritesRepository.read();
if (isDisposed || refreshVersion !== favoritesRefreshVersion) {
return;
}
cachedFavoritesState = state;
favoritesDrawer.setError("");
favoritesDrawer.render(state);
runWithoutMutationSync(() => {
syncFavoriteRowPickersForCurrentTable();
});
} catch {
if (isDisposed || refreshVersion !== favoritesRefreshVersion) {
return;
}
favoritesDrawer.setError("收藏夹暂不可用");
}
}
function syncFavoriteRowPickersForCurrentTable(
table = syncMarketTable(options.document)
): void {
if (!cachedFavoritesState || !table) {
return;
}
const folderIdsByAuthorId = new Map<string, Set<string>>();
cachedFavoritesState.memberships.forEach((membership) => {
const folderIds = folderIdsByAuthorId.get(membership.authorId) ?? new Set<string>();
folderIds.add(membership.folderId);
folderIdsByAuthorId.set(membership.authorId, folderIds);
});
const serializedCoreUserIds = readSerializedFavoriteCoreUserIds(options.document);
syncFavoriteRowPickers({
document: options.document,
folderIdsByAuthorId,
folders: cachedFavoritesState.folders,
onCreateFolder: createFavoriteFolder,
onSetCreatorFolderIds: setFavoriteCreatorFolderIds,
rows: table.rows.map((rowDom) => ({
actionCell: rowDom.actionCell,
authorId: rowDom.authorId,
authorName: rowDom.authorName,
coreUserId:
(rowDom as MarketRowDom & { coreUserId?: string }).coreUserId ??
serializedCoreUserIds.get(rowDom.authorId)
}))
});
favoriteActionButtons = Array.from(
options.document.querySelectorAll<HTMLButtonElement>(
'[data-sces-favorite-row-action="button"]'
)
);
}
async function createFavoriteFolder(): Promise<FavoriteFolder | null> {
const name = await promptFavoriteFolderName({ title: "新建收藏夹" });
if (name === null) {
return null;
}
return runFavoriteStorageMutation(async () => {
const folder = await favoritesRepository.createFolder(name);
await refreshFavorites();
return folder;
});
}
async function setFavoriteCreatorFolderIds(
creator: FavoriteCreatorInput,
folderIds: string[]
): Promise<void> {
await runFavoriteStorageMutation(async () => {
await favoritesRepository.setCreatorFolderIds(creator, folderIds);
await refreshFavorites();
});
}
async function renameFavoriteFolder(folderId: string): Promise<void> {
const folder = cachedFavoritesState?.folders.find((item) => item.id === folderId);
if (!folder) {
return;
}
const name = await promptFavoriteFolderName({
initialValue: folder.name,
title: "重命名收藏夹"
});
if (name === null) {
return;
}
await runFavoriteStorageMutation(async () => {
await favoritesRepository.renameFolder(folderId, name);
await refreshFavorites();
});
}
async function deleteFavoriteFolder(folderId: string): Promise<void> {
const folder = cachedFavoritesState?.folders.find((item) => item.id === folderId);
if (!folder) {
return;
}
if (!confirmFavoriteDelete(`确定删除收藏夹“${folder.name}”吗?`)) {
return;
}
await runFavoriteStorageMutation(async () => {
await favoritesRepository.deleteFolder(folderId);
await refreshFavorites();
});
}
async function removeFavoriteCreatorFromFolder(
authorId: string,
folderId: string
): Promise<void> {
await runFavoriteStorageMutation(async () => {
await favoritesRepository.removeCreatorFromFolder(authorId, folderId);
await refreshFavorites();
});
}
async function runFavoriteStorageMutation<T>(
mutation: () => Promise<T>
): Promise<T> {
favoritesDrawer.setBusy(true);
try {
return await mutation();
} catch (error) {
favoritesDrawer.setError(readFavoriteOperationError(error));
throw error;
} finally {
favoritesDrawer.setBusy(false);
}
}
async function submitFavoriteAuthors(authorIds: string[]): Promise<void> {
favoritesDrawer.setBusy(true);
try {
const requestedAuthorIds = new Set(
authorIds.map((authorId) => authorId.trim()).filter(Boolean)
);
const state = await favoritesRepository.read();
const records = toFavoriteMarketRecords(
state.creators.filter((creator) => requestedAuthorIds.has(creator.authorId))
);
if (records.length === 0) {
favoritesDrawer.setError("当前没有可导入的达人");
return;
}
const batchName = await promptBatchName();
if (batchName === null) {
return;
}
if (!batchName.trim()) {
favoritesDrawer.setError("请输入批次名称");
return;
}
const authState = await getAuthState();
if (!authState.isAuthenticated) {
throw new Error("请先登录插件");
}
await submitBatch(
createBatchPayload({
authState,
batchName,
createdAt: new Date().toISOString(),
records
})
);
favoritesDrawer.setError("批次提交成功");
} catch (error) {
favoritesDrawer.setError(readFavoriteBatchError(error));
} finally {
favoritesDrawer.setBusy(false);
}
}
function bindSelectionControls(table: ReturnType<typeof syncMarketTable>): void { function bindSelectionControls(table: ReturnType<typeof syncMarketTable>): void {
if (!table) { if (!table) {
return; return;
@@ -1629,6 +1885,58 @@ function readRowSnapshot(rowDom: MarketRowDom): MarketRowSnapshot {
}; };
} }
function readSerializedFavoriteCoreUserIds(document: Document): Map<string, string> {
const coreUserIds = new Map<string, string>();
const serializedRows = document.documentElement.getAttribute("data-sces-market-rows");
if (!serializedRows) {
return coreUserIds;
}
try {
const rows = JSON.parse(serializedRows) as unknown;
if (!Array.isArray(rows)) {
return coreUserIds;
}
rows.forEach((row) => {
if (!row || typeof row !== "object") {
return;
}
const authorId = (row as { authorId?: unknown }).authorId;
const coreUserId = (row as { coreUserId?: unknown }).coreUserId;
if (
typeof authorId === "string" &&
authorId.trim() &&
typeof coreUserId === "string" &&
coreUserId.trim()
) {
coreUserIds.set(authorId.trim(), coreUserId.trim());
}
});
} catch {
return coreUserIds;
}
return coreUserIds;
}
function readFavoriteOperationError(error: unknown): string {
if (error instanceof Error && error.message.trim()) {
return error.message;
}
return "收藏夹操作失败,请稍后重试";
}
function readFavoriteBatchError(error: unknown): string {
if (error instanceof Error && error.message.trim()) {
return error.message;
}
return "批次提交失败,请稍后重试";
}
function getNextSortState( function getNextSortState(
currentSort: MarketSortState | undefined, currentSort: MarketSortState | undefined,
field: MarketSortState["field"] field: MarketSortState["field"]
+337
View File
@@ -4,6 +4,10 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { createMarketResultStore } from "../src/content/market/result-store"; import { createMarketResultStore } from "../src/content/market/result-store";
import {
createFavoritesRepository,
type FavoritesStorage
} from "../src/content/market/favorites-store";
import type { SpreadInfoConfig } from "../src/content/market/types"; import type { SpreadInfoConfig } from "../src/content/market/types";
const disposers: Array<() => void> = []; const disposers: Array<() => void> = [];
@@ -20,6 +24,7 @@ describe("market-content-entry", () => {
}); });
afterEach(() => { afterEach(() => {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
vi.doUnmock("../src/content/market/index"); vi.doUnmock("../src/content/market/index");
delete ( delete (
globalThis as typeof globalThis & { globalThis as typeof globalThis & {
@@ -4544,6 +4549,291 @@ describe("market-content-entry", () => {
?.textContent ?.textContent
).toBe("0.8% - 1%"); ).toBe("0.8% - 1%");
}); });
test("mounts favorites controls without replacing the native action text", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
]);
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
favoritesRepository: createTestFavoritesRepository(),
loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }),
window
}));
await controller.ready;
expect(document.querySelector('[data-sces-favorites-tab="button"]')).not.toBeNull();
expect(document.querySelector('[data-sces-favorites-drawer="root"]')).not.toBeNull();
expect(
document.querySelector('[data-testid="action-cell-111"] [data-sces-favorite-row-action="button"]')
).not.toBeNull();
expect(document.querySelector('[data-testid="action-cell-111"]')?.textContent).toContain("下单");
});
test("saves the serialized market row identity through its favorite picker", async () => {
document.body.innerHTML = buildRealMarketFixtureWithoutAuthorIds([
{ authorName: "达人 A", price21To60s: "¥11,000" }
]);
document.documentElement.setAttribute(
"data-sces-market-rows",
JSON.stringify([
{ authorId: "111", authorName: "达人 A", coreUserId: "core-111" }
])
);
const repository = createTestFavoritesRepository();
const setCreatorFolderIds = vi.fn(repository.setCreatorFolderIds.bind(repository));
repository.setCreatorFolderIds = setCreatorFolderIds;
const promptFavoriteFolderName = vi.fn(() => "母婴优质达人");
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
favoritesRepository: repository,
loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }),
promptFavoriteFolderName,
window
}));
await controller.ready;
click('[data-sces-favorite-row-action="button"]');
click('[data-sces-favorite-row-picker="create-folder"]');
await waitForMockCall(setCreatorFolderIds);
await (setCreatorFolderIds.mock.results[0]?.value as Promise<void>);
await flush();
expect(repository.getStoredState()).toMatchObject({
creators: [
{
authorId: "111",
authorName: "达人 A",
coreUserId: "core-111"
}
],
folders: [{ name: "母婴优质达人" }]
});
expect(promptFavoriteFolderName).toHaveBeenCalledWith({ title: "新建收藏夹" });
expect(
document.querySelector('[data-sces-favorite-row-action="button"]')?.dataset.scesFavoriteState
).toBe("saved");
});
test("renders a creator once in all favorites and in its selected folder", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
]);
const repository = createTestFavoritesRepository();
const firstFolder = await repository.createFolder("母婴优质达人");
const secondFolder = await repository.createFolder("品牌合作");
await repository.setCreatorFolderIds(
{ authorId: "111", authorName: "达人 A", coreUserId: "core-111" },
[firstFolder.id, secondFolder.id]
);
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
favoritesRepository: repository,
loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }),
window
}));
await controller.ready;
click('[data-sces-favorites-tab="button"]');
expect(document.querySelectorAll('[data-sces-favorites-author-id="111"]')).toHaveLength(1);
click(`[data-sces-favorites-folder-id="${secondFolder.id}"]`);
expect(document.querySelectorAll('[data-sces-favorites-author-id="111"]')).toHaveLength(1);
});
test("submits selected favorites without scanning or exporting the market range", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
]);
const repository = createTestFavoritesRepository();
const folder = await repository.createFolder("母婴优质达人");
await repository.setCreatorFolderIds(
{ authorId: "111", authorName: "达人 A", coreUserId: "core-111" },
[folder.id]
);
const promptBatchName = vi.fn(() => "收藏夹批次");
const submitBatch = vi.fn(async () => ({ ok: true }));
const nextPage = document.querySelector('[data-testid="next-page"]') as HTMLButtonElement;
const onNextPage = vi.fn();
nextPage.addEventListener("click", onNextPage);
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
favoritesRepository: repository,
getAuthState: async () => authenticatedTestState(),
loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }),
promptBatchName,
submitBatch,
window
}));
await controller.ready;
click('[data-sces-favorites-tab="button"]');
const selected = document.querySelector(
'[data-sces-favorites-select-author-id="111"]'
) as HTMLInputElement;
selected.click();
click('[data-sces-favorites-drawer="import-selected"]');
await waitForMockCall(submitBatch);
expect(promptBatchName).toHaveBeenCalledTimes(1);
expect(submitBatch).toHaveBeenCalledWith(
expect.objectContaining({
authors: [{ authorId: "111", authorName: "达人 A", authorUid: "core-111" }],
batchName: "收藏夹批次"
})
);
expect(onNextPage).not.toHaveBeenCalled();
expect(repository.getStoredState()).toMatchObject({
creators: [{ authorId: "111" }]
});
expect(
document.querySelector('[data-sces-favorites-drawer="error"]')?.textContent
).toBe("批次提交成功");
});
test("does not prompt or submit a full folder when favorite import confirmation is declined", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
]);
const repository = createTestFavoritesRepository();
const folder = await repository.createFolder("母婴优质达人");
await repository.setCreatorFolderIds({ authorId: "111", authorName: "达人 A" }, [folder.id]);
const promptBatchName = vi.fn(() => "不应调用");
const submitBatch = vi.fn(async () => ({ ok: true }));
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
confirmFavoriteImport: () => false,
document,
favoritesRepository: repository,
getAuthState: async () => authenticatedTestState(),
loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }),
promptBatchName,
submitBatch,
window
}));
await controller.ready;
click('[data-sces-favorites-tab="button"]');
click(`[data-sces-favorites-folder-id="${folder.id}"]`);
click('[data-sces-favorites-drawer="import-folder"]');
await flush();
expect(promptBatchName).not.toHaveBeenCalled();
expect(submitBatch).not.toHaveBeenCalled();
});
test("honors favorite folder deletion confirmation", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
]);
const repository = createTestFavoritesRepository();
const folder = await repository.createFolder("母婴优质达人");
let shouldDelete = false;
const confirmFavoriteDelete = vi.fn(() => shouldDelete);
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
confirmFavoriteDelete,
document,
favoritesRepository: repository,
loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }),
window
}));
await controller.ready;
click('[data-sces-favorites-tab="button"]');
click(`[data-sces-favorites-folder-delete="${folder.id}"]`);
await flush();
expect(repository.getStoredState()).toMatchObject({ folders: [{ id: folder.id }] });
shouldDelete = true;
click(`[data-sces-favorites-folder-delete="${folder.id}"]`);
await waitForCondition(
() => document.querySelector(`[data-sces-favorites-folder-delete="${folder.id}"]`) === null
);
await flush();
expect(repository.getStoredState()).toMatchObject({ folders: [] });
expect(confirmFavoriteDelete).toHaveBeenLastCalledWith(
"确定删除收藏夹“母婴优质达人”吗?"
);
});
test("shows a favorite mutation error without disabling CSV export", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
]);
const repository = createTestFavoritesRepository();
repository.createFolder = async () => {
throw new Error();
};
const buildCsv = vi.fn(() => "csv-output");
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
buildCsv,
document,
favoritesRepository: repository,
loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }),
onCsvReady: vi.fn(),
promptFavoriteFolderName: () => "母婴优质达人",
window
}));
await controller.ready;
click('[data-sces-favorite-row-action="button"]');
click('[data-sces-favorite-row-picker="create-folder"]');
await waitForCondition(
() =>
document.querySelector('[data-sces-favorites-drawer="error"]')?.textContent ===
"收藏夹操作失败,请稍后重试"
);
const exportButton = document.querySelector(
'[data-plugin-export="button"]'
) as HTMLButtonElement;
expect(exportButton.disabled).toBe(false);
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
removeDefaultSpreadMetricFilter();
click('[data-plugin-export="button"]');
await waitForMockCall(buildCsv, 40, 50);
expect(buildCsv).toHaveBeenCalledWith(expect.any(Array));
});
test("keeps the market toolbar available when favorites storage cannot be read", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
]);
const repository = createTestFavoritesRepository();
repository.read = async () => {
throw new Error("storage unavailable");
};
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
favoritesRepository: repository,
loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }),
window
}));
await expect(controller.ready).resolves.toBeUndefined();
expect(
document.querySelector('[data-sces-favorites-drawer="error"]')?.textContent
).toBe("收藏夹暂不可用");
expect(document.querySelector('[data-plugin-export="button"]')).not.toBeNull();
expect(document.querySelector('[data-plugin-batch-submit="button"]')).not.toBeNull();
});
}); });
function clearLocalStorage(): void { function clearLocalStorage(): void {
@@ -5970,6 +6260,39 @@ function trackController<T extends { dispose?: () => void }>(controller: T): T {
return controller; return controller;
} }
function createTestFavoritesRepository(initial: unknown = undefined) {
let storedValue = initial;
let nextFolderId = 0;
const storage: FavoritesStorage = {
async get() {
return storedValue;
},
async set(value) {
storedValue = value;
}
};
const repository = createFavoritesRepository({
createId: () => `test-folder-${++nextFolderId}`,
now: () => "2026-07-17T00:00:00.000Z",
storage
});
return Object.assign(repository, {
getStoredState() {
return storedValue;
}
});
}
function authenticatedTestState() {
return {
isAuthenticated: true,
resource: "https://talent-search.intelligrow.cn",
userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" }
};
}
function expectButtonDisabled(selector: string, expected: boolean) { function expectButtonDisabled(selector: string, expected: boolean) {
const element = document.querySelector(selector) as HTMLButtonElement | null; const element = document.querySelector(selector) as HTMLButtonElement | null;
if (!element) { if (!element) {
@@ -6019,6 +6342,20 @@ async function waitForMockCall(
} }
} }
async function waitForCondition(
condition: () => boolean | Promise<boolean>,
maxAttempts = 40
): Promise<void> {
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
if (await condition()) {
return;
}
await flushWithTimers();
}
throw new Error("Timed out waiting for condition");
}
function createDeferred<T>() { function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void; let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void; let reject!: (reason?: unknown) => void;