feat: add full scan controller
This commit is contained in:
parent
86f776ad79
commit
f6b2bdc862
85
src/content/market/full-scan-controller.ts
Normal file
85
src/content/market/full-scan-controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
153
tests/full-scan-controller.test.ts
Normal file
153
tests/full-scan-controller.test.ts
Normal 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
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user