feat: import saved favorites to batches
This commit is contained in:
@@ -4,6 +4,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
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";
|
||||
|
||||
const disposers: Array<() => void> = [];
|
||||
@@ -20,6 +24,7 @@ describe("market-content-entry", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
||||
vi.doUnmock("../src/content/market/index");
|
||||
delete (
|
||||
globalThis as typeof globalThis & {
|
||||
@@ -4544,6 +4549,291 @@ describe("market-content-entry", () => {
|
||||
?.textContent
|
||||
).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 {
|
||||
@@ -5970,6 +6260,39 @@ function trackController<T extends { dispose?: () => void }>(controller: T): T {
|
||||
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) {
|
||||
const element = document.querySelector(selector) as HTMLButtonElement | null;
|
||||
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>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
|
||||
Reference in New Issue
Block a user