feat: add full scan controller

This commit is contained in:
admin123 2026-04-20 20:13:29 +08:00
parent 86f776ad79
commit f6b2bdc862
2 changed files with 238 additions and 0 deletions

View File

@ -0,0 +1,85 @@
import type {
MarketApiFailureReason,
MarketApiResult,
MarketRowSnapshot
} from "./types";
import type { AfterSearchRates } from "./types";
interface ResultStoreLike {
setAuthorFailed(authorId: string, reason: MarketApiFailureReason): void;
setAuthorLoading(authorId: string): void;
setAuthorSuccess(
authorId: string,
rates: Required<AfterSearchRates>
): void;
upsertMarketRow(row: MarketRowSnapshot): void;
}
interface FullScanControllerOptions {
goToNextPage(): Promise<boolean>;
hasNextPage(): boolean;
loadAuthorMetrics(authorId: string): Promise<MarketApiResult>;
readCurrentPageRows(): MarketRowSnapshot[];
resultStore: ResultStoreLike;
}
export function createFullScanController(options: FullScanControllerOptions) {
let completedScan = false;
let scanPromise: Promise<void> | null = null;
return {
ensureScanForExport() {
return ensureScan();
},
ensureScanForFilter() {
return ensureScan();
},
ensureScanForSort() {
return ensureScan();
}
};
function ensureScan(): Promise<void> {
if (completedScan) {
return Promise.resolve();
}
if (scanPromise) {
return scanPromise;
}
scanPromise = runScan().finally(() => {
scanPromise = null;
});
return scanPromise;
}
async function runScan(): Promise<void> {
do {
await scanCurrentPage();
if (!options.hasNextPage()) {
completedScan = true;
return;
}
} while (await options.goToNextPage());
completedScan = true;
}
async function scanCurrentPage(): Promise<void> {
const rows = options.readCurrentPageRows();
for (const row of rows) {
options.resultStore.upsertMarketRow(row);
options.resultStore.setAuthorLoading(row.authorId);
const metricsResult = await options.loadAuthorMetrics(row.authorId);
if (metricsResult.success) {
options.resultStore.setAuthorSuccess(row.authorId, metricsResult.rates);
continue;
}
options.resultStore.setAuthorFailed(row.authorId, metricsResult.reason);
}
}
}

View File

@ -0,0 +1,153 @@
import { describe, expect, test, vi } from "vitest";
import { createFullScanController } from "../src/content/market/full-scan-controller";
import { createMarketResultStore } from "../src/content/market/result-store";
describe("full-scan-controller", () => {
test("does not start a scan during initial construction", () => {
const readCurrentPageRows = vi.fn(() => []);
createFullScanController({
goToNextPage: async () => false,
hasNextPage: () => false,
loadAuthorMetrics: async () => ({
success: false as const,
reason: "request-failed" as const
}),
readCurrentPageRows,
resultStore: createMarketResultStore()
});
expect(readCurrentPageRows).not.toHaveBeenCalled();
});
test("starts a full scan for filter actions", async () => {
const harness = createHarness();
await harness.controller.ensureScanForFilter();
expect(harness.readCurrentPageRows).toHaveBeenCalledTimes(2);
expect(harness.loadAuthorMetrics).toHaveBeenCalledTimes(2);
});
test("starts a full scan for sort actions", async () => {
const harness = createHarness();
await harness.controller.ensureScanForSort();
expect(harness.readCurrentPageRows).toHaveBeenCalledTimes(2);
});
test("starts a full scan for export actions", async () => {
const harness = createHarness();
await harness.controller.ensureScanForExport();
expect(harness.readCurrentPageRows).toHaveBeenCalledTimes(2);
});
test("does not restart a completed scan unnecessarily", async () => {
const harness = createHarness();
await harness.controller.ensureScanForFilter();
await harness.controller.ensureScanForSort();
expect(harness.readCurrentPageRows).toHaveBeenCalledTimes(2);
expect(harness.loadAuthorMetrics).toHaveBeenCalledTimes(2);
});
test("records failed author fetches without aborting the whole scan", async () => {
const store = createMarketResultStore();
let pageIndex = 0;
const pages = [
[
{
authorId: "a",
authorName: "Alpha"
}
],
[
{
authorId: "b",
authorName: "Beta"
}
]
];
const controller = createFullScanController({
goToNextPage: async () => {
pageIndex += 1;
return true;
},
hasNextPage: () => pageIndex < pages.length - 1,
loadAuthorMetrics: async (authorId) =>
authorId === "a"
? {
success: false as const,
reason: "request-failed" as const
}
: {
success: true as const,
rates: {
singleVideoAfterSearchRate: "0.5% - 1%",
personalVideoAfterSearchRate: "0.02% - 0.1%"
}
},
readCurrentPageRows: vi.fn(() => pages[pageIndex]),
resultStore: store
});
await controller.ensureScanForExport();
expect(store.getRecord("a")).toMatchObject({
status: "failed",
failureReason: "request-failed"
});
expect(store.getRecord("b")).toMatchObject({
status: "success"
});
});
});
function createHarness() {
const store = createMarketResultStore();
let pageIndex = 0;
const pages = [
[
{
authorId: "a",
authorName: "Alpha"
}
],
[
{
authorId: "b",
authorName: "Beta"
}
]
];
const readCurrentPageRows = vi.fn(() => pages[pageIndex]);
const loadAuthorMetrics = vi.fn(async () => ({
success: true as const,
rates: {
singleVideoAfterSearchRate: "0.5% - 1%",
personalVideoAfterSearchRate: "0.02% - 0.1%"
}
}));
return {
controller: createFullScanController({
goToNextPage: async () => {
pageIndex += 1;
return true;
},
hasNextPage: () => pageIndex < pages.length - 1,
loadAuthorMetrics,
readCurrentPageRows,
resultStore: store
}),
loadAuthorMetrics,
readCurrentPageRows,
store
};
}