import { describe, expect, test, vi } from "vitest"; import { createMarketBatchLoader } from "../src/content/market/batch-loader"; describe("market batch loader", () => { test("puts current-page rows into loading before the request resolves", async () => { const deferred = createDeferred(); const row = createRowRecorder("111"); const loader = createMarketBatchLoader({ apiClient: { loadAuthorAseInfo: vi.fn(() => deferred.promise) }, concurrency: 4 }); const loadPromise = loader.loadRows({ listSeq: 1, rows: [row.row] }); expect(row.states[0]).toMatchObject({ authorId: "111", listSeq: 1, state: "loading" }); deferred.resolve({ rates: { personalVideoAfterSearchRate: "0.02% - 0.1%", singleVideoAfterSearchRate: "<0.02%" }, success: true }); await loadPromise; }); test("reuses the success cache for repeated authorIds", async () => { const apiClient = { loadAuthorAseInfo: vi.fn(async () => ({ rates: { personalVideoAfterSearchRate: "0.02% - 0.1%", singleVideoAfterSearchRate: "<0.02%" }, success: true as const })) }; const firstRow = createRowRecorder("111"); const secondRow = createRowRecorder("111"); const loader = createMarketBatchLoader({ apiClient, concurrency: 4 }); await loader.loadRows({ listSeq: 1, rows: [firstRow.row] }); await loader.loadRows({ listSeq: 2, rows: [secondRow.row] }); expect(apiClient.loadAuthorAseInfo).toHaveBeenCalledTimes(1); expect(secondRow.states.at(-1)).toMatchObject({ source: "cache", state: "success" }); }); test("deduplicates in-flight requests for the same authorId", async () => { const deferred = createDeferred(); const firstRow = createRowRecorder("111"); const secondRow = createRowRecorder("111"); const apiClient = { loadAuthorAseInfo: vi.fn(() => deferred.promise) }; const loader = createMarketBatchLoader({ apiClient, concurrency: 4 }); const loadPromise = loader.loadRows({ listSeq: 1, rows: [firstRow.row, secondRow.row] }); expect(apiClient.loadAuthorAseInfo).toHaveBeenCalledTimes(1); deferred.resolve({ rates: { personalVideoAfterSearchRate: "0.02% - 0.1%", singleVideoAfterSearchRate: "<0.02%" }, success: true }); await loadPromise; }); test("honors the concurrency cap", async () => { const deferreds = new Map([ ["111", createDeferred()], ["222", createDeferred()], ["333", createDeferred()] ]); const started: string[] = []; const loader = createMarketBatchLoader({ apiClient: { loadAuthorAseInfo: vi.fn((authorId: string) => { started.push(authorId); return deferreds.get(authorId)!.promise; }) }, concurrency: 2 }); const loadPromise = loader.loadRows({ listSeq: 1, rows: [createRowRecorder("111").row, createRowRecorder("222").row, createRowRecorder("333").row] }); expect(started).toEqual(["111", "222"]); deferreds.get("111")!.resolve(successResult()); await tick(); expect(started).toEqual(["111", "222", "333"]); deferreds.get("222")!.resolve(successResult()); deferreds.get("333")!.resolve(successResult()); await loadPromise; }); test("renders failed rows as error states", async () => { const row = createRowRecorder("111"); const loader = createMarketBatchLoader({ apiClient: { loadAuthorAseInfo: vi.fn(async () => ({ reason: "request-failed", success: false as const })) }, concurrency: 4 }); await loader.loadRows({ listSeq: 1, rows: [row.row] }); expect(row.states.at(-1)).toMatchObject({ reason: "request-failed", retryable: true, state: "error" }); }); test("retries the whole row when the provided retry handler is invoked", async () => { const row = createRowRecorder("111"); const apiClient = { loadAuthorAseInfo: vi .fn() .mockResolvedValueOnce({ reason: "request-failed", success: false as const }) .mockResolvedValueOnce(successResult()) }; const loader = createMarketBatchLoader({ apiClient, concurrency: 4 }); await loader.loadRows({ listSeq: 1, rows: [row.row] }); expect(row.retry).toBeTypeOf("function"); await row.retry?.(); expect(apiClient.loadAuthorAseInfo).toHaveBeenCalledTimes(2); expect(row.states.at(-2)).toMatchObject({ state: "loading" }); expect(row.states.at(-1)).toMatchObject({ state: "success" }); }); }); function createRowRecorder(authorId: string | null) { const states: unknown[] = []; let retry: (() => Promise | void) | undefined; return { row: { authorId, render( state: unknown, options?: { onRetry?: () => Promise | void } ) { states.push(state); retry = options?.onRetry; } }, get retry() { return retry; }, states }; } function createDeferred() { let resolve!: (value: T) => void; const promise = new Promise((nextResolve) => { resolve = nextResolve; }); return { promise, resolve }; } function successResult() { return { rates: { personalVideoAfterSearchRate: "0.02% - 0.1%", singleVideoAfterSearchRate: "<0.02%" }, success: true as const }; } function tick() { return new Promise((resolve) => { setTimeout(resolve, 0); }); }