Files
star-chart-search-enhancer/tests/market-content-entry.test.ts
T

4851 lines
154 KiB
TypeScript

// @vitest-environment jsdom
// @vitest-environment-options {"url":"https://xingtu.cn/"}
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> = [];
describe("market-content-entry", () => {
beforeEach(() => {
installUsableLocalStorage();
document.body.innerHTML = "";
document.documentElement.removeAttribute("data-sces-market-rows");
document.documentElement.removeAttribute("data-sces-market-request-snapshot");
document.documentElement.removeAttribute("data-test-page-index");
clearLocalStorage();
window.history.replaceState({}, "", "/");
});
afterEach(() => {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
vi.doUnmock("../src/content/market/index");
delete (
globalThis as typeof globalThis & {
chrome?: unknown;
}
).chrome;
delete (
window as Window & {
__starChartSearchEnhancerContentController?: unknown;
__SCES_MARKET_PAGE_BRIDGE_INSTALLED__?: boolean;
}
).__starChartSearchEnhancerContentController;
delete (
window as Window & {
__SCES_MARKET_PAGE_BRIDGE_INSTALLED__?: boolean;
}
).__SCES_MARKET_PAGE_BRIDGE_INSTALLED__;
delete (
globalThis as typeof globalThis & {
fetch?: unknown;
}
).fetch;
document.documentElement.removeAttribute("data-sces-market-rows");
document.documentElement.removeAttribute("data-sces-market-request-snapshot");
document.documentElement.removeAttribute("data-test-page-index");
vi.resetModules();
while (disposers.length > 0) {
disposers.pop()?.();
}
});
test("auto boots on import when chrome runtime is available", async () => {
const createMarketController = vi.fn(() => ({
ready: Promise.resolve()
}));
const sendMessage = vi.fn(async () => ({
ok: true,
type: "auth:state",
value: { isAuthenticated: true }
}));
window.history.replaceState({}, "", "/ad/creator/market");
(
globalThis as typeof globalThis & {
chrome?: { runtime?: { sendMessage?: (message: unknown) => Promise<unknown> } };
}
).chrome = {
runtime: {
sendMessage
}
};
vi.doMock("../src/content/market/index", () => ({
createMarketController
}));
await import("../src/content/index");
expect(createMarketController).toHaveBeenCalledTimes(1);
});
test("boots the market controller on the Xingtu market URL", async () => {
const createMarketController = vi.fn(() => ({
ready: Promise.resolve()
}));
window.history.replaceState({}, "", "/ad/creator/market");
const { bootContentScript } = await import("../src/content/index");
await bootContentScript({
createMarketController,
sendAuthMessage: vi.fn(async () => ({
ok: true,
type: "auth:state",
value: { isAuthenticated: true }
}))
});
expect(createMarketController).toHaveBeenCalledTimes(1);
expect(
document.documentElement.querySelector('[data-sces-market-bridge="script"]')
).not.toBeNull();
});
test("installs the market bridge before auth state resolves", async () => {
const createMarketController = vi.fn(() => ({
ready: Promise.resolve()
}));
let resolveAuthState: ((value: unknown) => void) | null = null;
window.history.replaceState({}, "", "/ad/creator/market");
const { bootContentScript } = await import("../src/content/index");
const bootPromise = bootContentScript({
createMarketController,
sendAuthMessage: vi.fn(
() =>
new Promise((resolve) => {
resolveAuthState = resolve;
})
)
});
expect(
document.documentElement.querySelector('[data-sces-market-bridge="script"]')
).not.toBeNull();
expect(createMarketController).not.toHaveBeenCalled();
resolveAuthState?.({
ok: true,
type: "auth:state",
value: { isAuthenticated: true }
});
await bootPromise;
expect(createMarketController).toHaveBeenCalledTimes(1);
});
test("boots the market controller on the www Xingtu market URL", async () => {
const createMarketController = vi.fn(() => ({
ready: Promise.resolve()
}));
const { bootContentScript } = await import("../src/content/index");
await bootContentScript({
createMarketController,
document,
sendAuthMessage: vi.fn(async () => ({
ok: true,
type: "auth:state",
value: { isAuthenticated: true }
})),
window: {
location: {
href: "https://www.xingtu.cn/ad/creator/market"
}
} as Window
});
expect(createMarketController).toHaveBeenCalledTimes(1);
});
test("booted export callback downloads the generated csv file", async () => {
const createMarketController = vi.fn(() => ({
ready: Promise.resolve()
}));
const createObjectURL = vi.fn(() => "blob:test-url");
const revokeObjectURL = vi.fn();
let clickedDownload: { download: string; href: string } | null = null;
const clickSpy = vi
.spyOn(HTMLAnchorElement.prototype, "click")
.mockImplementation(function (this: HTMLAnchorElement) {
clickedDownload = {
download: this.download,
href: this.href
};
});
window.history.replaceState({}, "", "/ad/creator/market");
Object.defineProperty(window.URL, "createObjectURL", {
configurable: true,
value: createObjectURL
});
Object.defineProperty(window.URL, "revokeObjectURL", {
configurable: true,
value: revokeObjectURL
});
const { bootContentScript } = await import("../src/content/index");
await bootContentScript({
createMarketController,
sendAuthMessage: vi.fn(async () => ({
ok: true,
type: "auth:state",
value: { isAuthenticated: true }
}))
});
const controllerOptions = createMarketController.mock.calls[0]?.[0];
expect(controllerOptions?.onCsvReady).toEqual(expect.any(Function));
controllerOptions.onCsvReady("列1,列2\n值1,值2");
expect(createObjectURL).toHaveBeenCalledTimes(1);
expect(clickSpy).toHaveBeenCalledTimes(1);
expect(clickedDownload).not.toBeNull();
expect(clickedDownload?.href).toBe("blob:test-url");
expect(clickedDownload?.download).toMatch(/\.csv$/);
expect(revokeObjectURL).toHaveBeenCalledWith("blob:test-url");
});
test("booted export callback can download a custom csv filename", async () => {
const createMarketController = vi.fn(() => ({
ready: Promise.resolve()
}));
let clickedDownload: { download: string; href: string } | null = null;
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (
this: HTMLAnchorElement
) {
clickedDownload = {
download: this.download,
href: this.href
};
});
window.history.replaceState({}, "", "/ad/creator/market");
Object.defineProperty(window.URL, "createObjectURL", {
configurable: true,
value: vi.fn(() => "blob:test-url")
});
Object.defineProperty(window.URL, "revokeObjectURL", {
configurable: true,
value: vi.fn()
});
const { bootContentScript } = await import("../src/content/index");
await bootContentScript({
createMarketController,
sendAuthMessage: vi.fn(async () => ({
ok: true,
type: "auth:state",
value: { isAuthenticated: true }
}))
});
const controllerOptions = createMarketController.mock.calls[0]?.[0];
controllerOptions.onCsvReady("列1,列2\n值1,值2", "达人连接用户画像_20260518_1530.csv");
expect(clickedDownload?.download).toBe("达人连接用户画像_20260518_1530.csv");
});
test("booted export callback sends the csv to extension runtime when available", async () => {
const createMarketController = vi.fn(() => ({
ready: Promise.resolve()
}));
const sendMessage = vi.fn();
window.history.replaceState({}, "", "/ad/creator/market");
(
globalThis as typeof globalThis & {
chrome?: { runtime?: { id?: string; sendMessage?: (message: unknown) => void } };
}
).chrome = {
runtime: {
id: "test-extension",
sendMessage
}
};
const { bootContentScript } = await import("../src/content/index");
sendMessage.mockClear();
await bootContentScript({
createMarketController,
sendAuthMessage: vi.fn(async () => ({
ok: true,
type: "auth:state",
value: { isAuthenticated: true }
}))
});
const controllerOptions = createMarketController.mock.calls[0]?.[0];
controllerOptions.onCsvReady("列1,列2\n值1,值2");
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith(
expect.objectContaining({
csv: "列1,列2\n值1,值2",
filename: expect.stringMatching(/^star-chart-search-enhancer-/),
type: "download-market-csv"
})
);
});
test("renders the plugin action bar inside the native market action row", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "a", authorName: "Alpha", price21To60s: "450000" },
{ authorId: "b", authorName: "Beta", price21To60s: "70000" }
]);
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
window
}));
await controller.ready;
const toolbar = document.querySelector('[data-plugin-toolbar="root"]');
const actionRow = document.querySelector('[data-testid="market-native-actions"]');
const customizeButton = document.querySelector('[data-testid="market-native-customize"]');
const nativeExportButton = document.querySelector('[data-testid="market-native-export"]');
expect(toolbar).not.toBeNull();
expect(actionRow).not.toBeNull();
expect(toolbar?.parentElement).toBe(actionRow);
expect(toolbar?.nextElementSibling).toBe(customizeButton);
expect(customizeButton?.nextElementSibling).toBe(nativeExportButton);
expect(document.querySelector('[data-plugin-filter-apply="button"]')).toBeNull();
expect(document.querySelector('[data-plugin-sort-apply="button"]')).toBeNull();
expect(document.querySelector('[data-plugin-filter-single="input"]')).toBeNull();
expect(document.querySelector('[data-plugin-sort-field="select"]')).toBeNull();
expect(document.body.firstElementChild).not.toBe(toolbar);
expect(
(document.querySelector('[data-plugin-export-range="select"]') as HTMLSelectElement | null)
?.hidden
).toBe(false);
expect(
(
document.querySelector(
'[data-plugin-export-custom-pages="input"]'
) as HTMLInputElement | null
)?.hidden
).toBe(true);
expect(document.querySelector('[data-plugin-export="button"]')).toBeNull();
expect(
document.querySelector('[data-plugin-export-audience-profile="button"]')
).not.toBeNull();
expect(
document.querySelector('[data-plugin-export-audience-profile-by-id="button"]')
).not.toBeNull();
expect(document.querySelector('[data-plugin-batch-submit="button"]')).not.toBeNull();
expect(document.querySelector('[data-plugin-export-status="text"]')).not.toBeNull();
const batchSubmitButton = document.querySelector(
'[data-plugin-batch-submit="button"]'
) as HTMLButtonElement | null;
const audienceProfileExportButton = document.querySelector(
'[data-plugin-export-audience-profile="button"]'
) as HTMLButtonElement | null;
const audienceProfileByIdExportButton = document.querySelector(
'[data-plugin-export-audience-profile-by-id="button"]'
) as HTMLButtonElement | null;
const audienceProfileFieldButton = document.querySelector(
'[data-plugin-audience-profile-fields="button"]'
) as HTMLButtonElement | null;
expect(audienceProfileExportButton?.textContent).toBe("导出选中达人数据");
expect(audienceProfileExportButton?.title).toBe(
"仅导出已勾选达人,包含内容数据、效果预估、画像等维度"
);
expect(audienceProfileByIdExportButton?.textContent).toBe("按星图ID导出");
expect(audienceProfileByIdExportButton?.title).toBe(
"粘贴达人星图ID后批量导出达人数据,不依赖当前列表勾选"
);
expect(audienceProfileFieldButton?.textContent).toBe("选择字段");
expect(audienceProfileFieldButton?.title).toBe(
"搜索并勾选本次CSV需要导出的字段,设置会自动保存"
);
expect(batchSubmitButton?.title).toBe("将当前选中的达人提交到后续业务批次");
expect(batchSubmitButton?.style.backgroundColor).toBe("rgb(127, 29, 45)");
expect(batchSubmitButton?.style.color).toBe("rgb(255, 255, 255)");
expect([
audienceProfileExportButton?.style.backgroundColor,
audienceProfileByIdExportButton?.style.backgroundColor,
audienceProfileFieldButton?.style.backgroundColor
]).toEqual([
"rgb(255, 255, 255)",
"rgb(255, 255, 255)",
"rgb(255, 255, 255)"
]);
expect([
audienceProfileExportButton?.style.color,
audienceProfileByIdExportButton?.style.color,
audienceProfileFieldButton?.style.color
]).toEqual([
"rgb(52, 64, 84)",
"rgb(52, 64, 84)",
"rgb(52, 64, 84)"
]);
});
test("renders the approved compact toolbar layout from the preview", async () => {
document.body.innerHTML = buildMarketFixture();
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
window
}));
await controller.ready;
const toolbar = document.querySelector(
'[data-plugin-toolbar="root"]'
) as HTMLElement | null;
const panel = document.querySelector(
'[data-plugin-toolbar-panel="root"]'
) as HTMLElement | null;
const primaryRow = document.querySelector(
'[data-plugin-toolbar-row="primary"]'
) as HTMLElement | null;
const dataGroup = document.querySelector(
'[data-plugin-toolbar-group="data"]'
) as HTMLElement | null;
const selectedAudienceExportGroup = document.querySelector(
'[data-plugin-toolbar-action-group="selected-audience-export"]'
) as HTMLElement | null;
const idExportGroup = document.querySelector(
'[data-plugin-toolbar-action-group="id-export"]'
) as HTMLElement | null;
const batchSubmitGroup = document.querySelector(
'[data-plugin-toolbar-action-group="batch-submit"]'
) as HTMLElement | null;
const statusText = document.querySelector(
'[data-plugin-export-status="text"]'
) as HTMLElement | null;
const exportRangeSelect = document.querySelector(
'[data-plugin-export-range="select"]'
) as HTMLSelectElement | null;
const exportCustomPagesInput = document.querySelector(
'[data-plugin-export-custom-pages="input"]'
) as HTMLInputElement | null;
const audienceProfileExportButton = document.querySelector(
'[data-plugin-export-audience-profile="button"]'
) as HTMLButtonElement | null;
const audienceProfileByIdExportButton = document.querySelector(
'[data-plugin-export-audience-profile-by-id="button"]'
) as HTMLButtonElement | null;
const audienceProfileFieldButton = document.querySelector(
'[data-plugin-audience-profile-fields="button"]'
) as HTMLButtonElement | null;
const batchSubmitButton = document.querySelector(
'[data-plugin-batch-submit="button"]'
) as HTMLButtonElement | null;
expect(toolbar?.style.flexWrap).toBe("nowrap");
expect(panel?.style.alignItems).toBe("center");
expect(primaryRow?.style.flexWrap).toBe("nowrap");
expect(panel?.style.overflow).toBe("visible");
expect(dataGroup?.parentElement).toBe(primaryRow);
expect(
document.querySelector('[data-plugin-toolbar-action-group="csv-export"]')
).toBeNull();
expect(selectedAudienceExportGroup?.parentElement).toBe(dataGroup);
expect(idExportGroup?.parentElement).toBe(dataGroup);
expect(batchSubmitGroup?.parentElement).toBe(dataGroup);
expect(Array.from(selectedAudienceExportGroup?.children ?? [])).toEqual([
audienceProfileExportButton,
audienceProfileFieldButton,
exportRangeSelect,
exportCustomPagesInput
]);
expect(Array.from(idExportGroup?.children ?? [])).toEqual([
audienceProfileByIdExportButton
]);
expect(Array.from(batchSubmitGroup?.children ?? [])).toEqual([
batchSubmitButton
]);
expect(statusText?.parentElement).toBe(primaryRow);
expect(primaryRow?.style.justifyContent).toBe("flex-start");
expect(document.querySelector('[data-plugin-toolbar-row="thresholds"]')).toBeNull();
expect(document.querySelector('[data-plugin-metric-catalog]')).toBeNull();
expect(document.querySelector('[data-plugin-metric-filter-rule]')).toBeNull();
expect(document.body.textContent).not.toContain("指标筛选");
expect([
audienceProfileExportButton?.textContent,
audienceProfileByIdExportButton?.textContent,
audienceProfileFieldButton?.textContent,
batchSubmitButton?.textContent
]).toEqual([
"导出选中达人数据",
"按星图ID导出",
"选择字段",
"提交批次"
]);
expect([
audienceProfileExportButton?.style.backgroundColor,
audienceProfileByIdExportButton?.style.backgroundColor,
audienceProfileFieldButton?.style.backgroundColor,
batchSubmitButton?.style.backgroundColor
]).toEqual([
"rgb(255, 255, 255)",
"rgb(255, 255, 255)",
"rgb(255, 255, 255)",
"rgb(127, 29, 45)"
]);
expect([
audienceProfileExportButton?.style.color,
audienceProfileByIdExportButton?.style.color,
audienceProfileFieldButton?.style.color,
batchSubmitButton?.style.color
]).toEqual([
"rgb(52, 64, 84)",
"rgb(52, 64, 84)",
"rgb(52, 64, 84)",
"rgb(255, 255, 255)"
]);
});
test("remounts the plugin action bar when the native market action row appears later", async () => {
document.body.innerHTML = buildMarketTableOnlyFixture();
const observer = createMutationObserverFactory();
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
mutationObserverFactory: observer.factory,
window
}));
await controller.ready;
const toolbar = document.querySelector('[data-plugin-toolbar="root"]');
expect(toolbar).not.toBeNull();
expect(toolbar?.parentElement).toBe(document.body);
expect((toolbar as HTMLElement | null)?.hidden).toBe(true);
document.body.insertAdjacentHTML("afterbegin", buildMarketPageShell(""));
observer.trigger();
await flushWithTimers();
await flushWithTimers();
const actionRow = document.querySelector('[data-testid="market-native-actions"]');
expect(toolbar?.parentElement).toBe(actionRow);
expect((toolbar as HTMLElement | null)?.hidden).toBe(false);
});
test("selection keeps a clicked creator checked after the table re-renders", async () => {
document.body.innerHTML = buildMarketFixture();
const mutationObserver = createMutationObserverFactory();
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
mutationObserverFactory: mutationObserver.factory,
window
}));
await controller.ready;
clickSelectionCheckboxForAuthor("a");
expect(readSelectionCheckboxForAuthor("a").checked).toBe(true);
const table = document.querySelector("[data-market-table]");
if (!(table instanceof HTMLElement)) {
throw new Error("Missing market table");
}
table.outerHTML = buildMarketTableOnlyFixture();
mutationObserver.trigger();
await flushWithTimers();
expect(readSelectionCheckboxForAuthor("a").checked).toBe(true);
});
test("selection survives a page change and re-render", async () => {
const pages = [
[
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" },
{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }
],
[
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" },
{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }
]
];
document.body.innerHTML = buildRealMarketFixture(pages[0]);
installAsyncPaginationHarness(pages);
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
window
}));
await controller.ready;
clickSelectionCheckboxForAuthor("111");
click('[data-testid="next-page"]');
await flushWithTimers();
expect(readSelectionCheckboxForAuthor("111").checked).toBe(true);
expect(readSelectionCheckboxForAuthor("333").checked).toBe(false);
});
test("selection header selects all visible creators on the current page", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" },
{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }
]);
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
window
}));
await controller.ready;
clickHeaderSelectionCheckbox();
expect(readSelectionCheckboxForAuthor("111").checked).toBe(true);
expect(readSelectionCheckboxForAuthor("222").checked).toBe(true);
});
test("selection header clears all visible creators on the current page", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" },
{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }
]);
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
window
}));
await controller.ready;
clickHeaderSelectionCheckbox();
clickHeaderSelectionCheckbox();
expect(readSelectionCheckboxForAuthor("111").checked).toBe(false);
expect(readSelectionCheckboxForAuthor("222").checked).toBe(false);
});
test("selection header becomes indeterminate when only part of the current page is selected", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" },
{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }
]);
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
window
}));
await controller.ready;
clickSelectionCheckboxForAuthor("111");
expect(readHeaderSelectionCheckbox().checked).toBe(false);
expect(readHeaderSelectionCheckbox().indeterminate).toBe(true);
});
test("hydrates current page rows on start", async () => {
document.body.innerHTML = buildMarketFixture();
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
loadAuthorMetrics: async (authorId) => ({
success: true,
rates:
authorId === "a"
? {
singleVideoAfterSearchRate: "0.02% - 0.1%",
personalVideoAfterSearchRate: "0.03% - 0.2%"
}
: {
singleVideoAfterSearchRate: "0.5%-1%",
personalVideoAfterSearchRate: "0.01% - 0.1%"
}
}),
window
}));
await controller.ready;
expect(
document.querySelector('[data-market-row-cell="singleVideoAfterSearchRate"]')
?.textContent
).toBe("0.02% - 0.1%");
expect(
document.querySelector('[data-market-row-cell="personalVideoAfterSearchRate"]')
?.textContent
).toBe("0.03% - 0.2%");
});
test("batch loads backend metrics for the visible page and renders the metrics panel", async () => {
document.body.innerHTML = buildMarketFixture();
const searchBackendMetrics = vi.fn(async (starIds: string[]) =>
starIds
.filter((starId) => starId === "a")
.map((starId) => ({
a3IncreaseCount: "78,366.22",
afterViewSearchCount: "9,689.96",
afterViewSearchRate: "0.36%",
cpSearch: "14.46",
cpa3: "1.79",
newA3Rate: "3.44%",
starId
}))
);
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
searchBackendMetrics,
window
}));
await controller.ready;
expect(searchBackendMetrics).toHaveBeenCalledTimes(1);
expect(searchBackendMetrics).toHaveBeenCalledWith(["a", "b"]);
expect(
document.querySelector('[data-market-row-cell="afterViewSearchRate"]')?.textContent
).toBe("0.36%");
expect(
document.querySelector('[data-market-row-cell="cpSearch"]')?.textContent
).toBe("14.46");
expect(
document.querySelectorAll('[data-market-row-cell="afterViewSearchRate"]')[1]?.textContent
).toBe("暂无数据");
});
test("boots the controller only after auth succeeds", async () => {
const createMarketController = vi.fn(() => ({
ready: Promise.resolve()
}));
window.history.replaceState({}, "", "/ad/creator/market");
const { bootContentScript } = await import("../src/content/index");
await bootContentScript({
createMarketController,
document,
sendAuthMessage: vi.fn(async () => ({
ok: true,
type: "auth:state",
value: { isAuthenticated: true }
})),
window
});
expect(createMarketController).toHaveBeenCalledTimes(1);
});
test("hydrates the real div-grid market rows on start", async () => {
document.body.innerHTML = buildRealMarketFixture([
{
authorId: "111",
authorName: "达人 A",
price21To60s: "¥450,000"
},
{
authorId: "222",
authorName: "达人 B",
price21To60s: "¥20,000"
}
]);
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
loadAuthorMetrics: async (authorId) => ({
success: true,
rates:
authorId === "111"
? {
singleVideoAfterSearchRate: "0.02% - 0.1%",
personalVideoAfterSearchRate: "0.03% - 0.2%"
}
: {
singleVideoAfterSearchRate: "0.5%-1%",
personalVideoAfterSearchRate: "0.01% - 0.1%"
}
}),
window
}));
await controller.ready;
expect(readDivRightRowTexts(0)).toEqual(["¥450,000", "下单"]);
expect(readDivPluginRowTexts(0)).toEqual([
"0.02% - 0.1%",
"0.03% - 0.2%",
"",
"",
"",
"",
"",
""
]);
expect(readDivRightRowTexts(1)).toEqual(["¥20,000", "下单"]);
expect(readDivPluginRowTexts(1)).toEqual([
"0.5% - 1%",
"0.01% - 0.1%",
"",
"",
"",
"",
"",
""
]);
});
test("uses the market list single-rate directly and still loads the missing personal rate", async () => {
document.body.innerHTML = buildRealMarketFixture([
{
authorId: "111",
authorName: "达人 A",
price21To60s: "¥450,000"
},
{
authorId: "222",
authorName: "达人 B",
price21To60s: "¥20,000"
}
]);
attachMarketListState([
{
attribute_datas: {
avg_search_after_view_rate_30d: "0.0002",
nickname: "达人 A"
},
star_id: "111"
},
{
attribute_datas: {
nickname: "达人 B"
},
star_id: "222"
}
]);
const loadAuthorMetrics = vi.fn(async (authorId: string) => ({
success: true as const,
rates:
authorId === "111"
? {
singleVideoAfterSearchRate: "0.02%",
personalVideoAfterSearchRate: "0.03% - 0.2%"
}
: {
singleVideoAfterSearchRate: "0.5%-1%",
personalVideoAfterSearchRate: "0.01% - 0.1%"
}
}));
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
loadAuthorMetrics,
window
}));
await controller.ready;
expect(loadAuthorMetrics).toHaveBeenCalledTimes(2);
expect(readDivRightRowTexts(0)).toEqual(["¥450,000", "下单"]);
expect(readDivPluginRowTexts(0)).toEqual([
"0.02%",
"0.03% - 0.2%",
"",
"",
"",
"",
"",
""
]);
expect(readDivRightRowTexts(1)).toEqual(["¥20,000", "下单"]);
expect(readDivPluginRowTexts(1)).toEqual([
"0.5% - 1%",
"0.01% - 0.1%",
"",
"",
"",
"",
"",
""
]);
});
test("keeps all plugin columns in loading state until backend metrics are ready", async () => {
document.body.innerHTML = buildRealMarketFixture([
{
authorId: "111",
authorName: "达人 A",
price21To60s: "¥450,000"
},
{
authorId: "222",
authorName: "达人 B",
price21To60s: "¥20,000"
}
]);
const backendDeferred = createDeferred<
Array<{
afterViewSearchRate: string;
starId: string;
}>
>();
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
loadAuthorMetrics: async (authorId) => ({
success: true,
rates:
authorId === "111"
? {
singleVideoAfterSearchRate: "0.02%",
personalVideoAfterSearchRate: "0.03% - 0.2%"
}
: {
singleVideoAfterSearchRate: "0.5%-1%",
personalVideoAfterSearchRate: "0.01% - 0.1%"
}
}),
searchBackendMetrics: () => backendDeferred.promise,
window
}));
await flushWithTimers();
expect(readDivPluginRowTexts(0)).toEqual([
"加载中...",
"加载中...",
"加载中...",
"加载中...",
"加载中...",
"加载中...",
"加载中...",
"加载中..."
]);
expect(readDivPluginRowTexts(1)).toEqual([
"加载中...",
"加载中...",
"加载中...",
"加载中...",
"加载中...",
"加载中...",
"加载中...",
"加载中..."
]);
backendDeferred.resolve([
{
afterViewSearchRate: "0.36%",
starId: "111"
},
{
afterViewSearchRate: "1.4%",
starId: "222"
}
]);
await controller.ready;
expect(readDivPluginRowTexts(0)).toEqual([
"0.02%",
"0.03% - 0.2%",
"0.36%",
"",
"",
"",
"",
""
]);
expect(readDivPluginRowTexts(1)).toEqual([
"0.5% - 1%",
"0.01% - 0.1%",
"1.4%",
"",
"",
"",
"",
""
]);
});
test("hydrates real rows from serialized market rows when vue state is unavailable", async () => {
document.body.innerHTML = buildRealMarketFixtureWithoutAuthorIds([
{
authorName: "达人 A",
price21To60s: "¥450,000"
},
{
authorName: "达人 B",
price21To60s: "¥20,000"
}
]);
document.documentElement.setAttribute(
"data-sces-market-rows",
JSON.stringify([
{
authorId: "111",
authorName: "达人 A",
singleVideoAfterSearchRate: "0.02%"
},
{
authorId: "222",
authorName: "达人 B"
}
])
);
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
loadAuthorMetrics: async (authorId) => ({
success: true,
rates:
authorId === "111"
? {
singleVideoAfterSearchRate: "0.02%",
personalVideoAfterSearchRate: "0.03% - 0.2%"
}
: {
singleVideoAfterSearchRate: "0.5%-1%",
personalVideoAfterSearchRate: "0.01% - 0.1%"
}
}),
window
}));
await controller.ready;
expect(readDivRightRowTexts(0)).toEqual(["¥450,000", "下单"]);
expect(readDivPluginRowTexts(0)).toEqual([
"0.02%",
"0.03% - 0.2%",
"",
"",
"",
"",
"",
""
]);
expect(readDivRightRowTexts(1)).toEqual(["¥20,000", "下单"]);
expect(readDivPluginRowTexts(1)).toEqual([
"0.5% - 1%",
"0.01% - 0.1%",
"",
"",
"",
"",
"",
""
]);
});
test("rehydrates real rows after serialized market rows arrive later", async () => {
document.body.innerHTML = buildRealMarketFixtureWithoutAuthorIds([
{
authorName: "达人 A",
price21To60s: "¥450,000"
},
{
authorName: "达人 B",
price21To60s: "¥20,000"
}
]);
const loadAuthorMetrics = vi.fn(async (authorId: string) => ({
success: true as const,
rates:
authorId === "111"
? {
singleVideoAfterSearchRate: "0.02%",
personalVideoAfterSearchRate: "0.03% - 0.2%"
}
: {
singleVideoAfterSearchRate: "0.5%-1%",
personalVideoAfterSearchRate: "0.01% - 0.1%"
}
}));
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
loadAuthorMetrics,
window
}));
await controller.ready;
expect(loadAuthorMetrics).not.toHaveBeenCalled();
expect(readDivPluginRowTexts(0)).toEqual(["", "", "", "", "", "", "", ""]);
expect(readDivPluginRowTexts(1)).toEqual(["", "", "", "", "", "", "", ""]);
document.documentElement.setAttribute(
"data-sces-market-rows",
JSON.stringify([
{
authorId: "111",
authorName: "达人 A",
singleVideoAfterSearchRate: "0.02%"
},
{
authorId: "222",
authorName: "达人 B"
}
])
);
await flushWithTimers();
await flushWithTimers();
expect(loadAuthorMetrics).toHaveBeenCalledTimes(2);
expect(readDivPluginRowTexts(0)).toEqual([
"0.02%",
"0.03% - 0.2%",
"",
"",
"",
"",
"",
""
]);
expect(readDivPluginRowTexts(1)).toEqual([
"0.5% - 1%",
"0.01% - 0.1%",
"",
"",
"",
"",
"",
""
]);
});
test("clicking plugin sort headers cycles sort state", async () => {
document.body.innerHTML = buildRealMarketFixture([
{
authorId: "111",
authorName: "达人 A",
price21To60s: "¥450,000"
},
{
authorId: "222",
authorName: "达人 B",
price21To60s: "¥20,000"
}
]);
const resultStore = createMarketResultStore();
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
resultStore,
window
}));
await controller.ready;
resultStore.setAuthorSuccess("111", {
singleVideoAfterSearchRate: "0.02% - 0.1%",
personalVideoAfterSearchRate: "0.03% - 0.2%"
});
resultStore.setAuthorSuccess("222", {
singleVideoAfterSearchRate: "0.5%-1%",
personalVideoAfterSearchRate: "0.01% - 0.1%"
});
click('[data-market-sort-field="singleVideoAfterSearchRate"]');
await flush();
expect(readDivAuthorOrder()).toEqual(["达人 B", "达人 A"]);
expect(
document.querySelector('[data-market-sort-field="singleVideoAfterSearchRate"]')
?.getAttribute("data-market-sort-direction")
).toBe("desc");
click('[data-market-sort-field="singleVideoAfterSearchRate"]');
await flush();
expect(readDivAuthorOrder()).toEqual(["达人 A", "达人 B"]);
expect(
document.querySelector('[data-market-sort-field="singleVideoAfterSearchRate"]')
?.getAttribute("data-market-sort-direction")
).toBe("asc");
click('[data-market-sort-field="singleVideoAfterSearchRate"]');
await flush();
expect(readDivAuthorOrder()).toEqual(["达人 A", "达人 B"]);
expect(
document.querySelector('[data-market-sort-field="singleVideoAfterSearchRate"]')
?.getAttribute("data-market-sort-direction")
).toBe("none");
});
test("clicking backend metric headers sorts by metric values on the current page", async () => {
document.body.innerHTML = buildRealMarketFixture([
{
authorId: "111",
authorName: "达人 A",
price21To60s: "¥450,000"
},
{
authorId: "222",
authorName: "达人 B",
price21To60s: "¥20,000"
}
]);
const resultStore = createMarketResultStore();
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
resultStore,
window
}));
await controller.ready;
resultStore.setBackendMetricsSuccess("111", {
afterViewSearchRate: "0.36%"
});
resultStore.setBackendMetricsSuccess("222", {
afterViewSearchRate: "1.4%"
});
click('[data-market-sort-field="afterViewSearchRate"]');
await flush();
expect(readDivAuthorOrder()).toEqual(["达人 B", "达人 A"]);
});
test("toolbar shows export range controls and reveals custom page input only for custom range", async () => {
document.body.innerHTML = buildMarketFixture();
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
window
}));
await controller.ready;
const exportRangeSelect = document.querySelector(
'[data-plugin-export-range="select"]'
) as HTMLSelectElement | null;
const customPagesInput = document.querySelector(
'[data-plugin-export-custom-pages="input"]'
) as HTMLInputElement | null;
expect(exportRangeSelect?.value).toBe("first-5");
expect(exportRangeSelect?.hidden).toBe(false);
expect(customPagesInput?.hidden).toBe(true);
expect(
document.querySelector('[data-plugin-batch-submit="button"]')
).not.toBeNull();
setSelectValue('[data-plugin-export-range="select"]', "custom");
dispatchChange('[data-plugin-export-range="select"]');
expect(exportRangeSelect?.hidden).toBe(false);
expect(customPagesInput?.hidden).toBe(false);
});
test("audience profile export requires selected creators outside of the all range", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
]);
const buildAudienceProfileCsv = vi.fn(() => "profile-csv");
const loadAudienceProfile = vi.fn();
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
buildAudienceProfileCsv,
document,
loadAudienceProfile,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
onCsvReady: vi.fn(),
window
}));
await controller.ready;
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
click('[data-plugin-export-audience-profile="button"]');
await flush();
expect(loadAudienceProfile).not.toHaveBeenCalled();
expect(buildAudienceProfileCsv).not.toHaveBeenCalled();
expect(
document.querySelector('[data-plugin-export-status="text"]')?.textContent
).toContain("请先勾选需要导出数据的达人");
});
test("audience profile export includes all filtered creators when all is selected without checks", async () => {
const records = [
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" },
{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }
];
document.body.innerHTML = buildRealMarketFixture(records);
document.documentElement.setAttribute(
"data-sces-market-request-snapshot",
JSON.stringify({
body: JSON.stringify({ page: 1 }),
method: "POST",
url: "https://xingtu.cn/api/mock-market-search"
})
);
(
globalThis as typeof globalThis & { fetch?: typeof fetch }
).fetch = vi.fn(async () => ({
json: async () => ({
data: {
marketList: buildMarketListResponseRows(records),
totalPages: 1
}
}),
ok: true
}));
const buildAudienceProfileCsv = vi.fn(() => "profile-csv");
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
buildAudienceProfileCsv,
document,
loadAudienceProfile: async () => ({ status: "success" as const }),
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
onCsvReady: vi.fn(),
window
}));
await controller.ready;
setSelectValue('[data-plugin-export-range="select"]', "all");
dispatchChange('[data-plugin-export-range="select"]');
click('[data-plugin-export-audience-profile="button"]');
await waitForMockCall(buildAudienceProfileCsv, 40, 50);
expect(
buildAudienceProfileCsv.mock.calls[0]?.[0].map(({ record }) => record.authorId)
).toEqual(["111", "222"]);
});
test("audience profile export gives checked creators priority in the all range", async () => {
const records = [
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" },
{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }
];
document.body.innerHTML = buildRealMarketFixture(records);
document.documentElement.setAttribute(
"data-sces-market-request-snapshot",
JSON.stringify({
body: JSON.stringify({ page: 1 }),
method: "POST",
url: "https://xingtu.cn/api/mock-market-search"
})
);
(
globalThis as typeof globalThis & { fetch?: typeof fetch }
).fetch = vi.fn(async () => ({
json: async () => ({
data: {
marketList: buildMarketListResponseRows(records),
totalPages: 1
}
}),
ok: true
}));
const buildAudienceProfileCsv = vi.fn(() => "profile-csv");
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
buildAudienceProfileCsv,
document,
loadAudienceProfile: async () => ({ status: "success" as const }),
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
onCsvReady: vi.fn(),
window
}));
await controller.ready;
clickSelectionCheckboxForAuthor("222");
setSelectValue('[data-plugin-export-range="select"]', "all");
dispatchChange('[data-plugin-export-range="select"]');
click('[data-plugin-export-audience-profile="button"]');
await waitForMockCall(buildAudienceProfileCsv, 40, 50);
expect(
buildAudienceProfileCsv.mock.calls[0]?.[0].map(({ record }) => record.authorId)
).toEqual(["222"]);
});
test("audience profile export loads profiles only for selected creators", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" },
{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }
]);
const buildAudienceProfileCsv = vi.fn(() => "profile-csv");
const loadBusinessAbility = vi.fn(async () => ({
estimates: {},
status: "success" as const
}));
const loadAudienceProfile = vi.fn(async (_record, target) => {
if (target.source === "fansDistribution" && target.authorType === 5) {
return {
age: [{ label: "31-40", value: "30%" }],
crowd: [{ label: "都市蓝领", value: "50%" }],
cityTier: [{ label: "一线城市", value: "70%" }],
status: "success" as const
};
}
return {
age: [{ label: "31-40", value: "60%" }],
crowd: [{ label: "都市蓝领", value: "80%" }],
cityTier: [{ label: "一线城市", value: "90%" }],
gender: [{ label: "男性", value: "60%" }],
status: "success" as const
};
});
const onCsvReady = vi.fn();
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
buildAudienceProfileCsv,
document,
loadBusinessAbility,
loadAudienceProfile,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
onCsvReady,
window
}));
await controller.ready;
clickSelectionCheckboxForAuthor("222");
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
click('[data-plugin-export-audience-profile="button"]');
await waitForMockCall(buildAudienceProfileCsv, 40, 50);
expect(loadAudienceProfile).toHaveBeenCalledTimes(3);
expect(loadBusinessAbility).toHaveBeenCalledTimes(1);
expect(loadBusinessAbility).toHaveBeenCalledWith(
expect.objectContaining({ authorId: "222" })
);
expect(loadAudienceProfile.mock.calls.map(([, target]) => target)).toEqual([
{ linkType: 5, source: "audienceDistribution" },
{ authorType: 1, source: "fansDistribution" },
{ authorType: 5, source: "fansDistribution" }
]);
expect(buildAudienceProfileCsv).toHaveBeenCalledWith(
[
{
profiles: {
audience: expect.objectContaining({ status: "success" }),
fans: expect.objectContaining({ status: "success" }),
longtimeFans: expect.objectContaining({ status: "success" })
},
businessAbility: expect.objectContaining({ status: "success" }),
record: expect.objectContaining({ authorId: "222" })
}
],
expect.objectContaining({
selectedHeaders: undefined
})
);
expect(onCsvReady).toHaveBeenCalledWith(
"profile-csv",
expect.stringMatching(/^达人连接用户画像_\d{8}_\d{4}\.csv$/)
);
});
test("audience profile export requests only the selected data sources", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
]);
window.localStorage.setItem(
"sces:audience-profile:selectedHeaders",
JSON.stringify([
"效果预估-20-60s视频-预期CPM",
"观众画像-女性占比"
])
);
const buildAudienceProfileCsv = vi.fn(() => "profile-csv");
const loadAudienceProfile = vi.fn(async () => ({
gender: [{ label: "女性", value: "60%" }],
status: "success" as const
}));
const loadBusinessAbility = vi.fn(async () => ({
estimates: {},
status: "success" as const
}));
const loadAuthorMetrics = vi.fn(async () => ({
reason: "request-failed" as const,
success: false as const
}));
const loadSpreadMetrics = vi.fn(async () => ({}));
const searchBackendMetrics = vi.fn(async () => []);
const onCsvReady = vi.fn();
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
buildAudienceProfileCsv,
document,
loadAudienceProfile,
loadAuthorMetrics,
loadBusinessAbility,
loadSpreadMetrics,
onCsvReady,
searchBackendMetrics,
window
}));
await controller.ready;
clickSelectionCheckboxForAuthor("111");
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
click('[data-plugin-export-audience-profile="button"]');
await waitForMockCall(buildAudienceProfileCsv, 40, 50);
expect(loadAudienceProfile).toHaveBeenCalledTimes(1);
expect(loadAudienceProfile).toHaveBeenCalledWith(
expect.objectContaining({ authorId: "111" }),
{ linkType: 5, source: "audienceDistribution" }
);
expect(loadBusinessAbility).toHaveBeenCalledTimes(1);
expect(loadSpreadMetrics).not.toHaveBeenCalled();
expect(buildAudienceProfileCsv).toHaveBeenCalled();
expect(buildAudienceProfileCsv.mock.calls[0][0][0]).toEqual(
expect.objectContaining({
profiles: {
audience: expect.objectContaining({ status: "success" }),
fans: { status: "skipped" },
longtimeFans: { status: "skipped" }
}
})
);
});
test("audience profile export requests only selected content-data parameter configs", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
]);
attachMarketListState([
{
attribute_datas: {
id: "spread-111",
nickname: "达人 A"
},
star_id: "111"
}
]);
window.localStorage.setItem(
"sces:audience-profile:selectedHeaders",
JSON.stringify([
"内容数据-个人视频-近30天-完播率",
"内容数据-个人视频-近30天-作品平均评论数",
"内容数据-只看指派-排除营销流量-星图视频-近90天-互动率"
])
);
const buildAudienceProfileCsv = vi.fn(() => "profile-csv");
const loadAudienceProfile = vi.fn();
const loadAuthorMetrics = vi.fn(async () => ({
reason: "request-failed" as const,
success: false as const
}));
const loadBusinessAbility = vi.fn();
const loadSpreadMetrics = vi.fn(async () => ({}));
const searchBackendMetrics = vi.fn(async () => []);
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
buildAudienceProfileCsv,
document,
loadAudienceProfile,
loadAuthorMetrics,
loadBusinessAbility,
loadSpreadMetrics,
searchBackendMetrics,
window
}));
await controller.ready;
loadAuthorMetrics.mockClear();
searchBackendMetrics.mockClear();
clickSelectionCheckboxForAuthor("111");
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
click('[data-plugin-export-audience-profile="button"]');
await waitForMockCall(buildAudienceProfileCsv, 40, 50);
expect(loadSpreadMetrics).toHaveBeenCalledTimes(1);
expect(loadSpreadMetrics).toHaveBeenCalledWith("spread-111", [
{
flowType: 0,
onlyAssign: false,
range: 2,
type: 1
},
{
flowType: 1,
onlyAssign: true,
range: 3,
type: 2
}
]);
expect(loadAudienceProfile).not.toHaveBeenCalled();
expect(loadBusinessAbility).not.toHaveBeenCalled();
});
test("audience profile export downloads effect estimates and reports profile failures", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
]);
window.localStorage.setItem(
"sces:audience-profile:selectedHeaders",
JSON.stringify([
"效果预估-20-60s视频-预期CPM",
"观众画像-女性占比",
"粉丝画像-女性占比",
"铁粉画像-18-23占比"
])
);
const loadAudienceProfile = vi.fn(async () => ({
failureReason: "timeout",
status: "failed" as const
}));
const loadBusinessAbility = vi.fn(async () => ({
estimates: {
twentyToSixty: {
expectedCpe: "3.7",
expectedCpm: "119.6",
expectedPlay: "20.9w",
hotRate: "缺失"
}
},
status: "success" as const
}));
const onCsvReady = vi.fn();
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
loadAudienceProfile,
loadAuthorMetrics: async () => ({
reason: "request-failed",
success: false
}),
loadBusinessAbility,
onCsvReady,
window
}));
await controller.ready;
clickSelectionCheckboxForAuthor("111");
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
click('[data-plugin-export-audience-profile="button"]');
await waitForMockCall(onCsvReady, 40, 50);
expect(loadAudienceProfile).toHaveBeenCalledTimes(3);
const csv = onCsvReady.mock.calls[0][0];
expect(readCsvCell(csv, "导出状态")).toBe("部分成功");
expect(readCsvCell(csv, "失败原因")).toBe(
"观众画像:timeout; 粉丝画像:timeout; 铁粉画像:timeout"
);
expect(readCsvCell(csv, "观众画像-女性占比")).toBe("");
expect(readCsvCell(csv, "粉丝画像-女性占比")).toBe("");
expect(readCsvCell(csv, "铁粉画像-18-23占比")).toBe("");
expect(readCsvCell(csv, "效果预估-20-60s视频-预期CPM")).toBe("119.6");
expect(onCsvReady).toHaveBeenCalledWith(
expect.stringContaining("观众画像:timeout"),
expect.stringMatching(/^达人连接用户画像_\d{8}_\d{4}\.csv$/)
);
});
test("audience profile export by id loads pasted creators without page selection", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
]);
const buildAudienceProfileCsv = vi.fn(() => "profile-csv");
const loadAuthorBaseInfo = vi.fn(async (authorId: string) => ({
authorId,
authorName: authorId === "6866044569306267651" ? "小九儿" : "达人 B",
status: "success" as const
}));
const loadBusinessAbility = vi.fn(async () => ({
estimates: {},
status: "success" as const
}));
const loadAudienceProfile = vi.fn(async () => ({
age: [{ label: "31-40", value: "60%" }],
crowd: [{ label: "都市蓝领", value: "80%" }],
cityTier: [{ label: "一线城市", value: "90%" }],
gender: [{ label: "男性", value: "60%" }],
status: "success" as const
}));
const loadAuthorMetrics = vi.fn(async (authorId: string) => ({
rates: {
personalVideoAfterSearchRate:
authorId === "6866044569306267651" ? "12.3%" : "45.6%",
singleVideoAfterSearchRate:
authorId === "6866044569306267651" ? "7.8%" : "9.1%"
},
success: true as const
}));
const searchBackendMetrics = vi.fn(async (starIds: string[]) =>
starIds.map((starId) => ({
a3IncreaseCount: starId === "6866044569306267651" ? "100" : "200",
afterViewSearchCount: starId === "6866044569306267651" ? "300" : "400",
afterViewSearchRate: starId === "6866044569306267651" ? "1.1%" : "2.2%",
cpSearch: starId === "6866044569306267651" ? "10" : "20",
cpa3: starId === "6866044569306267651" ? "30" : "40",
newA3Rate: starId === "6866044569306267651" ? "3.3%" : "4.4%",
starId
}))
);
const onCsvReady = vi.fn();
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
buildAudienceProfileCsv,
document,
loadAuthorBaseInfo,
loadBusinessAbility,
loadAudienceProfile,
loadAuthorMetrics,
onCsvReady,
promptAuthorIds: () => `
6866044569306267651
7040323176106033165
6866044569306267651
bad-id
`,
searchBackendMetrics,
window
}));
await controller.ready;
loadAuthorMetrics.mockClear();
searchBackendMetrics.mockClear();
click('[data-plugin-export-audience-profile-by-id="button"]');
await waitForMockCall(buildAudienceProfileCsv, 40, 50);
expect(loadAuthorBaseInfo.mock.calls.map(([authorId]) => authorId)).toEqual([
"6866044569306267651",
"7040323176106033165"
]);
expect(loadAudienceProfile).toHaveBeenCalledTimes(6);
expect(loadBusinessAbility).toHaveBeenCalledTimes(2);
expect(loadAuthorMetrics.mock.calls.map(([authorId]) => authorId)).toEqual([
"6866044569306267651",
"7040323176106033165"
]);
expect(searchBackendMetrics).toHaveBeenCalledTimes(1);
expect(searchBackendMetrics).toHaveBeenCalledWith([
"6866044569306267651",
"7040323176106033165"
]);
expect(buildAudienceProfileCsv).toHaveBeenCalledWith(
[
expect.objectContaining({
record: expect.objectContaining({
authorId: "6866044569306267651",
authorName: "小九儿",
backendMetrics: expect.objectContaining({
a3IncreaseCount: "100",
afterViewSearchCount: "300",
afterViewSearchRate: "1.1%",
cpSearch: "10",
cpa3: "30",
newA3Rate: "3.3%"
}),
exportFields: {
达人ID: "6866044569306267651",
达人名称: "小九儿",
导出状态: "成功",
失败原因: ""
},
rates: {
personalVideoAfterSearchRate: "12.3%",
singleVideoAfterSearchRate: "7.8%"
}
})
}),
expect.objectContaining({
record: expect.objectContaining({
authorId: "7040323176106033165",
authorName: "达人 B",
backendMetrics: expect.objectContaining({
a3IncreaseCount: "200",
afterViewSearchCount: "400",
afterViewSearchRate: "2.2%",
cpSearch: "20",
cpa3: "40",
newA3Rate: "4.4%"
}),
rates: {
personalVideoAfterSearchRate: "45.6%",
singleVideoAfterSearchRate: "9.1%"
}
})
})
],
expect.objectContaining({
selectedHeaders: undefined
})
);
expect(onCsvReady).toHaveBeenCalledWith(
"profile-csv",
expect.stringMatching(/^达人连接用户画像_按ID导出_\d{8}_\d{4}\.csv$/)
);
});
test("audience profile export by id skips every unselected data source", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
]);
window.localStorage.setItem(
"sces:audience-profile:selectedHeaders",
JSON.stringify(["效果预估-20-60s视频-预期CPM"])
);
const buildAudienceProfileCsv = vi.fn(() => "profile-csv");
const loadAuthorBaseInfo = vi.fn(async (authorId: string) => ({
authorId,
authorName: "达人 A",
status: "success" as const
}));
const loadAudienceProfile = vi.fn(async () => ({
status: "success" as const
}));
const loadAuthorMetrics = vi.fn(async () => ({
reason: "request-failed" as const,
success: false as const
}));
const loadBusinessAbility = vi.fn(async () => ({
estimates: {},
status: "success" as const
}));
const loadSpreadMetrics = vi.fn(async () => ({}));
const searchBackendMetrics = vi.fn(async () => []);
const onCsvReady = vi.fn();
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
buildAudienceProfileCsv,
document,
loadAudienceProfile,
loadAuthorBaseInfo,
loadAuthorMetrics,
loadBusinessAbility,
loadSpreadMetrics,
onCsvReady,
promptAuthorIds: () => "6866044569306267651",
searchBackendMetrics,
window
}));
await controller.ready;
loadAuthorMetrics.mockClear();
searchBackendMetrics.mockClear();
click('[data-plugin-export-audience-profile-by-id="button"]');
await waitForMockCall(buildAudienceProfileCsv, 40, 50);
expect(loadAuthorBaseInfo).toHaveBeenCalledTimes(1);
expect(loadBusinessAbility).toHaveBeenCalledTimes(1);
expect(loadAudienceProfile).not.toHaveBeenCalled();
expect(loadAuthorMetrics).not.toHaveBeenCalled();
expect(loadSpreadMetrics).not.toHaveBeenCalled();
expect(searchBackendMetrics).not.toHaveBeenCalled();
expect(buildAudienceProfileCsv.mock.calls[0][0][0]).toEqual(
expect.objectContaining({
profiles: {
audience: { status: "skipped" },
fans: { status: "skipped" },
longtimeFans: { status: "skipped" }
}
})
);
});
test("audience profile export uses persisted selected csv fields", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
]);
window.localStorage.setItem(
"sces:audience-profile:selectedHeaders",
JSON.stringify(["内容数据-个人视频-近30天-播放量中位数", "秒思api-看后搜数"])
);
const buildAudienceProfileCsv = vi.fn(() => "profile-csv");
const loadBusinessAbility = vi.fn(async () => ({
estimates: {},
status: "success" as const
}));
const loadAudienceProfile = vi.fn(async () => ({
age: [],
crowd: [],
cityTier: [],
gender: [],
status: "success" as const
}));
const onCsvReady = vi.fn();
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
buildAudienceProfileCsv,
document,
loadBusinessAbility,
loadAudienceProfile,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
onCsvReady,
window
}));
await controller.ready;
clickSelectionCheckboxForAuthor("111");
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
click('[data-plugin-export-audience-profile="button"]');
await waitForMockCall(buildAudienceProfileCsv, 40, 50);
expect(buildAudienceProfileCsv.mock.calls[0][1]).toEqual({
selectedHeaders: ["内容数据-个人视频-近30天-播放量中位数", "秒思api-看后搜数"]
});
});
test("audience profile field picker persists the next selected fields", 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,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
window
}));
await controller.ready;
click('[data-plugin-audience-profile-fields="button"]');
const searchInput = document.querySelector(
'[data-audience-profile-field-dialog-search="input"]'
) as HTMLInputElement | null;
expect(searchInput).not.toBeNull();
searchInput!.value = "看后搜数";
searchInput!.dispatchEvent(new Event("input"));
expect(
document.querySelector(
'[data-audience-profile-field-dialog-result-count="text"]'
)?.textContent
).toBe("找到 1 个");
const afterSearchCountInput = document.querySelector(
'input[data-audience-profile-field-dialog-field="checkbox"][value="秒思api-看后搜数"]'
) as HTMLInputElement | null;
expect(afterSearchCountInput).not.toBeNull();
afterSearchCountInput!.checked = false;
afterSearchCountInput!.dispatchEvent(new Event("change", { bubbles: true }));
click('[data-audience-profile-field-dialog-save="button"]');
await flush();
const savedHeaders = JSON.parse(
window.localStorage.getItem("sces:audience-profile:selectedHeaders") ?? "[]"
) as string[];
expect(savedHeaders).not.toContain("秒思api-看后搜数");
expect(savedHeaders).toContain("内容数据-个人视频-近30天-播放量中位数");
expect(
document.querySelector('[data-plugin-export-status="text"]')?.textContent
).toContain("字段已保存");
});
test("prompts for a batch name before submitting the current range", async () => {
document.body.innerHTML = buildMarketFixture();
const promptBatchName = vi.fn(() => "618达人筛选第一批");
const submitBatch = vi.fn(async () => ({ ok: true }));
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
getAuthState: async () => ({
isAuthenticated: true,
resource: "https://talent-search.intelligrow.cn",
userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" }
}),
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
promptBatchName,
submitBatch,
window
}));
await controller.ready;
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
click('[data-plugin-batch-submit="button"]');
await waitForMockCall(submitBatch, 40, 50);
expect(promptBatchName).toHaveBeenCalledTimes(1);
expect(submitBatch).toHaveBeenCalledWith(
expect.objectContaining({
batchName: "618达人筛选第一批",
logtoUserId: "p7pdhhtde8kj"
})
);
expect(submitBatch.mock.calls[0]?.[0]).not.toHaveProperty("batchId");
});
test("opens a custom batch name dialog before submitting", async () => {
document.body.innerHTML = buildMarketFixture();
const submitBatch = vi.fn(async () => ({ ok: true }));
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
getAuthState: async () => ({
isAuthenticated: true,
resource: "https://talent-search.intelligrow.cn",
userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" }
}),
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
submitBatch,
window
}));
await controller.ready;
click('[data-plugin-batch-submit="button"]');
expect(submitBatch).not.toHaveBeenCalled();
expect(
document.querySelector('[data-plugin-batch-name-dialog="root"]')
).not.toBeNull();
setInputValue('[data-plugin-batch-name-input="input"]', "618达人筛选第一批");
dispatchInput('[data-plugin-batch-name-input="input"]');
click('[data-plugin-batch-name-confirm="button"]');
await waitForMockCall(submitBatch, 40, 50);
expect(submitBatch).toHaveBeenCalledWith(
expect.objectContaining({
batchName: "618达人筛选第一批"
})
);
expect(
document.querySelector('[data-plugin-batch-name-dialog="root"]')
).toBeNull();
});
test("keeps the custom batch name dialog open and shows an inline error for blank values", async () => {
document.body.innerHTML = buildMarketFixture();
const submitBatch = vi.fn(async () => ({ ok: true }));
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
getAuthState: async () => ({
isAuthenticated: true,
resource: "https://talent-search.intelligrow.cn",
userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" }
}),
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
submitBatch,
window
}));
await controller.ready;
click('[data-plugin-batch-submit="button"]');
click('[data-plugin-batch-name-confirm="button"]');
await flush();
expect(submitBatch).not.toHaveBeenCalled();
expect(
document.querySelector('[data-plugin-batch-name-error="text"]')?.textContent
).toContain("请输入批次名称");
expect(
document.querySelector('[data-plugin-batch-name-dialog="root"]')
).not.toBeNull();
});
test("closes the custom batch name dialog when cancelled", async () => {
document.body.innerHTML = buildMarketFixture();
const submitBatch = vi.fn(async () => ({ ok: true }));
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
getAuthState: async () => ({
isAuthenticated: true,
resource: "https://talent-search.intelligrow.cn",
userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" }
}),
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
submitBatch,
window
}));
await controller.ready;
click('[data-plugin-batch-submit="button"]');
click('[data-plugin-batch-name-cancel="button"]');
await flush();
expect(submitBatch).not.toHaveBeenCalled();
expect(
document.querySelector('[data-plugin-batch-name-dialog="root"]')
).toBeNull();
});
test("selected batch submit uses only creators selected in the current range", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" },
{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" },
{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }
]);
const promptBatchName = vi.fn(() => "自动选择批次");
const submitBatch = vi.fn(async () => ({ ok: true }));
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
getAuthState: async () => ({
isAuthenticated: true,
resource: "https://talent-search.intelligrow.cn",
userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" }
}),
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
promptBatchName,
submitBatch,
window
}));
await controller.ready;
clickSelectionCheckboxForAuthor("222");
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
click('[data-plugin-batch-submit="button"]');
await waitForMockCall(submitBatch, 40, 50);
expect(submitBatch).toHaveBeenCalledWith(
expect.objectContaining({
authors: [{ authorId: "222", authorName: "达人 B" }]
})
);
});
test(
"selected batch submit keeps a generic loading status while submitting the default paged range",
async () => {
const pages = [
[
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" },
{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }
],
[{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }],
[{ authorId: "444", authorName: "达人 D", price21To60s: "¥44,000" }],
[{ authorId: "555", authorName: "达人 E", price21To60s: "¥55,000" }],
[{ authorId: "666", authorName: "达人 F", price21To60s: "¥66,000" }]
];
const secondPageDeferred = createDeferred<{
json(): Promise<unknown>;
ok: boolean;
}>();
const submitBatch = vi.fn(async () => ({ ok: true }));
document.body.innerHTML = buildRealMarketFixture(pages[0]);
const fetchMock = vi.fn(async (_input: string, init?: RequestInit) => {
const body = JSON.parse(String(init?.body ?? "{}")) as { page?: number };
const pageNumber = body.page ?? 1;
const response = {
json: async () => ({
data: {
marketList: buildMarketListResponseRows(pages[pageNumber - 1] ?? []),
totalPages: 5
}
}),
ok: true
};
if (pageNumber === 2) {
return secondPageDeferred.promise;
}
return response;
});
(
globalThis as typeof globalThis & {
fetch?: typeof fetchMock;
}
).fetch = fetchMock;
document.documentElement.setAttribute(
"data-sces-market-request-snapshot",
JSON.stringify({
body: JSON.stringify({
page: 1
}),
method: "POST",
url: "https://xingtu.cn/api/mock-market-search"
})
);
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
getAuthState: async () => ({
isAuthenticated: true,
resource: "https://talent-search.intelligrow.cn",
userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" }
}),
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
promptBatchName: vi.fn(() => "自动选择批次"),
submitBatch,
window
}));
await controller.ready;
clickSelectionCheckboxForAuthor("111");
click('[data-plugin-batch-submit="button"]');
for (let attempt = 0; attempt < 40; attempt += 1) {
if (
fetchMock.mock.calls.some(([, init]) => {
const body = JSON.parse(
String((init as RequestInit | undefined)?.body ?? "{}")
) as { page?: number };
return body.page === 2;
})
) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 50));
await Promise.resolve();
}
expect(
document.querySelector('[data-plugin-export-status="text"]')?.textContent
).toBe("提交已选达人中...");
secondPageDeferred.resolve({
json: async () => ({
data: {
marketList: buildMarketListResponseRows(pages[1]),
totalPages: 5
}
}),
ok: true
});
await waitForMockCall(submitBatch, 120, 50);
expect(submitBatch).toHaveBeenCalledWith(
expect.objectContaining({
authors: [{ authorId: "111", authorName: "达人 A" }]
})
);
}
);
test(
"batch submit respects checked row selection even when the selection change event was missed",
async () => {
const pages = [
[
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" },
{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }
],
[{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }],
[{ authorId: "444", authorName: "达人 D", price21To60s: "¥44,000" }],
[{ authorId: "555", authorName: "达人 E", price21To60s: "¥55,000" }],
[{ authorId: "666", authorName: "达人 F", price21To60s: "¥66,000" }]
];
const secondPageDeferred = createDeferred<{
json(): Promise<unknown>;
ok: boolean;
}>();
const submitBatch = vi.fn(async () => ({ ok: true }));
document.body.innerHTML = buildRealMarketFixture(pages[0]);
const fetchMock = vi.fn(async (_input: string, init?: RequestInit) => {
const body = JSON.parse(String(init?.body ?? "{}")) as { page?: number };
const pageNumber = body.page ?? 1;
const response = {
json: async () => ({
data: {
marketList: buildMarketListResponseRows(pages[pageNumber - 1] ?? []),
totalPages: 5
}
}),
ok: true
};
if (pageNumber === 2) {
return secondPageDeferred.promise;
}
return response;
});
(
globalThis as typeof globalThis & {
fetch?: typeof fetchMock;
}
).fetch = fetchMock;
document.documentElement.setAttribute(
"data-sces-market-request-snapshot",
JSON.stringify({
body: JSON.stringify({
page: 1
}),
method: "POST",
url: "https://xingtu.cn/api/mock-market-search"
})
);
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
getAuthState: async () => ({
isAuthenticated: true,
resource: "https://talent-search.intelligrow.cn",
userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" }
}),
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
promptBatchName: vi.fn(() => "自动选择批次"),
submitBatch,
window
}));
await controller.ready;
const rowSelectionCheckbox = readSelectionCheckboxForAuthor("111");
rowSelectionCheckbox.checked = true;
click('[data-plugin-batch-submit="button"]');
for (let attempt = 0; attempt < 40; attempt += 1) {
if (
fetchMock.mock.calls.some(([, init]) => {
const body = JSON.parse(
String((init as RequestInit | undefined)?.body ?? "{}")
) as { page?: number };
return body.page === 2;
})
) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 50));
await Promise.resolve();
}
expect(
document.querySelector('[data-plugin-export-status="text"]')?.textContent
).toBe("提交已选达人中...");
secondPageDeferred.resolve({
json: async () => ({
data: {
marketList: buildMarketListResponseRows(pages[1]),
totalPages: 5
}
}),
ok: true
});
await waitForMockCall(submitBatch, 120, 50);
expect(submitBatch).toHaveBeenCalledWith(
expect.objectContaining({
authors: [{ authorId: "111", authorName: "达人 A" }]
})
);
}
);
test("selected batch submit falls back to all creators in the current range when no selection matches", async () => {
const pages = [
[
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" },
{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }
],
[
{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" },
{ authorId: "444", authorName: "达人 D", price21To60s: "¥44,000" }
]
];
document.body.innerHTML = buildRealMarketFixture(pages[0]);
installAsyncPaginationHarness(pages);
const promptBatchName = vi.fn(() => "自动选择批次");
const submitBatch = vi.fn(async () => ({ ok: true }));
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
getAuthState: async () => ({
isAuthenticated: true,
resource: "https://talent-search.intelligrow.cn",
userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" }
}),
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
promptBatchName,
submitBatch,
window
}));
await controller.ready;
clickSelectionCheckboxForAuthor("111");
click('[data-testid="next-page"]');
await flushWithTimers();
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
click('[data-plugin-batch-submit="button"]');
await waitForMockCall(submitBatch, 40, 50);
expect(submitBatch).toHaveBeenCalledWith(
expect.objectContaining({
authors: [
{ authorId: "333", authorName: "达人 C" },
{ authorId: "444", authorName: "达人 D" }
]
})
);
});
test("all-range batch submit does not fall back when checked creators are outside the results", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
]);
document.documentElement.setAttribute(
"data-sces-market-request-snapshot",
JSON.stringify({
body: JSON.stringify({ page: 1 }),
method: "POST",
url: "https://xingtu.cn/api/mock-market-search"
})
);
(
globalThis as typeof globalThis & { fetch?: typeof fetch }
).fetch = vi.fn(async () => ({
json: async () => ({
data: {
marketList: buildMarketListResponseRows([
{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }
]),
totalPages: 1
}
}),
ok: true
}));
const submitBatch = vi.fn(async () => ({ ok: true }));
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
promptBatchName: vi.fn(() => "自动选择批次"),
submitBatch,
window
}));
await controller.ready;
clickSelectionCheckboxForAuthor("111");
setSelectValue('[data-plugin-export-range="select"]', "all");
dispatchChange('[data-plugin-export-range="select"]');
click('[data-plugin-batch-submit="button"]');
await waitForCondition(() =>
document.querySelector('[data-plugin-export-status="text"]')?.textContent ===
"全部范围内没有选中的达人"
);
expect(submitBatch).not.toHaveBeenCalled();
});
test(
"all-range batch submit keeps detailed progress when no creators are selected",
async () => {
const pages = [
[
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" },
{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }
],
[{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }],
[{ authorId: "444", authorName: "达人 D", price21To60s: "¥44,000" }],
[{ authorId: "555", authorName: "达人 E", price21To60s: "¥55,000" }],
[{ authorId: "666", authorName: "达人 F", price21To60s: "¥66,000" }]
];
const secondPageDeferred = createDeferred<{
json(): Promise<unknown>;
ok: boolean;
}>();
const submitBatch = vi.fn(async () => ({ ok: true }));
document.body.innerHTML = buildRealMarketFixture(pages[0]);
const fetchMock = vi.fn(async (_input: string, init?: RequestInit) => {
const body = JSON.parse(String(init?.body ?? "{}")) as { page?: number };
const pageNumber = body.page ?? 1;
const response = {
json: async () => ({
data: {
marketList: buildMarketListResponseRows(pages[pageNumber - 1] ?? []),
totalPages: 5
}
}),
ok: true
};
if (pageNumber === 2) {
return secondPageDeferred.promise;
}
return response;
});
(
globalThis as typeof globalThis & {
fetch?: typeof fetchMock;
}
).fetch = fetchMock;
document.documentElement.setAttribute(
"data-sces-market-request-snapshot",
JSON.stringify({
body: JSON.stringify({
page: 1
}),
method: "POST",
url: "https://xingtu.cn/api/mock-market-search"
})
);
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
getAuthState: async () => ({
isAuthenticated: true,
resource: "https://talent-search.intelligrow.cn",
userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" }
}),
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
promptBatchName: vi.fn(() => "默认批次"),
submitBatch,
window
}));
await controller.ready;
setSelectValue('[data-plugin-export-range="select"]', "all");
dispatchChange('[data-plugin-export-range="select"]');
click('[data-plugin-batch-submit="button"]');
for (let attempt = 0; attempt < 40; attempt += 1) {
if (
fetchMock.mock.calls.some(([, init]) => {
const body = JSON.parse(
String((init as RequestInit | undefined)?.body ?? "{}")
) as { page?: number };
return body.page === 2;
})
) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 50));
await Promise.resolve();
}
expect(
document.querySelector('[data-plugin-export-status="text"]')?.textContent
).toBe("提交中 2/5 页...");
secondPageDeferred.resolve({
json: async () => ({
data: {
marketList: buildMarketListResponseRows(pages[1]),
totalPages: 5
}
}),
ok: true
});
await waitForMockCall(submitBatch, 120, 50);
}
);
test("shows an error when the batch name is blank", async () => {
document.body.innerHTML = buildMarketFixture();
const promptBatchName = vi.fn(() => " ");
const submitBatch = vi.fn(async () => ({ ok: true }));
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
getAuthState: async () => ({
isAuthenticated: true,
resource: "https://talent-search.intelligrow.cn",
userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" }
}),
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
promptBatchName,
submitBatch,
window
}));
await controller.ready;
click('[data-plugin-batch-submit="button"]');
await flush();
expect(submitBatch).not.toHaveBeenCalled();
expect(
document.querySelector('[data-plugin-export-status="text"]')?.textContent
).toContain("请输入批次名称");
});
test("does nothing when the prompt is cancelled", async () => {
document.body.innerHTML = buildMarketFixture();
const promptBatchName = vi.fn(() => null);
const submitBatch = vi.fn(async () => ({ ok: true }));
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
getAuthState: async () => ({
isAuthenticated: true,
resource: "https://talent-search.intelligrow.cn",
userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" }
}),
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
promptBatchName,
submitBatch,
window
}));
await controller.ready;
click('[data-plugin-batch-submit="button"]');
await flush();
expect(promptBatchName).toHaveBeenCalledTimes(1);
expect(submitBatch).not.toHaveBeenCalled();
});
test("rehydrates rows when the market list DOM changes", async () => {
document.body.innerHTML = buildMarketFixture();
const observer = createMutationObserverFactory();
const loadAuthorMetrics = vi.fn(async (authorId: string) => ({
success: true as const,
rates:
authorId === "a"
? {
singleVideoAfterSearchRate: "0.02% - 0.1%",
personalVideoAfterSearchRate: "0.03% - 0.2%"
}
: {
singleVideoAfterSearchRate: "0.8%-1%",
personalVideoAfterSearchRate: "0.05% - 0.2%"
}
}));
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
loadAuthorMetrics,
mutationObserverFactory: observer.factory,
window
}));
await controller.ready;
document.querySelector("[data-market-body]")!.innerHTML = `
<div data-market-row="c" data-author-id="c">
<span data-market-field="authorName">Gamma</span>
<span data-market-field="price21To60s">88000</span>
</div>
`;
observer.trigger();
await flushWithTimers();
await flushWithTimers();
expect(loadAuthorMetrics.mock.calls.map(([authorId]) => authorId)).toEqual(
expect.arrayContaining(["a", "c"])
);
expect(readRowOrder()).toEqual(["c"]);
expect(
document.querySelector('[data-market-row-cell="singleVideoAfterSearchRate"]')
?.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("disposes an open favorite picker without retaining row callbacks", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
]);
const repository = createTestFavoritesRepository();
const createFolder = vi.fn(repository.createFolder.bind(repository));
const setCreatorFolderIds = vi.fn(repository.setCreatorFolderIds.bind(repository));
repository.createFolder = createFolder;
repository.setCreatorFolderIds = setCreatorFolderIds;
const { createMarketController } = await import("../src/content/market/index");
const controller = createMarketController({
document,
favoritesRepository: repository,
loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }),
window
});
await controller.ready;
const favoriteButton = document.querySelector(
'[data-sces-favorite-row-action="button"]'
) as HTMLButtonElement;
favoriteButton.click();
expect(document.querySelector('[data-sces-favorite-row-picker="root"]')).not.toBeNull();
controller.dispose();
expect(document.querySelector('[data-sces-favorite-row-picker="root"]')).toBeNull();
document.body.dispatchEvent(new Event("pointerdown", { bubbles: true }));
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
favoriteButton.click();
await flush();
expect(createFolder).not.toHaveBeenCalled();
expect(setCreatorFolderIds).not.toHaveBeenCalled();
});
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("preserves a late serialized core user id through favorite batch import", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
]);
const repository = createTestFavoritesRepository();
const observer = createMutationObserverFactory();
const promptBatchName = vi.fn(() => "收藏夹批次");
const promptFavoriteFolderName = vi.fn(() => "母婴优质达人");
const submitBatch = vi.fn(async () => ({ ok: true }));
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" }),
mutationObserverFactory: observer.factory,
promptBatchName,
promptFavoriteFolderName,
submitBatch,
window
}));
await controller.ready;
document.documentElement.setAttribute(
"data-sces-market-rows",
JSON.stringify([
{ authorId: "111", authorName: "达人 A", coreUserId: "core-111" }
])
);
observer.trigger();
await flushWithTimers();
click('[data-sces-favorite-row-action="button"]');
click('[data-sces-favorite-row-picker="create-folder"]');
await waitForCondition(
() =>
document.querySelector('[data-sces-favorite-row-action="button"]')?.dataset
.scesFavoriteState === "saved"
);
click('[data-sces-favorites-tab="button"]');
(
document.querySelector(
'[data-sces-favorites-select-author-id="111"]'
) as HTMLInputElement
).click();
click('[data-sces-favorites-drawer="import-selected"]');
await waitForMockCall(submitBatch);
expect(repository.getStoredState()).toMatchObject({
creators: [{ authorId: "111", coreUserId: "core-111" }]
});
expect(submitBatch).toHaveBeenCalledWith(
expect.objectContaining({
authors: [{ authorId: "111", authorName: "达人 A", authorUid: "core-111" }],
batchName: "收藏夹批次"
})
);
});
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 selected export", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
]);
const repository = createTestFavoritesRepository();
repository.createFolder = async () => {
throw new Error();
};
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
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 selectedExportButton = document.querySelector(
'[data-plugin-export-audience-profile="button"]'
) as HTMLButtonElement;
expect(selectedExportButton.disabled).toBe(false);
});
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"]')).toBeNull();
expect(
document.querySelector('[data-plugin-export-audience-profile="button"]')
).not.toBeNull();
expect(document.querySelector('[data-plugin-batch-submit="button"]')).not.toBeNull();
});
test("recovers favorite pickers after a later refresh failure and page replacement", 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 originalRead = repository.read.bind(repository);
let shouldRejectRead = false;
const read = vi.fn(async () => {
if (shouldRejectRead) {
throw new Error("storage unavailable");
}
return originalRead();
});
repository.read = read;
const observer = createMutationObserverFactory();
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
favoritesRepository: repository,
loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }),
mutationObserverFactory: observer.factory,
window
}));
await controller.ready;
expect(
document.querySelector('[data-sces-favorite-row-action="button"]')?.dataset.scesFavoriteState
).toBe("saved");
shouldRejectRead = true;
document.querySelector('[data-testid="action-cell-111"]')!.replaceChildren(
document.createTextNode("下单")
);
observer.trigger();
await waitForCondition(
() =>
document.querySelector('[data-sces-favorites-drawer="error"]')?.textContent ===
"收藏夹暂不可用"
);
expect(document.querySelector('[data-sces-favorite-row-action="button"]')).toBeNull();
expect(document.querySelector('[data-plugin-export="button"]')).toBeNull();
expect(
document.querySelector('[data-plugin-export-audience-profile="button"]')
).not.toBeNull();
shouldRejectRead = false;
document.documentElement.setAttribute("data-test-page-index", "2");
document.querySelector('[data-testid="action-cell-111"]')!.replaceChildren(
document.createTextNode("下单")
);
observer.trigger();
await waitForCondition(
() =>
document.querySelector('[data-sces-favorite-row-action="button"]')?.dataset
.scesFavoriteState === "saved"
);
expect(read).toHaveBeenCalledTimes(3);
expect(document.querySelector('[data-sces-favorites-drawer="error"]')).toBeNull();
});
test("awaits a follow-up sync triggered during initial scheduled hydration", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
]);
const repository = createTestFavoritesRepository();
const originalRead = repository.read.bind(repository);
const observer = createMutationObserverFactory();
const successfulMetrics = {
rates: {
personalVideoAfterSearchRate: "0.2%",
singleVideoAfterSearchRate: "0.1%"
},
success: true as const
};
const deferredSecondPageMetrics = createDeferred<typeof successfulMetrics>();
const deferredThirdPageMetrics = createDeferred<typeof successfulMetrics>();
let shouldReplaceInitialPage = true;
repository.read = async () => {
const state = await originalRead();
if (shouldReplaceInitialPage) {
shouldReplaceInitialPage = false;
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }
]);
observer.trigger();
}
return state;
};
const loadAuthorMetrics = vi.fn((authorId: string) => {
if (authorId === "222") {
return deferredSecondPageMetrics.promise;
}
if (authorId === "333") {
return deferredThirdPageMetrics.promise;
}
return Promise.resolve(successfulMetrics);
});
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
favoritesRepository: repository,
loadAuthorMetrics,
mutationObserverFactory: observer.factory,
window
}));
let readySettled = false;
void controller.ready.then(() => {
readySettled = true;
});
await waitForCondition(() =>
loadAuthorMetrics.mock.calls.some(([authorId]) => authorId === "222")
);
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }
]);
observer.trigger();
deferredSecondPageMetrics.resolve(successfulMetrics);
await waitForCondition(() =>
loadAuthorMetrics.mock.calls.some(([authorId]) => authorId === "333")
);
await Promise.resolve();
expect(readySettled).toBe(false);
deferredThirdPageMetrics.resolve({
rates: {
personalVideoAfterSearchRate: "0.33%",
singleVideoAfterSearchRate: "0.33%"
},
success: true
});
await controller.ready;
expect(
document.querySelector('[data-market-selection-checkbox="row"]')?.getAttribute(
"data-market-selection-author-id"
)
).toBe("333");
expect(readDivPluginRowTexts(0)[0]).toBe("0.33%");
});
test("keeps the friendly drawer error when a direct folder mutation has no message", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
]);
const repository = createTestFavoritesRepository();
repository.createFolder = async () => {
throw new Error();
};
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-favorites-tab="button"]');
click('[data-sces-favorites-drawer="create-folder"]');
await waitForCondition(
() =>
document.querySelector('[data-sces-favorites-drawer="error"]')?.textContent ===
"收藏夹操作失败,请稍后重试"
);
expect(document.querySelectorAll('[data-sces-favorites-folder-id]')).toHaveLength(0);
expect(document.querySelector('[data-sces-favorites-drawer="creators"]')?.textContent).toContain(
"暂无收藏达人"
);
});
});
function clearLocalStorage(): void {
if (typeof window.localStorage.clear === "function") {
window.localStorage.clear();
return;
}
for (let index = window.localStorage.length - 1; index >= 0; index -= 1) {
const key = window.localStorage.key(index);
if (key) {
window.localStorage.removeItem(key);
}
}
}
function installUsableLocalStorage(): void {
if (
typeof window.localStorage.getItem === "function" &&
typeof window.localStorage.setItem === "function" &&
typeof window.localStorage.removeItem === "function" &&
typeof window.localStorage.clear === "function"
) {
return;
}
const values = new Map<string, string>();
const storage: Storage = {
get length() {
return values.size;
},
clear() {
values.clear();
},
getItem(key: string) {
return values.get(key) ?? null;
},
key(index: number) {
return Array.from(values.keys())[index] ?? null;
},
removeItem(key: string) {
values.delete(key);
},
setItem(key: string, value: string) {
values.set(key, String(value));
}
};
Object.defineProperty(window, "localStorage", {
configurable: true,
value: storage
});
}
function buildMarketFixture() {
return buildMarketPageShell(buildMarketTableOnlyFixture());
}
function buildMarketTableOnlyFixture() {
return `
<div data-market-table>
<div data-market-header>
<div data-market-header-cell="authorName">达人</div>
<div data-market-header-cell="price21To60s">21-60s报价</div>
</div>
<div data-market-body>
<div data-market-row="a" data-author-id="a">
<span data-market-field="authorName">Alpha</span>
<span data-market-field="price21To60s">450000</span>
</div>
<div data-market-row="b" data-author-id="b">
<span data-market-field="authorName">Beta</span>
<span data-market-field="price21To60s">70000</span>
</div>
</div>
</div>
`;
}
function buildMarketPageShell(content: string) {
return `
<div class="search-content--header xt-space xt-space--medium" style="justify-content: flex-start; align-items: stretch; flex-direction: column;">
<div class="xt-space xt-space--medium" style="justify-content: space-between; align-items: center; flex-direction: row;">
<div class="xt-space xt-space--medium">
<span>找到 10000+ 个达人</span>
</div>
<div class="xt-space xt-space--medium" data-testid="market-native-actions" style="justify-content: flex-start; align-items: center; flex-direction: row;">
<button
data-testid="market-native-customize"
type="button"
class="el-button el-button--default el-button--medium xt-button xt-button--default"
>
<span>自定义指标</span>
</button>
<button
data-testid="market-native-export"
type="button"
class="el-button el-button--default el-button--medium xt-button xt-button--default"
>
<span>导出</span>
</button>
</div>
</div>
</div>
${content}
`;
}
function buildRealMarketFixture(
rows: Array<{
authorId: string;
authorName: string;
price21To60s: string;
}>
) {
return buildMarketPageShell(`
<div class="base-author-list" data-testid="market-root">
<div class="section-wrapper sticky-header hide-scrollbar">
<div style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
<div class="header-cell" style="min-width: 310px;">达人信息</div>
</div>
<div class="middle-columns" style="width: 190px; display: flex;">
<div class="header-cell" style="min-width: 190px;">代表视频</div>
</div>
<div data-testid="right-header" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
<div class="header-cell" style="min-width: 150px;">21-60s报价</div>
<div class="header-cell" style="min-width: 200px;">操作</div>
</div>
</div>
<div class="section-wrapper hide-scrollbar">
<div data-testid="author-section" class="content-section" style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
<div class="content-column" style="min-width: 310px;">
${rows
.map(
(row) => `
<div class="content-cell" data-testid="author-cell-${row.authorId}" style="height: 120px;">
<a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/${row.authorId}">${row.authorName}</a>
</div>
`
)
.join("")}
</div>
</div>
<div class="middle-columns" style="width: 190px; display: flex;">
<div class="content-column" style="min-width: 190px;">
${rows
.map(
(row) => `
<div class="content-cell" style="height: 120px;">代表视频${row.authorName}</div>
`
)
.join("")}
</div>
</div>
<div data-testid="right-section" class="content-section" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
<div class="content-column" style="min-width: 150px;">
${rows
.map(
(row) => `
<div class="content-cell" style="height: 120px;">${row.price21To60s}</div>
`
)
.join("")}
</div>
<div class="content-column" style="min-width: 200px;">
${rows
.map(
(row) => `
<div class="content-cell" data-testid="action-cell-${row.authorId}" data-author-id="${row.authorId}" style="height: 120px;">下单</div>
`
)
.join("")}
</div>
</div>
</div>
</div>
<button data-testid="next-page" type="button">下一页</button>
`);
}
function buildRichExportMarketFixture(
rows: Array<{
authorId: string;
authorName: string;
price21To60s: string;
}>
) {
const middleColumns = [
{
header: "代表视频",
readValue: (row: { authorName: string }) => `代表视频${row.authorName}`
},
{
header: "达人类型",
readValue: (row: { authorId: string }) =>
row.authorId === "444" ? "测评" : "剧情"
},
{
header: "内容主题",
readValue: (row: { authorName: string }) => `内容主题${row.authorName}`
},
{
header: "连接用户数",
readValue: (row: { authorId: string }) => `${row.authorId[0]}00w`
},
{
header: "粉丝数",
readValue: (row: { authorId: string }) => `${row.authorId[0]}${row.authorId[0]}.${row.authorId[0]}w`
},
{
header: "预期CPM",
readValue: (row: { authorId: string }) => `2${row.authorId[0]}.${row.authorId[0]}`
},
{
header: "预期播放量",
readValue: (row: { authorId: string }) => `6${row.authorId[0]}.${row.authorId[0]}w`
},
{
header: "互动率",
readValue: (row: { authorId: string }) => `7.${row.authorId[0]}%`
},
{
header: "完播率",
readValue: (row: { authorId: string }) => `26.${row.authorId[0]}%`
},
{
header: "爆文率",
readValue: (row: { authorId: string }) => `${Number(row.authorId[0]) + 7}%`
}
];
const middleWidth = middleColumns.reduce((width, column) => {
if (column.header === "内容主题") {
return width + 180;
}
if (column.header === "代表视频") {
return width + 190;
}
return width + 120;
}, 0);
return `
<div class="base-author-list" data-testid="market-root">
<div class="section-wrapper sticky-header hide-scrollbar">
<div style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
<div class="header-cell" style="min-width: 310px;">达人信息</div>
</div>
<div class="middle-columns" style="width: ${middleWidth}px; display: flex;">
${middleColumns
.map((column) => {
const width = column.header === "内容主题" ? 180 : column.header === "代表视频" ? 190 : 120;
return `<div class="header-cell" style="min-width: ${width}px;">${column.header}</div>`;
})
.join("")}
</div>
<div data-testid="right-header" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
<div class="header-cell" style="min-width: 150px;">21-60s报价</div>
<div class="header-cell" style="min-width: 200px;">操作</div>
</div>
</div>
<div class="section-wrapper hide-scrollbar">
<div data-testid="author-section" class="content-section" style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
<div class="content-column" style="min-width: 310px;">
${rows
.map(
(row) => `
<div class="content-cell" data-testid="author-cell-${row.authorId}" style="height: 120px;">
<a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/${row.authorId}">${row.authorName}</a>
</div>
`
)
.join("")}
</div>
</div>
<div class="middle-columns" style="width: ${middleWidth}px; display: flex;">
${middleColumns
.map((column) => {
const width = column.header === "内容主题" ? 180 : column.header === "代表视频" ? 190 : 120;
return `
<div class="content-column" style="min-width: ${width}px;">
${rows
.map(
(row) => `
<div class="content-cell" style="height: 120px;">${column.readValue(row)}</div>
`
)
.join("")}
</div>
`;
})
.join("")}
</div>
<div data-testid="right-section" class="content-section" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
<div class="content-column" style="min-width: 150px;">
${rows
.map(
(row) => `
<div class="content-cell" style="height: 120px;">${row.price21To60s}</div>
`
)
.join("")}
</div>
<div class="content-column" style="min-width: 200px;">
${rows
.map(
(row) => `
<div class="content-cell" data-testid="action-cell-${row.authorId}" data-author-id="${row.authorId}" style="height: 120px;">下单</div>
`
)
.join("")}
</div>
</div>
</div>
</div>
<button data-testid="next-page" type="button">下一页</button>
`;
}
function buildRealMarketFixtureWithoutAuthorIds(
rows: Array<{
authorName: string;
price21To60s: string;
}>
) {
return `
<div class="base-author-list">
<div class="section-wrapper sticky-header hide-scrollbar">
<div style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
<div class="header-cell" style="min-width: 310px;">达人信息</div>
</div>
<div class="middle-columns" style="width: 190px; display: flex;">
<div class="header-cell" style="min-width: 190px;">代表视频</div>
</div>
<div data-testid="right-header" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
<div class="header-cell" style="min-width: 150px;">21-60s报价</div>
<div class="header-cell" style="min-width: 200px;">操作</div>
</div>
</div>
<div class="section-wrapper hide-scrollbar">
<div class="content-section" data-testid="author-section" style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
<div class="content-column" style="min-width: 310px;">
${rows
.map(
(row) => `
<div class="content-cell" style="height: 120px;">
<div class="author-info-column">
<span class="author-nickname">${row.authorName}</span>
</div>
</div>
`
)
.join("")}
</div>
</div>
<div class="middle-columns" style="width: 190px; display: flex;">
<div class="content-column" style="min-width: 190px;">
${rows
.map(
(_, index) => `
<div class="content-cell" style="height: 120px;">代表视频${index + 1}</div>
`
)
.join("")}
</div>
</div>
<div data-testid="right-section" class="content-section" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
<div class="content-column" style="min-width: 150px;">
${rows
.map(
(row) => `
<div class="content-cell" style="height: 120px;">${row.price21To60s}</div>
`
)
.join("")}
</div>
<div class="content-column" style="min-width: 200px;">
${rows
.map(
() => `
<div class="content-cell" style="height: 120px;">下单</div>
`
)
.join("")}
</div>
</div>
</div>
</div>
`;
}
function attachMarketListState(
marketList: Array<{
attribute_datas?: {
avg_search_after_view_rate_30d?: string;
nickname?: string;
};
star_id?: string;
}>
) {
const marketRoot = document.querySelector('[data-testid="market-root"]');
if (!(marketRoot instanceof HTMLElement)) {
throw new Error("Missing market root");
}
Object.defineProperty(marketRoot, "__vue__", {
configurable: true,
value: {
_setupState: {
__$temp_1: {
marketList
}
}
}
});
}
function buildMarketListResponseRows(
rows: Array<{
authorId: string;
authorName: string;
price21To60s: string;
}>
): Array<Record<string, unknown>> {
return rows.map((row) => ({
attribute_datas: {
items: JSON.stringify([
{
title: `代表视频${row.authorName}`
}
]),
nick_name: row.authorName,
nickname: row.authorName,
price_20_60: Number(row.price21To60s.replace(/[^\d]/g, ""))
},
nick_name: row.authorName,
star_id: row.authorId
}));
}
function installPaginationHarness(
pages: Array<
Array<{
authorId: string;
authorName: string;
price21To60s: string;
}>
>
) {
let pageIndex = 0;
let clicks = 0;
const nextButton = document.querySelector(
'[data-testid="next-page"]'
) as HTMLButtonElement | null;
if (!nextButton) {
throw new Error("Missing next page button");
}
const renderPage = () => {
const authorColumn = readAuthorContentColumn();
const middleColumn = document.querySelector(
'.middle-columns .content-column'
) as HTMLElement | null;
const rightColumns = document.querySelectorAll(
'[data-testid="right-section"] > .content-column'
);
if (!authorColumn || !middleColumn || rightColumns.length < 2) {
throw new Error("Missing market columns for pagination harness");
}
const rows = pages[pageIndex];
authorColumn.innerHTML = rows
.map(
(row) => `
<div class="content-cell" data-testid="author-cell-${row.authorId}" style="height: 120px;">
<a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/${row.authorId}">${row.authorName}</a>
</div>
`
)
.join("");
middleColumn.innerHTML = rows
.map(
(row) => `
<div class="content-cell" style="height: 120px;">代表视频${row.authorName}</div>
`
)
.join("");
(rightColumns[0] as HTMLElement).innerHTML = rows
.map(
(row) => `
<div class="content-cell" style="height: 120px;">${row.price21To60s}</div>
`
)
.join("");
(rightColumns[1] as HTMLElement).innerHTML = rows
.map(
(row) => `
<div class="content-cell" data-testid="action-cell-${row.authorId}" data-author-id="${row.authorId}" style="height: 120px;">下单</div>
`
)
.join("");
nextButton.disabled = pageIndex >= pages.length - 1;
nextButton.setAttribute("aria-disabled", nextButton.disabled ? "true" : "false");
};
nextButton.addEventListener("click", () => {
if (pageIndex >= pages.length - 1) {
return;
}
clicks += 1;
pageIndex += 1;
renderPage();
});
renderPage();
return {
getClicks() {
return clicks;
}
};
}
function installAsyncPaginationHarness(
pages: Array<
Array<{
authorId: string;
authorName: string;
price21To60s: string;
}>
>
) {
let pageIndex = 0;
let clicks = 0;
let activeRenderToken = 0;
const nextButton = document.querySelector(
'[data-testid="next-page"]'
) as HTMLButtonElement | null;
if (!nextButton) {
throw new Error("Missing next page button");
}
const updatePaginationState = () => {
document.documentElement.setAttribute("data-test-page-index", String(pageIndex + 1));
nextButton.disabled = pageIndex >= pages.length - 1;
nextButton.setAttribute("aria-disabled", nextButton.disabled ? "true" : "false");
};
const renderPage = () => {
const authorColumn = readAuthorContentColumn();
const middleColumn = document.querySelector(
'.middle-columns .content-column'
) as HTMLElement | null;
const rightColumns = document.querySelectorAll(
'[data-testid="right-section"] > .content-column'
);
if (!authorColumn || !middleColumn || rightColumns.length < 2) {
throw new Error("Missing market columns for pagination harness");
}
const rows = pages[pageIndex];
authorColumn.innerHTML = rows
.map(
(row) => `
<div class="content-cell" data-testid="author-cell-${row.authorId}" style="height: 120px;">
<a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/${row.authorId}">${row.authorName}</a>
</div>
`
)
.join("");
middleColumn.innerHTML = rows
.map(
(row) => `
<div class="content-cell" style="height: 120px;">代表视频${row.authorName}</div>
`
)
.join("");
(rightColumns[0] as HTMLElement).innerHTML = rows
.map(
(row) => `
<div class="content-cell" style="height: 120px;">${row.price21To60s}</div>
`
)
.join("");
(rightColumns[1] as HTMLElement).innerHTML = rows
.map(
(row) => `
<div class="content-cell" data-testid="action-cell-${row.authorId}" data-author-id="${row.authorId}" style="height: 120px;">下单</div>
`
)
.join("");
updatePaginationState();
};
nextButton.addEventListener("click", () => {
if (pageIndex >= pages.length - 1) {
return;
}
clicks += 1;
pageIndex += 1;
const renderToken = ++activeRenderToken;
window.setTimeout(() => {
if (renderToken !== activeRenderToken) {
return;
}
renderPage();
}, 0);
});
renderPage();
return {
getClicks() {
return clicks;
}
};
}
function installLaggyPaginationHarness(
pages: Array<
Array<{
authorId: string;
authorName: string;
price21To60s: string;
}>
>,
options: {
renderDelayMs: number;
}
) {
let pageIndex = 0;
let clicks = 0;
let activeRenderToken = 0;
const nextButton = document.querySelector(
'[data-testid="next-page"]'
) as HTMLButtonElement | null;
if (!nextButton) {
throw new Error("Missing next page button");
}
const updatePaginationState = (visiblePageIndex: number) => {
document.documentElement.setAttribute(
"data-test-page-index",
String(visiblePageIndex + 1)
);
nextButton.disabled = visiblePageIndex >= pages.length - 1;
nextButton.setAttribute("aria-disabled", nextButton.disabled ? "true" : "false");
};
const renderPage = () => {
const authorColumn = readAuthorContentColumn();
const middleColumn = document.querySelector(
'.middle-columns .content-column'
) as HTMLElement | null;
const rightColumns = document.querySelectorAll(
'[data-testid="right-section"] > .content-column'
);
if (!authorColumn || !middleColumn || rightColumns.length < 2) {
throw new Error("Missing market columns for pagination harness");
}
const rows = pages[pageIndex];
authorColumn.innerHTML = rows
.map(
(row) => `
<div class="content-cell" data-testid="author-cell-${row.authorId}" style="height: 120px;">
<a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/${row.authorId}">${row.authorName}</a>
</div>
`
)
.join("");
middleColumn.innerHTML = rows
.map(
(row) => `
<div class="content-cell" style="height: 120px;">代表视频${row.authorName}</div>
`
)
.join("");
(rightColumns[0] as HTMLElement).innerHTML = rows
.map(
(row) => `
<div class="content-cell" style="height: 120px;">${row.price21To60s}</div>
`
)
.join("");
(rightColumns[1] as HTMLElement).innerHTML = rows
.map(
(row) => `
<div class="content-cell" data-testid="action-cell-${row.authorId}" data-author-id="${row.authorId}" style="height: 120px;">下单</div>
`
)
.join("");
updatePaginationState(pageIndex);
};
nextButton.addEventListener("click", () => {
if (pageIndex >= pages.length - 1) {
return;
}
clicks += 1;
pageIndex += 1;
updatePaginationState(pageIndex);
const authorColumn = readAuthorContentColumn();
const middleColumn = document.querySelector(
'.middle-columns .content-column'
) as HTMLElement | null;
const rightColumns = document.querySelectorAll(
'[data-testid="right-section"] > .content-column'
);
authorColumn!.innerHTML = "";
middleColumn!.innerHTML = "";
rightColumns.forEach((column) => {
(column as HTMLElement).innerHTML = "";
});
const renderToken = ++activeRenderToken;
window.setTimeout(() => {
if (renderToken !== activeRenderToken) {
return;
}
renderPage();
}, options.renderDelayMs);
});
renderPage();
return {
getClicks() {
return clicks;
}
};
}
function installProgressivePaginationHarness(
pages: Array<
Array<{
authorId: string;
authorName: string;
price21To60s: string;
}>
>,
options: {
firstRenderCount: number;
firstRenderDelayMs: number;
fullRenderDelayMs: number;
}
) {
let pageIndex = 0;
let clicks = 0;
let activeRenderToken = 0;
const nextButton = document.querySelector(
'[data-testid="next-page"]'
) as HTMLButtonElement | null;
if (!nextButton) {
throw new Error("Missing next page button");
}
const updatePaginationState = (visiblePageIndex: number) => {
document.documentElement.setAttribute(
"data-test-page-index",
String(visiblePageIndex + 1)
);
nextButton.disabled = visiblePageIndex >= pages.length - 1;
nextButton.setAttribute("aria-disabled", nextButton.disabled ? "true" : "false");
};
const renderRows = (
rows: Array<{
authorId: string;
authorName: string;
price21To60s: string;
}>
) => {
const authorColumn = readAuthorContentColumn();
const middleColumn = document.querySelector(
'.middle-columns .content-column'
) as HTMLElement | null;
const rightColumns = document.querySelectorAll(
'[data-testid="right-section"] > .content-column'
);
if (!authorColumn || !middleColumn || rightColumns.length < 2) {
throw new Error("Missing market columns for pagination harness");
}
authorColumn.innerHTML = rows
.map(
(row) => `
<div class="content-cell" data-testid="author-cell-${row.authorId}" style="height: 120px;">
<a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/${row.authorId}">${row.authorName}</a>
</div>
`
)
.join("");
middleColumn.innerHTML = rows
.map(
(row) => `
<div class="content-cell" style="height: 120px;">代表视频${row.authorName}</div>
`
)
.join("");
(rightColumns[0] as HTMLElement).innerHTML = rows
.map(
(row) => `
<div class="content-cell" style="height: 120px;">${row.price21To60s}</div>
`
)
.join("");
(rightColumns[1] as HTMLElement).innerHTML = rows
.map(
(row) => `
<div class="content-cell" data-testid="action-cell-${row.authorId}" data-author-id="${row.authorId}" style="height: 120px;">下单</div>
`
)
.join("");
};
const renderFullPage = () => {
renderRows(pages[pageIndex]);
updatePaginationState(pageIndex);
};
nextButton.addEventListener("click", () => {
if (pageIndex >= pages.length - 1) {
return;
}
clicks += 1;
pageIndex += 1;
updatePaginationState(pageIndex);
renderRows([]);
const renderToken = ++activeRenderToken;
window.setTimeout(() => {
if (renderToken !== activeRenderToken) {
return;
}
renderRows(pages[pageIndex].slice(0, options.firstRenderCount));
}, options.firstRenderDelayMs);
window.setTimeout(() => {
if (renderToken !== activeRenderToken) {
return;
}
renderFullPage();
}, options.fullRenderDelayMs);
});
renderFullPage();
return {
getClicks() {
return clicks;
}
};
}
function installLazyFieldHydrationHarness(options: {
hideAuthorIdentity?: boolean;
hiddenRowIndexes: number[];
hideAuthorCells?: boolean;
hydrateDelayMs?: number;
scrollContainer: HTMLElement;
}) {
const {
hideAuthorIdentity = false,
hiddenRowIndexes,
hideAuthorCells = false,
hydrateDelayMs = 0,
scrollContainer
} = options;
const rightColumns = document.querySelectorAll(
'[data-testid="right-section"] > .content-column'
);
const authorCells = readAuthorContentCells();
const middleCells = Array.from(
document.querySelectorAll(".middle-columns .content-column .content-cell")
) as HTMLElement[];
const priceCells = Array.from(rightColumns[0]?.querySelectorAll(".content-cell") ?? []) as
HTMLElement[];
const hiddenCells = hiddenRowIndexes.flatMap((rowIndex) => {
const authorCell = hideAuthorCells ? authorCells[rowIndex] ?? null : null;
const middleCell = middleCells[rowIndex] ?? null;
const priceCell = priceCells[rowIndex] ?? null;
return [authorCell, middleCell, priceCell]
.filter((cell): cell is HTMLElement => cell !== null)
.map((cell) => ({
cell,
text: cell.textContent ?? ""
}));
});
const hiddenAuthorIdentityCells = hideAuthorIdentity
? hiddenRowIndexes
.map((rowIndex) => authorCells[rowIndex] ?? null)
.filter((cell): cell is HTMLElement => cell !== null)
.map((cell) => ({
cell,
html: cell.innerHTML
}))
: [];
hiddenCells.forEach(({ cell }) => {
cell.textContent = "";
});
hiddenAuthorIdentityCells.forEach(({ cell }) => {
cell.innerHTML = "";
});
let hydrated = false;
let scrollTopValue = 0;
Object.defineProperty(scrollContainer, "clientHeight", {
configurable: true,
value: 120
});
Object.defineProperty(scrollContainer, "scrollHeight", {
configurable: true,
value: 480
});
Object.defineProperty(scrollContainer, "scrollTop", {
configurable: true,
get() {
return scrollTopValue;
},
set(value: number) {
scrollTopValue = value;
if (hydrated || value <= 0) {
return;
}
hydrated = true;
window.setTimeout(() => {
hiddenAuthorIdentityCells.forEach(({ cell, html }) => {
cell.innerHTML = html;
});
hiddenCells.forEach(({ cell, text }) => {
cell.textContent = text;
});
}, hydrateDelayMs);
}
});
}
function installRichLazyFieldHydrationHarness(options: {
hiddenRowIndexes: number[];
hydrateDelayMs?: number;
scrollContainer: HTMLElement;
}) {
const { hiddenRowIndexes, hydrateDelayMs = 0, scrollContainer } = options;
const middleColumns = Array.from(
document.querySelectorAll(".middle-columns .content-column")
) as HTMLElement[];
const delayedColumns = middleColumns.slice(1);
const hiddenCells = hiddenRowIndexes.flatMap((rowIndex) => {
const rowCells = delayedColumns.map(
(column) =>
(Array.from(column.querySelectorAll(".content-cell"))[rowIndex] as HTMLElement | undefined) ??
null
);
return rowCells
.filter((cell): cell is HTMLElement => cell !== null)
.map((cell) => ({
cell,
html: cell.innerHTML
}));
});
hiddenCells.forEach(({ cell }) => {
cell.innerHTML = "";
});
let hydrated = false;
let scrollTopValue = 0;
Object.defineProperty(scrollContainer, "clientHeight", {
configurable: true,
value: 120
});
Object.defineProperty(scrollContainer, "scrollHeight", {
configurable: true,
value: 480
});
Object.defineProperty(scrollContainer, "scrollTop", {
configurable: true,
get() {
return scrollTopValue;
},
set(value: number) {
scrollTopValue = value;
if (hydrated || value <= 0) {
return;
}
hydrated = true;
window.setTimeout(() => {
hiddenCells.forEach(({ cell, html }) => {
cell.innerHTML = html;
});
}, hydrateDelayMs);
}
});
}
function installPagedLazyFieldHydrationHarness(options: {
hideAuthorIdentity?: boolean;
hiddenRowIndexes: number[];
hideAuthorCells?: boolean;
hydrateDelayMs?: number;
scrollContainer: HTMLElement;
}) {
const {
hideAuthorIdentity = false,
hiddenRowIndexes,
hideAuthorCells = false,
hydrateDelayMs = 0,
scrollContainer
} = options;
const observers: MutationObserver[] = [];
let currentPageToken = "";
let hiddenPageToken = "";
let hydratedPageToken = "";
let hiddenTextCells: Array<{ cell: HTMLElement; text: string }> = [];
let hiddenAuthorIdentityCells: Array<{ cell: HTMLElement; html: string }> = [];
let scrollTopValue = 0;
const readPageToken = () =>
document.documentElement.getAttribute("data-test-page-index") ?? "1";
const clearPageState = () => {
hiddenTextCells = [];
hiddenAuthorIdentityCells = [];
};
const hideCurrentPage = () => {
const pageToken = readPageToken();
if (pageToken === hiddenPageToken) {
return;
}
const rightColumns = document.querySelectorAll(
'[data-testid="right-section"] > .content-column'
);
const authorCells = readAuthorContentCells();
const middleCells = Array.from(
document.querySelectorAll(".middle-columns .content-column .content-cell")
) as HTMLElement[];
const priceCells = Array.from(
rightColumns[0]?.querySelectorAll(".content-cell") ?? []
) as HTMLElement[];
clearPageState();
hiddenTextCells = hiddenRowIndexes.flatMap((rowIndex) => {
const authorCell = hideAuthorCells ? authorCells[rowIndex] ?? null : null;
const middleCell = middleCells[rowIndex] ?? null;
const priceCell = priceCells[rowIndex] ?? null;
return [authorCell, middleCell, priceCell]
.filter((cell): cell is HTMLElement => cell !== null)
.map((cell) => ({
cell,
text: cell.textContent ?? ""
}));
});
hiddenAuthorIdentityCells = hideAuthorIdentity
? hiddenRowIndexes
.map((rowIndex) => authorCells[rowIndex] ?? null)
.filter((cell): cell is HTMLElement => cell !== null)
.map((cell) => ({
cell,
html: cell.innerHTML
}))
: [];
hiddenTextCells.forEach(({ cell }) => {
cell.textContent = "";
});
hiddenAuthorIdentityCells.forEach(({ cell }) => {
cell.innerHTML = "";
});
currentPageToken = pageToken;
hiddenPageToken = pageToken;
hydratedPageToken = "";
};
const hydrateCurrentPage = () => {
const pageToken = readPageToken();
if (pageToken !== hiddenPageToken || pageToken === hydratedPageToken) {
return;
}
hydratedPageToken = pageToken;
window.setTimeout(() => {
hiddenAuthorIdentityCells.forEach(({ cell, html }) => {
cell.innerHTML = html;
});
hiddenTextCells.forEach(({ cell, text }) => {
cell.textContent = text;
});
}, hydrateDelayMs);
};
const hideObserver = new MutationObserver(() => {
const pageToken = readPageToken();
if (pageToken !== currentPageToken) {
currentPageToken = pageToken;
hiddenPageToken = "";
hydratedPageToken = "";
}
window.setTimeout(() => {
hideCurrentPage();
}, 0);
});
hideObserver.observe(document.body, {
childList: true,
subtree: true
});
observers.push(hideObserver);
const pageObserver = new MutationObserver(() => {
const pageToken = readPageToken();
if (pageToken === currentPageToken) {
return;
}
currentPageToken = pageToken;
hiddenPageToken = "";
hydratedPageToken = "";
window.setTimeout(() => {
hideCurrentPage();
}, 0);
});
pageObserver.observe(document.documentElement, {
attributeFilter: ["data-test-page-index"],
attributes: true
});
observers.push(pageObserver);
Object.defineProperty(scrollContainer, "clientHeight", {
configurable: true,
value: 120
});
Object.defineProperty(scrollContainer, "scrollHeight", {
configurable: true,
value: 480
});
Object.defineProperty(scrollContainer, "scrollTop", {
configurable: true,
get() {
return scrollTopValue;
},
set(value: number) {
scrollTopValue = value;
if (value <= 0) {
return;
}
hydrateCurrentPage();
}
});
hideCurrentPage();
disposers.push(() => {
observers.forEach((observer) => observer.disconnect());
});
}
function createMutationObserverFactory() {
let callback: MutationCallback = () => undefined;
return {
factory(nextCallback: MutationCallback) {
callback = nextCallback;
return {
disconnect() {},
observe() {}
};
},
trigger() {
callback([], {} as MutationObserver);
}
};
}
function click(selector: string) {
const element = document.querySelector(selector) as HTMLButtonElement | null;
if (!element) {
throw new Error(`Missing element: ${selector}`);
}
element.click();
}
function createNoopToolbarHandlers() {
return {
onConfigureAudienceProfileFields: vi.fn(),
onExportAudienceProfile: vi.fn(),
onExportAudienceProfileByIds: vi.fn(),
onSubmitBatch: vi.fn()
};
}
function clickSelectionCheckboxForAuthor(authorId: string) {
readSelectionCheckboxForAuthor(authorId).click();
}
function clickHeaderSelectionCheckbox() {
readHeaderSelectionCheckbox().click();
}
function readSelectionCheckboxForAuthor(authorId: string) {
const bySelectionAuthorId = document.querySelector(
`[data-market-selection-author-id="${authorId}"]`
) as HTMLInputElement | null;
if (bySelectionAuthorId) {
return bySelectionAuthorId;
}
const bySyntheticRow = document.querySelector(
`[data-market-row][data-author-id="${authorId}"] [data-market-selection-checkbox="row"]`
) as HTMLInputElement | null;
if (bySyntheticRow) {
return bySyntheticRow;
}
const byAuthorCell = document.querySelector(
`[data-testid="author-cell-${authorId}"] [data-market-selection-checkbox="row"]`
) as HTMLInputElement | null;
if (byAuthorCell) {
return byAuthorCell;
}
throw new Error(`Missing selection checkbox for author: ${authorId}`);
}
function readHeaderSelectionCheckbox() {
const checkbox = document.querySelector(
'[data-market-selection-checkbox="header"]'
) as HTMLInputElement | null;
if (!checkbox) {
throw new Error("Missing header selection checkbox");
}
return checkbox;
}
function setInputValue(selector: string, value: string) {
const element = document.querySelector(selector) as HTMLInputElement | null;
if (!element) {
throw new Error(`Missing input: ${selector}`);
}
element.value = value;
}
function dispatchInput(selector: string) {
const element = document.querySelector(selector) as HTMLElement | null;
if (!element) {
throw new Error(`Missing element: ${selector}`);
}
element.dispatchEvent(new Event("input"));
}
function setSelectValue(selector: string, value: string) {
const element = document.querySelector(selector) as HTMLSelectElement | null;
if (!element) {
throw new Error(`Missing select: ${selector}`);
}
element.value = value;
}
function expectSelectValue(selector: string, expected: string) {
const element = document.querySelector(selector) as HTMLSelectElement | null;
if (!element) {
throw new Error(`Missing select: ${selector}`);
}
expect(element.value).toBe(expected);
}
function dispatchChange(selector: string) {
const element = document.querySelector(selector) as HTMLElement | null;
if (!element) {
throw new Error(`Missing element: ${selector}`);
}
element.dispatchEvent(new Event("change"));
}
function readRowOrder() {
return Array.from(document.querySelectorAll("[data-market-row]")).map(
(row) => row.getAttribute("data-author-id")
);
}
function readDivAuthorOrder() {
const authorColumn = readAuthorContentColumn();
return readVisualCells(authorColumn).map(
(cell) => cell.querySelector("a")?.textContent?.trim() ?? ""
);
}
function readAuthorContentColumn(): HTMLElement | null {
const nativeColumns = Array.from(
document.querySelectorAll('[data-testid="author-section"] > .content-column')
).filter(
(column): column is HTMLElement =>
column instanceof HTMLElement && !column.dataset.marketColumnGroup
);
if (nativeColumns.length === 1) {
return nativeColumns[0];
}
return (
nativeColumns.find((column) =>
Boolean(
column.querySelector('a[href*="/author-homepage/"]') ||
column.querySelector(".author-nickname") ||
column.querySelector("[data-testid^='author-cell-']")
)
) ?? null
);
}
function readAuthorContentCells(): HTMLElement[] {
return Array.from(
readAuthorContentColumn()?.querySelectorAll(":scope > .content-cell") ?? []
).filter((cell): cell is HTMLElement => cell instanceof HTMLElement);
}
function readDivRightRowTexts(rowIndex: number) {
return Array.from(
document.querySelectorAll('[data-testid="right-section"] > .content-column'),
(column) => readVisualCells(column as Element)[rowIndex]?.textContent?.trim() ?? ""
);
}
function readDivPluginRowTexts(rowIndex: number) {
return Array.from(
document.querySelectorAll('[data-testid="plugin-section"] > .content-column'),
(column) => readVisualCells(column as Element)[rowIndex]?.textContent?.trim() ?? ""
);
}
function readVisualCells(root: Element | null): HTMLElement[] {
if (!root) {
return [];
}
return Array.from(root.querySelectorAll(":scope > .content-cell"))
.filter((cell): cell is HTMLElement => cell instanceof HTMLElement)
.sort((left, right) => {
const leftOrder = Number(left.style.order || "0");
const rightOrder = Number(right.style.order || "0");
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
const cells = Array.from(root.querySelectorAll(":scope > .content-cell"));
return cells.indexOf(left) - cells.indexOf(right);
});
}
function trackController<T extends { dispose?: () => void }>(controller: T): T {
if (controller.dispose) {
disposers.push(() => controller.dispose?.());
}
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) {
throw new Error(`Missing button: ${selector}`);
}
expect(element.disabled).toBe(expected);
}
function expectSelectDisabled(selector: string, expected: boolean) {
const element = document.querySelector(selector) as HTMLSelectElement | null;
if (!element) {
throw new Error(`Missing select: ${selector}`);
}
expect(element.disabled).toBe(expected);
}
async function flush() {
await Promise.resolve();
await Promise.resolve();
}
async function flushWithTimers() {
await new Promise((resolve) => setTimeout(resolve, 0));
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
function readCsvCell(csv: string, header: string): string {
const [headerLine, rowLine] = csv.split("\n");
const columnIndex = headerLine.split(",").indexOf(header);
expect(columnIndex).toBeGreaterThanOrEqual(0);
return rowLine.split(",")[columnIndex] ?? "";
}
async function waitForMockCall(
mockFn: { mock: { calls: unknown[][] } },
maxAttempts = 10,
pollDelayMs = 0
) {
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
if (mockFn.mock.calls.length > 0) {
return;
}
if (pollDelayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, pollDelayMs));
await Promise.resolve();
continue;
}
await flushWithTimers();
}
}
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;
const promise = new Promise<T>((nextResolve, nextReject) => {
resolve = nextResolve;
reject = nextReject;
});
return {
promise,
reject,
resolve
};
}