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(
`
`,
{
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(
`
`,
{
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();
}