star-chart-search-enhancer/tests/market-batch-loader.test.ts

236 lines
5.6 KiB
TypeScript

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> | void) | undefined;
return {
row: {
authorId,
render(
state: unknown,
options?: { onRetry?: () => Promise<void> | void }
) {
states.push(state);
retry = options?.onRetry;
}
},
get retry() {
return retry;
},
states
};
}
function createDeferred<T = unknown>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((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);
});
}