// @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 } }; } ).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="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("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("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("clicking plugin sort headers cycles sort state and syncs the toolbar", 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"]); expectSelectValue('[data-plugin-sort-field="select"]', "singleVideoAfterSearchRate"); expectSelectValue('[data-plugin-sort-direction="select"]', "desc"); 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"]); expectSelectValue('[data-plugin-sort-direction="select"]', "asc"); 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"]); expectSelectValue('[data-plugin-sort-field="select"]', ""); expectSelectValue('[data-plugin-sort-direction="select"]', "desc"); 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"]); expectSelectValue('[data-plugin-sort-field="select"]', "afterViewSearchRate"); expectSelectValue('[data-plugin-sort-direction="select"]', "desc"); }); 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 = `
${buildRealMarketFixture(rows)}
`; 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 = `
${buildRealMarketFixture(rows)}
`; 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 = `
${buildRealMarketFixture(rows)}
`; 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 = `
${buildRealMarketFixture(rows)}
`; 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 = `
${buildRichExportMarketFixture(rows)}
`; 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 = `
${buildRealMarketFixture(pages[0])}
`; 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 = `
Gamma 88000
`; 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 `
达人
21-60s报价
Alpha 450000
Beta 70000
`; } function buildRealMarketFixture( rows: Array<{ authorId: string; authorName: string; price21To60s: string; }> ) { return `
${rows .map( (row) => ` ` ) .join("")}
${rows .map( (row) => `
代表视频${row.authorName}
` ) .join("")}
${rows .map( (row) => `
${row.price21To60s}
` ) .join("")}
${rows .map( (row) => `
下单
` ) .join("")}
`; } 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 `
${rows .map( (row) => ` ` ) .join("")}
${middleColumns .map((column) => { const width = column.header === "内容主题" ? 180 : column.header === "代表视频" ? 190 : 120; return `
${rows .map( (row) => `
${column.readValue(row)}
` ) .join("")}
`; }) .join("")}
${rows .map( (row) => `
${row.price21To60s}
` ) .join("")}
${rows .map( (row) => `
下单
` ) .join("")}
`; } function buildRealMarketFixtureWithoutAuthorIds( rows: Array<{ authorName: string; price21To60s: string; }> ) { return `
${rows .map( (row) => `
${row.authorName}
` ) .join("")}
${rows .map( (_, index) => `
代表视频${index + 1}
` ) .join("")}
${rows .map( (row) => `
${row.price21To60s}
` ) .join("")}
${rows .map( () => `
下单
` ) .join("")}
`; } 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) => `
${row.authorName}
` ) .join(""); middleColumn.innerHTML = rows .map( (row) => `
代表视频${row.authorName}
` ) .join(""); (rightColumns[0] as HTMLElement).innerHTML = rows .map( (row) => `
${row.price21To60s}
` ) .join(""); (rightColumns[1] as HTMLElement).innerHTML = rows .map( (row) => `
下单
` ) .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) => `
${row.authorName}
` ) .join(""); middleColumn.innerHTML = rows .map( (row) => `
代表视频${row.authorName}
` ) .join(""); (rightColumns[0] as HTMLElement).innerHTML = rows .map( (row) => `
${row.price21To60s}
` ) .join(""); (rightColumns[1] as HTMLElement).innerHTML = rows .map( (row) => `
下单
` ) .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) => `
${row.authorName}
` ) .join(""); middleColumn.innerHTML = rows .map( (row) => `
代表视频${row.authorName}
` ) .join(""); (rightColumns[0] as HTMLElement).innerHTML = rows .map( (row) => `
${row.price21To60s}
` ) .join(""); (rightColumns[1] as HTMLElement).innerHTML = rows .map( (row) => `
下单
` ) .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) => `
${row.authorName}
` ) .join(""); middleColumn.innerHTML = rows .map( (row) => `
代表视频${row.authorName}
` ) .join(""); (rightColumns[0] as HTMLElement).innerHTML = rows .map( (row) => `
${row.price21To60s}
` ) .join(""); (rightColumns[1] as HTMLElement).innerHTML = rows .map( (row) => `
下单
` ) .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 = Array.from( document.querySelectorAll('[data-testid="author-section"] .content-column .content-cell') ) as HTMLElement[]; 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 = Array.from( document.querySelectorAll( '[data-testid="author-section"] .content-column .content-cell' ) ) as HTMLElement[]; 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 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 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() { return Array.from( document.querySelectorAll('[data-testid^="author-cell-"] a'), (link) => link.textContent?.trim() ?? "" ); } function readDivRightRowTexts(rowIndex: number) { return Array.from( document.querySelectorAll('[data-testid="right-section"] > .content-column'), (column) => column.querySelectorAll(".content-cell")[rowIndex]?.textContent?.trim() ?? "" ); } function readDivPluginRowTexts(rowIndex: number) { return Array.from( document.querySelectorAll('[data-testid="plugin-section"] > .content-column'), (column) => column.querySelectorAll(".content-cell")[rowIndex]?.textContent?.trim() ?? "" ); } function trackController 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(); } }