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