// @vitest-environment jsdom // @vitest-environment-options {"url":"https://xingtu.cn/"} import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { createMarketResultStore } from "../src/content/market/result-store"; import { createFavoritesRepository, type FavoritesStorage } from "../src/content/market/favorites-store"; import type { SpreadInfoConfig } from "../src/content/market/types"; const disposers: Array<() => void> = []; describe("market-content-entry", () => { beforeEach(() => { installUsableLocalStorage(); document.body.innerHTML = ""; document.documentElement.removeAttribute("data-sces-market-rows"); document.documentElement.removeAttribute("data-sces-market-request-snapshot"); document.documentElement.removeAttribute("data-test-page-index"); clearLocalStorage(); window.history.replaceState({}, "", "/"); }); afterEach(() => { document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); vi.doUnmock("../src/content/market/index"); delete ( globalThis as typeof globalThis & { chrome?: unknown; } ).chrome; delete ( window as Window & { __starChartSearchEnhancerContentController?: unknown; __SCES_MARKET_PAGE_BRIDGE_INSTALLED__?: boolean; } ).__starChartSearchEnhancerContentController; delete ( window as Window & { __SCES_MARKET_PAGE_BRIDGE_INSTALLED__?: boolean; } ).__SCES_MARKET_PAGE_BRIDGE_INSTALLED__; delete ( globalThis as typeof globalThis & { fetch?: unknown; } ).fetch; document.documentElement.removeAttribute("data-sces-market-rows"); document.documentElement.removeAttribute("data-sces-market-request-snapshot"); document.documentElement.removeAttribute("data-test-page-index"); vi.resetModules(); while (disposers.length > 0) { disposers.pop()?.(); } }); test("auto boots on import when chrome runtime is available", async () => { const createMarketController = vi.fn(() => ({ ready: Promise.resolve() })); const sendMessage = vi.fn(async () => ({ ok: true, type: "auth:state", value: { isAuthenticated: true } })); window.history.replaceState({}, "", "/ad/creator/market"); ( globalThis as typeof globalThis & { chrome?: { runtime?: { sendMessage?: (message: unknown) => Promise } }; } ).chrome = { runtime: { sendMessage } }; vi.doMock("../src/content/market/index", () => ({ createMarketController })); await import("../src/content/index"); expect(createMarketController).toHaveBeenCalledTimes(1); }); test("boots the market controller on the Xingtu market URL", async () => { const createMarketController = vi.fn(() => ({ ready: Promise.resolve() })); window.history.replaceState({}, "", "/ad/creator/market"); const { bootContentScript } = await import("../src/content/index"); await bootContentScript({ createMarketController, sendAuthMessage: vi.fn(async () => ({ ok: true, type: "auth:state", value: { isAuthenticated: true } })) }); expect(createMarketController).toHaveBeenCalledTimes(1); expect( document.documentElement.querySelector('[data-sces-market-bridge="script"]') ).not.toBeNull(); }); test("installs the market bridge before auth state resolves", async () => { const createMarketController = vi.fn(() => ({ ready: Promise.resolve() })); let resolveAuthState: ((value: unknown) => void) | null = null; window.history.replaceState({}, "", "/ad/creator/market"); const { bootContentScript } = await import("../src/content/index"); const bootPromise = bootContentScript({ createMarketController, sendAuthMessage: vi.fn( () => new Promise((resolve) => { resolveAuthState = resolve; }) ) }); expect( document.documentElement.querySelector('[data-sces-market-bridge="script"]') ).not.toBeNull(); expect(createMarketController).not.toHaveBeenCalled(); resolveAuthState?.({ ok: true, type: "auth:state", value: { isAuthenticated: true } }); await bootPromise; expect(createMarketController).toHaveBeenCalledTimes(1); }); test("boots the market controller on the www Xingtu market URL", async () => { const createMarketController = vi.fn(() => ({ ready: Promise.resolve() })); const { bootContentScript } = await import("../src/content/index"); await bootContentScript({ createMarketController, document, sendAuthMessage: vi.fn(async () => ({ ok: true, type: "auth:state", value: { isAuthenticated: true } })), window: { location: { href: "https://www.xingtu.cn/ad/creator/market" } } as Window }); expect(createMarketController).toHaveBeenCalledTimes(1); }); test("booted export callback downloads the generated csv file", async () => { const createMarketController = vi.fn(() => ({ ready: Promise.resolve() })); const createObjectURL = vi.fn(() => "blob:test-url"); const revokeObjectURL = vi.fn(); let clickedDownload: { download: string; href: string } | null = null; const clickSpy = vi .spyOn(HTMLAnchorElement.prototype, "click") .mockImplementation(function (this: HTMLAnchorElement) { clickedDownload = { download: this.download, href: this.href }; }); window.history.replaceState({}, "", "/ad/creator/market"); Object.defineProperty(window.URL, "createObjectURL", { configurable: true, value: createObjectURL }); Object.defineProperty(window.URL, "revokeObjectURL", { configurable: true, value: revokeObjectURL }); const { bootContentScript } = await import("../src/content/index"); await bootContentScript({ createMarketController, sendAuthMessage: vi.fn(async () => ({ ok: true, type: "auth:state", value: { isAuthenticated: true } })) }); const controllerOptions = createMarketController.mock.calls[0]?.[0]; expect(controllerOptions?.onCsvReady).toEqual(expect.any(Function)); controllerOptions.onCsvReady("列1,列2\n值1,值2"); expect(createObjectURL).toHaveBeenCalledTimes(1); expect(clickSpy).toHaveBeenCalledTimes(1); expect(clickedDownload).not.toBeNull(); expect(clickedDownload?.href).toBe("blob:test-url"); expect(clickedDownload?.download).toMatch(/\.csv$/); expect(revokeObjectURL).toHaveBeenCalledWith("blob:test-url"); }); test("booted export callback can download a custom csv filename", async () => { const createMarketController = vi.fn(() => ({ ready: Promise.resolve() })); let clickedDownload: { download: string; href: string } | null = null; vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function ( this: HTMLAnchorElement ) { clickedDownload = { download: this.download, href: this.href }; }); window.history.replaceState({}, "", "/ad/creator/market"); Object.defineProperty(window.URL, "createObjectURL", { configurable: true, value: vi.fn(() => "blob:test-url") }); Object.defineProperty(window.URL, "revokeObjectURL", { configurable: true, value: vi.fn() }); const { bootContentScript } = await import("../src/content/index"); await bootContentScript({ createMarketController, sendAuthMessage: vi.fn(async () => ({ ok: true, type: "auth:state", value: { isAuthenticated: true } })) }); const controllerOptions = createMarketController.mock.calls[0]?.[0]; controllerOptions.onCsvReady("列1,列2\n值1,值2", "达人连接用户画像_20260518_1530.csv"); expect(clickedDownload?.download).toBe("达人连接用户画像_20260518_1530.csv"); }); test("booted export callback sends the csv to extension runtime when available", async () => { const createMarketController = vi.fn(() => ({ ready: Promise.resolve() })); const sendMessage = vi.fn(); window.history.replaceState({}, "", "/ad/creator/market"); ( globalThis as typeof globalThis & { chrome?: { runtime?: { id?: string; sendMessage?: (message: unknown) => void } }; } ).chrome = { runtime: { id: "test-extension", sendMessage } }; const { bootContentScript } = await import("../src/content/index"); sendMessage.mockClear(); await bootContentScript({ createMarketController, sendAuthMessage: vi.fn(async () => ({ ok: true, type: "auth:state", value: { isAuthenticated: true } })) }); const controllerOptions = createMarketController.mock.calls[0]?.[0]; controllerOptions.onCsvReady("列1,列2\n值1,值2"); expect(sendMessage).toHaveBeenCalledTimes(1); expect(sendMessage).toHaveBeenCalledWith( expect.objectContaining({ csv: "列1,列2\n值1,值2", filename: expect.stringMatching(/^star-chart-search-enhancer-/), type: "download-market-csv" }) ); }); test("renders the plugin action bar inside the native market action row", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "a", authorName: "Alpha", price21To60s: "450000" }, { authorId: "b", authorName: "Beta", price21To60s: "70000" } ]); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), window })); await controller.ready; const toolbar = document.querySelector('[data-plugin-toolbar="root"]'); const actionRow = document.querySelector('[data-testid="market-native-actions"]'); const customizeButton = document.querySelector('[data-testid="market-native-customize"]'); const nativeExportButton = document.querySelector('[data-testid="market-native-export"]'); expect(toolbar).not.toBeNull(); expect(actionRow).not.toBeNull(); expect(toolbar?.parentElement).toBe(actionRow); expect(toolbar?.nextElementSibling).toBe(customizeButton); expect(customizeButton?.nextElementSibling).toBe(nativeExportButton); expect(document.querySelector('[data-plugin-filter-apply="button"]')).toBeNull(); expect(document.querySelector('[data-plugin-sort-apply="button"]')).toBeNull(); expect(document.querySelector('[data-plugin-filter-single="input"]')).toBeNull(); expect(document.querySelector('[data-plugin-sort-field="select"]')).toBeNull(); expect(document.body.firstElementChild).not.toBe(toolbar); expect( (document.querySelector('[data-plugin-export-range="select"]') as HTMLSelectElement | null) ?.hidden ).toBe(false); expect( ( document.querySelector( '[data-plugin-export-custom-pages="input"]' ) as HTMLInputElement | null )?.hidden ).toBe(true); expect( (document.querySelector('[data-plugin-export="button"]') as HTMLButtonElement | null) ?.hidden ).toBe(false); expect( document.querySelector('[data-plugin-export-audience-profile="button"]') ).not.toBeNull(); expect( document.querySelector('[data-plugin-export-audience-profile-by-id="button"]') ).not.toBeNull(); expect(document.querySelector('[data-plugin-batch-submit="button"]')).not.toBeNull(); expect(document.querySelector('[data-plugin-export-status="text"]')).not.toBeNull(); const batchSubmitButton = document.querySelector( '[data-plugin-batch-submit="button"]' ) as HTMLButtonElement | null; const audienceProfileExportButton = document.querySelector( '[data-plugin-export-audience-profile="button"]' ) as HTMLButtonElement | null; const audienceProfileByIdExportButton = document.querySelector( '[data-plugin-export-audience-profile-by-id="button"]' ) as HTMLButtonElement | null; const audienceProfileFieldButton = document.querySelector( '[data-plugin-audience-profile-fields="button"]' ) as HTMLButtonElement | null; const exportButton = document.querySelector( '[data-plugin-export="button"]' ) as HTMLButtonElement | null; expect(audienceProfileExportButton?.textContent).toBe("导出选中达人数据"); expect(audienceProfileExportButton?.title).toBe( "仅导出已勾选达人,包含内容数据、效果预估、画像等维度" ); expect(audienceProfileByIdExportButton?.textContent).toBe("按星图ID导出"); expect(audienceProfileByIdExportButton?.title).toBe( "粘贴达人星图ID后批量导出达人数据,不依赖当前列表勾选" ); expect(audienceProfileFieldButton?.textContent).toBe("选择字段"); expect(audienceProfileFieldButton?.title).toBe( "勾选本次CSV需要导出的字段,设置会自动保存" ); expect(batchSubmitButton?.title).toBe("将当前选中的达人提交到后续业务批次"); expect(batchSubmitButton?.style.backgroundColor).toBe("rgb(127, 29, 45)"); expect(batchSubmitButton?.style.color).toBe("rgb(255, 255, 255)"); expect([ exportButton?.style.backgroundColor, audienceProfileExportButton?.style.backgroundColor, audienceProfileByIdExportButton?.style.backgroundColor, audienceProfileFieldButton?.style.backgroundColor ]).toEqual([ "rgb(255, 255, 255)", "rgb(255, 255, 255)", "rgb(255, 255, 255)", "rgb(255, 255, 255)" ]); expect([ exportButton?.style.color, audienceProfileExportButton?.style.color, audienceProfileByIdExportButton?.style.color, audienceProfileFieldButton?.style.color ]).toEqual([ "rgb(52, 64, 84)", "rgb(52, 64, 84)", "rgb(52, 64, 84)", "rgb(52, 64, 84)" ]); }); test("renders the approved compact toolbar layout from the preview", async () => { document.body.innerHTML = buildMarketFixture(); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), window })); await controller.ready; const toolbar = document.querySelector( '[data-plugin-toolbar="root"]' ) as HTMLElement | null; const panel = document.querySelector( '[data-plugin-toolbar-panel="root"]' ) as HTMLElement | null; const primaryRow = document.querySelector( '[data-plugin-toolbar-row="primary"]' ) as HTMLElement | null; const thresholdRow = document.querySelector( '[data-plugin-toolbar-row="thresholds"]' ) as HTMLElement | null; const dataGroup = document.querySelector( '[data-plugin-toolbar-group="data"]' ) as HTMLElement | null; const csvExportGroup = document.querySelector( '[data-plugin-toolbar-action-group="csv-export"]' ) as HTMLElement | null; const selectedAudienceExportGroup = document.querySelector( '[data-plugin-toolbar-action-group="selected-audience-export"]' ) as HTMLElement | null; const idExportGroup = document.querySelector( '[data-plugin-toolbar-action-group="id-export"]' ) as HTMLElement | null; const batchSubmitGroup = document.querySelector( '[data-plugin-toolbar-action-group="batch-submit"]' ) as HTMLElement | null; const metricCatalog = document.querySelector( '[data-plugin-spread-metric-catalog="root"]' ) as HTMLElement | null; const rulesGroup = document.querySelector( '[data-plugin-spread-rules="root"]' ) as HTMLElement | null; const statusText = document.querySelector( '[data-plugin-export-status="text"]' ) as HTMLElement | null; const titles = Array.from( document.querySelectorAll("[data-plugin-toolbar-title]") ) as HTMLElement[]; const exportButton = document.querySelector( '[data-plugin-export="button"]' ) as HTMLButtonElement | null; const exportRangeSelect = document.querySelector( '[data-plugin-export-range="select"]' ) as HTMLSelectElement | null; const exportCustomPagesInput = document.querySelector( '[data-plugin-export-custom-pages="input"]' ) as HTMLInputElement | null; const audienceProfileExportButton = document.querySelector( '[data-plugin-export-audience-profile="button"]' ) as HTMLButtonElement | null; const audienceProfileByIdExportButton = document.querySelector( '[data-plugin-export-audience-profile-by-id="button"]' ) as HTMLButtonElement | null; const audienceProfileFieldButton = document.querySelector( '[data-plugin-audience-profile-fields="button"]' ) as HTMLButtonElement | null; const batchSubmitButton = document.querySelector( '[data-plugin-batch-submit="button"]' ) as HTMLButtonElement | null; const operators = Array.from( document.querySelectorAll("[data-plugin-spread-threshold-operator]") ).map((element) => element.textContent); const ruleRows = Array.from( document.querySelectorAll("[data-plugin-spread-rule]") ) as HTMLElement[]; const thresholdInputs = Array.from( document.querySelectorAll("[data-plugin-spread-threshold]") ) as HTMLInputElement[]; expect(toolbar?.style.flexWrap).toBe("nowrap"); expect(panel?.style.flexDirection).toBe("column"); expect(panel?.style.alignItems).toBe("center"); expect(primaryRow?.style.flexWrap).toBe("nowrap"); expect(thresholdRow?.style.flexWrap).toBe("nowrap"); expect(thresholdRow?.style.alignItems).toBe("center"); expect(panel?.style.overflowX).toBe("visible"); expect(panel?.style.overflowY).toBe("visible"); expect(dataGroup?.parentElement).toBe(primaryRow); expect(csvExportGroup?.parentElement).toBe(dataGroup); expect(selectedAudienceExportGroup?.parentElement).toBe(dataGroup); expect(idExportGroup?.parentElement).toBe(dataGroup); expect(batchSubmitGroup?.parentElement).toBe(dataGroup); expect(Array.from(csvExportGroup?.children ?? [])).toEqual([ exportButton, exportRangeSelect, exportCustomPagesInput ]); expect(Array.from(selectedAudienceExportGroup?.children ?? [])).toEqual([ audienceProfileExportButton, audienceProfileFieldButton ]); expect(Array.from(idExportGroup?.children ?? [])).toEqual([ audienceProfileByIdExportButton ]); expect(Array.from(batchSubmitGroup?.children ?? [])).toEqual([ batchSubmitButton ]); expect(statusText?.parentElement).toBe(primaryRow); expect(metricCatalog?.parentElement).toBe(thresholdRow); expect(rulesGroup?.parentElement).toBe(thresholdRow); expect(rulesGroup?.style.flexDirection).toBe("column"); expect(primaryRow?.style.justifyContent).toBe("flex-start"); expect(thresholdRow?.style.justifyContent).toBe("flex-start"); expect(titles.map((element) => element.textContent)).toEqual([ "传播指标筛选" ]); expect(titles[0]?.style.background).toBe("rgb(238, 245, 255)"); expect(operators).toEqual(["≥", "≥"]); expect( document.querySelector('[data-plugin-spread-metric-catalog-trigger="button"]') ?.textContent ).toBe("添加筛选指标"); expect( document.querySelector('[data-plugin-spread-metric-selected-count="text"]') ?.textContent ).toBe("已选 1 项"); expect( (document.querySelector( '[data-plugin-spread-metric-catalog-panel="root"]' ) as HTMLElement | null)?.hidden ).toBe(true); expect(ruleRows.map((row) => row.hidden)).toEqual([false, true]); expect(thresholdInputs.map((input) => input.placeholder)).toEqual([ "", "" ]); expect(thresholdInputs.map((input) => input.step)).toEqual([ "0.1", "0.1" ]); expect([ exportButton?.textContent, audienceProfileExportButton?.textContent, audienceProfileByIdExportButton?.textContent, audienceProfileFieldButton?.textContent, batchSubmitButton?.textContent ]).toEqual([ "导出CSV", "导出选中达人数据", "按星图ID导出", "选择字段", "提交批次" ]); expect([ exportButton?.style.backgroundColor, audienceProfileExportButton?.style.backgroundColor, audienceProfileByIdExportButton?.style.backgroundColor, audienceProfileFieldButton?.style.backgroundColor, batchSubmitButton?.style.backgroundColor ]).toEqual([ "rgb(255, 255, 255)", "rgb(255, 255, 255)", "rgb(255, 255, 255)", "rgb(255, 255, 255)", "rgb(127, 29, 45)" ]); expect([ exportButton?.style.color, audienceProfileExportButton?.style.color, audienceProfileByIdExportButton?.style.color, audienceProfileFieldButton?.style.color, batchSubmitButton?.style.color ]).toEqual([ "rgb(52, 64, 84)", "rgb(52, 64, 84)", "rgb(52, 64, 84)", "rgb(52, 64, 84)", "rgb(255, 255, 255)" ]); }); test("remounts the plugin action bar when the native market action row appears later", async () => { document.body.innerHTML = buildMarketTableOnlyFixture(); const observer = createMutationObserverFactory(); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), mutationObserverFactory: observer.factory, window })); await controller.ready; const toolbar = document.querySelector('[data-plugin-toolbar="root"]'); expect(toolbar).not.toBeNull(); expect(toolbar?.parentElement).toBe(document.body); expect((toolbar as HTMLElement | null)?.hidden).toBe(true); document.body.insertAdjacentHTML("afterbegin", buildMarketPageShell("")); observer.trigger(); await flushWithTimers(); await flushWithTimers(); const actionRow = document.querySelector('[data-testid="market-native-actions"]'); expect(toolbar?.parentElement).toBe(actionRow); expect((toolbar as HTMLElement | null)?.hidden).toBe(false); }); test("selection keeps a clicked creator checked after the table re-renders", async () => { document.body.innerHTML = buildMarketFixture(); const mutationObserver = createMutationObserverFactory(); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), mutationObserverFactory: mutationObserver.factory, window })); await controller.ready; clickSelectionCheckboxForAuthor("a"); expect(readSelectionCheckboxForAuthor("a").checked).toBe(true); const table = document.querySelector("[data-market-table]"); if (!(table instanceof HTMLElement)) { throw new Error("Missing market table"); } table.outerHTML = buildMarketTableOnlyFixture(); mutationObserver.trigger(); await flushWithTimers(); expect(readSelectionCheckboxForAuthor("a").checked).toBe(true); }); test("selection survives a page change and re-render", async () => { const pages = [ [ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }, { authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" } ], [ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }, { authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" } ] ]; document.body.innerHTML = buildRealMarketFixture(pages[0]); installAsyncPaginationHarness(pages); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), window })); await controller.ready; clickSelectionCheckboxForAuthor("111"); click('[data-testid="next-page"]'); await flushWithTimers(); expect(readSelectionCheckboxForAuthor("111").checked).toBe(true); expect(readSelectionCheckboxForAuthor("333").checked).toBe(false); }); test("selection header selects all visible creators on the current page", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }, { authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" } ]); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), window })); await controller.ready; clickHeaderSelectionCheckbox(); expect(readSelectionCheckboxForAuthor("111").checked).toBe(true); expect(readSelectionCheckboxForAuthor("222").checked).toBe(true); }); test("selection header clears all visible creators on the current page", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }, { authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" } ]); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), window })); await controller.ready; clickHeaderSelectionCheckbox(); clickHeaderSelectionCheckbox(); expect(readSelectionCheckboxForAuthor("111").checked).toBe(false); expect(readSelectionCheckboxForAuthor("222").checked).toBe(false); }); test("selection header becomes indeterminate when only part of the current page is selected", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }, { authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" } ]); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), window })); await controller.ready; clickSelectionCheckboxForAuthor("111"); expect(readHeaderSelectionCheckbox().checked).toBe(false); expect(readHeaderSelectionCheckbox().indeterminate).toBe(true); }); test("hydrates current page rows on start", async () => { document.body.innerHTML = buildMarketFixture(); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, loadAuthorMetrics: async (authorId) => ({ success: true, rates: authorId === "a" ? { singleVideoAfterSearchRate: "0.02% - 0.1%", personalVideoAfterSearchRate: "0.03% - 0.2%" } : { singleVideoAfterSearchRate: "0.5%-1%", personalVideoAfterSearchRate: "0.01% - 0.1%" } }), window })); await controller.ready; expect( document.querySelector('[data-market-row-cell="singleVideoAfterSearchRate"]') ?.textContent ).toBe("0.02% - 0.1%"); expect( document.querySelector('[data-market-row-cell="personalVideoAfterSearchRate"]') ?.textContent ).toBe("0.03% - 0.2%"); }); test("batch loads backend metrics for the visible page and renders the metrics panel", async () => { document.body.innerHTML = buildMarketFixture(); const searchBackendMetrics = vi.fn(async (starIds: string[]) => starIds .filter((starId) => starId === "a") .map((starId) => ({ a3IncreaseCount: "78,366.22", afterViewSearchCount: "9,689.96", afterViewSearchRate: "0.36%", cpSearch: "14.46", cpa3: "1.79", newA3Rate: "3.44%", starId })) ); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), searchBackendMetrics, window })); await controller.ready; expect(searchBackendMetrics).toHaveBeenCalledTimes(1); expect(searchBackendMetrics).toHaveBeenCalledWith(["a", "b"]); expect( document.querySelector('[data-market-row-cell="afterViewSearchRate"]')?.textContent ).toBe("0.36%"); expect( document.querySelector('[data-market-row-cell="cpSearch"]')?.textContent ).toBe("14.46"); expect( document.querySelectorAll('[data-market-row-cell="afterViewSearchRate"]')[1]?.textContent ).toBe("暂无数据"); }); test("boots the controller only after auth succeeds", async () => { const createMarketController = vi.fn(() => ({ ready: Promise.resolve() })); window.history.replaceState({}, "", "/ad/creator/market"); const { bootContentScript } = await import("../src/content/index"); await bootContentScript({ createMarketController, document, sendAuthMessage: vi.fn(async () => ({ ok: true, type: "auth:state", value: { isAuthenticated: true } })), window }); expect(createMarketController).toHaveBeenCalledTimes(1); }); test("hydrates the real div-grid market rows on start", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥450,000" }, { authorId: "222", authorName: "达人 B", price21To60s: "¥20,000" } ]); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, loadAuthorMetrics: async (authorId) => ({ success: true, rates: authorId === "111" ? { singleVideoAfterSearchRate: "0.02% - 0.1%", personalVideoAfterSearchRate: "0.03% - 0.2%" } : { singleVideoAfterSearchRate: "0.5%-1%", personalVideoAfterSearchRate: "0.01% - 0.1%" } }), window })); await controller.ready; expect(readDivRightRowTexts(0)).toEqual(["¥450,000", "下单"]); expect(readDivPluginRowTexts(0)).toEqual([ "0.02% - 0.1%", "0.03% - 0.2%", "", "", "", "", "", "" ]); expect(readDivRightRowTexts(1)).toEqual(["¥20,000", "下单"]); expect(readDivPluginRowTexts(1)).toEqual([ "0.5% - 1%", "0.01% - 0.1%", "", "", "", "", "", "" ]); }); test("uses the market list single-rate directly and still loads the missing personal rate", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥450,000" }, { authorId: "222", authorName: "达人 B", price21To60s: "¥20,000" } ]); attachMarketListState([ { attribute_datas: { avg_search_after_view_rate_30d: "0.0002", nickname: "达人 A" }, star_id: "111" }, { attribute_datas: { nickname: "达人 B" }, star_id: "222" } ]); const loadAuthorMetrics = vi.fn(async (authorId: string) => ({ success: true as const, rates: authorId === "111" ? { singleVideoAfterSearchRate: "0.02%", personalVideoAfterSearchRate: "0.03% - 0.2%" } : { singleVideoAfterSearchRate: "0.5%-1%", personalVideoAfterSearchRate: "0.01% - 0.1%" } })); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, loadAuthorMetrics, window })); await controller.ready; expect(loadAuthorMetrics).toHaveBeenCalledTimes(2); expect(readDivRightRowTexts(0)).toEqual(["¥450,000", "下单"]); expect(readDivPluginRowTexts(0)).toEqual([ "0.02%", "0.03% - 0.2%", "", "", "", "", "", "" ]); expect(readDivRightRowTexts(1)).toEqual(["¥20,000", "下单"]); expect(readDivPluginRowTexts(1)).toEqual([ "0.5% - 1%", "0.01% - 0.1%", "", "", "", "", "", "" ]); }); test("keeps all plugin columns in loading state until backend metrics are ready", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥450,000" }, { authorId: "222", authorName: "达人 B", price21To60s: "¥20,000" } ]); const backendDeferred = createDeferred< Array<{ afterViewSearchRate: string; starId: string; }> >(); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, loadAuthorMetrics: async (authorId) => ({ success: true, rates: authorId === "111" ? { singleVideoAfterSearchRate: "0.02%", personalVideoAfterSearchRate: "0.03% - 0.2%" } : { singleVideoAfterSearchRate: "0.5%-1%", personalVideoAfterSearchRate: "0.01% - 0.1%" } }), searchBackendMetrics: () => backendDeferred.promise, window })); await flushWithTimers(); expect(readDivPluginRowTexts(0)).toEqual([ "加载中...", "加载中...", "加载中...", "加载中...", "加载中...", "加载中...", "加载中...", "加载中..." ]); expect(readDivPluginRowTexts(1)).toEqual([ "加载中...", "加载中...", "加载中...", "加载中...", "加载中...", "加载中...", "加载中...", "加载中..." ]); backendDeferred.resolve([ { afterViewSearchRate: "0.36%", starId: "111" }, { afterViewSearchRate: "1.4%", starId: "222" } ]); await controller.ready; expect(readDivPluginRowTexts(0)).toEqual([ "0.02%", "0.03% - 0.2%", "0.36%", "", "", "", "", "" ]); expect(readDivPluginRowTexts(1)).toEqual([ "0.5% - 1%", "0.01% - 0.1%", "1.4%", "", "", "", "", "" ]); }); test("hydrates real rows from serialized market rows when vue state is unavailable", async () => { document.body.innerHTML = buildRealMarketFixtureWithoutAuthorIds([ { authorName: "达人 A", price21To60s: "¥450,000" }, { authorName: "达人 B", price21To60s: "¥20,000" } ]); document.documentElement.setAttribute( "data-sces-market-rows", JSON.stringify([ { authorId: "111", authorName: "达人 A", singleVideoAfterSearchRate: "0.02%" }, { authorId: "222", authorName: "达人 B" } ]) ); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, loadAuthorMetrics: async (authorId) => ({ success: true, rates: authorId === "111" ? { singleVideoAfterSearchRate: "0.02%", personalVideoAfterSearchRate: "0.03% - 0.2%" } : { singleVideoAfterSearchRate: "0.5%-1%", personalVideoAfterSearchRate: "0.01% - 0.1%" } }), window })); await controller.ready; expect(readDivRightRowTexts(0)).toEqual(["¥450,000", "下单"]); expect(readDivPluginRowTexts(0)).toEqual([ "0.02%", "0.03% - 0.2%", "", "", "", "", "", "" ]); expect(readDivRightRowTexts(1)).toEqual(["¥20,000", "下单"]); expect(readDivPluginRowTexts(1)).toEqual([ "0.5% - 1%", "0.01% - 0.1%", "", "", "", "", "", "" ]); }); test("rehydrates real rows after serialized market rows arrive later", async () => { document.body.innerHTML = buildRealMarketFixtureWithoutAuthorIds([ { authorName: "达人 A", price21To60s: "¥450,000" }, { authorName: "达人 B", price21To60s: "¥20,000" } ]); const loadAuthorMetrics = vi.fn(async (authorId: string) => ({ success: true as const, rates: authorId === "111" ? { singleVideoAfterSearchRate: "0.02%", personalVideoAfterSearchRate: "0.03% - 0.2%" } : { singleVideoAfterSearchRate: "0.5%-1%", personalVideoAfterSearchRate: "0.01% - 0.1%" } })); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, loadAuthorMetrics, window })); await controller.ready; expect(loadAuthorMetrics).not.toHaveBeenCalled(); expect(readDivPluginRowTexts(0)).toEqual(["", "", "", "", "", "", "", ""]); expect(readDivPluginRowTexts(1)).toEqual(["", "", "", "", "", "", "", ""]); document.documentElement.setAttribute( "data-sces-market-rows", JSON.stringify([ { authorId: "111", authorName: "达人 A", singleVideoAfterSearchRate: "0.02%" }, { authorId: "222", authorName: "达人 B" } ]) ); await flushWithTimers(); await flushWithTimers(); expect(loadAuthorMetrics).toHaveBeenCalledTimes(2); expect(readDivPluginRowTexts(0)).toEqual([ "0.02%", "0.03% - 0.2%", "", "", "", "", "", "" ]); expect(readDivPluginRowTexts(1)).toEqual([ "0.5% - 1%", "0.01% - 0.1%", "", "", "", "", "", "" ]); }); test("clicking plugin sort headers cycles sort state", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥450,000" }, { authorId: "222", authorName: "达人 B", price21To60s: "¥20,000" } ]); const resultStore = createMarketResultStore(); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), resultStore, window })); await controller.ready; resultStore.setAuthorSuccess("111", { singleVideoAfterSearchRate: "0.02% - 0.1%", personalVideoAfterSearchRate: "0.03% - 0.2%" }); resultStore.setAuthorSuccess("222", { singleVideoAfterSearchRate: "0.5%-1%", personalVideoAfterSearchRate: "0.01% - 0.1%" }); click('[data-market-sort-field="singleVideoAfterSearchRate"]'); await flush(); expect(readDivAuthorOrder()).toEqual(["达人 B", "达人 A"]); expect( document.querySelector('[data-market-sort-field="singleVideoAfterSearchRate"]') ?.getAttribute("data-market-sort-direction") ).toBe("desc"); click('[data-market-sort-field="singleVideoAfterSearchRate"]'); await flush(); expect(readDivAuthorOrder()).toEqual(["达人 A", "达人 B"]); expect( document.querySelector('[data-market-sort-field="singleVideoAfterSearchRate"]') ?.getAttribute("data-market-sort-direction") ).toBe("asc"); click('[data-market-sort-field="singleVideoAfterSearchRate"]'); await flush(); expect(readDivAuthorOrder()).toEqual(["达人 A", "达人 B"]); expect( document.querySelector('[data-market-sort-field="singleVideoAfterSearchRate"]') ?.getAttribute("data-market-sort-direction") ).toBe("none"); }); test("clicking backend metric headers sorts by metric values on the current page", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥450,000" }, { authorId: "222", authorName: "达人 B", price21To60s: "¥20,000" } ]); const resultStore = createMarketResultStore(); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), resultStore, window })); await controller.ready; resultStore.setBackendMetricsSuccess("111", { afterViewSearchRate: "0.36%" }); resultStore.setBackendMetricsSuccess("222", { afterViewSearchRate: "1.4%" }); click('[data-market-sort-field="afterViewSearchRate"]'); await flush(); expect(readDivAuthorOrder()).toEqual(["达人 B", "达人 A"]); }); test("toolbar shows export range controls and reveals custom page input only for custom range", async () => { document.body.innerHTML = buildMarketFixture(); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), window })); await controller.ready; const exportRangeSelect = document.querySelector( '[data-plugin-export-range="select"]' ) as HTMLSelectElement | null; const customPagesInput = document.querySelector( '[data-plugin-export-custom-pages="input"]' ) as HTMLInputElement | null; expect(exportRangeSelect?.value).toBe("first-5"); expect(exportRangeSelect?.hidden).toBe(false); expect(customPagesInput?.hidden).toBe(true); expect( document.querySelector('[data-plugin-batch-submit="button"]') ).not.toBeNull(); setSelectValue('[data-plugin-export-range="select"]', "custom"); dispatchChange('[data-plugin-export-range="select"]'); expect(exportRangeSelect?.hidden).toBe(false); expect(customPagesInput?.hidden).toBe(false); }); test("removes the toolbar resize listener after its root is detached", async () => { document.body.innerHTML = buildMarketFixture(); const addEventListener = vi.spyOn(window, "addEventListener"); const removeEventListener = vi.spyOn(window, "removeEventListener"); try { const { ensurePluginToolbar, isPluginToolbarMounted } = await import( "../src/content/market/plugin-toolbar" ); const toolbar = ensurePluginToolbar(document, createNoopToolbarHandlers()); const resizeHandler = addEventListener.mock.calls.find( ([eventName]) => eventName === "resize" )?.[1]; toolbar.root.remove(); expect(isPluginToolbarMounted(toolbar.root, document)).toBe(false); expect(removeEventListener).toHaveBeenCalledWith("resize", resizeHandler); } finally { addEventListener.mockRestore(); removeEventListener.mockRestore(); } }); test("moves the toolbar resize listener when its root is adopted by another window", async () => { document.body.innerHTML = buildMarketFixture(); const addEventListener = vi.spyOn(window, "addEventListener"); const removeEventListener = vi.spyOn(window, "removeEventListener"); const alternateAddEventListener = vi.fn(); const alternateRemoveEventListener = vi.fn(); const alternateWindow = { addEventListener: alternateAddEventListener, removeEventListener: alternateRemoveEventListener } as unknown as Window; const alternateDocument = { createTreeWalker: document.createTreeWalker.bind(document), defaultView: alternateWindow } as unknown as Document; try { const { ensurePluginToolbar, isPluginToolbarMounted } = await import( "../src/content/market/plugin-toolbar" ); const handlers = createNoopToolbarHandlers(); const toolbar = ensurePluginToolbar(document, handlers); const resizeHandler = addEventListener.mock.calls.find( ([eventName]) => eventName === "resize" )?.[1]; const originalOwnerDocument = toolbar.root.ownerDocument; Object.defineProperty(toolbar.root, "ownerDocument", { configurable: true, value: alternateDocument }); const reusedToolbar = ensurePluginToolbar(document, handlers); expect(reusedToolbar.root).toBe(toolbar.root); expect(removeEventListener).toHaveBeenCalledWith("resize", resizeHandler); expect(alternateAddEventListener).toHaveBeenCalledWith("resize", resizeHandler); Object.defineProperty(toolbar.root, "ownerDocument", { configurable: true, value: originalOwnerDocument }); toolbar.root.remove(); isPluginToolbarMounted(toolbar.root, document); } finally { addEventListener.mockRestore(); removeEventListener.mockRestore(); } }); test("removes the toolbar resize listener when its root has no owner window", async () => { document.body.innerHTML = buildMarketFixture(); const addEventListener = vi.spyOn(window, "addEventListener"); const removeEventListener = vi.spyOn(window, "removeEventListener"); let root: HTMLElement | null = null; let originalOwnerDocument: Document | null = null; let isPluginToolbarMounted: | ((toolbarRoot: HTMLElement, toolbarDocument: Document) => boolean) | undefined; try { const toolbarModule = await import("../src/content/market/plugin-toolbar"); isPluginToolbarMounted = toolbarModule.isPluginToolbarMounted; const handlers = createNoopToolbarHandlers(); const toolbar = toolbarModule.ensurePluginToolbar(document, handlers); root = toolbar.root; originalOwnerDocument = root.ownerDocument; const resizeHandler = addEventListener.mock.calls.find( ([eventName]) => eventName === "resize" )?.[1]; Object.defineProperty(root, "ownerDocument", { configurable: true, value: { createTreeWalker: document.createTreeWalker.bind(document), defaultView: null } as unknown as Document }); toolbarModule.ensurePluginToolbar(document, handlers); expect(removeEventListener).toHaveBeenCalledWith("resize", resizeHandler); expect(addEventListener).toHaveBeenCalledTimes(1); Object.defineProperty(root, "ownerDocument", { configurable: true, value: originalOwnerDocument }); toolbarModule.ensurePluginToolbar(document, handlers); const resizeCalls = addEventListener.mock.calls.filter( ([eventName]) => eventName === "resize" ); expect(resizeCalls).toHaveLength(2); expect(resizeCalls[1]).toEqual(["resize", expect.any(Function)]); toolbarModule.ensurePluginToolbar(document, handlers); expect( addEventListener.mock.calls.filter( ([eventName]) => eventName === "resize" ) ).toHaveLength(2); } finally { if (root && originalOwnerDocument) { Object.defineProperty(root, "ownerDocument", { configurable: true, value: originalOwnerDocument }); root.remove(); isPluginToolbarMounted?.(root, document); } addEventListener.mockRestore(); removeEventListener.mockRestore(); } }); test("catalog starts with the default finish-rate rule and adds independent metrics", 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 catalogTrigger = document.querySelector( '[data-plugin-spread-metric-catalog-trigger="button"]' ) as HTMLButtonElement | null; const catalogPanel = document.querySelector( '[data-plugin-spread-metric-catalog-panel="root"]' ) as HTMLElement | null; const finishRule = document.querySelector( '[data-plugin-spread-rule="finishRate"]' ) as HTMLElement | null; const interactionRule = document.querySelector( '[data-plugin-spread-rule="interactionRate"]' ) as HTMLElement | null; const finishRateInput = document.querySelector( '[data-plugin-spread-threshold="finishRate"]' ) as HTMLInputElement | null; expect(catalogPanel?.hidden).toBe(true); expect(catalogPanel?.style.zIndex).toBe("100"); expect(finishRule?.hidden).toBe(false); expect(interactionRule?.hidden).toBe(true); expect(interactionRule?.style.display).toBe("none"); expect(finishRateInput?.placeholder).toBe(""); expect( document.querySelector('[data-plugin-spread-metric-selected-count="text"]') ?.textContent ).toBe("已选 1 项"); expect(readSpreadRuleSelect("finishRate", "type").value).toBe("2"); expect(readSpreadRuleSelect("finishRate", "onlyAssign").value).toBe("true"); expect(readSpreadRuleSelect("finishRate", "flowType").value).toBe("0"); expect(readSpreadRuleSelect("finishRate", "range").value).toBe("2"); catalogTrigger?.click(); expect(catalogPanel?.hidden).toBe(false); click('[data-plugin-spread-metric-catalog-action="interactionRate"]'); setSpreadRuleSelect("finishRate", "type", "2"); setSpreadRuleSelect("finishRate", "onlyAssign", "true"); setSpreadRuleSelect("finishRate", "flowType", "1"); setSpreadRuleSelect("interactionRate", "type", "2"); setSpreadRuleSelect("interactionRate", "onlyAssign", "true"); setSpreadRuleSelect("interactionRate", "flowType", "1"); setSpreadRuleSelect("interactionRate", "type", "1"); const finishAssignSelect = readSpreadRuleSelect( "finishRate", "onlyAssign" ); const finishFlowTypeSelect = readSpreadRuleSelect( "finishRate", "flowType" ); const interactionAssignSelect = readSpreadRuleSelect( "interactionRate", "onlyAssign" ); const interactionFlowTypeSelect = readSpreadRuleSelect( "interactionRate", "flowType" ); expect(finishRule?.hidden).toBe(false); expect(interactionRule?.hidden).toBe(false); expect(interactionRule?.style.display).toBe("flex"); expect(finishAssignSelect.value).toBe("true"); expect(finishAssignSelect.disabled).toBe(false); expect(finishFlowTypeSelect.value).toBe("1"); expect(finishFlowTypeSelect.disabled).toBe(false); expect(interactionAssignSelect.value).toBe("false"); expect(interactionAssignSelect.disabled).toBe(true); expect(interactionFlowTypeSelect.value).toBe("0"); expect(interactionFlowTypeSelect.disabled).toBe(true); setInputValue('[data-plugin-spread-threshold="finishRate"]', "30"); click('[data-plugin-spread-rule-remove="finishRate"]'); expect(finishRule?.hidden).toBe(true); expect(finishRule?.style.display).toBe("none"); catalogTrigger?.click(); click('[data-plugin-spread-metric-catalog-action="finishRate"]'); expect(finishRateInput?.value).toBe(""); expectSelectValue( '[data-plugin-spread-rule="finishRate"] [data-plugin-spread-filter="type"]', "2" ); expectSelectValue( '[data-plugin-spread-rule="finishRate"] [data-plugin-spread-filter="onlyAssign"]', "true" ); expectSelectValue( '[data-plugin-spread-rule="finishRate"] [data-plugin-spread-filter="flowType"]', "0" ); expectSelectValue( '[data-plugin-spread-rule="finishRate"] [data-plugin-spread-filter="range"]', "2" ); }); test("catalog disables duplicate metrics and restores them after deletion", 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 finishAction = document.querySelector( '[data-plugin-spread-metric-catalog-action="finishRate"]' ) as HTMLButtonElement | null; expect(finishAction?.disabled).toBe(true); expect(finishAction?.textContent).toBe("已添加"); click('[data-plugin-spread-rule-remove="finishRate"]'); expect(finishAction?.disabled).toBe(false); expect(finishAction?.textContent).toBe("添加"); }); test("catalog does not render a metric search input", 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; click('[data-plugin-spread-metric-catalog-trigger="button"]'); expect( document.querySelector('[data-plugin-spread-metric-catalog-search="input"]') ).toBeNull(); }); test("busy toolbar disables catalog and selected rule actions", 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 { ensurePluginToolbar, setToolbarBusyState } = await import( "../src/content/market/plugin-toolbar" ); const toolbar = ensurePluginToolbar(document, createNoopToolbarHandlers()); setToolbarBusyState(toolbar, true); expect( (document.querySelector( '[data-plugin-spread-metric-catalog-trigger="button"]' ) as HTMLButtonElement | null)?.disabled ).toBe(true); expect( (document.querySelector( '[data-plugin-spread-metric-catalog-action="interactionRate"]' ) as HTMLButtonElement | null)?.disabled ).toBe(true); expect( (document.querySelector( '[data-plugin-spread-rule-remove="finishRate"]' ) as HTMLButtonElement | null)?.disabled ).toBe(true); }); test("uses a collapsible catalog drawer and secondary rule controls on narrow screens", async () => { const originalInnerWidth = window.innerWidth; Object.defineProperty(window, "innerWidth", { configurable: true, value: 640 }); 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; window.dispatchEvent(new Event("resize")); click('[data-plugin-spread-metric-catalog-trigger="button"]'); const catalogPanel = document.querySelector( '[data-plugin-spread-metric-catalog-panel="root"]' ) as HTMLElement | null; expect(catalogPanel?.hidden).toBe(false); const secondaryControls = document.querySelector( '[data-plugin-spread-rule-secondary="finishRate"]' ) as HTMLElement | null; expect(secondaryControls?.hidden).toBe(true); click('[data-plugin-spread-rule-details="finishRate"]'); expect(secondaryControls?.hidden).toBe(false); click('[data-plugin-spread-metric-catalog-trigger="button"]'); click('[data-plugin-spread-metric-catalog-close="button"]'); expect(catalogPanel?.hidden).toBe(true); Object.defineProperty(window, "innerWidth", { configurable: true, value: originalInnerWidth }); window.dispatchEvent(new Event("resize")); }); test("reads selected spread metrics as independent validated rules", 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 { ensurePluginToolbar, readToolbarSpreadFilter } = await import( "../src/content/market/plugin-toolbar" ); const toolbar = ensurePluginToolbar(document, createNoopToolbarHandlers()); expect(readToolbarSpreadFilter(toolbar)).toEqual({ error: "请输入有效的完播率筛选阈值" }); setInputValue('[data-plugin-spread-threshold="finishRate"]', "30"); click('[data-plugin-spread-metric-catalog-trigger="button"]'); click('[data-plugin-spread-metric-catalog-action="interactionRate"]'); expect(readSpreadRuleSelect("interactionRate", "type").value).toBe("2"); expect(readSpreadRuleSelect("interactionRate", "onlyAssign").value).toBe("true"); expect(readSpreadRuleSelect("interactionRate", "flowType").value).toBe("0"); expect(readSpreadRuleSelect("interactionRate", "range").value).toBe("2"); expect( (document.querySelector( '[data-plugin-spread-threshold="interactionRate"]' ) as HTMLInputElement | null)?.value ).toBe(""); expect(readToolbarSpreadFilter(toolbar)).toEqual({ error: "请输入有效的互动率筛选阈值" }); setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5"); expect(readToolbarSpreadFilter(toolbar)).toEqual({ filter: { rules: [ { config: { flowType: 0, onlyAssign: true, range: 2, type: 2 }, metric: "finishRate", threshold: 30 }, { config: { flowType: 0, onlyAssign: true, range: 2, type: 2 }, metric: "interactionRate", threshold: 5 } ] } }); }); test("export uses the current page ordering without triggering a full scan", async () => { document.body.innerHTML = buildMarketFixture(); const resultStore = createMarketResultStore(); const buildCsv = vi.fn(() => "csv-output"); const onCsvReady = vi.fn(); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ buildCsv, document, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), onCsvReady, resultStore, window })); await controller.ready; resultStore.setAuthorSuccess("a", { singleVideoAfterSearchRate: "0.02% - 0.1%", personalVideoAfterSearchRate: "0.03% - 0.2%" }); resultStore.setAuthorSuccess("b", { singleVideoAfterSearchRate: "0.5%-1%", personalVideoAfterSearchRate: "0.01% - 0.1%" }); click('[data-market-sort-field="singleVideoAfterSearchRate"]'); await flush(); setSelectValue('[data-plugin-export-range="select"]', "current"); dispatchChange('[data-plugin-export-range="select"]'); removeDefaultSpreadMetricFilter(); click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 80, 50); expect(buildCsv).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ authorId: "a" }), expect.objectContaining({ authorId: "b" }) ]) ); expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([ "b", "a" ]); expect(onCsvReady).toHaveBeenCalledWith("csv-output"); }); test("export hydrates spread info with attribute_datas.id before building csv", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "a", authorName: "Alpha", price21To60s: "450000" }, { authorId: "b", authorName: "Beta", price21To60s: "70000" } ]); attachMarketListState([ { attribute_datas: { id: "spread-a", nickname: "Alpha" }, star_id: "a" }, { attribute_datas: { id: "spread-b", nickname: "Beta" }, star_id: "b" } ]); const buildCsv = vi.fn(() => "csv-output"); const loadSpreadMetrics = vi.fn(async (spreadAuthorId: string) => ({ "内容数据-个人视频-近30天-完播率": spreadAuthorId === "spread-a" ? "28.24%" : "18.24%" })); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ buildCsv, document, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), loadSpreadMetrics, onCsvReady: vi.fn(), window })); await controller.ready; setSelectValue('[data-plugin-export-range="select"]', "current"); dispatchChange('[data-plugin-export-range="select"]'); removeDefaultSpreadMetricFilter(); click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 80, 50); expect(loadSpreadMetrics).toHaveBeenCalledWith("spread-a"); expect(loadSpreadMetrics).toHaveBeenCalledWith("spread-b"); expect(buildCsv.mock.calls[0][0]).toEqual([ expect.objectContaining({ authorId: "a", spreadAuthorId: "spread-a", spreadMetrics: { "内容数据-个人视频-近30天-完播率": "28.24%" } }), expect.objectContaining({ authorId: "b", spreadAuthorId: "spread-b", spreadMetrics: { "内容数据-个人视频-近30天-完播率": "18.24%" } }) ]); }); test("export requires independent spread metric configs to all match", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "a", authorName: "Alpha", price21To60s: "450000" }, { authorId: "b", authorName: "Beta", price21To60s: "70000" } ]); attachMarketListState([ { attribute_datas: { id: "spread-a", nickname: "Alpha" }, star_id: "a" }, { attribute_datas: { id: "spread-b", nickname: "Beta" }, star_id: "b" } ]); const buildCsv = vi.fn(() => "csv-output"); const loadSpreadFilterMetrics = vi.fn(async ( spreadAuthorId: string, config: SpreadInfoConfig ) => { if (config.type === 2) { return { finishRate: "35%" }; } return { interactionRate: spreadAuthorId === "spread-a" ? "6%" : "4%" }; }); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ buildCsv, document, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), loadSpreadFilterMetrics, loadSpreadMetrics: async () => ({}), onCsvReady: vi.fn(), window })); await controller.ready; setSelectValue('[data-plugin-export-range="select"]', "current"); dispatchChange('[data-plugin-export-range="select"]'); enableSpreadMetric("finishRate"); enableSpreadMetric("interactionRate"); setInputValue('[data-plugin-spread-threshold="finishRate"]', "30"); setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5"); setSpreadRuleSelect("finishRate", "type", "2"); setSpreadRuleSelect("finishRate", "onlyAssign", "true"); setSpreadRuleSelect("interactionRate", "type", "1"); setSpreadRuleSelect("interactionRate", "range", "3"); click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 80, 50); expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-a", { flowType: 0, onlyAssign: true, range: 2, type: 2 }); expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-a", { flowType: 0, onlyAssign: false, range: 3, type: 1 }); expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([ "a" ]); }); test("export reuses one spread snapshot when metric configs match", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "a", authorName: "Alpha", price21To60s: "450000" } ]); attachMarketListState([ { attribute_datas: { id: "spread-a", nickname: "Alpha" }, star_id: "a" } ]); const buildCsv = vi.fn(() => "csv-output"); const loadSpreadFilterMetrics = vi.fn(async () => ({ finishRate: "35%", interactionRate: "6%" })); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ buildCsv, document, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), loadSpreadFilterMetrics, loadSpreadMetrics: async () => ({}), onCsvReady: vi.fn(), window })); await controller.ready; setSelectValue('[data-plugin-export-range="select"]', "current"); dispatchChange('[data-plugin-export-range="select"]'); enableSpreadMetric("finishRate"); enableSpreadMetric("interactionRate"); setInputValue('[data-plugin-spread-threshold="finishRate"]', "30"); setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5"); click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 80, 50); expect(loadSpreadFilterMetrics).toHaveBeenCalledTimes(1); expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-a", { flowType: 0, onlyAssign: true, range: 2, type: 2 }); expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([ "a" ]); }); test("export excludes missing-id and failed spread metric records without aborting", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "a", authorName: "Alpha", price21To60s: "450000" }, { authorId: "b", authorName: "Beta", price21To60s: "70000" } ]); attachMarketListState([ { star_id: "a" }, { attribute_datas: { id: "spread-b", nickname: "Beta" }, star_id: "b" } ]); const buildCsv = vi.fn(() => "csv-output"); const loadSpreadFilterMetrics = vi.fn(async () => { throw new Error("request failed"); }); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ buildCsv, document, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), loadSpreadFilterMetrics, loadSpreadMetrics: async () => ({}), onCsvReady: vi.fn(), window })); await controller.ready; setSelectValue('[data-plugin-export-range="select"]', "current"); dispatchChange('[data-plugin-export-range="select"]'); enableSpreadMetric("finishRate"); setInputValue('[data-plugin-spread-threshold="finishRate"]', "30"); click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 80, 50); expect(loadSpreadFilterMetrics).toHaveBeenCalledTimes(1); expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-b", { flowType: 0, onlyAssign: true, range: 2, type: 2 }); expect(buildCsv.mock.calls[0][0]).toEqual([]); }); 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; removeDefaultSpreadMetricFilter(); click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 120, 100); expect(pagination.getClicks()).toBe(4); expect(buildCsv).toHaveBeenCalledTimes(1); expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([ "111", "222", "333", "444" ]); expect(buildCsv.mock.calls[0][0]).toEqual( expect.arrayContaining([ expect.objectContaining({ authorId: "222", exportFields: expect.objectContaining({ "21-60s报价": "¥22,000" }) }) ]) ); expect(onCsvReady).toHaveBeenCalledWith("csv-output"); }, 15000 ); test( "default export replays captured market requests silently without paging the visible table", async () => { const pages = [ [{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }], [{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }], [{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }], [{ authorId: "444", authorName: "达人 D", price21To60s: "¥44,000" }], [{ authorId: "555", authorName: "达人 E", price21To60s: "¥55,000" }] ]; document.body.innerHTML = buildRealMarketFixture(pages[0]); const pagination = installAsyncPaginationHarness(pages); const buildCsv = vi.fn(() => "csv-output"); const onCsvReady = vi.fn(); const fetchMock = vi.fn(async (_input: string, init?: RequestInit) => { const body = JSON.parse(String(init?.body ?? "{}")) as { page?: number }; const pageIndex = Math.max((body.page ?? 1) - 1, 0); return { json: async () => ({ data: { marketList: buildMarketListResponseRows(pages[pageIndex] ?? []) } }), ok: true }; }); ( globalThis as typeof globalThis & { fetch?: typeof fetchMock; } ).fetch = fetchMock; document.documentElement.setAttribute( "data-sces-market-request-snapshot", JSON.stringify({ body: JSON.stringify({ page: 1 }), method: "POST", url: "https://xingtu.cn/api/mock-market-search" }) ); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ buildCsv, document, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), onCsvReady, window })); await controller.ready; removeDefaultSpreadMetricFilter(); click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 120, 100); expect(pagination.getClicks()).toBe(0); expect(fetchMock).toHaveBeenCalledTimes(5); expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([ "111", "222", "333", "444", "555" ]); expect(onCsvReady).toHaveBeenCalledWith("csv-output"); }, 15000 ); test( "default export falls back to visible pagination when no captured market request is available", async () => { const pages = [ [{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }], [{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }], [{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }], [{ authorId: "444", authorName: "达人 D", price21To60s: "¥44,000" }], [{ authorId: "555", authorName: "达人 E", price21To60s: "¥55,000" }] ]; document.body.innerHTML = buildRealMarketFixture(pages[0]); const pagination = installAsyncPaginationHarness(pages); const buildCsv = vi.fn(() => "csv-output"); document.documentElement.removeAttribute("data-sces-market-request-snapshot"); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ buildCsv, document, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), onCsvReady: vi.fn(), window })); await controller.ready; removeDefaultSpreadMetricFilter(); click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 120, 100); expect(pagination.getClicks()).toBe(4); expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([ "111", "222", "333", "444", "555" ]); }, 15000 ); test( "default export waits for the next page rows instead of only the pager state", async () => { const pages = [ [{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }], [{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }], [{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }], [{ authorId: "444", authorName: "达人 D", price21To60s: "¥44,000" }], [{ authorId: "555", authorName: "达人 E", price21To60s: "¥55,000" }] ]; document.body.innerHTML = buildRealMarketFixture(pages[0]); const pagination = installLaggyPaginationHarness(pages, { renderDelayMs: 250 }); const buildCsv = vi.fn(() => "csv-output"); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ buildCsv, document, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), onCsvReady: vi.fn(), window })); await controller.ready; removeDefaultSpreadMetricFilter(); 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"]'); removeDefaultSpreadMetricFilter(); click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 120, 100); expect(pagination.getClicks()).toBe(2); expect(buildCsv).toHaveBeenCalledTimes(1); expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([ "111", "112", "113", "221", "222", "223", "331", "332", "333" ]); }, 15000 ); test( "exporting all pages disables the native action bar controls during the task and stops at the final page", async () => { const pages = [ [{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }], [{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }], [{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }] ]; document.body.innerHTML = buildRealMarketFixture(pages[0]); const pagination = installPaginationHarness(pages); const buildCsv = vi.fn(() => "csv-output"); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ buildCsv, document, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), onCsvReady: vi.fn(), window })); await controller.ready; setSelectValue('[data-plugin-export-range="select"]', "all"); dispatchChange('[data-plugin-export-range="select"]'); removeDefaultSpreadMetricFilter(); click('[data-plugin-export="button"]'); expectButtonDisabled('[data-plugin-batch-submit="button"]', true); expectButtonDisabled('[data-plugin-export="button"]', true); expectSelectDisabled('[data-plugin-export-range="select"]', true); expect( document.querySelector('[data-plugin-export-status="text"]')?.textContent ).toContain("导出中"); await waitForMockCall(buildCsv, 120, 100); expect(pagination.getClicks()).toBe(2); expectButtonDisabled('[data-plugin-batch-submit="button"]', false); expectButtonDisabled('[data-plugin-export="button"]', false); expectSelectDisabled('[data-plugin-export-range="select"]', false); expect(buildCsv).toHaveBeenCalledTimes(1); expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual( expect.arrayContaining(["222", "333"]) ); }, 15000 ); test("custom export range blocks invalid page counts", async () => { document.body.innerHTML = buildMarketFixture(); const buildCsv = vi.fn(() => "csv-output"); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ buildCsv, document, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), onCsvReady: vi.fn(), window })); await controller.ready; setSelectValue('[data-plugin-export-range="select"]', "custom"); dispatchChange('[data-plugin-export-range="select"]'); setInputValue('[data-plugin-export-custom-pages="input"]', "0"); click('[data-plugin-export="button"]'); await flush(); expect(buildCsv).not.toHaveBeenCalled(); expect( document.querySelector('[data-plugin-export-status="text"]')?.textContent ).toContain("有效页数"); }); test("selected export uses only creators selected in the current range", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }, { authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }, { authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" } ]); const buildCsv = vi.fn(() => "csv-output"); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ buildCsv, document, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), onCsvReady: vi.fn(), window })); await controller.ready; clickSelectionCheckboxForAuthor("111"); clickSelectionCheckboxForAuthor("333"); setSelectValue('[data-plugin-export-range="select"]', "current"); dispatchChange('[data-plugin-export-range="select"]'); removeDefaultSpreadMetricFilter(); click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 40, 50); expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([ "111", "333" ]); }); test("audience profile export requires selected creators", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } ]); const buildAudienceProfileCsv = vi.fn(() => "profile-csv"); const loadAudienceProfile = vi.fn(); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ buildAudienceProfileCsv, document, loadAudienceProfile, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), onCsvReady: vi.fn(), window })); await controller.ready; setSelectValue('[data-plugin-export-range="select"]', "current"); dispatchChange('[data-plugin-export-range="select"]'); click('[data-plugin-export-audience-profile="button"]'); await flush(); expect(loadAudienceProfile).not.toHaveBeenCalled(); expect(buildAudienceProfileCsv).not.toHaveBeenCalled(); expect( document.querySelector('[data-plugin-export-status="text"]')?.textContent ).toContain("请先勾选需要导出数据的达人"); }); test("audience profile export loads profiles only for selected creators", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }, { authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" } ]); const buildAudienceProfileCsv = vi.fn(() => "profile-csv"); const loadBusinessAbility = vi.fn(async () => ({ estimates: {}, status: "success" as const })); const loadAudienceProfile = vi.fn(async (_record, target) => { if (target.source === "fansDistribution" && target.authorType === 5) { return { age: [{ label: "31-40", value: "30%" }], crowd: [{ label: "都市蓝领", value: "50%" }], cityTier: [{ label: "一线城市", value: "70%" }], status: "success" as const }; } return { age: [{ label: "31-40", value: "60%" }], crowd: [{ label: "都市蓝领", value: "80%" }], cityTier: [{ label: "一线城市", value: "90%" }], gender: [{ label: "男性", value: "60%" }], status: "success" as const }; }); const onCsvReady = vi.fn(); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ buildAudienceProfileCsv, document, loadBusinessAbility, loadAudienceProfile, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), onCsvReady, window })); await controller.ready; clickSelectionCheckboxForAuthor("222"); setSelectValue('[data-plugin-export-range="select"]', "current"); dispatchChange('[data-plugin-export-range="select"]'); click('[data-plugin-export-audience-profile="button"]'); await waitForMockCall(buildAudienceProfileCsv, 40, 50); expect(loadAudienceProfile).toHaveBeenCalledTimes(3); expect(loadBusinessAbility).toHaveBeenCalledTimes(1); expect(loadBusinessAbility).toHaveBeenCalledWith( expect.objectContaining({ authorId: "222" }) ); expect(loadAudienceProfile.mock.calls.map(([, target]) => target)).toEqual([ { linkType: 5, source: "audienceDistribution" }, { authorType: 1, source: "fansDistribution" }, { authorType: 5, source: "fansDistribution" } ]); expect(buildAudienceProfileCsv).toHaveBeenCalledWith( [ { profiles: { audience: expect.objectContaining({ status: "success" }), fans: expect.objectContaining({ status: "success" }), longtimeFans: expect.objectContaining({ status: "success" }) }, businessAbility: expect.objectContaining({ status: "success" }), record: expect.objectContaining({ authorId: "222" }) } ], expect.objectContaining({ selectedHeaders: expect.arrayContaining(["秒思api-看后搜数"]) }) ); expect(onCsvReady).toHaveBeenCalledWith( "profile-csv", expect.stringMatching(/^达人连接用户画像_\d{8}_\d{4}\.csv$/) ); }); test("audience profile export by id loads pasted creators without page selection", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } ]); const buildAudienceProfileCsv = vi.fn(() => "profile-csv"); const loadAuthorBaseInfo = vi.fn(async (authorId: string) => ({ authorId, authorName: authorId === "6866044569306267651" ? "小九儿" : "达人 B", status: "success" as const })); const loadBusinessAbility = vi.fn(async () => ({ estimates: {}, status: "success" as const })); const loadAudienceProfile = vi.fn(async () => ({ age: [{ label: "31-40", value: "60%" }], crowd: [{ label: "都市蓝领", value: "80%" }], cityTier: [{ label: "一线城市", value: "90%" }], gender: [{ label: "男性", value: "60%" }], status: "success" as const })); const loadAuthorMetrics = vi.fn(async (authorId: string) => ({ rates: { personalVideoAfterSearchRate: authorId === "6866044569306267651" ? "12.3%" : "45.6%", singleVideoAfterSearchRate: authorId === "6866044569306267651" ? "7.8%" : "9.1%" }, success: true as const })); const searchBackendMetrics = vi.fn(async (starIds: string[]) => starIds.map((starId) => ({ a3IncreaseCount: starId === "6866044569306267651" ? "100" : "200", afterViewSearchCount: starId === "6866044569306267651" ? "300" : "400", afterViewSearchRate: starId === "6866044569306267651" ? "1.1%" : "2.2%", cpSearch: starId === "6866044569306267651" ? "10" : "20", cpa3: starId === "6866044569306267651" ? "30" : "40", newA3Rate: starId === "6866044569306267651" ? "3.3%" : "4.4%", starId })) ); const onCsvReady = vi.fn(); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ buildAudienceProfileCsv, document, loadAuthorBaseInfo, loadBusinessAbility, loadAudienceProfile, loadAuthorMetrics, onCsvReady, promptAuthorIds: () => ` 6866044569306267651 7040323176106033165 6866044569306267651 bad-id `, searchBackendMetrics, window })); await controller.ready; loadAuthorMetrics.mockClear(); searchBackendMetrics.mockClear(); click('[data-plugin-export-audience-profile-by-id="button"]'); await waitForMockCall(buildAudienceProfileCsv, 40, 50); expect(loadAuthorBaseInfo.mock.calls.map(([authorId]) => authorId)).toEqual([ "6866044569306267651", "7040323176106033165" ]); expect(loadAudienceProfile).toHaveBeenCalledTimes(6); expect(loadBusinessAbility).toHaveBeenCalledTimes(2); expect(loadAuthorMetrics.mock.calls.map(([authorId]) => authorId)).toEqual([ "6866044569306267651", "7040323176106033165" ]); expect(searchBackendMetrics).toHaveBeenCalledTimes(1); expect(searchBackendMetrics).toHaveBeenCalledWith([ "6866044569306267651", "7040323176106033165" ]); expect(buildAudienceProfileCsv).toHaveBeenCalledWith( [ expect.objectContaining({ record: expect.objectContaining({ authorId: "6866044569306267651", authorName: "小九儿", backendMetrics: expect.objectContaining({ a3IncreaseCount: "100", afterViewSearchCount: "300", afterViewSearchRate: "1.1%", cpSearch: "10", cpa3: "30", newA3Rate: "3.3%" }), exportFields: { 达人ID: "6866044569306267651", 达人名称: "小九儿", 导出状态: "成功", 失败原因: "" }, rates: { personalVideoAfterSearchRate: "12.3%", singleVideoAfterSearchRate: "7.8%" } }) }), expect.objectContaining({ record: expect.objectContaining({ authorId: "7040323176106033165", authorName: "达人 B", backendMetrics: expect.objectContaining({ a3IncreaseCount: "200", afterViewSearchCount: "400", afterViewSearchRate: "2.2%", cpSearch: "20", cpa3: "40", newA3Rate: "4.4%" }), rates: { personalVideoAfterSearchRate: "45.6%", singleVideoAfterSearchRate: "9.1%" } }) }) ], expect.objectContaining({ selectedHeaders: expect.arrayContaining(["秒思api-看后搜数"]) }) ); expect(onCsvReady).toHaveBeenCalledWith( "profile-csv", expect.stringMatching(/^达人连接用户画像_按ID导出_\d{8}_\d{4}\.csv$/) ); }); test("audience profile export uses persisted selected csv fields", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } ]); window.localStorage.setItem( "sces:audience-profile:selectedHeaders", JSON.stringify(["内容数据-个人视频-近30天-播放量中位数", "秒思api-看后搜数"]) ); const buildAudienceProfileCsv = vi.fn(() => "profile-csv"); const loadBusinessAbility = vi.fn(async () => ({ estimates: {}, status: "success" as const })); const loadAudienceProfile = vi.fn(async () => ({ age: [], crowd: [], cityTier: [], gender: [], status: "success" as const })); const onCsvReady = vi.fn(); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ buildAudienceProfileCsv, document, loadBusinessAbility, loadAudienceProfile, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), onCsvReady, window })); await controller.ready; clickSelectionCheckboxForAuthor("111"); setSelectValue('[data-plugin-export-range="select"]', "current"); dispatchChange('[data-plugin-export-range="select"]'); click('[data-plugin-export-audience-profile="button"]'); await waitForMockCall(buildAudienceProfileCsv, 40, 50); expect(buildAudienceProfileCsv.mock.calls[0][1]).toEqual({ selectedHeaders: ["内容数据-个人视频-近30天-播放量中位数", "秒思api-看后搜数"] }); }); test("audience profile field picker persists the next selected fields", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } ]); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), window })); await controller.ready; click('[data-plugin-audience-profile-fields="button"]'); const afterSearchCountInput = document.querySelector( 'input[data-audience-profile-field-dialog-field="checkbox"][value="秒思api-看后搜数"]' ) as HTMLInputElement | null; expect(afterSearchCountInput).not.toBeNull(); afterSearchCountInput!.checked = false; afterSearchCountInput!.dispatchEvent(new Event("change", { bubbles: true })); click('[data-audience-profile-field-dialog-save="button"]'); await flush(); const savedHeaders = JSON.parse( window.localStorage.getItem("sces:audience-profile:selectedHeaders") ?? "[]" ) as string[]; expect(savedHeaders).not.toContain("秒思api-看后搜数"); expect(savedHeaders).toContain("内容数据-个人视频-近30天-播放量中位数"); expect( document.querySelector('[data-plugin-export-status="text"]')?.textContent ).toContain("字段已保存"); }); test( "selected export keeps a generic loading status while exporting the default paged range", async () => { const pages = [ [ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }, { authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" } ], [{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }], [{ authorId: "444", authorName: "达人 D", price21To60s: "¥44,000" }], [{ authorId: "555", authorName: "达人 E", price21To60s: "¥55,000" }], [{ authorId: "666", authorName: "达人 F", price21To60s: "¥66,000" }] ]; const secondPageDeferred = createDeferred<{ json(): Promise; ok: boolean; }>(); document.body.innerHTML = buildRealMarketFixture(pages[0]); const buildCsv = vi.fn(() => "csv-output"); const fetchMock = vi.fn(async (_input: string, init?: RequestInit) => { const body = JSON.parse(String(init?.body ?? "{}")) as { page?: number }; const pageNumber = body.page ?? 1; const response = { json: async () => ({ data: { marketList: buildMarketListResponseRows(pages[pageNumber - 1] ?? []), totalPages: 5 } }), ok: true }; if (pageNumber === 2) { return secondPageDeferred.promise; } return response; }); ( globalThis as typeof globalThis & { fetch?: typeof fetchMock; } ).fetch = fetchMock; document.documentElement.setAttribute( "data-sces-market-request-snapshot", JSON.stringify({ body: JSON.stringify({ page: 1 }), method: "POST", url: "https://xingtu.cn/api/mock-market-search" }) ); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ buildCsv, document, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), onCsvReady: vi.fn(), window })); await controller.ready; clickSelectionCheckboxForAuthor("111"); removeDefaultSpreadMetricFilter(); click('[data-plugin-export="button"]'); for (let attempt = 0; attempt < 40; attempt += 1) { if ( fetchMock.mock.calls.some(([, init]) => { const body = JSON.parse( String((init as RequestInit | undefined)?.body ?? "{}") ) as { page?: number }; return body.page === 2; }) ) { break; } await new Promise((resolve) => setTimeout(resolve, 50)); await Promise.resolve(); } expect( document.querySelector('[data-plugin-export-status="text"]')?.textContent ).toBe("导出中..."); secondPageDeferred.resolve({ json: async () => ({ data: { marketList: buildMarketListResponseRows(pages[1]), totalPages: 5 } }), ok: true }); await waitForMockCall(buildCsv, 120, 50); expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([ "111" ]); }, 15000 ); test("selected export falls back to all creators in the current range when no selection matches", async () => { const pages = [ [ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }, { authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" } ], [ { authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }, { authorId: "444", authorName: "达人 D", price21To60s: "¥44,000" } ] ]; document.body.innerHTML = buildRealMarketFixture(pages[0]); installAsyncPaginationHarness(pages); const buildCsv = vi.fn(() => "csv-output"); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ buildCsv, document, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), onCsvReady: vi.fn(), window })); await controller.ready; clickSelectionCheckboxForAuthor("111"); click('[data-testid="next-page"]'); await flushWithTimers(); setSelectValue('[data-plugin-export-range="select"]', "current"); dispatchChange('[data-plugin-export-range="select"]'); removeDefaultSpreadMetricFilter(); click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 40, 50); expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([ "333", "444" ]); }); test("prompts for a batch name before submitting the current range", async () => { document.body.innerHTML = buildMarketFixture(); const promptBatchName = vi.fn(() => "618达人筛选第一批"); const submitBatch = vi.fn(async () => ({ ok: true })); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, getAuthState: async () => ({ isAuthenticated: true, resource: "https://talent-search.intelligrow.cn", userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" } }), loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), promptBatchName, submitBatch, window })); await controller.ready; setSelectValue('[data-plugin-export-range="select"]', "current"); dispatchChange('[data-plugin-export-range="select"]'); removeDefaultSpreadMetricFilter(); click('[data-plugin-batch-submit="button"]'); await waitForMockCall(submitBatch, 40, 50); expect(promptBatchName).toHaveBeenCalledTimes(1); expect(submitBatch).toHaveBeenCalledWith( expect.objectContaining({ batchName: "618达人筛选第一批", logtoUserId: "p7pdhhtde8kj" }) ); expect(submitBatch.mock.calls[0]?.[0]).not.toHaveProperty("batchId"); }); test("batch submit applies all independent spread metric rules", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "a", authorName: "Alpha", price21To60s: "450000" }, { authorId: "b", authorName: "Beta", price21To60s: "70000" } ]); attachMarketListState([ { attribute_datas: { id: "spread-a", nickname: "Alpha" }, star_id: "a" }, { attribute_datas: { id: "spread-b", nickname: "Beta" }, star_id: "b" } ]); const submitBatch = vi.fn(async () => ({ ok: true })); const loadSpreadFilterMetrics = vi.fn(async ( spreadAuthorId: string, config: SpreadInfoConfig ) => { if (config.type === 2) { return { finishRate: spreadAuthorId === "spread-a" ? "35%" : "20%" }; } return { interactionRate: spreadAuthorId === "spread-a" ? "6%" : "4%" }; }); 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" }), loadSpreadFilterMetrics, promptBatchName: () => "筛选批次", submitBatch, window })); await controller.ready; setSelectValue('[data-plugin-export-range="select"]', "current"); dispatchChange('[data-plugin-export-range="select"]'); enableSpreadMetric("finishRate"); enableSpreadMetric("interactionRate"); setInputValue('[data-plugin-spread-threshold="finishRate"]', "30"); setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5"); setSpreadRuleSelect("finishRate", "type", "2"); setSpreadRuleSelect("interactionRate", "type", "1"); setSpreadRuleSelect("interactionRate", "range", "3"); click('[data-plugin-batch-submit="button"]'); await waitForMockCall(submitBatch, 80, 50); expect(submitBatch.mock.calls[0]?.[0].authors).toEqual([ expect.objectContaining({ authorId: "a" }) ]); }); test("opens a custom batch name dialog before submitting", async () => { document.body.innerHTML = buildMarketFixture(); const submitBatch = vi.fn(async () => ({ ok: true })); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, getAuthState: async () => ({ isAuthenticated: true, resource: "https://talent-search.intelligrow.cn", userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" } }), loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), submitBatch, window })); await controller.ready; removeDefaultSpreadMetricFilter(); click('[data-plugin-batch-submit="button"]'); expect(submitBatch).not.toHaveBeenCalled(); expect( document.querySelector('[data-plugin-batch-name-dialog="root"]') ).not.toBeNull(); setInputValue('[data-plugin-batch-name-input="input"]', "618达人筛选第一批"); dispatchInput('[data-plugin-batch-name-input="input"]'); click('[data-plugin-batch-name-confirm="button"]'); await waitForMockCall(submitBatch, 40, 50); expect(submitBatch).toHaveBeenCalledWith( expect.objectContaining({ batchName: "618达人筛选第一批" }) ); expect( document.querySelector('[data-plugin-batch-name-dialog="root"]') ).toBeNull(); }); test("keeps the custom batch name dialog open and shows an inline error for blank values", async () => { document.body.innerHTML = buildMarketFixture(); const submitBatch = vi.fn(async () => ({ ok: true })); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, getAuthState: async () => ({ isAuthenticated: true, resource: "https://talent-search.intelligrow.cn", userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" } }), loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), submitBatch, window })); await controller.ready; removeDefaultSpreadMetricFilter(); click('[data-plugin-batch-submit="button"]'); click('[data-plugin-batch-name-confirm="button"]'); await flush(); expect(submitBatch).not.toHaveBeenCalled(); expect( document.querySelector('[data-plugin-batch-name-error="text"]')?.textContent ).toContain("请输入批次名称"); expect( document.querySelector('[data-plugin-batch-name-dialog="root"]') ).not.toBeNull(); }); test("closes the custom batch name dialog when cancelled", async () => { document.body.innerHTML = buildMarketFixture(); const submitBatch = vi.fn(async () => ({ ok: true })); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, getAuthState: async () => ({ isAuthenticated: true, resource: "https://talent-search.intelligrow.cn", userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" } }), loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), submitBatch, window })); await controller.ready; removeDefaultSpreadMetricFilter(); click('[data-plugin-batch-submit="button"]'); click('[data-plugin-batch-name-cancel="button"]'); await flush(); expect(submitBatch).not.toHaveBeenCalled(); expect( document.querySelector('[data-plugin-batch-name-dialog="root"]') ).toBeNull(); }); test("selected batch submit uses only creators selected in the current range", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }, { authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }, { authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" } ]); const promptBatchName = vi.fn(() => "自动选择批次"); const submitBatch = vi.fn(async () => ({ ok: true })); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, getAuthState: async () => ({ isAuthenticated: true, resource: "https://talent-search.intelligrow.cn", userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" } }), loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), promptBatchName, submitBatch, window })); await controller.ready; clickSelectionCheckboxForAuthor("222"); setSelectValue('[data-plugin-export-range="select"]', "current"); dispatchChange('[data-plugin-export-range="select"]'); removeDefaultSpreadMetricFilter(); click('[data-plugin-batch-submit="button"]'); await waitForMockCall(submitBatch, 40, 50); expect(submitBatch).toHaveBeenCalledWith( expect.objectContaining({ authors: [{ authorId: "222", authorName: "达人 B" }] }) ); }); test( "selected batch submit keeps a generic loading status while submitting the default paged range", async () => { const pages = [ [ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }, { authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" } ], [{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }], [{ authorId: "444", authorName: "达人 D", price21To60s: "¥44,000" }], [{ authorId: "555", authorName: "达人 E", price21To60s: "¥55,000" }], [{ authorId: "666", authorName: "达人 F", price21To60s: "¥66,000" }] ]; const secondPageDeferred = createDeferred<{ json(): Promise; ok: boolean; }>(); const submitBatch = vi.fn(async () => ({ ok: true })); document.body.innerHTML = buildRealMarketFixture(pages[0]); const fetchMock = vi.fn(async (_input: string, init?: RequestInit) => { const body = JSON.parse(String(init?.body ?? "{}")) as { page?: number }; const pageNumber = body.page ?? 1; const response = { json: async () => ({ data: { marketList: buildMarketListResponseRows(pages[pageNumber - 1] ?? []), totalPages: 5 } }), ok: true }; if (pageNumber === 2) { return secondPageDeferred.promise; } return response; }); ( globalThis as typeof globalThis & { fetch?: typeof fetchMock; } ).fetch = fetchMock; document.documentElement.setAttribute( "data-sces-market-request-snapshot", JSON.stringify({ body: JSON.stringify({ page: 1 }), method: "POST", url: "https://xingtu.cn/api/mock-market-search" }) ); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, getAuthState: async () => ({ isAuthenticated: true, resource: "https://talent-search.intelligrow.cn", userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" } }), loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), promptBatchName: vi.fn(() => "自动选择批次"), submitBatch, window })); await controller.ready; clickSelectionCheckboxForAuthor("111"); removeDefaultSpreadMetricFilter(); click('[data-plugin-batch-submit="button"]'); for (let attempt = 0; attempt < 40; attempt += 1) { if ( fetchMock.mock.calls.some(([, init]) => { const body = JSON.parse( String((init as RequestInit | undefined)?.body ?? "{}") ) as { page?: number }; return body.page === 2; }) ) { break; } await new Promise((resolve) => setTimeout(resolve, 50)); await Promise.resolve(); } expect( document.querySelector('[data-plugin-export-status="text"]')?.textContent ).toBe("提交已选达人中..."); secondPageDeferred.resolve({ json: async () => ({ data: { marketList: buildMarketListResponseRows(pages[1]), totalPages: 5 } }), ok: true }); await waitForMockCall(submitBatch, 120, 50); expect(submitBatch).toHaveBeenCalledWith( expect.objectContaining({ authors: [{ authorId: "111", authorName: "达人 A" }] }) ); } ); test( "batch submit respects checked row selection even when the selection change event was missed", async () => { const pages = [ [ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }, { authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" } ], [{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }], [{ authorId: "444", authorName: "达人 D", price21To60s: "¥44,000" }], [{ authorId: "555", authorName: "达人 E", price21To60s: "¥55,000" }], [{ authorId: "666", authorName: "达人 F", price21To60s: "¥66,000" }] ]; const secondPageDeferred = createDeferred<{ json(): Promise; ok: boolean; }>(); const submitBatch = vi.fn(async () => ({ ok: true })); document.body.innerHTML = buildRealMarketFixture(pages[0]); const fetchMock = vi.fn(async (_input: string, init?: RequestInit) => { const body = JSON.parse(String(init?.body ?? "{}")) as { page?: number }; const pageNumber = body.page ?? 1; const response = { json: async () => ({ data: { marketList: buildMarketListResponseRows(pages[pageNumber - 1] ?? []), totalPages: 5 } }), ok: true }; if (pageNumber === 2) { return secondPageDeferred.promise; } return response; }); ( globalThis as typeof globalThis & { fetch?: typeof fetchMock; } ).fetch = fetchMock; document.documentElement.setAttribute( "data-sces-market-request-snapshot", JSON.stringify({ body: JSON.stringify({ page: 1 }), method: "POST", url: "https://xingtu.cn/api/mock-market-search" }) ); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, getAuthState: async () => ({ isAuthenticated: true, resource: "https://talent-search.intelligrow.cn", userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" } }), loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), promptBatchName: vi.fn(() => "自动选择批次"), submitBatch, window })); await controller.ready; const rowSelectionCheckbox = readSelectionCheckboxForAuthor("111"); rowSelectionCheckbox.checked = true; removeDefaultSpreadMetricFilter(); click('[data-plugin-batch-submit="button"]'); for (let attempt = 0; attempt < 40; attempt += 1) { if ( fetchMock.mock.calls.some(([, init]) => { const body = JSON.parse( String((init as RequestInit | undefined)?.body ?? "{}") ) as { page?: number }; return body.page === 2; }) ) { break; } await new Promise((resolve) => setTimeout(resolve, 50)); await Promise.resolve(); } expect( document.querySelector('[data-plugin-export-status="text"]')?.textContent ).toBe("提交已选达人中..."); secondPageDeferred.resolve({ json: async () => ({ data: { marketList: buildMarketListResponseRows(pages[1]), totalPages: 5 } }), ok: true }); await waitForMockCall(submitBatch, 120, 50); expect(submitBatch).toHaveBeenCalledWith( expect.objectContaining({ authors: [{ authorId: "111", authorName: "达人 A" }] }) ); } ); test("selected batch submit falls back to all creators in the current range when no selection matches", async () => { const pages = [ [ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }, { authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" } ], [ { authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }, { authorId: "444", authorName: "达人 D", price21To60s: "¥44,000" } ] ]; document.body.innerHTML = buildRealMarketFixture(pages[0]); installAsyncPaginationHarness(pages); const promptBatchName = vi.fn(() => "自动选择批次"); const submitBatch = vi.fn(async () => ({ ok: true })); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, getAuthState: async () => ({ isAuthenticated: true, resource: "https://talent-search.intelligrow.cn", userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" } }), loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), promptBatchName, submitBatch, window })); await controller.ready; clickSelectionCheckboxForAuthor("111"); click('[data-testid="next-page"]'); await flushWithTimers(); setSelectValue('[data-plugin-export-range="select"]', "current"); dispatchChange('[data-plugin-export-range="select"]'); removeDefaultSpreadMetricFilter(); click('[data-plugin-batch-submit="button"]'); await waitForMockCall(submitBatch, 40, 50); expect(submitBatch).toHaveBeenCalledWith( expect.objectContaining({ authors: [ { authorId: "333", authorName: "达人 C" }, { authorId: "444", authorName: "达人 D" } ] }) ); }); test( "default paged batch submit keeps detailed progress when no creators are selected", async () => { const pages = [ [ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }, { authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" } ], [{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }], [{ authorId: "444", authorName: "达人 D", price21To60s: "¥44,000" }], [{ authorId: "555", authorName: "达人 E", price21To60s: "¥55,000" }], [{ authorId: "666", authorName: "达人 F", price21To60s: "¥66,000" }] ]; const secondPageDeferred = createDeferred<{ json(): Promise; ok: boolean; }>(); const submitBatch = vi.fn(async () => ({ ok: true })); document.body.innerHTML = buildRealMarketFixture(pages[0]); const fetchMock = vi.fn(async (_input: string, init?: RequestInit) => { const body = JSON.parse(String(init?.body ?? "{}")) as { page?: number }; const pageNumber = body.page ?? 1; const response = { json: async () => ({ data: { marketList: buildMarketListResponseRows(pages[pageNumber - 1] ?? []), totalPages: 5 } }), ok: true }; if (pageNumber === 2) { return secondPageDeferred.promise; } return response; }); ( globalThis as typeof globalThis & { fetch?: typeof fetchMock; } ).fetch = fetchMock; document.documentElement.setAttribute( "data-sces-market-request-snapshot", JSON.stringify({ body: JSON.stringify({ page: 1 }), method: "POST", url: "https://xingtu.cn/api/mock-market-search" }) ); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, getAuthState: async () => ({ isAuthenticated: true, resource: "https://talent-search.intelligrow.cn", userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" } }), loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), promptBatchName: vi.fn(() => "默认批次"), submitBatch, window })); await controller.ready; removeDefaultSpreadMetricFilter(); click('[data-plugin-batch-submit="button"]'); for (let attempt = 0; attempt < 40; attempt += 1) { if ( fetchMock.mock.calls.some(([, init]) => { const body = JSON.parse( String((init as RequestInit | undefined)?.body ?? "{}") ) as { page?: number }; return body.page === 2; }) ) { break; } await new Promise((resolve) => setTimeout(resolve, 50)); await Promise.resolve(); } expect( document.querySelector('[data-plugin-export-status="text"]')?.textContent ).toBe("提交中 2/5 页..."); secondPageDeferred.resolve({ json: async () => ({ data: { marketList: buildMarketListResponseRows(pages[1]), totalPages: 5 } }), ok: true }); await waitForMockCall(submitBatch, 120, 50); } ); test("shows an error when the batch name is blank", async () => { document.body.innerHTML = buildMarketFixture(); const promptBatchName = vi.fn(() => " "); const submitBatch = vi.fn(async () => ({ ok: true })); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, getAuthState: async () => ({ isAuthenticated: true, resource: "https://talent-search.intelligrow.cn", userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" } }), loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), promptBatchName, submitBatch, window })); await controller.ready; removeDefaultSpreadMetricFilter(); 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; removeDefaultSpreadMetricFilter(); 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%" }); removeDefaultSpreadMetricFilter(); 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%" }); removeDefaultSpreadMetricFilter(); 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"]'); removeDefaultSpreadMetricFilter(); 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"]'); removeDefaultSpreadMetricFilter(); 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"]'); removeDefaultSpreadMetricFilter(); 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"]'); removeDefaultSpreadMetricFilter(); 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"]'); removeDefaultSpreadMetricFilter(); 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"]'); removeDefaultSpreadMetricFilter(); 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"]'); removeDefaultSpreadMetricFilter(); 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" }) }) ]) ); }, 30_000 ); 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("mounts favorites controls without replacing the native action text", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } ]); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, favoritesRepository: createTestFavoritesRepository(), loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), window })); await controller.ready; expect(document.querySelector('[data-sces-favorites-tab="button"]')).not.toBeNull(); expect(document.querySelector('[data-sces-favorites-drawer="root"]')).not.toBeNull(); expect( document.querySelector('[data-testid="action-cell-111"] [data-sces-favorite-row-action="button"]') ).not.toBeNull(); expect(document.querySelector('[data-testid="action-cell-111"]')?.textContent).toContain("下单"); }); test("disposes an open favorite picker without retaining row callbacks", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } ]); const repository = createTestFavoritesRepository(); const createFolder = vi.fn(repository.createFolder.bind(repository)); const setCreatorFolderIds = vi.fn(repository.setCreatorFolderIds.bind(repository)); repository.createFolder = createFolder; repository.setCreatorFolderIds = setCreatorFolderIds; const { createMarketController } = await import("../src/content/market/index"); const controller = createMarketController({ document, favoritesRepository: repository, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), window }); await controller.ready; const favoriteButton = document.querySelector( '[data-sces-favorite-row-action="button"]' ) as HTMLButtonElement; favoriteButton.click(); expect(document.querySelector('[data-sces-favorite-row-picker="root"]')).not.toBeNull(); controller.dispose(); expect(document.querySelector('[data-sces-favorite-row-picker="root"]')).toBeNull(); document.body.dispatchEvent(new Event("pointerdown", { bubbles: true })); document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); favoriteButton.click(); await flush(); expect(createFolder).not.toHaveBeenCalled(); expect(setCreatorFolderIds).not.toHaveBeenCalled(); }); test("saves the serialized market row identity through its favorite picker", async () => { document.body.innerHTML = buildRealMarketFixtureWithoutAuthorIds([ { authorName: "达人 A", price21To60s: "¥11,000" } ]); document.documentElement.setAttribute( "data-sces-market-rows", JSON.stringify([ { authorId: "111", authorName: "达人 A", coreUserId: "core-111" } ]) ); const repository = createTestFavoritesRepository(); const setCreatorFolderIds = vi.fn(repository.setCreatorFolderIds.bind(repository)); repository.setCreatorFolderIds = setCreatorFolderIds; const promptFavoriteFolderName = vi.fn(() => "母婴优质达人"); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, favoritesRepository: repository, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), promptFavoriteFolderName, window })); await controller.ready; click('[data-sces-favorite-row-action="button"]'); click('[data-sces-favorite-row-picker="create-folder"]'); await waitForMockCall(setCreatorFolderIds); await (setCreatorFolderIds.mock.results[0]?.value as Promise); await flush(); expect(repository.getStoredState()).toMatchObject({ creators: [ { authorId: "111", authorName: "达人 A", coreUserId: "core-111" } ], folders: [{ name: "母婴优质达人" }] }); expect(promptFavoriteFolderName).toHaveBeenCalledWith({ title: "新建收藏夹" }); expect( document.querySelector('[data-sces-favorite-row-action="button"]')?.dataset.scesFavoriteState ).toBe("saved"); }); test("preserves a late serialized core user id through favorite batch import", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } ]); const repository = createTestFavoritesRepository(); const observer = createMutationObserverFactory(); const promptBatchName = vi.fn(() => "收藏夹批次"); const promptFavoriteFolderName = vi.fn(() => "母婴优质达人"); const submitBatch = vi.fn(async () => ({ ok: true })); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, favoritesRepository: repository, getAuthState: async () => authenticatedTestState(), loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), mutationObserverFactory: observer.factory, promptBatchName, promptFavoriteFolderName, submitBatch, window })); await controller.ready; document.documentElement.setAttribute( "data-sces-market-rows", JSON.stringify([ { authorId: "111", authorName: "达人 A", coreUserId: "core-111" } ]) ); observer.trigger(); await flushWithTimers(); click('[data-sces-favorite-row-action="button"]'); click('[data-sces-favorite-row-picker="create-folder"]'); await waitForCondition( () => document.querySelector('[data-sces-favorite-row-action="button"]')?.dataset .scesFavoriteState === "saved" ); click('[data-sces-favorites-tab="button"]'); ( document.querySelector( '[data-sces-favorites-select-author-id="111"]' ) as HTMLInputElement ).click(); click('[data-sces-favorites-drawer="import-selected"]'); await waitForMockCall(submitBatch); expect(repository.getStoredState()).toMatchObject({ creators: [{ authorId: "111", coreUserId: "core-111" }] }); expect(submitBatch).toHaveBeenCalledWith( expect.objectContaining({ authors: [{ authorId: "111", authorName: "达人 A", authorUid: "core-111" }], batchName: "收藏夹批次" }) ); }); test("renders a creator once in all favorites and in its selected folder", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } ]); const repository = createTestFavoritesRepository(); const firstFolder = await repository.createFolder("母婴优质达人"); const secondFolder = await repository.createFolder("品牌合作"); await repository.setCreatorFolderIds( { authorId: "111", authorName: "达人 A", coreUserId: "core-111" }, [firstFolder.id, secondFolder.id] ); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, favoritesRepository: repository, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), window })); await controller.ready; click('[data-sces-favorites-tab="button"]'); expect(document.querySelectorAll('[data-sces-favorites-author-id="111"]')).toHaveLength(1); click(`[data-sces-favorites-folder-id="${secondFolder.id}"]`); expect(document.querySelectorAll('[data-sces-favorites-author-id="111"]')).toHaveLength(1); }); test("submits selected favorites without scanning or exporting the market range", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } ]); const repository = createTestFavoritesRepository(); const folder = await repository.createFolder("母婴优质达人"); await repository.setCreatorFolderIds( { authorId: "111", authorName: "达人 A", coreUserId: "core-111" }, [folder.id] ); const promptBatchName = vi.fn(() => "收藏夹批次"); const submitBatch = vi.fn(async () => ({ ok: true })); const nextPage = document.querySelector('[data-testid="next-page"]') as HTMLButtonElement; const onNextPage = vi.fn(); nextPage.addEventListener("click", onNextPage); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, favoritesRepository: repository, getAuthState: async () => authenticatedTestState(), loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), promptBatchName, submitBatch, window })); await controller.ready; click('[data-sces-favorites-tab="button"]'); const selected = document.querySelector( '[data-sces-favorites-select-author-id="111"]' ) as HTMLInputElement; selected.click(); click('[data-sces-favorites-drawer="import-selected"]'); await waitForMockCall(submitBatch); expect(promptBatchName).toHaveBeenCalledTimes(1); expect(submitBatch).toHaveBeenCalledWith( expect.objectContaining({ authors: [{ authorId: "111", authorName: "达人 A", authorUid: "core-111" }], batchName: "收藏夹批次" }) ); expect(onNextPage).not.toHaveBeenCalled(); expect(repository.getStoredState()).toMatchObject({ creators: [{ authorId: "111" }] }); expect( document.querySelector('[data-sces-favorites-drawer="error"]')?.textContent ).toBe("批次提交成功"); }); test("does not prompt or submit a full folder when favorite import confirmation is declined", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } ]); const repository = createTestFavoritesRepository(); const folder = await repository.createFolder("母婴优质达人"); await repository.setCreatorFolderIds({ authorId: "111", authorName: "达人 A" }, [folder.id]); const promptBatchName = vi.fn(() => "不应调用"); const submitBatch = vi.fn(async () => ({ ok: true })); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ confirmFavoriteImport: () => false, document, favoritesRepository: repository, getAuthState: async () => authenticatedTestState(), loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), promptBatchName, submitBatch, window })); await controller.ready; click('[data-sces-favorites-tab="button"]'); click(`[data-sces-favorites-folder-id="${folder.id}"]`); click('[data-sces-favorites-drawer="import-folder"]'); await flush(); expect(promptBatchName).not.toHaveBeenCalled(); expect(submitBatch).not.toHaveBeenCalled(); }); test("honors favorite folder deletion confirmation", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } ]); const repository = createTestFavoritesRepository(); const folder = await repository.createFolder("母婴优质达人"); let shouldDelete = false; const confirmFavoriteDelete = vi.fn(() => shouldDelete); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ confirmFavoriteDelete, document, favoritesRepository: repository, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), window })); await controller.ready; click('[data-sces-favorites-tab="button"]'); click(`[data-sces-favorites-folder-delete="${folder.id}"]`); await flush(); expect(repository.getStoredState()).toMatchObject({ folders: [{ id: folder.id }] }); shouldDelete = true; click(`[data-sces-favorites-folder-delete="${folder.id}"]`); await waitForCondition( () => document.querySelector(`[data-sces-favorites-folder-delete="${folder.id}"]`) === null ); await flush(); expect(repository.getStoredState()).toMatchObject({ folders: [] }); expect(confirmFavoriteDelete).toHaveBeenLastCalledWith( "确定删除收藏夹“母婴优质达人”吗?" ); }); test("shows a favorite mutation error without disabling CSV export", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } ]); const repository = createTestFavoritesRepository(); repository.createFolder = async () => { throw new Error(); }; const buildCsv = vi.fn(() => "csv-output"); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ buildCsv, document, favoritesRepository: repository, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), onCsvReady: vi.fn(), promptFavoriteFolderName: () => "母婴优质达人", window })); await controller.ready; click('[data-sces-favorite-row-action="button"]'); click('[data-sces-favorite-row-picker="create-folder"]'); await waitForCondition( () => document.querySelector('[data-sces-favorites-drawer="error"]')?.textContent === "收藏夹操作失败,请稍后重试" ); const exportButton = document.querySelector( '[data-plugin-export="button"]' ) as HTMLButtonElement; expect(exportButton.disabled).toBe(false); setSelectValue('[data-plugin-export-range="select"]', "current"); dispatchChange('[data-plugin-export-range="select"]'); removeDefaultSpreadMetricFilter(); click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 40, 50); expect(buildCsv).toHaveBeenCalledWith(expect.any(Array)); }); test("keeps the market toolbar available when favorites storage cannot be read", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } ]); const repository = createTestFavoritesRepository(); repository.read = async () => { throw new Error("storage unavailable"); }; const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, favoritesRepository: repository, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), window })); await expect(controller.ready).resolves.toBeUndefined(); expect( document.querySelector('[data-sces-favorites-drawer="error"]')?.textContent ).toBe("收藏夹暂不可用"); expect(document.querySelector('[data-plugin-export="button"]')).not.toBeNull(); expect(document.querySelector('[data-plugin-batch-submit="button"]')).not.toBeNull(); }); test("recovers favorite pickers after a later refresh failure and page replacement", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } ]); const repository = createTestFavoritesRepository(); const folder = await repository.createFolder("母婴优质达人"); await repository.setCreatorFolderIds({ authorId: "111", authorName: "达人 A" }, [folder.id]); const originalRead = repository.read.bind(repository); let shouldRejectRead = false; const read = vi.fn(async () => { if (shouldRejectRead) { throw new Error("storage unavailable"); } return originalRead(); }); repository.read = read; const observer = createMutationObserverFactory(); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, favoritesRepository: repository, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), mutationObserverFactory: observer.factory, window })); await controller.ready; expect( document.querySelector('[data-sces-favorite-row-action="button"]')?.dataset.scesFavoriteState ).toBe("saved"); shouldRejectRead = true; document.querySelector('[data-testid="action-cell-111"]')!.replaceChildren( document.createTextNode("下单") ); observer.trigger(); await waitForCondition( () => document.querySelector('[data-sces-favorites-drawer="error"]')?.textContent === "收藏夹暂不可用" ); expect(document.querySelector('[data-sces-favorite-row-action="button"]')).toBeNull(); expect(document.querySelector('[data-plugin-export="button"]')).not.toBeNull(); shouldRejectRead = false; document.documentElement.setAttribute("data-test-page-index", "2"); document.querySelector('[data-testid="action-cell-111"]')!.replaceChildren( document.createTextNode("下单") ); observer.trigger(); await waitForCondition( () => document.querySelector('[data-sces-favorite-row-action="button"]')?.dataset .scesFavoriteState === "saved" ); expect(read).toHaveBeenCalledTimes(3); expect(document.querySelector('[data-sces-favorites-drawer="error"]')).toBeNull(); }); test("settles the initial observer resync before ready enables an immediate export", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } ]); const repository = createTestFavoritesRepository(); const originalRead = repository.read.bind(repository); const observer = createMutationObserverFactory(); let shouldScheduleInitialResync = true; repository.read = async () => { if (shouldScheduleInitialResync) { shouldScheduleInitialResync = false; document.querySelector('[data-plugin-toolbar="root"]')?.remove(); observer.trigger(); } return originalRead(); }; const buildCsv = vi.fn(() => "csv-output"); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ buildCsv, document, favoritesRepository: repository, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), mutationObserverFactory: observer.factory, onCsvReady: vi.fn(), window })); await controller.ready; expect(document.querySelector('[data-plugin-toolbar="root"]')).not.toBeNull(); setSelectValue('[data-plugin-export-range="select"]', "current"); dispatchChange('[data-plugin-export-range="select"]'); removeDefaultSpreadMetricFilter(); click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 40, 50); expect(buildCsv).toHaveBeenCalledTimes(1); }); test("awaits a scheduled initial hydration before ready enables an immediate export", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } ]); const repository = createTestFavoritesRepository(); const originalRead = repository.read.bind(repository); const observer = createMutationObserverFactory(); const successfulMetrics = { rates: { personalVideoAfterSearchRate: "0.2%", singleVideoAfterSearchRate: "0.1%" }, success: true as const }; const deferredMetrics = createDeferred(); let shouldReplaceInitialPage = true; let activeMetricLoads = 0; let maxActiveMetricLoads = 0; repository.read = async () => { const state = await originalRead(); if (shouldReplaceInitialPage) { shouldReplaceInitialPage = false; document.body.innerHTML = buildRealMarketFixture([ { authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" } ]); observer.trigger(); } return state; }; const loadAuthorMetrics = vi.fn(async (authorId: string) => { activeMetricLoads += 1; maxActiveMetricLoads = Math.max(maxActiveMetricLoads, activeMetricLoads); try { if (authorId === "222") { return await deferredMetrics.promise; } return successfulMetrics; } finally { activeMetricLoads -= 1; } }); const buildCsv = vi.fn(() => "csv-output"); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ buildCsv, document, favoritesRepository: repository, loadAuthorMetrics, mutationObserverFactory: observer.factory, onCsvReady: vi.fn(), window })); let readySettled = false; void controller.ready.then(() => { readySettled = true; }); await waitForCondition(() => loadAuthorMetrics.mock.calls.some(([authorId]) => authorId === "222") ); await Promise.resolve(); expect(readySettled).toBe(false); deferredMetrics.resolve(successfulMetrics); await controller.ready; setSelectValue('[data-plugin-export-range="select"]', "current"); dispatchChange('[data-plugin-export-range="select"]'); removeDefaultSpreadMetricFilter(); click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 40, 50); expect(buildCsv).toHaveBeenCalledTimes(1); expect(loadAuthorMetrics.mock.calls.map(([authorId]) => authorId)).toEqual(["111", "222"]); expect(maxActiveMetricLoads).toBe(1); }); test("awaits a follow-up sync triggered during initial scheduled hydration", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } ]); const repository = createTestFavoritesRepository(); const originalRead = repository.read.bind(repository); const observer = createMutationObserverFactory(); const successfulMetrics = { rates: { personalVideoAfterSearchRate: "0.2%", singleVideoAfterSearchRate: "0.1%" }, success: true as const }; const deferredSecondPageMetrics = createDeferred(); const deferredThirdPageMetrics = createDeferred(); let shouldReplaceInitialPage = true; repository.read = async () => { const state = await originalRead(); if (shouldReplaceInitialPage) { shouldReplaceInitialPage = false; document.body.innerHTML = buildRealMarketFixture([ { authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" } ]); observer.trigger(); } return state; }; const loadAuthorMetrics = vi.fn((authorId: string) => { if (authorId === "222") { return deferredSecondPageMetrics.promise; } if (authorId === "333") { return deferredThirdPageMetrics.promise; } return Promise.resolve(successfulMetrics); }); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, favoritesRepository: repository, loadAuthorMetrics, mutationObserverFactory: observer.factory, window })); let readySettled = false; void controller.ready.then(() => { readySettled = true; }); await waitForCondition(() => loadAuthorMetrics.mock.calls.some(([authorId]) => authorId === "222") ); document.body.innerHTML = buildRealMarketFixture([ { authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" } ]); observer.trigger(); deferredSecondPageMetrics.resolve(successfulMetrics); await waitForCondition(() => loadAuthorMetrics.mock.calls.some(([authorId]) => authorId === "333") ); await Promise.resolve(); expect(readySettled).toBe(false); deferredThirdPageMetrics.resolve({ rates: { personalVideoAfterSearchRate: "0.33%", singleVideoAfterSearchRate: "0.33%" }, success: true }); await controller.ready; expect( document.querySelector('[data-market-selection-checkbox="row"]')?.getAttribute( "data-market-selection-author-id" ) ).toBe("333"); expect(readDivPluginRowTexts(0)[0]).toBe("0.33%"); }); test("keeps the friendly drawer error when a direct folder mutation has no message", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } ]); const repository = createTestFavoritesRepository(); repository.createFolder = async () => { throw new Error(); }; const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, favoritesRepository: repository, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), promptFavoriteFolderName: () => "不应出现的收藏夹", window })); await controller.ready; click('[data-sces-favorites-tab="button"]'); click('[data-sces-favorites-drawer="create-folder"]'); await waitForCondition( () => document.querySelector('[data-sces-favorites-drawer="error"]')?.textContent === "收藏夹操作失败,请稍后重试" ); expect(document.querySelectorAll('[data-sces-favorites-folder-id]')).toHaveLength(0); expect(document.querySelector('[data-sces-favorites-drawer="creators"]')?.textContent).toContain( "暂无收藏达人" ); }); }); function clearLocalStorage(): void { if (typeof window.localStorage.clear === "function") { window.localStorage.clear(); return; } for (let index = window.localStorage.length - 1; index >= 0; index -= 1) { const key = window.localStorage.key(index); if (key) { window.localStorage.removeItem(key); } } } function installUsableLocalStorage(): void { if ( typeof window.localStorage.getItem === "function" && typeof window.localStorage.setItem === "function" && typeof window.localStorage.removeItem === "function" && typeof window.localStorage.clear === "function" ) { return; } const values = new Map(); const storage: Storage = { get length() { return values.size; }, clear() { values.clear(); }, getItem(key: string) { return values.get(key) ?? null; }, key(index: number) { return Array.from(values.keys())[index] ?? null; }, removeItem(key: string) { values.delete(key); }, setItem(key: string, value: string) { values.set(key, String(value)); } }; Object.defineProperty(window, "localStorage", { configurable: true, value: storage }); } function buildMarketFixture() { return buildMarketPageShell(buildMarketTableOnlyFixture()); } function buildMarketTableOnlyFixture() { return `
达人
21-60s报价
Alpha 450000
Beta 70000
`; } function buildMarketPageShell(content: string) { return `
找到 10000+ 个达人
${content} `; } function buildRealMarketFixture( rows: Array<{ authorId: string; authorName: string; price21To60s: string; }> ) { return buildMarketPageShell(`
${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 buildMarketListResponseRows( rows: Array<{ authorId: string; authorName: string; price21To60s: string; }> ): Array> { return rows.map((row) => ({ attribute_datas: { items: JSON.stringify([ { title: `代表视频${row.authorName}` } ]), nick_name: row.authorName, nickname: row.authorName, price_20_60: Number(row.price21To60s.replace(/[^\d]/g, "")) }, nick_name: row.authorName, star_id: row.authorId })); } function installPaginationHarness( pages: Array< Array<{ authorId: string; authorName: string; price21To60s: string; }> > ) { let pageIndex = 0; let clicks = 0; const nextButton = document.querySelector( '[data-testid="next-page"]' ) as HTMLButtonElement | null; if (!nextButton) { throw new Error("Missing next page button"); } const renderPage = () => { const authorColumn = readAuthorContentColumn(); const middleColumn = document.querySelector( '.middle-columns .content-column' ) as HTMLElement | null; const rightColumns = document.querySelectorAll( '[data-testid="right-section"] > .content-column' ); if (!authorColumn || !middleColumn || rightColumns.length < 2) { throw new Error("Missing market columns for pagination harness"); } const rows = pages[pageIndex]; authorColumn.innerHTML = rows .map( (row) => ` ` ) .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 = readAuthorContentColumn(); const middleColumn = document.querySelector( '.middle-columns .content-column' ) as HTMLElement | null; const rightColumns = document.querySelectorAll( '[data-testid="right-section"] > .content-column' ); if (!authorColumn || !middleColumn || rightColumns.length < 2) { throw new Error("Missing market columns for pagination harness"); } const rows = pages[pageIndex]; authorColumn.innerHTML = rows .map( (row) => ` ` ) .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 = readAuthorContentColumn(); const middleColumn = document.querySelector( '.middle-columns .content-column' ) as HTMLElement | null; const rightColumns = document.querySelectorAll( '[data-testid="right-section"] > .content-column' ); if (!authorColumn || !middleColumn || rightColumns.length < 2) { throw new Error("Missing market columns for pagination harness"); } const rows = pages[pageIndex]; authorColumn.innerHTML = rows .map( (row) => ` ` ) .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 = readAuthorContentColumn(); const middleColumn = document.querySelector( '.middle-columns .content-column' ) as HTMLElement | null; const rightColumns = document.querySelectorAll( '[data-testid="right-section"] > .content-column' ); authorColumn!.innerHTML = ""; middleColumn!.innerHTML = ""; rightColumns.forEach((column) => { (column as HTMLElement).innerHTML = ""; }); const renderToken = ++activeRenderToken; window.setTimeout(() => { if (renderToken !== activeRenderToken) { return; } renderPage(); }, options.renderDelayMs); }); renderPage(); return { getClicks() { return clicks; } }; } function installProgressivePaginationHarness( pages: Array< Array<{ authorId: string; authorName: string; price21To60s: string; }> >, options: { firstRenderCount: number; firstRenderDelayMs: number; fullRenderDelayMs: number; } ) { let pageIndex = 0; let clicks = 0; let activeRenderToken = 0; const nextButton = document.querySelector( '[data-testid="next-page"]' ) as HTMLButtonElement | null; if (!nextButton) { throw new Error("Missing next page button"); } const updatePaginationState = (visiblePageIndex: number) => { document.documentElement.setAttribute( "data-test-page-index", String(visiblePageIndex + 1) ); nextButton.disabled = visiblePageIndex >= pages.length - 1; nextButton.setAttribute("aria-disabled", nextButton.disabled ? "true" : "false"); }; const renderRows = ( rows: Array<{ authorId: string; authorName: string; price21To60s: string; }> ) => { const authorColumn = readAuthorContentColumn(); const middleColumn = document.querySelector( '.middle-columns .content-column' ) as HTMLElement | null; const rightColumns = document.querySelectorAll( '[data-testid="right-section"] > .content-column' ); if (!authorColumn || !middleColumn || rightColumns.length < 2) { throw new Error("Missing market columns for pagination harness"); } authorColumn.innerHTML = rows .map( (row) => ` ` ) .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 = readAuthorContentCells(); const middleCells = Array.from( document.querySelectorAll(".middle-columns .content-column .content-cell") ) as HTMLElement[]; const priceCells = Array.from(rightColumns[0]?.querySelectorAll(".content-cell") ?? []) as HTMLElement[]; const hiddenCells = hiddenRowIndexes.flatMap((rowIndex) => { const authorCell = hideAuthorCells ? authorCells[rowIndex] ?? null : null; const middleCell = middleCells[rowIndex] ?? null; const priceCell = priceCells[rowIndex] ?? null; return [authorCell, middleCell, priceCell] .filter((cell): cell is HTMLElement => cell !== null) .map((cell) => ({ cell, text: cell.textContent ?? "" })); }); const hiddenAuthorIdentityCells = hideAuthorIdentity ? hiddenRowIndexes .map((rowIndex) => authorCells[rowIndex] ?? null) .filter((cell): cell is HTMLElement => cell !== null) .map((cell) => ({ cell, html: cell.innerHTML })) : []; hiddenCells.forEach(({ cell }) => { cell.textContent = ""; }); hiddenAuthorIdentityCells.forEach(({ cell }) => { cell.innerHTML = ""; }); let hydrated = false; let scrollTopValue = 0; Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 120 }); Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 480 }); Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, get() { return scrollTopValue; }, set(value: number) { scrollTopValue = value; if (hydrated || value <= 0) { return; } hydrated = true; window.setTimeout(() => { hiddenAuthorIdentityCells.forEach(({ cell, html }) => { cell.innerHTML = html; }); hiddenCells.forEach(({ cell, text }) => { cell.textContent = text; }); }, hydrateDelayMs); } }); } function installRichLazyFieldHydrationHarness(options: { hiddenRowIndexes: number[]; hydrateDelayMs?: number; scrollContainer: HTMLElement; }) { const { hiddenRowIndexes, hydrateDelayMs = 0, scrollContainer } = options; const middleColumns = Array.from( document.querySelectorAll(".middle-columns .content-column") ) as HTMLElement[]; const delayedColumns = middleColumns.slice(1); const hiddenCells = hiddenRowIndexes.flatMap((rowIndex) => { const rowCells = delayedColumns.map( (column) => (Array.from(column.querySelectorAll(".content-cell"))[rowIndex] as HTMLElement | undefined) ?? null ); return rowCells .filter((cell): cell is HTMLElement => cell !== null) .map((cell) => ({ cell, html: cell.innerHTML })); }); hiddenCells.forEach(({ cell }) => { cell.innerHTML = ""; }); let hydrated = false; let scrollTopValue = 0; Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 120 }); Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 480 }); Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, get() { return scrollTopValue; }, set(value: number) { scrollTopValue = value; if (hydrated || value <= 0) { return; } hydrated = true; window.setTimeout(() => { hiddenCells.forEach(({ cell, html }) => { cell.innerHTML = html; }); }, hydrateDelayMs); } }); } function installPagedLazyFieldHydrationHarness(options: { hideAuthorIdentity?: boolean; hiddenRowIndexes: number[]; hideAuthorCells?: boolean; hydrateDelayMs?: number; scrollContainer: HTMLElement; }) { const { hideAuthorIdentity = false, hiddenRowIndexes, hideAuthorCells = false, hydrateDelayMs = 0, scrollContainer } = options; const observers: MutationObserver[] = []; let currentPageToken = ""; let hiddenPageToken = ""; let hydratedPageToken = ""; let hiddenTextCells: Array<{ cell: HTMLElement; text: string }> = []; let hiddenAuthorIdentityCells: Array<{ cell: HTMLElement; html: string }> = []; let scrollTopValue = 0; const readPageToken = () => document.documentElement.getAttribute("data-test-page-index") ?? "1"; const clearPageState = () => { hiddenTextCells = []; hiddenAuthorIdentityCells = []; }; const hideCurrentPage = () => { const pageToken = readPageToken(); if (pageToken === hiddenPageToken) { return; } const rightColumns = document.querySelectorAll( '[data-testid="right-section"] > .content-column' ); const authorCells = readAuthorContentCells(); const middleCells = Array.from( document.querySelectorAll(".middle-columns .content-column .content-cell") ) as HTMLElement[]; const priceCells = Array.from( rightColumns[0]?.querySelectorAll(".content-cell") ?? [] ) as HTMLElement[]; clearPageState(); hiddenTextCells = hiddenRowIndexes.flatMap((rowIndex) => { const authorCell = hideAuthorCells ? authorCells[rowIndex] ?? null : null; const middleCell = middleCells[rowIndex] ?? null; const priceCell = priceCells[rowIndex] ?? null; return [authorCell, middleCell, priceCell] .filter((cell): cell is HTMLElement => cell !== null) .map((cell) => ({ cell, text: cell.textContent ?? "" })); }); hiddenAuthorIdentityCells = hideAuthorIdentity ? hiddenRowIndexes .map((rowIndex) => authorCells[rowIndex] ?? null) .filter((cell): cell is HTMLElement => cell !== null) .map((cell) => ({ cell, html: cell.innerHTML })) : []; hiddenTextCells.forEach(({ cell }) => { cell.textContent = ""; }); hiddenAuthorIdentityCells.forEach(({ cell }) => { cell.innerHTML = ""; }); currentPageToken = pageToken; hiddenPageToken = pageToken; hydratedPageToken = ""; }; const hydrateCurrentPage = () => { const pageToken = readPageToken(); if (pageToken !== hiddenPageToken || pageToken === hydratedPageToken) { return; } hydratedPageToken = pageToken; window.setTimeout(() => { hiddenAuthorIdentityCells.forEach(({ cell, html }) => { cell.innerHTML = html; }); hiddenTextCells.forEach(({ cell, text }) => { cell.textContent = text; }); }, hydrateDelayMs); }; const hideObserver = new MutationObserver(() => { const pageToken = readPageToken(); if (pageToken !== currentPageToken) { currentPageToken = pageToken; hiddenPageToken = ""; hydratedPageToken = ""; } window.setTimeout(() => { hideCurrentPage(); }, 0); }); hideObserver.observe(document.body, { childList: true, subtree: true }); observers.push(hideObserver); const pageObserver = new MutationObserver(() => { const pageToken = readPageToken(); if (pageToken === currentPageToken) { return; } currentPageToken = pageToken; hiddenPageToken = ""; hydratedPageToken = ""; window.setTimeout(() => { hideCurrentPage(); }, 0); }); pageObserver.observe(document.documentElement, { attributeFilter: ["data-test-page-index"], attributes: true }); observers.push(pageObserver); Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 120 }); Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 480 }); Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, get() { return scrollTopValue; }, set(value: number) { scrollTopValue = value; if (value <= 0) { return; } hydrateCurrentPage(); } }); hideCurrentPage(); disposers.push(() => { observers.forEach((observer) => observer.disconnect()); }); } function createMutationObserverFactory() { let callback: MutationCallback = () => undefined; return { factory(nextCallback: MutationCallback) { callback = nextCallback; return { disconnect() {}, observe() {} }; }, trigger() { callback([], {} as MutationObserver); } }; } function click(selector: string) { const element = document.querySelector(selector) as HTMLButtonElement | null; if (!element) { throw new Error(`Missing element: ${selector}`); } element.click(); } function removeDefaultSpreadMetricFilter() { click('[data-plugin-spread-rule-remove="finishRate"]'); } function enableSpreadMetric(metric: "finishRate" | "interactionRate") { const selector = `[data-plugin-spread-metric="${metric}"]`; const input = document.querySelector(selector) as HTMLInputElement | null; if (!input) { throw new Error(`Missing spread metric toggle: ${metric}`); } input.checked = true; dispatchChange(selector); } function readSpreadRuleSelect( metric: "finishRate" | "interactionRate", field: "type" | "onlyAssign" | "flowType" | "range" ): HTMLSelectElement { const selector = `[data-plugin-spread-rule="${metric}"] ` + `[data-plugin-spread-filter="${field}"]`; const select = document.querySelector(selector) as HTMLSelectElement | null; if (!select) { throw new Error(`Missing spread rule select: ${metric}.${field}`); } return select; } function setSpreadRuleSelect( metric: "finishRate" | "interactionRate", field: "type" | "onlyAssign" | "flowType" | "range", value: string ) { const select = readSpreadRuleSelect(metric, field); select.value = value; select.dispatchEvent(new Event("change")); } function createNoopToolbarHandlers() { return { onConfigureAudienceProfileFields: vi.fn(), onExport: vi.fn(), onExportAudienceProfile: vi.fn(), onExportAudienceProfileByIds: vi.fn(), onSubmitBatch: vi.fn() }; } function clickSelectionCheckboxForAuthor(authorId: string) { readSelectionCheckboxForAuthor(authorId).click(); } function clickHeaderSelectionCheckbox() { readHeaderSelectionCheckbox().click(); } function readSelectionCheckboxForAuthor(authorId: string) { const bySelectionAuthorId = document.querySelector( `[data-market-selection-author-id="${authorId}"]` ) as HTMLInputElement | null; if (bySelectionAuthorId) { return bySelectionAuthorId; } const bySyntheticRow = document.querySelector( `[data-market-row][data-author-id="${authorId}"] [data-market-selection-checkbox="row"]` ) as HTMLInputElement | null; if (bySyntheticRow) { return bySyntheticRow; } const byAuthorCell = document.querySelector( `[data-testid="author-cell-${authorId}"] [data-market-selection-checkbox="row"]` ) as HTMLInputElement | null; if (byAuthorCell) { return byAuthorCell; } throw new Error(`Missing selection checkbox for author: ${authorId}`); } function readHeaderSelectionCheckbox() { const checkbox = document.querySelector( '[data-market-selection-checkbox="header"]' ) as HTMLInputElement | null; if (!checkbox) { throw new Error("Missing header selection checkbox"); } return checkbox; } function setInputValue(selector: string, value: string) { const element = document.querySelector(selector) as HTMLInputElement | null; if (!element) { throw new Error(`Missing input: ${selector}`); } element.value = value; } function dispatchInput(selector: string) { const element = document.querySelector(selector) as HTMLElement | null; if (!element) { throw new Error(`Missing element: ${selector}`); } element.dispatchEvent(new Event("input")); } function setSelectValue(selector: string, value: string) { const element = document.querySelector(selector) as HTMLSelectElement | null; if (!element) { throw new Error(`Missing select: ${selector}`); } element.value = value; } function expectSelectValue(selector: string, expected: string) { const element = document.querySelector(selector) as HTMLSelectElement | null; if (!element) { throw new Error(`Missing select: ${selector}`); } expect(element.value).toBe(expected); } function dispatchChange(selector: string) { const element = document.querySelector(selector) as HTMLElement | null; if (!element) { throw new Error(`Missing element: ${selector}`); } element.dispatchEvent(new Event("change")); } function readRowOrder() { return Array.from(document.querySelectorAll("[data-market-row]")).map( (row) => row.getAttribute("data-author-id") ); } function readDivAuthorOrder() { const authorColumn = readAuthorContentColumn(); return readVisualCells(authorColumn).map( (cell) => cell.querySelector("a")?.textContent?.trim() ?? "" ); } function readAuthorContentColumn(): HTMLElement | null { const nativeColumns = Array.from( document.querySelectorAll('[data-testid="author-section"] > .content-column') ).filter( (column): column is HTMLElement => column instanceof HTMLElement && !column.dataset.marketColumnGroup ); if (nativeColumns.length === 1) { return nativeColumns[0]; } return ( nativeColumns.find((column) => Boolean( column.querySelector('a[href*="/author-homepage/"]') || column.querySelector(".author-nickname") || column.querySelector("[data-testid^='author-cell-']") ) ) ?? null ); } function readAuthorContentCells(): HTMLElement[] { return Array.from( readAuthorContentColumn()?.querySelectorAll(":scope > .content-cell") ?? [] ).filter((cell): cell is HTMLElement => cell instanceof HTMLElement); } function readDivRightRowTexts(rowIndex: number) { return Array.from( document.querySelectorAll('[data-testid="right-section"] > .content-column'), (column) => readVisualCells(column as Element)[rowIndex]?.textContent?.trim() ?? "" ); } function readDivPluginRowTexts(rowIndex: number) { return Array.from( document.querySelectorAll('[data-testid="plugin-section"] > .content-column'), (column) => readVisualCells(column as Element)[rowIndex]?.textContent?.trim() ?? "" ); } function readVisualCells(root: Element | null): HTMLElement[] { if (!root) { return []; } return Array.from(root.querySelectorAll(":scope > .content-cell")) .filter((cell): cell is HTMLElement => cell instanceof HTMLElement) .sort((left, right) => { const leftOrder = Number(left.style.order || "0"); const rightOrder = Number(right.style.order || "0"); if (leftOrder !== rightOrder) { return leftOrder - rightOrder; } const cells = Array.from(root.querySelectorAll(":scope > .content-cell")); return cells.indexOf(left) - cells.indexOf(right); }); } function trackController void }>(controller: T): T { if (controller.dispose) { disposers.push(() => controller.dispose?.()); } return controller; } function createTestFavoritesRepository(initial: unknown = undefined) { let storedValue = initial; let nextFolderId = 0; const storage: FavoritesStorage = { async get() { return storedValue; }, async set(value) { storedValue = value; } }; const repository = createFavoritesRepository({ createId: () => `test-folder-${++nextFolderId}`, now: () => "2026-07-17T00:00:00.000Z", storage }); return Object.assign(repository, { getStoredState() { return storedValue; } }); } function authenticatedTestState() { return { isAuthenticated: true, resource: "https://talent-search.intelligrow.cn", userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" } }; } function expectButtonDisabled(selector: string, expected: boolean) { const element = document.querySelector(selector) as HTMLButtonElement | null; if (!element) { throw new Error(`Missing button: ${selector}`); } expect(element.disabled).toBe(expected); } function expectSelectDisabled(selector: string, expected: boolean) { const element = document.querySelector(selector) as HTMLSelectElement | null; if (!element) { throw new Error(`Missing select: ${selector}`); } expect(element.disabled).toBe(expected); } async function flush() { await Promise.resolve(); await Promise.resolve(); } async function flushWithTimers() { await new Promise((resolve) => setTimeout(resolve, 0)); await Promise.resolve(); await new Promise((resolve) => setTimeout(resolve, 0)); } async function waitForMockCall( mockFn: { mock: { calls: unknown[][] } }, maxAttempts = 10, pollDelayMs = 0 ) { for (let attempt = 0; attempt < maxAttempts; attempt += 1) { if (mockFn.mock.calls.length > 0) { return; } if (pollDelayMs > 0) { await new Promise((resolve) => setTimeout(resolve, pollDelayMs)); await Promise.resolve(); continue; } await flushWithTimers(); } } async function waitForCondition( condition: () => boolean | Promise, maxAttempts = 40 ): Promise { for (let attempt = 0; attempt < maxAttempts; attempt += 1) { if (await condition()) { return; } await flushWithTimers(); } throw new Error("Timed out waiting for condition"); } function createDeferred() { let resolve!: (value: T | PromiseLike) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((nextResolve, nextReject) => { resolve = nextResolve; reject = nextReject; }); return { promise, reject, resolve }; }