4933 lines
151 KiB
TypeScript
4933 lines
151 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";
|
||
|
||
const disposers: Array<() => void> = [];
|
||
|
||
describe("market-content-entry", () => {
|
||
beforeEach(() => {
|
||
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");
|
||
window.localStorage.clear();
|
||
window.history.replaceState({}, "", "/");
|
||
});
|
||
|
||
afterEach(() => {
|
||
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 = 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"]');
|
||
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"]')).not.toBeNull();
|
||
expect(document.querySelector('[data-plugin-export="button"]')).not.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 exportButton = document.querySelector(
|
||
'[data-plugin-export="button"]'
|
||
) as HTMLButtonElement | null;
|
||
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(exportButton?.textContent).toBe("导出列表CSV");
|
||
expect(exportButton?.title).toBe("导出当前达人列表中的基础字段和秒思指标");
|
||
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(exportButton?.style.backgroundColor).toBe("rgb(127, 29, 45)");
|
||
expect(batchSubmitButton?.style.backgroundColor).toBe("rgb(127, 29, 45)");
|
||
expect(audienceProfileExportButton?.style.backgroundColor).toBe("rgb(127, 29, 45)");
|
||
expect(audienceProfileByIdExportButton?.style.backgroundColor).toBe("rgb(127, 29, 45)");
|
||
expect(exportButton?.style.color).toBe("rgb(255, 255, 255)");
|
||
expect(batchSubmitButton?.style.color).toBe("rgb(255, 255, 255)");
|
||
expect(audienceProfileExportButton?.style.color).toBe("rgb(255, 255, 255)");
|
||
expect(audienceProfileByIdExportButton?.style.color).toBe("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 defaults export range to the first 5 pages and reveals custom input on demand", 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(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(customPagesInput?.hidden).toBe(false);
|
||
});
|
||
|
||
test("export uses the current page ordering without triggering a full scan", async () => {
|
||
document.body.innerHTML = buildMarketFixture();
|
||
const resultStore = createMarketResultStore();
|
||
const buildCsv = vi.fn(() => "csv-output");
|
||
const onCsvReady = vi.fn();
|
||
|
||
const { createMarketController } = await import("../src/content/market/index");
|
||
const controller = trackController(createMarketController({
|
||
buildCsv,
|
||
document,
|
||
loadAuthorMetrics: async () => ({
|
||
success: false,
|
||
reason: "request-failed"
|
||
}),
|
||
onCsvReady,
|
||
resultStore,
|
||
window
|
||
}));
|
||
|
||
await controller.ready;
|
||
resultStore.setAuthorSuccess("a", {
|
||
singleVideoAfterSearchRate: "0.02% - 0.1%",
|
||
personalVideoAfterSearchRate: "0.03% - 0.2%"
|
||
});
|
||
resultStore.setAuthorSuccess("b", {
|
||
singleVideoAfterSearchRate: "0.5%-1%",
|
||
personalVideoAfterSearchRate: "0.01% - 0.1%"
|
||
});
|
||
click('[data-market-sort-field="singleVideoAfterSearchRate"]');
|
||
await flush();
|
||
setSelectValue('[data-plugin-export-range="select"]', "current");
|
||
dispatchChange('[data-plugin-export-range="select"]');
|
||
click('[data-plugin-export="button"]');
|
||
await waitForMockCall(buildCsv, 80, 50);
|
||
|
||
expect(buildCsv).toHaveBeenCalledWith(
|
||
expect.arrayContaining([
|
||
expect.objectContaining({ authorId: "a" }),
|
||
expect.objectContaining({ authorId: "b" })
|
||
])
|
||
);
|
||
expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([
|
||
"b",
|
||
"a"
|
||
]);
|
||
expect(onCsvReady).toHaveBeenCalledWith("csv-output");
|
||
});
|
||
|
||
test(
|
||
"default export captures the first 5 pages and keeps non-empty fields when merging duplicates",
|
||
async () => {
|
||
const pages = [
|
||
[{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }],
|
||
[{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }],
|
||
[{ authorId: "222", authorName: "达人 B", price21To60s: "" }],
|
||
[{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }],
|
||
[{ authorId: "444", authorName: "达人 D", price21To60s: "¥44,000" }],
|
||
[{ authorId: "555", authorName: "达人 E", price21To60s: "¥55,000" }]
|
||
];
|
||
|
||
document.body.innerHTML = buildRealMarketFixture(pages[0]);
|
||
const pagination = installAsyncPaginationHarness(pages);
|
||
const buildCsv = vi.fn(() => "csv-output");
|
||
const onCsvReady = vi.fn();
|
||
|
||
const { createMarketController } = await import("../src/content/market/index");
|
||
const controller = trackController(createMarketController({
|
||
buildCsv,
|
||
document,
|
||
loadAuthorMetrics: async () => ({
|
||
success: false,
|
||
reason: "request-failed"
|
||
}),
|
||
onCsvReady,
|
||
window
|
||
}));
|
||
|
||
await controller.ready;
|
||
click('[data-plugin-export="button"]');
|
||
await waitForMockCall(buildCsv, 120, 100);
|
||
|
||
expect(pagination.getClicks()).toBe(4);
|
||
expect(buildCsv).toHaveBeenCalledTimes(1);
|
||
expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([
|
||
"111",
|
||
"222",
|
||
"333",
|
||
"444"
|
||
]);
|
||
expect(buildCsv.mock.calls[0][0]).toEqual(
|
||
expect.arrayContaining([
|
||
expect.objectContaining({
|
||
authorId: "222",
|
||
exportFields: expect.objectContaining({
|
||
"21-60s报价": "¥22,000"
|
||
})
|
||
})
|
||
])
|
||
);
|
||
expect(onCsvReady).toHaveBeenCalledWith("csv-output");
|
||
},
|
||
15000
|
||
);
|
||
|
||
test(
|
||
"default export replays captured market requests silently without paging the visible table",
|
||
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" }]
|
||
];
|
||
|
||
document.body.innerHTML = buildRealMarketFixture(pages[0]);
|
||
const pagination = installAsyncPaginationHarness(pages);
|
||
const buildCsv = vi.fn(() => "csv-output");
|
||
const onCsvReady = vi.fn();
|
||
const fetchMock = vi.fn(async (_input: string, init?: RequestInit) => {
|
||
const body = JSON.parse(String(init?.body ?? "{}")) as { page?: number };
|
||
const pageIndex = Math.max((body.page ?? 1) - 1, 0);
|
||
return {
|
||
json: async () => ({
|
||
data: {
|
||
marketList: buildMarketListResponseRows(pages[pageIndex] ?? [])
|
||
}
|
||
}),
|
||
ok: true
|
||
};
|
||
});
|
||
|
||
(
|
||
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({
|
||
buildCsv,
|
||
document,
|
||
loadAuthorMetrics: async () => ({
|
||
success: false,
|
||
reason: "request-failed"
|
||
}),
|
||
onCsvReady,
|
||
window
|
||
}));
|
||
|
||
await controller.ready;
|
||
click('[data-plugin-export="button"]');
|
||
await waitForMockCall(buildCsv, 120, 100);
|
||
|
||
expect(pagination.getClicks()).toBe(0);
|
||
expect(fetchMock).toHaveBeenCalledTimes(5);
|
||
expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([
|
||
"111",
|
||
"222",
|
||
"333",
|
||
"444",
|
||
"555"
|
||
]);
|
||
expect(onCsvReady).toHaveBeenCalledWith("csv-output");
|
||
},
|
||
15000
|
||
);
|
||
|
||
test(
|
||
"default export falls back to visible pagination when no captured market request is available",
|
||
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" }]
|
||
];
|
||
|
||
document.body.innerHTML = buildRealMarketFixture(pages[0]);
|
||
const pagination = installAsyncPaginationHarness(pages);
|
||
const buildCsv = vi.fn(() => "csv-output");
|
||
|
||
document.documentElement.removeAttribute("data-sces-market-request-snapshot");
|
||
|
||
const { createMarketController } = await import("../src/content/market/index");
|
||
const controller = trackController(createMarketController({
|
||
buildCsv,
|
||
document,
|
||
loadAuthorMetrics: async () => ({
|
||
success: false,
|
||
reason: "request-failed"
|
||
}),
|
||
onCsvReady: vi.fn(),
|
||
window
|
||
}));
|
||
|
||
await controller.ready;
|
||
click('[data-plugin-export="button"]');
|
||
await waitForMockCall(buildCsv, 120, 100);
|
||
|
||
expect(pagination.getClicks()).toBe(4);
|
||
expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([
|
||
"111",
|
||
"222",
|
||
"333",
|
||
"444",
|
||
"555"
|
||
]);
|
||
},
|
||
15000
|
||
);
|
||
|
||
test(
|
||
"default export waits for the next page rows instead of only the pager state",
|
||
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" }]
|
||
];
|
||
|
||
document.body.innerHTML = buildRealMarketFixture(pages[0]);
|
||
const pagination = installLaggyPaginationHarness(pages, {
|
||
renderDelayMs: 250
|
||
});
|
||
const buildCsv = vi.fn(() => "csv-output");
|
||
|
||
const { createMarketController } = await import("../src/content/market/index");
|
||
const controller = trackController(createMarketController({
|
||
buildCsv,
|
||
document,
|
||
loadAuthorMetrics: async () => ({
|
||
success: false,
|
||
reason: "request-failed"
|
||
}),
|
||
onCsvReady: vi.fn(),
|
||
window
|
||
}));
|
||
|
||
await controller.ready;
|
||
click('[data-plugin-export="button"]');
|
||
await waitForMockCall(buildCsv, 120, 100);
|
||
|
||
expect(pagination.getClicks()).toBe(4);
|
||
expect(buildCsv).toHaveBeenCalledTimes(1);
|
||
expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([
|
||
"111",
|
||
"222",
|
||
"333",
|
||
"444",
|
||
"555"
|
||
]);
|
||
},
|
||
15000
|
||
);
|
||
|
||
test(
|
||
"export waits for a slow page to finish rendering all rows before continuing",
|
||
async () => {
|
||
const pages = [
|
||
[
|
||
{ authorId: "111", authorName: "达人 A1", price21To60s: "¥11,000" },
|
||
{ authorId: "112", authorName: "达人 A2", price21To60s: "¥12,000" },
|
||
{ authorId: "113", authorName: "达人 A3", price21To60s: "¥13,000" }
|
||
],
|
||
[
|
||
{ authorId: "221", authorName: "达人 B1", price21To60s: "¥21,000" },
|
||
{ authorId: "222", authorName: "达人 B2", price21To60s: "¥22,000" },
|
||
{ authorId: "223", authorName: "达人 B3", price21To60s: "¥23,000" }
|
||
],
|
||
[
|
||
{ authorId: "331", authorName: "达人 C1", price21To60s: "¥31,000" },
|
||
{ authorId: "332", authorName: "达人 C2", price21To60s: "¥32,000" },
|
||
{ authorId: "333", authorName: "达人 C3", price21To60s: "¥33,000" }
|
||
]
|
||
];
|
||
|
||
document.body.innerHTML = buildRealMarketFixture(pages[0]);
|
||
const pagination = installProgressivePaginationHarness(pages, {
|
||
firstRenderCount: 1,
|
||
firstRenderDelayMs: 100,
|
||
fullRenderDelayMs: 450
|
||
});
|
||
const buildCsv = vi.fn(() => "csv-output");
|
||
|
||
const { createMarketController } = await import("../src/content/market/index");
|
||
const controller = trackController(createMarketController({
|
||
buildCsv,
|
||
document,
|
||
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="button"]');
|
||
await waitForMockCall(buildCsv, 120, 100);
|
||
|
||
expect(pagination.getClicks()).toBe(2);
|
||
expect(buildCsv).toHaveBeenCalledTimes(1);
|
||
expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([
|
||
"111",
|
||
"112",
|
||
"113",
|
||
"221",
|
||
"222",
|
||
"223",
|
||
"331",
|
||
"332",
|
||
"333"
|
||
]);
|
||
},
|
||
15000
|
||
);
|
||
|
||
test(
|
||
"exporting all pages disables the native action bar controls during the task and stops at the final page",
|
||
async () => {
|
||
const pages = [
|
||
[{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }],
|
||
[{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }],
|
||
[{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }]
|
||
];
|
||
|
||
document.body.innerHTML = buildRealMarketFixture(pages[0]);
|
||
const pagination = installPaginationHarness(pages);
|
||
const buildCsv = vi.fn(() => "csv-output");
|
||
|
||
const { createMarketController } = await import("../src/content/market/index");
|
||
const controller = trackController(createMarketController({
|
||
buildCsv,
|
||
document,
|
||
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="button"]');
|
||
|
||
expectButtonDisabled('[data-plugin-batch-submit="button"]', true);
|
||
expectButtonDisabled('[data-plugin-export="button"]', true);
|
||
expectSelectDisabled('[data-plugin-export-range="select"]', true);
|
||
expect(
|
||
document.querySelector('[data-plugin-export-status="text"]')?.textContent
|
||
).toContain("导出中");
|
||
|
||
await waitForMockCall(buildCsv, 120, 100);
|
||
|
||
expect(pagination.getClicks()).toBe(2);
|
||
expectButtonDisabled('[data-plugin-batch-submit="button"]', false);
|
||
expectButtonDisabled('[data-plugin-export="button"]', false);
|
||
expectSelectDisabled('[data-plugin-export-range="select"]', false);
|
||
expect(buildCsv).toHaveBeenCalledTimes(1);
|
||
expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual(
|
||
expect.arrayContaining(["222", "333"])
|
||
);
|
||
},
|
||
15000
|
||
);
|
||
|
||
test("custom export range blocks invalid page counts", async () => {
|
||
document.body.innerHTML = buildMarketFixture();
|
||
const buildCsv = vi.fn(() => "csv-output");
|
||
|
||
const { createMarketController } = await import("../src/content/market/index");
|
||
const controller = trackController(createMarketController({
|
||
buildCsv,
|
||
document,
|
||
loadAuthorMetrics: async () => ({
|
||
success: false,
|
||
reason: "request-failed"
|
||
}),
|
||
onCsvReady: vi.fn(),
|
||
window
|
||
}));
|
||
|
||
await controller.ready;
|
||
setSelectValue('[data-plugin-export-range="select"]', "custom");
|
||
dispatchChange('[data-plugin-export-range="select"]');
|
||
setInputValue('[data-plugin-export-custom-pages="input"]', "0");
|
||
|
||
click('[data-plugin-export="button"]');
|
||
await flush();
|
||
|
||
expect(buildCsv).not.toHaveBeenCalled();
|
||
expect(
|
||
document.querySelector('[data-plugin-export-status="text"]')?.textContent
|
||
).toContain("有效页数");
|
||
});
|
||
|
||
test("selected export 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 buildCsv = vi.fn(() => "csv-output");
|
||
|
||
const { createMarketController } = await import("../src/content/market/index");
|
||
const controller = trackController(createMarketController({
|
||
buildCsv,
|
||
document,
|
||
loadAuthorMetrics: async () => ({
|
||
success: false,
|
||
reason: "request-failed"
|
||
}),
|
||
onCsvReady: vi.fn(),
|
||
window
|
||
}));
|
||
|
||
await controller.ready;
|
||
clickSelectionCheckboxForAuthor("111");
|
||
clickSelectionCheckboxForAuthor("333");
|
||
setSelectValue('[data-plugin-export-range="select"]', "current");
|
||
dispatchChange('[data-plugin-export-range="select"]');
|
||
|
||
click('[data-plugin-export="button"]');
|
||
await waitForMockCall(buildCsv, 40, 50);
|
||
|
||
expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([
|
||
"111",
|
||
"333"
|
||
]);
|
||
});
|
||
|
||
test("audience profile export requires selected creators", 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 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,
|
||
videos: {}
|
||
}));
|
||
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: expect.arrayContaining(["秒思api-看后搜数"])
|
||
})
|
||
);
|
||
expect(onCsvReady).toHaveBeenCalledWith(
|
||
"profile-csv",
|
||
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,
|
||
videos: {}
|
||
}));
|
||
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: expect.arrayContaining(["秒思api-看后搜数"])
|
||
})
|
||
);
|
||
expect(onCsvReady).toHaveBeenCalledWith(
|
||
"profile-csv",
|
||
expect.stringMatching(/^达人连接用户画像_按ID导出_\d{8}_\d{4}\.csv$/)
|
||
);
|
||
});
|
||
|
||
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(["内容数据-个人视频-播放量中位数", "秒思api-看后搜数"])
|
||
);
|
||
const buildAudienceProfileCsv = vi.fn(() => "profile-csv");
|
||
const loadBusinessAbility = vi.fn(async () => ({
|
||
estimates: {},
|
||
status: "success" as const,
|
||
videos: {}
|
||
}));
|
||
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: ["内容数据-个人视频-播放量中位数", "秒思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 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("内容数据-个人视频-播放量中位数");
|
||
expect(
|
||
document.querySelector('[data-plugin-export-status="text"]')?.textContent
|
||
).toContain("画像字段已保存");
|
||
});
|
||
|
||
test(
|
||
"selected export keeps a generic loading status while exporting 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;
|
||
}>();
|
||
|
||
document.body.innerHTML = buildRealMarketFixture(pages[0]);
|
||
const buildCsv = vi.fn(() => "csv-output");
|
||
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({
|
||
buildCsv,
|
||
document,
|
||
loadAuthorMetrics: async () => ({
|
||
success: false,
|
||
reason: "request-failed"
|
||
}),
|
||
onCsvReady: vi.fn(),
|
||
window
|
||
}));
|
||
|
||
await controller.ready;
|
||
clickSelectionCheckboxForAuthor("111");
|
||
|
||
click('[data-plugin-export="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(buildCsv, 120, 50);
|
||
|
||
expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([
|
||
"111"
|
||
]);
|
||
},
|
||
15000
|
||
);
|
||
|
||
test("selected export 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 buildCsv = vi.fn(() => "csv-output");
|
||
|
||
const { createMarketController } = await import("../src/content/market/index");
|
||
const controller = trackController(createMarketController({
|
||
buildCsv,
|
||
document,
|
||
loadAuthorMetrics: async () => ({
|
||
success: false,
|
||
reason: "request-failed"
|
||
}),
|
||
onCsvReady: vi.fn(),
|
||
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-export="button"]');
|
||
await waitForMockCall(buildCsv, 40, 50);
|
||
|
||
expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([
|
||
"333",
|
||
"444"
|
||
]);
|
||
});
|
||
|
||
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(
|
||
"default paged 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;
|
||
|
||
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("export only includes records that are present on the current page", async () => {
|
||
document.body.innerHTML = buildMarketFixture();
|
||
const resultStore = createMarketResultStore();
|
||
const buildCsv = vi.fn(() => "csv-output");
|
||
const onCsvReady = vi.fn();
|
||
|
||
const { createMarketController } = await import("../src/content/market/index");
|
||
const controller = trackController(createMarketController({
|
||
buildCsv,
|
||
document,
|
||
loadAuthorMetrics: async () => ({
|
||
success: false,
|
||
reason: "request-failed"
|
||
}),
|
||
onCsvReady,
|
||
resultStore,
|
||
window
|
||
}));
|
||
|
||
await controller.ready;
|
||
resultStore.setAuthorSuccess("a", {
|
||
singleVideoAfterSearchRate: "0.02% - 0.1%",
|
||
personalVideoAfterSearchRate: "0.03% - 0.2%"
|
||
});
|
||
resultStore.setAuthorSuccess("b", {
|
||
singleVideoAfterSearchRate: "0.5%-1%",
|
||
personalVideoAfterSearchRate: "0.01% - 0.1%"
|
||
});
|
||
resultStore.upsertMarketRow({
|
||
authorId: "c",
|
||
authorName: "Gamma"
|
||
});
|
||
resultStore.setAuthorSuccess("c", {
|
||
singleVideoAfterSearchRate: "9% - 10%",
|
||
personalVideoAfterSearchRate: "8% - 9%"
|
||
});
|
||
|
||
click('[data-plugin-export="button"]');
|
||
await waitForMockCall(buildCsv, 40, 50);
|
||
|
||
expect(buildCsv).toHaveBeenCalledWith(
|
||
expect.arrayContaining([
|
||
expect.objectContaining({ authorId: "a" }),
|
||
expect.objectContaining({ authorId: "b" })
|
||
])
|
||
);
|
||
expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).not.toContain(
|
||
"c"
|
||
);
|
||
expect(onCsvReady).toHaveBeenCalledWith("csv-output");
|
||
});
|
||
|
||
test("export prefers fresh current-page fields over stale store export fields", async () => {
|
||
document.body.innerHTML = buildMarketFixture();
|
||
const resultStore = createMarketResultStore();
|
||
const buildCsv = vi.fn(() => "csv-output");
|
||
const onCsvReady = vi.fn();
|
||
|
||
const { createMarketController } = await import("../src/content/market/index");
|
||
const controller = trackController(createMarketController({
|
||
buildCsv,
|
||
document,
|
||
loadAuthorMetrics: async () => ({
|
||
success: false,
|
||
reason: "request-failed"
|
||
}),
|
||
onCsvReady,
|
||
resultStore,
|
||
window
|
||
}));
|
||
|
||
await controller.ready;
|
||
resultStore.upsertMarketRow({
|
||
authorId: "a",
|
||
authorName: "Old Alpha",
|
||
exportFields: {
|
||
达人: "Old Alpha"
|
||
}
|
||
});
|
||
resultStore.setAuthorSuccess("a", {
|
||
singleVideoAfterSearchRate: "0.02% - 0.1%",
|
||
personalVideoAfterSearchRate: "0.03% - 0.2%"
|
||
});
|
||
|
||
click('[data-plugin-export="button"]');
|
||
await waitForMockCall(buildCsv, 40, 50);
|
||
|
||
expect(buildCsv.mock.calls[0][0][0]).toEqual(
|
||
expect.objectContaining({
|
||
authorId: "a",
|
||
authorName: "Alpha",
|
||
exportFields: {
|
||
"21-60s报价": "450000",
|
||
达人: "Alpha"
|
||
}
|
||
})
|
||
);
|
||
expect(onCsvReady).toHaveBeenCalledWith("csv-output");
|
||
});
|
||
|
||
test("export harvests lazy current-page fields before building csv", async () => {
|
||
const rows = [
|
||
{
|
||
authorId: "111",
|
||
authorName: "达人 A",
|
||
price21To60s: "¥450,000"
|
||
},
|
||
{
|
||
authorId: "222",
|
||
authorName: "达人 B",
|
||
price21To60s: "¥20,000"
|
||
},
|
||
{
|
||
authorId: "333",
|
||
authorName: "达人 C",
|
||
price21To60s: "¥30,000"
|
||
},
|
||
{
|
||
authorId: "444",
|
||
authorName: "达人 D",
|
||
price21To60s: "¥40,000"
|
||
}
|
||
];
|
||
|
||
document.body.innerHTML = `
|
||
<div data-testid="market-scroll-shell" style="overflow-y: auto; max-height: 120px;">
|
||
${buildRealMarketFixture(rows)}
|
||
</div>
|
||
`;
|
||
installLazyFieldHydrationHarness({
|
||
hiddenRowIndexes: [2, 3],
|
||
scrollContainer: document.querySelector(
|
||
'[data-testid="market-scroll-shell"]'
|
||
) as HTMLElement
|
||
});
|
||
|
||
const resultStore = createMarketResultStore();
|
||
const buildCsv = vi.fn(() => "csv-output");
|
||
const onCsvReady = vi.fn();
|
||
const { createMarketController } = await import("../src/content/market/index");
|
||
const controller = trackController(createMarketController({
|
||
buildCsv,
|
||
document,
|
||
loadAuthorMetrics: async () => ({
|
||
success: false,
|
||
reason: "request-failed"
|
||
}),
|
||
onCsvReady,
|
||
resultStore,
|
||
window
|
||
}));
|
||
|
||
await controller.ready;
|
||
rows.forEach((row, index) => {
|
||
resultStore.setAuthorSuccess(row.authorId, {
|
||
personalVideoAfterSearchRate: `0.0${index + 1}%`,
|
||
singleVideoAfterSearchRate: `0.1${index + 1}%`
|
||
});
|
||
});
|
||
|
||
setSelectValue('[data-plugin-export-range="select"]', "current");
|
||
dispatchChange('[data-plugin-export-range="select"]');
|
||
click('[data-plugin-export="button"]');
|
||
await waitForMockCall(buildCsv, 120, 50);
|
||
|
||
expect(buildCsv.mock.calls[0][0]).toEqual(
|
||
expect.arrayContaining([
|
||
expect.objectContaining({
|
||
authorId: "333",
|
||
exportFields: expect.objectContaining({
|
||
"21-60s报价": "¥30,000",
|
||
代表视频: "代表视频达人 C"
|
||
})
|
||
}),
|
||
expect.objectContaining({
|
||
authorId: "444",
|
||
exportFields: expect.objectContaining({
|
||
"21-60s报价": "¥40,000",
|
||
代表视频: "代表视频达人 D"
|
||
})
|
||
})
|
||
])
|
||
);
|
||
expect(onCsvReady).toHaveBeenCalledWith("csv-output");
|
||
});
|
||
|
||
test("export harvests lazy current-page fields from the effective scroll container", async () => {
|
||
const rows = [
|
||
{
|
||
authorId: "111",
|
||
authorName: "达人 A",
|
||
price21To60s: "¥450,000"
|
||
},
|
||
{
|
||
authorId: "222",
|
||
authorName: "达人 B",
|
||
price21To60s: "¥20,000"
|
||
},
|
||
{
|
||
authorId: "333",
|
||
authorName: "达人 C",
|
||
price21To60s: "¥30,000"
|
||
},
|
||
{
|
||
authorId: "444",
|
||
authorName: "达人 D",
|
||
price21To60s: "¥40,000"
|
||
}
|
||
];
|
||
|
||
document.body.innerHTML = `
|
||
<div data-testid="market-outer-scroll-shell" style="overflow-y: auto; max-height: 120px;">
|
||
<div data-testid="market-inner-scroll-shell" style="overflow-y: auto; max-height: 120px;">
|
||
${buildRealMarketFixture(rows)}
|
||
</div>
|
||
</div>
|
||
`;
|
||
const outerScrollContainer = document.querySelector(
|
||
'[data-testid="market-outer-scroll-shell"]'
|
||
) as HTMLElement;
|
||
const innerScrollContainer = document.querySelector(
|
||
'[data-testid="market-inner-scroll-shell"]'
|
||
) as HTMLElement;
|
||
installLazyFieldHydrationHarness({
|
||
hiddenRowIndexes: [2, 3],
|
||
scrollContainer: outerScrollContainer
|
||
});
|
||
|
||
let innerScrollTop = 0;
|
||
Object.defineProperty(innerScrollContainer, "clientHeight", {
|
||
configurable: true,
|
||
value: 120
|
||
});
|
||
Object.defineProperty(innerScrollContainer, "scrollHeight", {
|
||
configurable: true,
|
||
value: 240
|
||
});
|
||
Object.defineProperty(innerScrollContainer, "scrollTop", {
|
||
configurable: true,
|
||
get() {
|
||
return innerScrollTop;
|
||
},
|
||
set(value: number) {
|
||
innerScrollTop = value;
|
||
}
|
||
});
|
||
|
||
const resultStore = createMarketResultStore();
|
||
const buildCsv = vi.fn(() => "csv-output");
|
||
const onCsvReady = vi.fn();
|
||
const { createMarketController } = await import("../src/content/market/index");
|
||
const controller = trackController(createMarketController({
|
||
buildCsv,
|
||
document,
|
||
loadAuthorMetrics: async () => ({
|
||
success: false,
|
||
reason: "request-failed"
|
||
}),
|
||
onCsvReady,
|
||
resultStore,
|
||
window
|
||
}));
|
||
|
||
await controller.ready;
|
||
rows.forEach((row, index) => {
|
||
resultStore.setAuthorSuccess(row.authorId, {
|
||
personalVideoAfterSearchRate: `0.0${index + 1}%`,
|
||
singleVideoAfterSearchRate: `0.1${index + 1}%`
|
||
});
|
||
});
|
||
|
||
setSelectValue('[data-plugin-export-range="select"]', "current");
|
||
dispatchChange('[data-plugin-export-range="select"]');
|
||
click('[data-plugin-export="button"]');
|
||
await waitForMockCall(buildCsv, 120, 50);
|
||
|
||
expect(buildCsv.mock.calls[0][0]).toEqual(
|
||
expect.arrayContaining([
|
||
expect.objectContaining({
|
||
authorId: "333",
|
||
exportFields: expect.objectContaining({
|
||
"21-60s报价": "¥30,000",
|
||
代表视频: "代表视频达人 C"
|
||
})
|
||
}),
|
||
expect.objectContaining({
|
||
authorId: "444",
|
||
exportFields: expect.objectContaining({
|
||
"21-60s报价": "¥40,000",
|
||
代表视频: "代表视频达人 D"
|
||
})
|
||
})
|
||
])
|
||
);
|
||
expect(onCsvReady).toHaveBeenCalledWith("csv-output");
|
||
});
|
||
|
||
test("export reloads backend metrics for rows discovered during scroll harvest", async () => {
|
||
const rows = [
|
||
{
|
||
authorId: "111",
|
||
authorName: "达人 A",
|
||
price21To60s: "¥450,000"
|
||
},
|
||
{
|
||
authorId: "222",
|
||
authorName: "达人 B",
|
||
price21To60s: "¥20,000"
|
||
},
|
||
{
|
||
authorId: "333",
|
||
authorName: "达人 C",
|
||
price21To60s: "¥30,000"
|
||
},
|
||
{
|
||
authorId: "444",
|
||
authorName: "达人 D",
|
||
price21To60s: "¥40,000"
|
||
}
|
||
];
|
||
|
||
document.body.innerHTML = `
|
||
<div data-testid="market-scroll-shell" style="overflow-y: auto; max-height: 120px;">
|
||
${buildRealMarketFixture(rows)}
|
||
</div>
|
||
`;
|
||
installLazyFieldHydrationHarness({
|
||
hiddenRowIndexes: [2, 3],
|
||
hideAuthorIdentity: true,
|
||
scrollContainer: document.querySelector(
|
||
'[data-testid="market-scroll-shell"]'
|
||
) as HTMLElement
|
||
});
|
||
|
||
const buildCsv = vi.fn(() => "csv-output");
|
||
const onCsvReady = vi.fn();
|
||
const searchBackendMetrics = vi.fn(async (starIds: string[]) =>
|
||
starIds.map((starId) => ({
|
||
a3IncreaseCount: `${starId}-a3`,
|
||
afterViewSearchCount: `${starId}-count`,
|
||
afterViewSearchRate: `${starId}-rate`,
|
||
cpSearch: `${starId}-cp-search`,
|
||
cpa3: `${starId}-cpa3`,
|
||
newA3Rate: `${starId}-new-a3`,
|
||
starId
|
||
}))
|
||
);
|
||
const { createMarketController } = await import("../src/content/market/index");
|
||
const controller = trackController(createMarketController({
|
||
buildCsv,
|
||
document,
|
||
loadAuthorMetrics: async () => ({
|
||
success: false,
|
||
reason: "request-failed"
|
||
}),
|
||
onCsvReady,
|
||
searchBackendMetrics,
|
||
window
|
||
}));
|
||
|
||
await controller.ready;
|
||
setSelectValue('[data-plugin-export-range="select"]', "current");
|
||
dispatchChange('[data-plugin-export-range="select"]');
|
||
click('[data-plugin-export="button"]');
|
||
await waitForMockCall(buildCsv, 120, 50);
|
||
|
||
expect(buildCsv.mock.calls[0][0]).toEqual(
|
||
expect.arrayContaining([
|
||
expect.objectContaining({
|
||
authorId: "333",
|
||
backendMetrics: expect.objectContaining({
|
||
afterViewSearchRate: "333-rate"
|
||
})
|
||
}),
|
||
expect.objectContaining({
|
||
authorId: "444",
|
||
backendMetrics: expect.objectContaining({
|
||
afterViewSearchRate: "444-rate"
|
||
})
|
||
})
|
||
])
|
||
);
|
||
expect(onCsvReady).toHaveBeenCalledWith("csv-output");
|
||
});
|
||
|
||
test("export excludes rows whose author name is still empty", async () => {
|
||
document.body.innerHTML = buildMarketFixture();
|
||
const blankRow = document.querySelector('[data-market-row=\"b\"]');
|
||
if (!(blankRow instanceof HTMLElement)) {
|
||
throw new Error("Missing blank-row fixture");
|
||
}
|
||
const authorNameCell = blankRow.querySelector('[data-market-field=\"authorName\"]');
|
||
if (!(authorNameCell instanceof HTMLElement)) {
|
||
throw new Error("Missing author name cell");
|
||
}
|
||
authorNameCell.textContent = "";
|
||
|
||
const buildCsv = vi.fn(() => "csv-output");
|
||
const onCsvReady = vi.fn();
|
||
const { createMarketController } = await import("../src/content/market/index");
|
||
const controller = trackController(createMarketController({
|
||
buildCsv,
|
||
document,
|
||
loadAuthorMetrics: async () => ({
|
||
success: false,
|
||
reason: "request-failed"
|
||
}),
|
||
onCsvReady,
|
||
window
|
||
}));
|
||
|
||
await controller.ready;
|
||
setSelectValue('[data-plugin-export-range="select"]', "current");
|
||
dispatchChange('[data-plugin-export-range="select"]');
|
||
click('[data-plugin-export="button"]');
|
||
await waitForMockCall(buildCsv, 120, 50);
|
||
|
||
expect(buildCsv.mock.calls[0][0]).not.toEqual(
|
||
expect.arrayContaining([
|
||
expect.objectContaining({
|
||
authorId: "b"
|
||
})
|
||
])
|
||
);
|
||
expect(onCsvReady).toHaveBeenCalledWith("csv-output");
|
||
});
|
||
|
||
test(
|
||
"export waits for delayed lazy field hydration before reading current-page rows",
|
||
async () => {
|
||
const rows = [
|
||
{
|
||
authorId: "111",
|
||
authorName: "达人 A",
|
||
price21To60s: "¥450,000"
|
||
},
|
||
{
|
||
authorId: "222",
|
||
authorName: "达人 B",
|
||
price21To60s: "¥20,000"
|
||
},
|
||
{
|
||
authorId: "333",
|
||
authorName: "达人 C",
|
||
price21To60s: "¥30,000"
|
||
},
|
||
{
|
||
authorId: "444",
|
||
authorName: "达人 D",
|
||
price21To60s: "¥40,000"
|
||
}
|
||
];
|
||
|
||
document.body.innerHTML = `
|
||
<div data-testid="market-scroll-shell" style="overflow-y: auto; max-height: 120px;">
|
||
${buildRealMarketFixture(rows)}
|
||
</div>
|
||
`;
|
||
installLazyFieldHydrationHarness({
|
||
hiddenRowIndexes: [2, 3],
|
||
hydrateDelayMs: 350,
|
||
scrollContainer: document.querySelector(
|
||
'[data-testid="market-scroll-shell"]'
|
||
) as HTMLElement
|
||
});
|
||
|
||
const resultStore = createMarketResultStore();
|
||
const buildCsv = vi.fn(() => "csv-output");
|
||
const onCsvReady = vi.fn();
|
||
const { createMarketController } = await import("../src/content/market/index");
|
||
const controller = trackController(createMarketController({
|
||
buildCsv,
|
||
document,
|
||
loadAuthorMetrics: async () => ({
|
||
success: false,
|
||
reason: "request-failed"
|
||
}),
|
||
onCsvReady,
|
||
resultStore,
|
||
window
|
||
}));
|
||
|
||
await controller.ready;
|
||
rows.forEach((row, index) => {
|
||
resultStore.setAuthorSuccess(row.authorId, {
|
||
personalVideoAfterSearchRate: `0.0${index + 1}%`,
|
||
singleVideoAfterSearchRate: `0.1${index + 1}%`
|
||
});
|
||
});
|
||
|
||
setSelectValue('[data-plugin-export-range="select"]', "current");
|
||
dispatchChange('[data-plugin-export-range="select"]');
|
||
click('[data-plugin-export="button"]');
|
||
await waitForMockCall(buildCsv, 120, 50);
|
||
|
||
expect(buildCsv).toHaveBeenCalledTimes(1);
|
||
expect(buildCsv.mock.calls[0][0]).toEqual(
|
||
expect.arrayContaining([
|
||
expect.objectContaining({
|
||
authorId: "333",
|
||
exportFields: expect.objectContaining({
|
||
"21-60s报价": "¥30,000",
|
||
代表视频: "代表视频达人 C"
|
||
})
|
||
}),
|
||
expect.objectContaining({
|
||
authorId: "444",
|
||
exportFields: expect.objectContaining({
|
||
"21-60s报价": "¥40,000",
|
||
代表视频: "代表视频达人 D"
|
||
})
|
||
})
|
||
])
|
||
);
|
||
expect(onCsvReady).toHaveBeenCalledWith("csv-output");
|
||
},
|
||
15000
|
||
);
|
||
|
||
test(
|
||
"export waits for delayed rich field hydration before reading current-page rows",
|
||
async () => {
|
||
const rows = [
|
||
{
|
||
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 = `
|
||
<div data-testid="market-scroll-shell" style="overflow-y: auto; max-height: 120px;">
|
||
${buildRichExportMarketFixture(rows)}
|
||
</div>
|
||
`;
|
||
installRichLazyFieldHydrationHarness({
|
||
hiddenRowIndexes: [2, 3],
|
||
hydrateDelayMs: 350,
|
||
scrollContainer: document.querySelector(
|
||
'[data-testid="market-scroll-shell"]'
|
||
) as HTMLElement
|
||
});
|
||
|
||
const buildCsv = vi.fn(() => "csv-output");
|
||
const onCsvReady = vi.fn();
|
||
const { createMarketController } = await import("../src/content/market/index");
|
||
const controller = trackController(createMarketController({
|
||
buildCsv,
|
||
document,
|
||
loadAuthorMetrics: async () => ({
|
||
success: false,
|
||
reason: "request-failed"
|
||
}),
|
||
onCsvReady,
|
||
window
|
||
}));
|
||
|
||
await controller.ready;
|
||
setSelectValue('[data-plugin-export-range="select"]', "current");
|
||
dispatchChange('[data-plugin-export-range="select"]');
|
||
click('[data-plugin-export="button"]');
|
||
await waitForMockCall(buildCsv, 120, 50);
|
||
|
||
expect(buildCsv).toHaveBeenCalledTimes(1);
|
||
expect(buildCsv.mock.calls[0][0]).toEqual(
|
||
expect.arrayContaining([
|
||
expect.objectContaining({
|
||
authorId: "333",
|
||
exportFields: expect.objectContaining({
|
||
"21-60s报价": "¥33,000",
|
||
互动率: "7.3%",
|
||
代表视频: "代表视频达人 C",
|
||
内容主题: "内容主题达人 C",
|
||
完播率: "26.3%",
|
||
爆文率: "10%",
|
||
粉丝数: "33.3w",
|
||
达人信息: "达人 C",
|
||
达人类型: "剧情",
|
||
连接用户数: "300w",
|
||
预期CPM: "23.3",
|
||
预期播放量: "63.3w"
|
||
})
|
||
}),
|
||
expect.objectContaining({
|
||
authorId: "444",
|
||
exportFields: expect.objectContaining({
|
||
"21-60s报价": "¥44,000",
|
||
互动率: "7.4%",
|
||
代表视频: "代表视频达人 D",
|
||
内容主题: "内容主题达人 D",
|
||
完播率: "26.4%",
|
||
爆文率: "11%",
|
||
粉丝数: "44.4w",
|
||
达人信息: "达人 D",
|
||
达人类型: "测评",
|
||
连接用户数: "400w",
|
||
预期CPM: "24.4",
|
||
预期播放量: "64.4w"
|
||
})
|
||
})
|
||
])
|
||
);
|
||
expect(onCsvReady).toHaveBeenCalledWith("csv-output");
|
||
},
|
||
15000
|
||
);
|
||
|
||
test(
|
||
"default export harvests lazy fields and backend metrics across the first 5 pages",
|
||
async () => {
|
||
const pages = [
|
||
[
|
||
{ authorId: "111", authorName: "达人 A1", price21To60s: "¥11,000" },
|
||
{ authorId: "112", authorName: "达人 A2", price21To60s: "¥12,000" }
|
||
],
|
||
[
|
||
{ authorId: "221", authorName: "达人 B1", price21To60s: "¥21,000" },
|
||
{ authorId: "222", authorName: "达人 B2", price21To60s: "¥22,000" }
|
||
],
|
||
[
|
||
{ authorId: "331", authorName: "达人 C1", price21To60s: "¥31,000" },
|
||
{ authorId: "332", authorName: "达人 C2", price21To60s: "¥32,000" }
|
||
],
|
||
[
|
||
{ authorId: "441", authorName: "达人 D1", price21To60s: "¥41,000" },
|
||
{ authorId: "442", authorName: "达人 D2", price21To60s: "¥42,000" }
|
||
],
|
||
[
|
||
{ authorId: "551", authorName: "达人 E1", price21To60s: "¥51,000" },
|
||
{ authorId: "552", authorName: "达人 E2", price21To60s: "¥52,000" }
|
||
]
|
||
];
|
||
|
||
document.body.innerHTML = `
|
||
<div data-testid="market-scroll-shell" style="overflow-y: auto; max-height: 120px;">
|
||
${buildRealMarketFixture(pages[0])}
|
||
</div>
|
||
`;
|
||
const pagination = installAsyncPaginationHarness(pages);
|
||
installPagedLazyFieldHydrationHarness({
|
||
hiddenRowIndexes: [1],
|
||
hideAuthorIdentity: true,
|
||
scrollContainer: document.querySelector(
|
||
'[data-testid="market-scroll-shell"]'
|
||
) as HTMLElement
|
||
});
|
||
|
||
const buildCsv = vi.fn(() => "csv-output");
|
||
const searchBackendMetrics = vi.fn(async (starIds: string[]) =>
|
||
starIds.map((starId) => ({
|
||
a3IncreaseCount: `${starId}-a3`,
|
||
afterViewSearchCount: `${starId}-count`,
|
||
afterViewSearchRate: `${starId}-rate`,
|
||
cpSearch: `${starId}-cp-search`,
|
||
cpa3: `${starId}-cpa3`,
|
||
newA3Rate: `${starId}-new-a3`,
|
||
starId
|
||
}))
|
||
);
|
||
|
||
const { createMarketController } = await import("../src/content/market/index");
|
||
const controller = trackController(createMarketController({
|
||
buildCsv,
|
||
document,
|
||
loadAuthorMetrics: async () => ({
|
||
success: false,
|
||
reason: "request-failed"
|
||
}),
|
||
onCsvReady: vi.fn(),
|
||
searchBackendMetrics,
|
||
window
|
||
}));
|
||
|
||
await controller.ready;
|
||
setSelectValue('[data-plugin-export-range="select"]', "first-5");
|
||
dispatchChange('[data-plugin-export-range="select"]');
|
||
click('[data-plugin-export="button"]');
|
||
await waitForMockCall(buildCsv, 160, 100);
|
||
|
||
expect(pagination.getClicks()).toBe(4);
|
||
expect(buildCsv).toHaveBeenCalledTimes(1);
|
||
expect(
|
||
buildCsv.mock.calls[0][0].map((record) => record.authorId).sort()
|
||
).toEqual([
|
||
"111",
|
||
"112",
|
||
"221",
|
||
"222",
|
||
"331",
|
||
"332",
|
||
"441",
|
||
"442",
|
||
"551",
|
||
"552"
|
||
]);
|
||
expect(buildCsv.mock.calls[0][0]).toEqual(
|
||
expect.arrayContaining([
|
||
expect.objectContaining({
|
||
authorId: "112",
|
||
backendMetrics: expect.objectContaining({
|
||
afterViewSearchRate: "112-rate"
|
||
}),
|
||
exportFields: expect.objectContaining({
|
||
"21-60s报价": "¥12,000",
|
||
"达人信息": "达人 A2"
|
||
})
|
||
}),
|
||
expect.objectContaining({
|
||
authorId: "552",
|
||
backendMetrics: expect.objectContaining({
|
||
afterViewSearchRate: "552-rate"
|
||
}),
|
||
exportFields: expect.objectContaining({
|
||
"21-60s报价": "¥52,000",
|
||
"达人信息": "达人 E2"
|
||
})
|
||
})
|
||
])
|
||
);
|
||
},
|
||
20000
|
||
);
|
||
|
||
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%");
|
||
});
|
||
});
|
||
|
||
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 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 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));
|
||
}
|
||
|
||
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();
|
||
}
|
||
}
|
||
|
||
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
|
||
};
|
||
}
|