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

2180 lines
64 KiB
TypeScript

// @vitest-environment jsdom
// @vitest-environment-options {"url":"https://xingtu.cn/"}
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { createMarketResultStore } from "../src/content/market/result-store";
const disposers: Array<() => void> = [];
describe("market-content-entry", () => {
beforeEach(() => {
document.body.innerHTML = "";
document.documentElement.removeAttribute("data-sces-market-rows");
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__;
document.documentElement.removeAttribute("data-sces-market-rows");
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("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 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",
type: "download-market-csv"
})
);
});
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="backendMetrics"]')?.textContent
).toContain("看后搜率");
expect(
document.querySelector('[data-market-row-cell="backendMetrics"]')?.textContent
).toContain("0.36%");
expect(
document.querySelectorAll('[data-market-row-cell="backendMetrics"]')[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",
"0.02% - 0.1%",
"0.03% - 0.2%",
"",
"下单"
]);
expect(readDivRightRowTexts(1)).toEqual([
"¥20,000",
"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",
"0.02%",
"0.03% - 0.2%",
"",
"下单"
]);
expect(readDivRightRowTexts(1)).toEqual([
"¥20,000",
"0.5% - 1%",
"0.01% - 0.1%",
"",
"下单"
]);
});
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",
"0.02%",
"0.03% - 0.2%",
"",
"下单"
]);
expect(readDivRightRowTexts(1)).toEqual([
"¥20,000",
"0.5% - 1%",
"0.01% - 0.1%",
"",
"下单"
]);
});
test("applying plugin filters hides non-matching current-page rows without a full scan", async () => {
document.body.innerHTML = buildMarketFixture();
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("a", {
singleVideoAfterSearchRate: "0.02% - 0.1%",
personalVideoAfterSearchRate: "0.03% - 0.2%"
});
resultStore.setAuthorSuccess("b", {
singleVideoAfterSearchRate: "0.5%-1%",
personalVideoAfterSearchRate: "0.01% - 0.1%"
});
setInputValue('[data-plugin-filter-single="input"]', "0.1");
click('[data-plugin-filter-apply="button"]');
await flush();
expect(
document.querySelector('[data-market-row="a"]')?.hasAttribute("hidden")
).toBe(true);
expect(
document.querySelector('[data-market-row="b"]')?.hasAttribute("hidden")
).toBe(false);
});
test("applying plugin sorting reorders the current page without triggering a full scan", async () => {
document.body.innerHTML = buildMarketFixture();
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("a", {
singleVideoAfterSearchRate: "0.02% - 0.1%",
personalVideoAfterSearchRate: "0.03% - 0.2%"
});
resultStore.setAuthorSuccess("b", {
singleVideoAfterSearchRate: "0.5%-1%",
personalVideoAfterSearchRate: "0.01% - 0.1%"
});
setSelectValue('[data-plugin-sort-field="select"]', "singleVideoAfterSearchRate");
setSelectValue('[data-plugin-sort-direction="select"]', "desc");
click('[data-plugin-sort-apply="button"]');
await flush();
expect(readRowOrder()).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%"
});
setSelectValue('[data-plugin-sort-field="select"]', "singleVideoAfterSearchRate");
setSelectValue('[data-plugin-sort-direction="select"]', "desc");
click('[data-plugin-sort-apply="button"]');
await flush();
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)).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 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 toolbar 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 = 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;
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);
expectButtonDisabled('[data-plugin-filter-apply="button"]', true);
expectButtonDisabled('[data-plugin-sort-apply="button"]', true);
expectSelectDisabled('[data-plugin-export-range="select"]', true);
expect(
document.querySelector('[data-plugin-export-status="text"]')?.textContent
).toContain("导出中");
await waitForMockCall(buildCsv, 80, 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([
"111",
"222",
"333"
]);
});
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("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({
batchId: expect.stringContaining("618达人筛选第一批-"),
batchName: "618达人筛选第一批",
logtoUserId: "p7pdhhtde8kj"
})
);
});
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 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("rehydrates rows when the market list DOM changes", async () => {
document.body.innerHTML = buildMarketFixture();
const observer = createMutationObserverFactory();
const loadAuthorMetrics = vi.fn(async (authorId: string) => ({
success: true as const,
rates:
authorId === "a"
? {
singleVideoAfterSearchRate: "0.02% - 0.1%",
personalVideoAfterSearchRate: "0.03% - 0.2%"
}
: {
singleVideoAfterSearchRate: "0.8%-1%",
personalVideoAfterSearchRate: "0.05% - 0.2%"
}
}));
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
loadAuthorMetrics,
mutationObserverFactory: observer.factory,
window
}));
await controller.ready;
document.querySelector("[data-market-body]")!.innerHTML = `
<div data-market-row="c" data-author-id="c">
<span data-market-field="authorName">Gamma</span>
<span data-market-field="price21To60s">88000</span>
</div>
`;
observer.trigger();
await flushWithTimers();
await flushWithTimers();
expect(loadAuthorMetrics.mock.calls.map(([authorId]) => authorId)).toEqual(
expect.arrayContaining(["a", "c"])
);
expect(readRowOrder()).toEqual(["c"]);
expect(
document.querySelector('[data-market-row-cell="singleVideoAfterSearchRate"]')
?.textContent
).toBe("0.8% - 1%");
});
test("applying a filter on the real market view stays on the current page", async () => {
const pages = [
[
{
authorId: "111",
authorName: "达人 A",
price21To60s: "¥450,000"
}
],
[
{
authorId: "222",
authorName: "达人 B",
price21To60s: "¥20,000"
}
]
];
document.body.innerHTML = buildRealMarketFixture(pages[0]);
const pagination = installPaginationHarness(pages);
const loadAuthorMetrics = vi.fn(async (authorId: string) => ({
success: true as const,
rates:
authorId === "111"
? {
singleVideoAfterSearchRate: "0.02% - 0.1%",
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;
setInputValue('[data-plugin-filter-single="input"]', "0.1");
click('[data-plugin-filter-apply="button"]');
await flush();
await flush();
expect(pagination.getClicks()).toBe(0);
expect(loadAuthorMetrics.mock.calls.map(([authorId]) => authorId)).not.toContain(
"222"
);
});
});
function buildMarketFixture() {
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 buildRealMarketFixture(
rows: Array<{
authorId: string;
authorName: string;
price21To60s: string;
}>
) {
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: 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 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 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 = document.querySelector(
'[data-testid="author-section"] .content-column'
) as HTMLElement | null;
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 = document.querySelector(
'[data-testid="author-section"] .content-column'
) as HTMLElement | null;
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 = document.querySelector(
'[data-testid="author-section"] .content-column'
) as HTMLElement | null;
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 = document.querySelector(
'[data-testid="author-section"] .content-column'
) as HTMLElement | null;
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 = document.querySelector(
'[data-testid="author-section"] .content-column'
) as HTMLElement | null;
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: {
hiddenRowIndexes: number[];
hydrateDelayMs?: number;
scrollContainer: HTMLElement;
}) {
const { hiddenRowIndexes, hydrateDelayMs = 0, scrollContainer } = options;
const rightColumns = document.querySelectorAll(
'[data-testid="right-section"] > .content-column'
);
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 middleCell = middleCells[rowIndex] ?? null;
const priceCell = priceCells[rowIndex] ?? null;
return [middleCell, priceCell]
.filter((cell): cell is HTMLElement => cell !== null)
.map((cell) => ({
cell,
text: cell.textContent ?? ""
}));
});
hiddenCells.forEach(({ cell }) => {
cell.textContent = "";
});
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, text }) => {
cell.textContent = text;
});
}, hydrateDelayMs);
}
});
}
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 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 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 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 readDivRightRowTexts(rowIndex: number) {
return Array.from(
document.querySelectorAll('[data-testid="right-section"] > .content-column'),
(column) =>
column.querySelectorAll(".content-cell")[rowIndex]?.textContent?.trim() ?? ""
);
}
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();
}
}