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

4929 lines
151 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @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"]') as HTMLButtonElement | null)
?.hidden
).toBe(true);
expect(
document.querySelector('[data-plugin-export-audience-profile="button"]')
).not.toBeNull();
expect(
document.querySelector('[data-plugin-export-audience-profile-by-id="button"]')
).not.toBeNull();
expect(document.querySelector('[data-plugin-batch-submit="button"]')).not.toBeNull();
expect(document.querySelector('[data-plugin-export-status="text"]')).not.toBeNull();
const batchSubmitButton = document.querySelector(
'[data-plugin-batch-submit="button"]'
) as HTMLButtonElement | null;
const audienceProfileExportButton = document.querySelector(
'[data-plugin-export-audience-profile="button"]'
) as HTMLButtonElement | null;
const audienceProfileByIdExportButton = document.querySelector(
'[data-plugin-export-audience-profile-by-id="button"]'
) as HTMLButtonElement | null;
const audienceProfileFieldButton = document.querySelector(
'[data-plugin-audience-profile-fields="button"]'
) as HTMLButtonElement | null;
expect(audienceProfileExportButton?.textContent).toBe("导出选中达人数据");
expect(audienceProfileExportButton?.title).toBe(
"仅导出已勾选达人,包含内容数据、效果预估、画像等维度"
);
expect(audienceProfileByIdExportButton?.textContent).toBe("按星图ID导出");
expect(audienceProfileByIdExportButton?.title).toBe(
"粘贴达人星图ID后批量导出达人数据不依赖当前列表勾选"
);
expect(audienceProfileFieldButton?.textContent).toBe("选择字段");
expect(audienceProfileFieldButton?.title).toBe(
"勾选本次CSV需要导出的字段设置会自动保存"
);
expect(batchSubmitButton?.title).toBe("将当前选中的达人提交到后续业务批次");
expect(batchSubmitButton?.style.backgroundColor).toBe("rgb(127, 29, 45)");
expect(audienceProfileExportButton?.style.backgroundColor).toBe("rgb(127, 29, 45)");
expect(audienceProfileByIdExportButton?.style.backgroundColor).toBe("rgb(127, 29, 45)");
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
};
}