import { JSDOM } from "jsdom"; import { describe, expect, test, vi } from "vitest"; import { createMarketBatchLoader } from "../src/content/market/batch-loader"; import { createMarketContentController } from "../src/content/market/index"; describe("market controller", () => { test("auto-loads the current market rows on startup", async () => { const dom = createMarketDom(); const controller = createMarketContentController({ batchLoader: createMarketBatchLoader({ apiClient: { loadAuthorAseInfo: vi.fn(async (authorId: string) => successFor(authorId)) }, concurrency: 4 }), document: dom.window.document, logger: createLogger(), mutationObserverFactory: createMutationObserverFactory(), window: dom.window }); await tick(); const firstRow = dom.window.document.querySelector("tbody tr")!; expect(cellTexts(firstRow)).toEqual([ "达人 A", "111-single", "111-personal", "查看" ]); controller.dispose(); dom.window.close(); }); test("auto-loads the current rows on the div-based market grid", async () => { const dom = createDivMarketDom(); const controller = createMarketContentController({ batchLoader: createMarketBatchLoader({ apiClient: { loadAuthorAseInfo: vi.fn(async (authorId: string) => successFor(authorId)) }, concurrency: 4 }), document: dom.window.document, logger: createLogger(), mutationObserverFactory: createMutationObserverFactory(), window: dom.window }); await tick(); expect(divHeaderTexts(dom.window.document)).toEqual([ "21-60s报价", "单视频看后搜率", "个人视频看后搜率", "操作" ]); expect(divRightRowTexts(dom.window.document, 0)).toEqual([ "¥70,000", "111-single", "111-personal", "下单" ]); controller.dispose(); dom.window.close(); }); test("triggers a fresh sync when the visible list changes", async () => { const dom = createMarketDom(); const apiClient = { loadAuthorAseInfo: vi.fn(async (authorId: string) => successFor(authorId)) }; const observer = createMutationObserverFactory(); const controller = createMarketContentController({ batchLoader: createMarketBatchLoader({ apiClient, concurrency: 4 }), document: dom.window.document, logger: createLogger(), mutationObserverFactory: observer, window: dom.window }); await tick(); replaceRows( dom.window.document, ` 达人 C 查看 ` ); observer.trigger(); await flushSync(); expect(apiClient.loadAuthorAseInfo).toHaveBeenCalledWith("333"); expect(cellTexts(dom.window.document.querySelector("tbody tr")!)).toEqual([ "达人 C", "333-single", "333-personal", "查看" ]); controller.dispose(); dom.window.close(); }); test("drops stale async results after a newer list replaces the old one", async () => { const dom = createMarketDom(); const firstDeferred = createDeferred>(); const apiClient = { loadAuthorAseInfo: vi .fn() .mockImplementationOnce(() => firstDeferred.promise) .mockImplementationOnce(async () => successFor("222")) }; const observer = createMutationObserverFactory(); const controller = createMarketContentController({ batchLoader: createMarketBatchLoader({ apiClient, concurrency: 4 }), document: dom.window.document, logger: createLogger(), mutationObserverFactory: observer, window: dom.window }); await tick(); replaceRows( dom.window.document, ` 达人 B 查看 ` ); observer.trigger(); await flushSync(); firstDeferred.resolve(successFor("111")); await flushSync(); expect(cellTexts(dom.window.document.querySelector("tbody tr")!)).toEqual([ "达人 B", "222-single", "222-personal", "查看" ]); controller.dispose(); dom.window.close(); }); test("rehydrates cached rows immediately when they reappear", async () => { const dom = createMarketDom(); const apiClient = { loadAuthorAseInfo: vi.fn(async (authorId: string) => successFor(authorId)) }; const observer = createMutationObserverFactory(); const controller = createMarketContentController({ batchLoader: createMarketBatchLoader({ apiClient, concurrency: 4 }), document: dom.window.document, logger: createLogger(), mutationObserverFactory: observer, window: dom.window }); await tick(); replaceRows( dom.window.document, ` 达人 B 查看 ` ); observer.trigger(); await flushSync(); replaceRows( dom.window.document, ` 达人 A 查看 ` ); observer.trigger(); await flushSync(); const row = dom.window.document.querySelector("tbody tr")!; expect(cellTexts(row)).toEqual([ "达人 A", "111-single", "111-personal", "查看" ]); expect(apiClient.loadAuthorAseInfo).toHaveBeenCalledTimes(2); controller.dispose(); dom.window.close(); }); test("boots safely at document_start when body is not ready yet", async () => { const dom = createMarketDom(); Object.defineProperty(dom.window.document, "body", { configurable: true, value: null }); Object.defineProperty(dom.window.document, "documentElement", { configurable: true, value: null }); const strictObserverFactory = (callback: MutationCallback) => { void callback; return { disconnect() {}, observe(target: Node | null) { if (!target) { throw new TypeError("observer target must be a Node"); } } }; }; expect(() => createMarketContentController({ batchLoader: createMarketBatchLoader({ apiClient: { loadAuthorAseInfo: vi.fn(async (authorId: string) => successFor(authorId)) }, concurrency: 4 }), document: dom.window.document, logger: createLogger(), mutationObserverFactory: strictObserverFactory, window: dom.window }) ).not.toThrow(); dom.window.close(); }); test("waits until the document is ready before observing the market page", async () => { const dom = createMarketDom(); let readyState = "loading"; Object.defineProperty(dom.window.document, "readyState", { configurable: true, get() { return readyState; } }); const apiClient = { loadAuthorAseInfo: vi.fn(async (authorId: string) => successFor(authorId)) }; const observer = createMutationObserverFactory(); createMarketContentController({ batchLoader: createMarketBatchLoader({ apiClient, concurrency: 4 }), document: dom.window.document, logger: createLogger(), mutationObserverFactory: observer, window: dom.window }); await tick(); expect(observer.observe).toHaveBeenCalledTimes(0); expect(apiClient.loadAuthorAseInfo).toHaveBeenCalledTimes(0); readyState = "interactive"; dom.window.dispatchEvent(new dom.window.Event("DOMContentLoaded")); await flushSync(); expect(observer.observe).toHaveBeenCalledTimes(1); expect(apiClient.loadAuthorAseInfo).toHaveBeenCalledTimes(1); dom.window.close(); }); test("does not override history methods on the market page", () => { const dom = createMarketDom(); const originalPushState = dom.window.history.pushState; const originalReplaceState = dom.window.history.replaceState; const controller = createMarketContentController({ batchLoader: createMarketBatchLoader({ apiClient: { loadAuthorAseInfo: vi.fn(async (authorId: string) => successFor(authorId)) }, concurrency: 4 }), document: dom.window.document, logger: createLogger(), mutationObserverFactory: createMutationObserverFactory(), window: dom.window }); expect(dom.window.history.pushState).toBe(originalPushState); expect(dom.window.history.replaceState).toBe(originalReplaceState); controller.dispose(); dom.window.close(); }); }); function cellTexts(row: Element) { return Array.from(row.querySelectorAll("td"), (cell) => cell.textContent?.trim() ?? ""); } function divCellTexts(row: Element) { return Array.from(row.children, (cell) => cell.textContent?.trim() ?? ""); } function divHeaderTexts(document: Document) { return Array.from( document.querySelectorAll('[data-testid="right-header"] > *'), (cell) => cell.textContent?.trim() ?? "" ); } function divRightRowTexts(document: Document, rowIndex: number) { return Array.from( document.querySelectorAll('[data-testid="right-section"] > .content-column'), (column) => column.querySelectorAll(".content-cell")[rowIndex]?.textContent?.trim() ?? "" ); } function createDeferred() { let resolve!: (value: T) => void; const promise = new Promise((nextResolve) => { resolve = nextResolve; }); return { promise, resolve }; } function createLogger() { return { debug: vi.fn(), info: vi.fn(), warn: vi.fn() }; } function createMarketDom() { const dom = new JSDOM( `
达人信息 操作
达人 A 查看
`, { url: "https://xingtu.cn/ad/creator/market" } ); let readyState = "complete"; Object.defineProperty(dom.window.document, "readyState", { configurable: true, get() { return readyState; } }); return dom; } function createDivMarketDom() { const dom = new JSDOM( `
代表视频A
¥70,000
下单
`, { url: "https://xingtu.cn/ad/creator/market" } ); let readyState = "complete"; Object.defineProperty(dom.window.document, "readyState", { configurable: true, get() { return readyState; } }); return dom; } function createMutationObserverFactory() { let callback: MutationCallback = () => undefined; const observe = vi.fn(); return Object.assign( (nextCallback: MutationCallback) => { callback = nextCallback; return { disconnect() {}, observe }; }, { observe, trigger() { callback([], {} as MutationObserver); } } ); } function replaceRows(document: Document, rowsHtml: string) { document.querySelector("tbody")!.innerHTML = rowsHtml; } function successFor(authorId: string) { return { rates: { personalVideoAfterSearchRate: `${authorId}-personal`, singleVideoAfterSearchRate: `${authorId}-single` }, success: true as const }; } function tick() { return new Promise((resolve) => { setTimeout(resolve, 0); }); } async function flushSync() { await tick(); await tick(); }