// @vitest-environment jsdom // @vitest-environment-options {"url":"https://xingtu.cn/"} import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { createMarketResultStore } from "../src/content/market/result-store"; const disposers: Array<() => void> = []; describe("market-content-entry", () => { beforeEach(() => { document.body.innerHTML = ""; document.documentElement.removeAttribute("data-sces-market-rows"); window.history.replaceState({}, "", "/"); }); afterEach(() => { vi.doUnmock("../src/content/market/index"); delete ( globalThis as typeof globalThis & { chrome?: unknown; } ).chrome; delete ( window as Window & { __starChartSearchEnhancerContentController?: unknown; __SCES_MARKET_PAGE_BRIDGE_INSTALLED__?: boolean; } ).__starChartSearchEnhancerContentController; delete ( window as Window & { __SCES_MARKET_PAGE_BRIDGE_INSTALLED__?: boolean; } ).__SCES_MARKET_PAGE_BRIDGE_INSTALLED__; document.documentElement.removeAttribute("data-sces-market-rows"); vi.resetModules(); while (disposers.length > 0) { disposers.pop()?.(); } }); test("auto boots on import when chrome runtime is available", async () => { const createMarketController = vi.fn(() => ({ ready: Promise.resolve() })); window.history.replaceState({}, "", "/ad/creator/market"); ( globalThis as typeof globalThis & { chrome?: { runtime?: object }; } ).chrome = { runtime: {} }; 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 }); expect(createMarketController).toHaveBeenCalledTimes(1); expect( document.documentElement.querySelector('[data-sces-market-bridge="script"]') ).not.toBeNull(); }); test("boots the market controller on the www Xingtu market URL", async () => { const createMarketController = vi.fn(() => ({ ready: Promise.resolve() })); const { bootContentScript } = await import("../src/content/index"); await bootContentScript({ createMarketController, document, 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 }); const controllerOptions = createMarketController.mock.calls[0]?.[0]; expect(controllerOptions?.onCsvReady).toEqual(expect.any(Function)); controllerOptions.onCsvReady("列1,列2\n值1,值2"); expect(createObjectURL).toHaveBeenCalledTimes(1); expect(clickSpy).toHaveBeenCalledTimes(1); expect(clickedDownload).not.toBeNull(); expect(clickedDownload?.href).toBe("blob:test-url"); expect(clickedDownload?.download).toMatch(/\.csv$/); expect(revokeObjectURL).toHaveBeenCalledWith("blob:test-url"); }); test("booted export callback sends the csv to extension runtime when available", async () => { const createMarketController = vi.fn(() => ({ ready: Promise.resolve() })); const sendMessage = vi.fn(); window.history.replaceState({}, "", "/ad/creator/market"); ( globalThis as typeof globalThis & { chrome?: { runtime?: { id?: string; sendMessage?: (message: unknown) => void } }; } ).chrome = { runtime: { id: "test-extension", sendMessage } }; const { bootContentScript } = await import("../src/content/index"); await bootContentScript({ createMarketController }); const controllerOptions = createMarketController.mock.calls[0]?.[0]; controllerOptions.onCsvReady("列1,列2\n值1,值2"); expect(sendMessage).toHaveBeenCalledTimes(1); expect(sendMessage).toHaveBeenCalledWith( expect.objectContaining({ csv: "列1,列2\n值1,值2", type: "download-market-csv" }) ); }); test("hydrates current page rows on start", async () => { document.body.innerHTML = buildMarketFixture(); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, loadAuthorMetrics: async (authorId) => ({ success: true, rates: authorId === "a" ? { singleVideoAfterSearchRate: "0.02% - 0.1%", personalVideoAfterSearchRate: "0.03% - 0.2%" } : { singleVideoAfterSearchRate: "0.5%-1%", personalVideoAfterSearchRate: "0.01% - 0.1%" } }), window })); await controller.ready; expect( document.querySelector('[data-market-row-cell="singleVideoAfterSearchRate"]') ?.textContent ).toBe("0.02% - 0.1%"); expect( document.querySelector('[data-market-row-cell="personalVideoAfterSearchRate"]') ?.textContent ).toBe("0.03% - 0.2%"); }); test("hydrates the real div-grid market rows on start", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥450,000" }, { authorId: "222", authorName: "达人 B", price21To60s: "¥20,000" } ]); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, loadAuthorMetrics: async (authorId) => ({ success: true, rates: authorId === "111" ? { singleVideoAfterSearchRate: "0.02% - 0.1%", personalVideoAfterSearchRate: "0.03% - 0.2%" } : { singleVideoAfterSearchRate: "0.5%-1%", personalVideoAfterSearchRate: "0.01% - 0.1%" } }), window })); await controller.ready; expect(readDivRightRowTexts(0)).toEqual([ "¥450,000", "0.02% - 0.1%", "0.03% - 0.2%", "下单" ]); expect(readDivRightRowTexts(1)).toEqual([ "¥20,000", "0.5% - 1%", "0.01% - 0.1%", "下单" ]); }); test("uses the market list single-rate directly and still loads the missing personal rate", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥450,000" }, { authorId: "222", authorName: "达人 B", price21To60s: "¥20,000" } ]); attachMarketListState([ { attribute_datas: { avg_search_after_view_rate_30d: "0.0002", nickname: "达人 A" }, star_id: "111" }, { attribute_datas: { nickname: "达人 B" }, star_id: "222" } ]); const loadAuthorMetrics = vi.fn(async (authorId: string) => ({ success: true as const, rates: authorId === "111" ? { singleVideoAfterSearchRate: "0.02%", personalVideoAfterSearchRate: "0.03% - 0.2%" } : { singleVideoAfterSearchRate: "0.5%-1%", personalVideoAfterSearchRate: "0.01% - 0.1%" } })); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, loadAuthorMetrics, window })); await controller.ready; expect(loadAuthorMetrics).toHaveBeenCalledTimes(2); expect(readDivRightRowTexts(0)).toEqual([ "¥450,000", "0.02%", "0.03% - 0.2%", "下单" ]); expect(readDivRightRowTexts(1)).toEqual([ "¥20,000", "0.5% - 1%", "0.01% - 0.1%", "下单" ]); }); test("hydrates real rows from serialized market rows when vue state is unavailable", async () => { document.body.innerHTML = buildRealMarketFixtureWithoutAuthorIds([ { authorName: "达人 A", price21To60s: "¥450,000" }, { authorName: "达人 B", price21To60s: "¥20,000" } ]); document.documentElement.setAttribute( "data-sces-market-rows", JSON.stringify([ { authorId: "111", authorName: "达人 A", singleVideoAfterSearchRate: "0.02%" }, { authorId: "222", authorName: "达人 B" } ]) ); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, loadAuthorMetrics: async (authorId) => ({ success: true, rates: authorId === "111" ? { singleVideoAfterSearchRate: "0.02%", personalVideoAfterSearchRate: "0.03% - 0.2%" } : { singleVideoAfterSearchRate: "0.5%-1%", personalVideoAfterSearchRate: "0.01% - 0.1%" } }), window })); await controller.ready; expect(readDivRightRowTexts(0)).toEqual([ "¥450,000", "0.02%", "0.03% - 0.2%", "下单" ]); expect(readDivRightRowTexts(1)).toEqual([ "¥20,000", "0.5% - 1%", "0.01% - 0.1%", "下单" ]); }); test("applying plugin filters hides non-matching current-page rows without a full scan", async () => { document.body.innerHTML = buildMarketFixture(); const resultStore = createMarketResultStore(); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), resultStore, window })); await controller.ready; resultStore.setAuthorSuccess("a", { singleVideoAfterSearchRate: "0.02% - 0.1%", personalVideoAfterSearchRate: "0.03% - 0.2%" }); resultStore.setAuthorSuccess("b", { singleVideoAfterSearchRate: "0.5%-1%", personalVideoAfterSearchRate: "0.01% - 0.1%" }); setInputValue('[data-plugin-filter-single="input"]', "0.1"); click('[data-plugin-filter-apply="button"]'); await flush(); expect( document.querySelector('[data-market-row="a"]')?.hasAttribute("hidden") ).toBe(true); expect( document.querySelector('[data-market-row="b"]')?.hasAttribute("hidden") ).toBe(false); }); test("applying plugin sorting reorders the current page without triggering a full scan", async () => { document.body.innerHTML = buildMarketFixture(); const resultStore = createMarketResultStore(); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), resultStore, window })); await controller.ready; resultStore.setAuthorSuccess("a", { singleVideoAfterSearchRate: "0.02% - 0.1%", personalVideoAfterSearchRate: "0.03% - 0.2%" }); resultStore.setAuthorSuccess("b", { singleVideoAfterSearchRate: "0.5%-1%", personalVideoAfterSearchRate: "0.01% - 0.1%" }); setSelectValue('[data-plugin-sort-field="select"]', "singleVideoAfterSearchRate"); setSelectValue('[data-plugin-sort-direction="select"]', "desc"); click('[data-plugin-sort-apply="button"]'); await flush(); expect(readRowOrder()).toEqual(["b", "a"]); }); test("toolbar defaults export range to the first 5 pages and reveals custom input on demand", async () => { document.body.innerHTML = buildMarketFixture(); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), window })); await controller.ready; const exportRangeSelect = document.querySelector( '[data-plugin-export-range="select"]' ) as HTMLSelectElement | null; const customPagesInput = document.querySelector( '[data-plugin-export-custom-pages="input"]' ) as HTMLInputElement | null; expect(exportRangeSelect?.value).toBe("first-5"); expect(customPagesInput?.hidden).toBe(true); setSelectValue('[data-plugin-export-range="select"]', "custom"); dispatchChange('[data-plugin-export-range="select"]'); expect(customPagesInput?.hidden).toBe(false); }); test("export uses the current page ordering without triggering a full scan", async () => { document.body.innerHTML = buildMarketFixture(); const resultStore = createMarketResultStore(); const buildCsv = vi.fn(() => "csv-output"); const onCsvReady = vi.fn(); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ buildCsv, document, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), onCsvReady, resultStore, window })); await controller.ready; resultStore.setAuthorSuccess("a", { singleVideoAfterSearchRate: "0.02% - 0.1%", personalVideoAfterSearchRate: "0.03% - 0.2%" }); resultStore.setAuthorSuccess("b", { singleVideoAfterSearchRate: "0.5%-1%", personalVideoAfterSearchRate: "0.01% - 0.1%" }); setSelectValue('[data-plugin-sort-field="select"]', "singleVideoAfterSearchRate"); setSelectValue('[data-plugin-sort-direction="select"]', "desc"); click('[data-plugin-sort-apply="button"]'); await flush(); click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 40, 50); expect(buildCsv).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ authorId: "a" }), expect.objectContaining({ authorId: "b" }) ]) ); expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([ "b", "a" ]); expect(onCsvReady).toHaveBeenCalledWith("csv-output"); }); test( "default export captures the first 5 pages and keeps non-empty fields when merging duplicates", async () => { const pages = [ [{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }], [{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }], [{ authorId: "222", authorName: "达人 B", price21To60s: "" }], [{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }], [{ authorId: "444", authorName: "达人 D", price21To60s: "¥44,000" }], [{ authorId: "555", authorName: "达人 E", price21To60s: "¥55,000" }] ]; document.body.innerHTML = buildRealMarketFixture(pages[0]); const pagination = installAsyncPaginationHarness(pages); const buildCsv = vi.fn(() => "csv-output"); const onCsvReady = vi.fn(); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ buildCsv, document, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), onCsvReady, window })); await controller.ready; click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 120, 100); expect(pagination.getClicks()).toBe(4); expect(buildCsv).toHaveBeenCalledTimes(1); expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([ "111", "222", "333", "444" ]); expect(buildCsv.mock.calls[0][0]).toEqual( expect.arrayContaining([ expect.objectContaining({ authorId: "222", exportFields: expect.objectContaining({ "21-60s报价": "¥22,000" }) }) ]) ); expect(onCsvReady).toHaveBeenCalledWith("csv-output"); }, 15000 ); test( "default export waits for the next page rows instead of only the pager state", async () => { const pages = [ [{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }], [{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }], [{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }], [{ authorId: "444", authorName: "达人 D", price21To60s: "¥44,000" }], [{ authorId: "555", authorName: "达人 E", price21To60s: "¥55,000" }] ]; document.body.innerHTML = buildRealMarketFixture(pages[0]); const pagination = installLaggyPaginationHarness(pages, { renderDelayMs: 250 }); const buildCsv = vi.fn(() => "csv-output"); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ buildCsv, document, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), onCsvReady: vi.fn(), window })); await controller.ready; click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 120, 100); expect(pagination.getClicks()).toBe(4); expect(buildCsv).toHaveBeenCalledTimes(1); expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([ "111", "222", "333", "444", "555" ]); }, 15000 ); test( "export waits for a slow page to finish rendering all rows before continuing", async () => { const pages = [ [ { authorId: "111", authorName: "达人 A1", price21To60s: "¥11,000" }, { authorId: "112", authorName: "达人 A2", price21To60s: "¥12,000" }, { authorId: "113", authorName: "达人 A3", price21To60s: "¥13,000" } ], [ { authorId: "221", authorName: "达人 B1", price21To60s: "¥21,000" }, { authorId: "222", authorName: "达人 B2", price21To60s: "¥22,000" }, { authorId: "223", authorName: "达人 B3", price21To60s: "¥23,000" } ], [ { authorId: "331", authorName: "达人 C1", price21To60s: "¥31,000" }, { authorId: "332", authorName: "达人 C2", price21To60s: "¥32,000" }, { authorId: "333", authorName: "达人 C3", price21To60s: "¥33,000" } ] ]; document.body.innerHTML = buildRealMarketFixture(pages[0]); const pagination = installProgressivePaginationHarness(pages, { firstRenderCount: 1, firstRenderDelayMs: 100, fullRenderDelayMs: 450 }); const buildCsv = vi.fn(() => "csv-output"); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ buildCsv, document, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), onCsvReady: vi.fn(), window })); await controller.ready; setSelectValue('[data-plugin-export-range="select"]', "all"); dispatchChange('[data-plugin-export-range="select"]'); click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 120, 100); expect(pagination.getClicks()).toBe(2); expect(buildCsv).toHaveBeenCalledTimes(1); expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([ "111", "112", "113", "221", "222", "223", "331", "332", "333" ]); }, 15000 ); test("exporting all pages disables the toolbar during the task and stops at the final page", async () => { const pages = [ [{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }], [{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }], [{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }] ]; document.body.innerHTML = buildRealMarketFixture(pages[0]); const pagination = installAsyncPaginationHarness(pages); const buildCsv = vi.fn(() => "csv-output"); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ buildCsv, document, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), onCsvReady: vi.fn(), window })); await controller.ready; setSelectValue('[data-plugin-export-range="select"]', "all"); dispatchChange('[data-plugin-export-range="select"]'); click('[data-plugin-export="button"]'); expectButtonDisabled('[data-plugin-export="button"]', true); expectButtonDisabled('[data-plugin-filter-apply="button"]', true); expectButtonDisabled('[data-plugin-sort-apply="button"]', true); expectSelectDisabled('[data-plugin-export-range="select"]', true); expect( document.querySelector('[data-plugin-export-status="text"]')?.textContent ).toContain("导出中"); await waitForMockCall(buildCsv, 80, 100); expect(pagination.getClicks()).toBe(2); expectButtonDisabled('[data-plugin-export="button"]', false); expectSelectDisabled('[data-plugin-export-range="select"]', false); expect(buildCsv).toHaveBeenCalledTimes(1); expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([ "111", "222", "333" ]); }); test("custom export range blocks invalid page counts", async () => { document.body.innerHTML = buildMarketFixture(); const buildCsv = vi.fn(() => "csv-output"); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ buildCsv, document, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), onCsvReady: vi.fn(), window })); await controller.ready; setSelectValue('[data-plugin-export-range="select"]', "custom"); dispatchChange('[data-plugin-export-range="select"]'); setInputValue('[data-plugin-export-custom-pages="input"]', "0"); click('[data-plugin-export="button"]'); await flush(); expect(buildCsv).not.toHaveBeenCalled(); expect( document.querySelector('[data-plugin-export-status="text"]')?.textContent ).toContain("有效页数"); }); test("export only includes records that are present on the current page", async () => { document.body.innerHTML = buildMarketFixture(); const resultStore = createMarketResultStore(); const buildCsv = vi.fn(() => "csv-output"); const onCsvReady = vi.fn(); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ buildCsv, document, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), onCsvReady, resultStore, window })); await controller.ready; resultStore.setAuthorSuccess("a", { singleVideoAfterSearchRate: "0.02% - 0.1%", personalVideoAfterSearchRate: "0.03% - 0.2%" }); resultStore.setAuthorSuccess("b", { singleVideoAfterSearchRate: "0.5%-1%", personalVideoAfterSearchRate: "0.01% - 0.1%" }); resultStore.upsertMarketRow({ authorId: "c", authorName: "Gamma" }); resultStore.setAuthorSuccess("c", { singleVideoAfterSearchRate: "9% - 10%", personalVideoAfterSearchRate: "8% - 9%" }); click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 40, 50); expect(buildCsv).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ authorId: "a" }), expect.objectContaining({ authorId: "b" }) ]) ); expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).not.toContain( "c" ); expect(onCsvReady).toHaveBeenCalledWith("csv-output"); }); test("export prefers fresh current-page fields over stale store export fields", async () => { document.body.innerHTML = buildMarketFixture(); const resultStore = createMarketResultStore(); const buildCsv = vi.fn(() => "csv-output"); const onCsvReady = vi.fn(); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ buildCsv, document, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), onCsvReady, resultStore, window })); await controller.ready; resultStore.upsertMarketRow({ authorId: "a", authorName: "Old Alpha", exportFields: { 达人: "Old Alpha" } }); resultStore.setAuthorSuccess("a", { singleVideoAfterSearchRate: "0.02% - 0.1%", personalVideoAfterSearchRate: "0.03% - 0.2%" }); click('[data-plugin-export="button"]'); await waitForMockCall(buildCsv, 40, 50); expect(buildCsv.mock.calls[0][0][0]).toEqual( expect.objectContaining({ authorId: "a", authorName: "Alpha", exportFields: { "21-60s报价": "450000", 达人: "Alpha" } }) ); expect(onCsvReady).toHaveBeenCalledWith("csv-output"); }); test("export harvests lazy current-page fields before building csv", async () => { const rows = [ { authorId: "111", authorName: "达人 A", price21To60s: "¥450,000" }, { authorId: "222", authorName: "达人 B", price21To60s: "¥20,000" }, { authorId: "333", authorName: "达人 C", price21To60s: "¥30,000" }, { authorId: "444", authorName: "达人 D", price21To60s: "¥40,000" } ]; document.body.innerHTML = `