From f6b2bdc862df94afc39610824225ff1d53e16e2f Mon Sep 17 00:00:00 2001 From: admin123 Date: Mon, 20 Apr 2026 20:13:29 +0800 Subject: [PATCH] feat: add full scan controller --- src/content/market/full-scan-controller.ts | 85 ++++++++++++ tests/full-scan-controller.test.ts | 153 +++++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 src/content/market/full-scan-controller.ts create mode 100644 tests/full-scan-controller.test.ts diff --git a/src/content/market/full-scan-controller.ts b/src/content/market/full-scan-controller.ts new file mode 100644 index 0000000..dc3eaef --- /dev/null +++ b/src/content/market/full-scan-controller.ts @@ -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 + ): void; + upsertMarketRow(row: MarketRowSnapshot): void; +} + +interface FullScanControllerOptions { + goToNextPage(): Promise; + hasNextPage(): boolean; + loadAuthorMetrics(authorId: string): Promise; + readCurrentPageRows(): MarketRowSnapshot[]; + resultStore: ResultStoreLike; +} + +export function createFullScanController(options: FullScanControllerOptions) { + let completedScan = false; + let scanPromise: Promise | null = null; + + return { + ensureScanForExport() { + return ensureScan(); + }, + ensureScanForFilter() { + return ensureScan(); + }, + ensureScanForSort() { + return ensureScan(); + } + }; + + function ensureScan(): Promise { + if (completedScan) { + return Promise.resolve(); + } + + if (scanPromise) { + return scanPromise; + } + + scanPromise = runScan().finally(() => { + scanPromise = null; + }); + return scanPromise; + } + + async function runScan(): Promise { + do { + await scanCurrentPage(); + if (!options.hasNextPage()) { + completedScan = true; + return; + } + } while (await options.goToNextPage()); + + completedScan = true; + } + + async function scanCurrentPage(): Promise { + 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); + } + } +} diff --git a/tests/full-scan-controller.test.ts b/tests/full-scan-controller.test.ts new file mode 100644 index 0000000..3ab9d3b --- /dev/null +++ b/tests/full-scan-controller.test.ts @@ -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 + }; +}