star-chart-search-enhancer/tests/market-controller.test.ts
opencode 8f44e157f1 feat: add market page div-grid support with after-search-rate columns
- Support both HTML table and div-based grid layouts on creator/market
- Harden DOM insertion by using actual parent nodes (prevents NotFoundError
  when page nesting differs from test fixtures)
- Skip malformed/empty table rows instead of throwing on missing action cell
- Add rowKey to BatchLoaderRow to align LoadRowsOptions typing
- Add tests for div-grid sync and controller lifecycle at document_start
2026-04-15 18:04:10 +08:00

498 lines
14 KiB
TypeScript

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,
`
<tr>
<td><a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/333">达人 C</a></td>
<td>查看</td>
</tr>
`
);
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<ReturnType<typeof successFor>>();
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,
`
<tr>
<td><a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/222">达人 B</a></td>
<td>查看</td>
</tr>
`
);
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,
`
<tr>
<td><a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/222">达人 B</a></td>
<td>查看</td>
</tr>
`
);
observer.trigger();
await flushSync();
replaceRows(
dom.window.document,
`
<tr>
<td><a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/111">达人 A</a></td>
<td>查看</td>
</tr>
`
);
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<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
}
function createLogger() {
return {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn()
};
}
function createMarketDom() {
const dom = new JSDOM(
`
<table>
<thead>
<tr>
<th>达人信息</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr>
<td><a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/111">达人 A</a></td>
<td>查看</td>
</tr>
</tbody>
</table>
`,
{
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(
`
<div class="base-author-list">
<div class="section-wrapper sticky-header hide-scrollbar">
<div style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
<div class="header-cell" style="min-width: 310px;">达人信息</div>
</div>
<div class="middle-columns" style="width: 190px; display: flex;">
<div class="header-cell" style="min-width: 190px;">代表视频</div>
</div>
<div data-testid="right-header" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
<div class="header-cell" style="min-width: 150px;">21-60s报价</div>
<div class="header-cell" style="min-width: 200px;">操作</div>
</div>
</div>
<div class="section-wrapper hide-scrollbar">
<div class="content-section" style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
<div class="content-column" style="min-width: 310px;">
<div class="content-cell" data-testid="author-row-a" style="height: 120px;">
<a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/111">达人 A</a>
</div>
</div>
</div>
<div class="middle-columns" style="width: 190px; display: flex;">
<div class="content-column" style="min-width: 190px;">
<div class="content-cell" style="height: 120px;">代表视频A</div>
</div>
</div>
<div data-testid="right-section" class="content-section" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
<div class="content-column" style="min-width: 150px;">
<div class="content-cell" style="height: 120px;">¥70,000</div>
</div>
<div class="content-column" style="min-width: 200px;">
<div class="content-cell" style="height: 120px;">下单</div>
</div>
</div>
</div>
</div>
`,
{
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();
}