// @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("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 triggers full scan and hides non-matching rows", async () => { document.body.innerHTML = buildMarketFixture(); const resultStore = createMarketResultStore(); const ensureScanForFilter = vi.fn(async () => { 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%" }); }); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, fullScanController: { ensureScanForExport: vi.fn(async () => {}), ensureScanForFilter, ensureScanForSort: vi.fn(async () => {}) }, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), resultStore, window })); await controller.ready; setInputValue('[data-plugin-filter-single="input"]', "0.1"); click('[data-plugin-filter-apply="button"]'); await flush(); expect(ensureScanForFilter).toHaveBeenCalledTimes(1); 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 triggers full scan and reorders rows", async () => { document.body.innerHTML = buildMarketFixture(); const resultStore = createMarketResultStore(); const ensureScanForSort = vi.fn(async () => { 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%" }); }); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, fullScanController: { ensureScanForExport: vi.fn(async () => {}), ensureScanForFilter: vi.fn(async () => {}), ensureScanForSort }, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), resultStore, window })); await controller.ready; setSelectValue('[data-plugin-sort-field="select"]', "singleVideoAfterSearchRate"); setSelectValue('[data-plugin-sort-direction="select"]', "desc"); click('[data-plugin-sort-apply="button"]'); await flush(); expect(ensureScanForSort).toHaveBeenCalledTimes(1); expect(readRowOrder()).toEqual(["b", "a"]); }); test("export triggers full scan and hands ordered visible records to the csv exporter", async () => { document.body.innerHTML = buildMarketFixture(); const resultStore = createMarketResultStore(); const buildCsv = vi.fn(() => "csv-output"); const onCsvReady = vi.fn(); const ensureScanForExport = vi.fn(async () => { 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%" }); }); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ buildCsv, document, fullScanController: { ensureScanForExport, ensureScanForFilter: vi.fn(async () => {}), ensureScanForSort: vi.fn(async () => {}) }, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), onCsvReady, resultStore, window })); await controller.ready; 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 flush(); expect(ensureScanForExport).toHaveBeenCalledTimes(1); 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("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("default full scan walks the real market pagination when applying a filter", async () => { const pages = [ [ { authorId: "111", authorName: "达人 A", price21To60s: "¥450,000" } ], [ { authorId: "222", authorName: "达人 B", price21To60s: "¥20,000" } ] ]; document.body.innerHTML = buildRealMarketFixture(pages[0]); const pagination = installPaginationHarness(pages); const loadAuthorMetrics = vi.fn(async (authorId: string) => ({ success: true as const, rates: authorId === "111" ? { singleVideoAfterSearchRate: "0.02% - 0.1%", personalVideoAfterSearchRate: "0.03% - 0.2%" } : { singleVideoAfterSearchRate: "0.5%-1%", personalVideoAfterSearchRate: "0.01% - 0.1%" } })); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, loadAuthorMetrics, window })); await controller.ready; setInputValue('[data-plugin-filter-single="input"]', "0.1"); click('[data-plugin-filter-apply="button"]'); await flush(); await flush(); expect(pagination.getClicks()).toBe(1); expect(loadAuthorMetrics.mock.calls.map(([authorId]) => authorId)).toEqual( expect.arrayContaining(["111", "222"]) ); }); }); function buildMarketFixture() { return `
达人
21-60s报价
Alpha 450000
Beta 70000
`; } function buildRealMarketFixture( rows: Array<{ authorId: string; authorName: string; price21To60s: string; }> ) { return `
${rows .map( (row) => ` ` ) .join("")}
${rows .map( (row) => `
代表视频${row.authorName}
` ) .join("")}
${rows .map( (row) => `
${row.price21To60s}
` ) .join("")}
${rows .map( (row) => `
下单
` ) .join("")}
`; } function buildRealMarketFixtureWithoutAuthorIds( rows: Array<{ authorName: string; price21To60s: string; }> ) { return `
${rows .map( (row) => `
${row.authorName}
` ) .join("")}
${rows .map( (_, index) => `
代表视频${index + 1}
` ) .join("")}
${rows .map( (row) => `
${row.price21To60s}
` ) .join("")}
${rows .map( () => `
下单
` ) .join("")}
`; } function attachMarketListState( marketList: Array<{ attribute_datas?: { avg_search_after_view_rate_30d?: string; nickname?: string; }; star_id?: string; }> ) { const marketRoot = document.querySelector('[data-testid="market-root"]'); if (!(marketRoot instanceof HTMLElement)) { throw new Error("Missing market root"); } Object.defineProperty(marketRoot, "__vue__", { configurable: true, value: { _setupState: { __$temp_1: { marketList } } } }); } function installPaginationHarness( pages: Array< Array<{ authorId: string; authorName: string; price21To60s: string; }> > ) { let pageIndex = 0; let clicks = 0; const nextButton = document.querySelector( '[data-testid="next-page"]' ) as HTMLButtonElement | null; if (!nextButton) { throw new Error("Missing next page button"); } const renderPage = () => { const authorColumn = document.querySelector( '[data-testid="author-section"] .content-column' ) as HTMLElement | null; const middleColumn = document.querySelector( '.middle-columns .content-column' ) as HTMLElement | null; const rightColumns = document.querySelectorAll( '[data-testid="right-section"] > .content-column' ); if (!authorColumn || !middleColumn || rightColumns.length < 2) { throw new Error("Missing market columns for pagination harness"); } const rows = pages[pageIndex]; authorColumn.innerHTML = rows .map( (row) => `
${row.authorName}
` ) .join(""); middleColumn.innerHTML = rows .map( (row) => `
代表视频${row.authorName}
` ) .join(""); (rightColumns[0] as HTMLElement).innerHTML = rows .map( (row) => `
${row.price21To60s}
` ) .join(""); (rightColumns[1] as HTMLElement).innerHTML = rows .map( (row) => `
下单
` ) .join(""); nextButton.disabled = pageIndex >= pages.length - 1; nextButton.setAttribute("aria-disabled", nextButton.disabled ? "true" : "false"); }; nextButton.addEventListener("click", () => { if (pageIndex >= pages.length - 1) { return; } clicks += 1; pageIndex += 1; renderPage(); }); renderPage(); return { getClicks() { return clicks; } }; } function createMutationObserverFactory() { let callback: MutationCallback = () => undefined; return { factory(nextCallback: MutationCallback) { callback = nextCallback; return { disconnect() {}, observe() {} }; }, trigger() { callback([], {} as MutationObserver); } }; } function click(selector: string) { const element = document.querySelector(selector) as HTMLButtonElement | null; if (!element) { throw new Error(`Missing element: ${selector}`); } element.click(); } function setInputValue(selector: string, value: string) { const element = document.querySelector(selector) as HTMLInputElement | null; if (!element) { throw new Error(`Missing input: ${selector}`); } element.value = value; } function setSelectValue(selector: string, value: string) { const element = document.querySelector(selector) as HTMLSelectElement | null; if (!element) { throw new Error(`Missing select: ${selector}`); } element.value = value; } function readRowOrder() { return Array.from(document.querySelectorAll("[data-market-row]")).map( (row) => row.getAttribute("data-author-id") ); } function readDivRightRowTexts(rowIndex: number) { return Array.from( document.querySelectorAll('[data-testid="right-section"] > .content-column'), (column) => column.querySelectorAll(".content-cell")[rowIndex]?.textContent?.trim() ?? "" ); } function trackController void }>(controller: T): T { if (controller.dispose) { disposers.push(() => controller.dispose?.()); } return controller; } 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)); }