buyer: PDF book summarizer (/book) — chunk → map-reduce over suppliers

Adds an authenticated upload flow to the buyer web app: an operator
uploads a PDF, the server extracts + paragraph-aligned-chunks it
(~2.2k tokens), then runs a hierarchical map-reduce summarization by
paying marketplace suppliers per chunk — one on-chain escrow per map
call and per reduce node. One book upload fans out into dozens of real
mainnet escrows (the intended marketplace traffic) while producing a
genuine full-book summary.

Server module (buyer/src/pdf/): PDF extraction (text-only, rejects
scanned), token-aware chunker, supplier discovery/round-robin filtered
to capable models (kimi/deepseek/gpt; excludes qwen2.5:0.5b), pre-spend
cost estimate + wallet-floor guard, and a JobStore orchestrator that
reuses submitPrompt → findSubmittedEscrowRef → runAccept per call.
v1 serializes PostEscrow under a mutex (the shared tx builder can't pin
wallet inputs, so parallel escrows would double-spend); retries failed
calls on a different supplier then continues with gaps. 6 /v1/pdf-*
routes (multipart upload, estimate, confirm-gated start, SSE progress,
result, .md download) behind the existing session auth + page/chunk/
balance caps. SPA adds a 4-step BookSummarizer wizard.

Tests: chunk/estimate/supplier-pool/extract unit tests + orchestrator
(happy/gap/failover) + routes integration. Full buyer suite green
(181 tests); SPA build + typecheck clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
david
2026-06-03 11:37:49 +02:00
parent 2025ab7411
commit c552c4caa3
25 changed files with 2518 additions and 0 deletions

View File

@@ -0,0 +1,64 @@
/**
* buyer-pdf-chunk.test.ts — the paragraph/sentence-aligned chunker must never
* cut a sentence in half, must respect the target size, and must be
* deterministic.
*/
import { describe, it, expect } from "vitest";
import { chunkText } from "../../buyer/src/pdf/chunk.js";
describe("chunkText", () => {
const sentences = Array.from(
{ length: 50 },
(_, i) => `This is sentence number ${i + 1} with some filler words added to bump the token count up.`,
);
const prose = sentences.join(" ");
it("never splits mid-sentence — every chunk ends on sentence punctuation", () => {
const chunks = chunkText(prose, { targetTokens: 60, maxTokens: 200 });
expect(chunks.length).toBeGreaterThan(1);
for (const c of chunks) {
expect(/[.!?]$/.test(c.text.trim())).toBe(true);
}
});
it("preserves every sentence verbatim (no loss, no breakage)", () => {
const chunks = chunkText(prose, { targetTokens: 60, maxTokens: 200 });
for (const s of sentences) {
expect(chunks.some((c) => c.text.includes(s))).toBe(true);
}
});
it("assigns sequential 0-based indices", () => {
const chunks = chunkText(prose, { targetTokens: 60, maxTokens: 200 });
chunks.forEach((c, i) => expect(c.index).toBe(i));
});
it("is deterministic", () => {
const a = chunkText(prose, { targetTokens: 60, maxTokens: 200 });
const b = chunkText(prose, { targetTokens: 60, maxTokens: 200 });
expect(b.map((c) => c.text)).toEqual(a.map((c) => c.text));
});
it("keeps each chunk within a bounded size of the target", () => {
const chunks = chunkText(prose, { targetTokens: 60, maxTokens: 200 });
for (const c of chunks) {
// packing flushes before exceeding target, so a chunk is at most
// target + one unit (a single sentence here, ~20 tokens) ≤ maxTokens.
expect(c.tokenEstimate).toBeLessThanOrEqual(200);
}
});
it("hard-splits a single oversize sentence (no punctuation) without losing words", () => {
const giant = Array.from({ length: 400 }, (_, i) => `word${i}`).join(" ");
const chunks = chunkText(giant, { targetTokens: 50, maxTokens: 80 });
expect(chunks.length).toBeGreaterThan(1);
const reassembled = chunks.map((c) => c.text).join(" ").split(/\s+/).sort();
expect(reassembled).toEqual(giant.split(/\s+/).sort());
});
it("returns no chunks for empty/whitespace input", () => {
expect(chunkText("", { targetTokens: 60, maxTokens: 200 })).toEqual([]);
expect(chunkText(" \n\n ", { targetTokens: 60, maxTokens: 200 })).toEqual([]);
});
});

View File

@@ -0,0 +1,60 @@
/**
* buyer-pdf-estimate.test.ts — reduce-tree call counting + cost bound math.
*/
import { describe, it, expect } from "vitest";
import { reduceCallCount, estimateJob, feeHeadroomLovelace } from "../../buyer/src/pdf/estimate.js";
import { SupplierPool } from "../../buyer/src/pdf/supplier-pool.js";
import type { PoolSupplier } from "../../buyer/src/pdf/types.js";
function sup(price: bigint): PoolSupplier {
return {
advertRef: { txHash: "a".repeat(64), index: 0 },
utxoRef: `${"a".repeat(64)}#0`,
supplierPkh: "pkh",
model: "kimi",
priceLovelace: price,
maxOutputTokens: 512,
endpointUrl: "http://x",
};
}
describe("reduceCallCount", () => {
it("matches the F-ary tree", () => {
expect(reduceCallCount(90, 8)).toBe(15); // 12 + 2 + 1
expect(reduceCallCount(8, 8)).toBe(1);
expect(reduceCallCount(64, 8)).toBe(9); // 8 + 1
expect(reduceCallCount(1, 8)).toBe(0);
expect(reduceCallCount(0, 8)).toBe(0);
expect(reduceCallCount(2, 8)).toBe(1);
});
it("is safe for degenerate fan-in", () => {
expect(reduceCallCount(10, 1)).toBe(0);
});
});
describe("estimateJob", () => {
it("bounds total with the priciest supplier and counts map+reduce", () => {
const pool = new SupplierPool([sup(2_000_000n), sup(3_000_000n)]);
const est = estimateJob(90, 8, pool);
expect(est.mapCalls).toBe(90);
expect(est.reduceCalls).toBe(15);
expect(est.totalCalls).toBe(105);
expect(est.perCallMaxLovelace).toBe("3000000");
expect(est.totalLovelace).toBe((3_000_000n * 105n).toString());
expect(est.totalAp3x).toBe("315.00");
});
it("handles a single-chunk job (no reduce)", () => {
const pool = new SupplierPool([sup(2_000_000n)]);
const est = estimateJob(1, 8, pool);
expect(est.totalCalls).toBe(1);
});
});
describe("feeHeadroomLovelace", () => {
it("scales with call count plus a fixed buffer", () => {
expect(feeHeadroomLovelace(10)).toBe(12_000_000n);
});
});

View File

@@ -0,0 +1,27 @@
/**
* buyer-pdf-extract.test.ts — extraction error handling. A non-PDF buffer must
* surface as a structured PdfExtractionError (not an unhandled throw), so the
* upload route can map it to a clean 4xx.
*/
import { describe, it, expect } from "vitest";
import { extractPdfText } from "../../buyer/src/pdf/extract.js";
import { PdfExtractionError } from "../../buyer/src/pdf/types.js";
describe("extractPdfText", () => {
it("throws a structured PdfExtractionError on a non-PDF buffer", async () => {
await expect(extractPdfText(Buffer.from("definitely not a pdf"))).rejects.toBeInstanceOf(
PdfExtractionError,
);
});
it("error carries a machine-readable reason", async () => {
try {
await extractPdfText(Buffer.from("nope"));
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(PdfExtractionError);
expect((e as PdfExtractionError).reason).toBeTruthy();
}
});
});

View File

@@ -0,0 +1,198 @@
/**
* buyer-pdf-job.test.ts — the map-reduce orchestrator, exercised with an
* injected per-call lifecycle (no chain/lucid). Covers the happy path,
* cost/coverage accounting, SSE done signalling, and the retry→gap policy.
*/
import { describe, it, expect } from "vitest";
import { JobStore, type RunCallFn } from "../../buyer/src/pdf/summarize-job.js";
import { loadPdfCaps } from "../../buyer/src/pdf/caps.js";
import type { Chunk } from "../../buyer/src/pdf/types.js";
import type { Marketplace } from "../../buyer/src/sdk/Marketplace.js";
import type { SupplierView } from "../../buyer/src/sdk/types.js";
import type { ChainProvider } from "@marketplace/shared/chain";
import type { WalletKey } from "@marketplace/shared/tx";
function supplierView(model: string, ref: string, price: string): SupplierView {
return {
utxo_ref: ref,
supplier_pkh: `pkh_${model}`,
capability_id: "llm.text.generate.v1",
model,
max_output_tokens: 512,
max_processing_ms: 60000,
price_lovelace: price,
supplier_bond_lovelace: "1000000",
buyer_bond_lovelace: "1000000",
endpoint_url: "http://supplier",
detail_uri: "",
detail_hash: "",
advertised_at: 0,
status: "active",
advert_status: "Active",
current_escrow_ref: null,
last_seen_iso: null,
created_slot: 0,
};
}
const MARKETPLACE = {
discoverSuppliers: async () => [
supplierView("kimi-k2", `${"a".repeat(64)}#0`, "2000000"),
supplierView("deepseek", `${"b".repeat(64)}#0`, "3000000"),
],
} as unknown as Marketplace;
const WALLET = { address: "addr_test", pubKeyHash: "pkh", pubKeyHex: "", privateKeyHex: "0".repeat(64) } as WalletKey;
const CHAIN = {} as ChainProvider;
function chunks(n: number): Chunk[] {
return Array.from({ length: n }, (_, i) => ({ index: i, text: `chunk ${i} text`, tokenEstimate: 5 }));
}
function waitForDone(store: JobStore, job: { subscribe: (f: (frame: string) => void) => () => void }): Promise<void> {
return new Promise<void>((resolve) => {
job.subscribe((frame) => {
if (frame.startsWith("event: done")) resolve();
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
store.start(job as any);
});
}
describe("JobStore orchestrator", () => {
it("estimates map + reduce calls", async () => {
const caps = loadPdfCaps({ PDF_RETRY_K: "1" });
const store = new JobStore({
marketplace: MARKETPLACE,
chain: CHAIN,
walletKey: WALLET,
indexerUrl: "http://indexer",
caps,
walletBalance: async () => 10_000_000_000n,
});
const job = store.createJob("book.pdf", 10, chunks(20));
const est = await store.estimate(job);
expect(est.mapCalls).toBe(20);
expect(est.reduceCalls).toBe(reduceExpected(20, caps.reduceFanin));
expect(est.no_capable_suppliers).toBe(false);
expect(est.would_drop_below_floor).toBe(false);
expect(est.perCallMaxLovelace).toBe("3000000"); // priciest supplier
});
it("flags would_drop_below_floor when balance is too low", async () => {
const caps = loadPdfCaps({ PDF_WALLET_FLOOR_LOVELACE: "50000000" });
const store = new JobStore({
marketplace: MARKETPLACE,
chain: CHAIN,
walletKey: WALLET,
indexerUrl: "http://indexer",
caps,
walletBalance: async () => 1_000_000n, // basically empty
});
const job = store.createJob("book.pdf", 1, chunks(3));
const est = await store.estimate(job);
expect(est.would_drop_below_floor).toBe(true);
});
it("runs map+reduce to a final summary, accounting cost + escrows", async () => {
const caps = loadPdfCaps({ PDF_RETRY_K: "1" });
let n = 0;
const runCall: RunCallFn = async (sup, prompt) => {
n += 1;
return {
response: `S[${prompt.slice(0, 14)}]`,
escrowRef: `${"f".repeat(64)}#${n}`,
supplierPkh: sup.supplierPkh,
model: sup.model,
receipt: { supplier_pkh: sup.supplierPkh, model: sup.model },
receiptSignature: "sig",
};
};
const store = new JobStore({
marketplace: MARKETPLACE,
chain: CHAIN,
walletKey: WALLET,
indexerUrl: "http://indexer",
caps,
runCall,
walletBalance: async () => 10_000_000_000n,
});
const job = store.createJob("book.pdf", 5, chunks(5));
await waitForDone(store, job);
expect(job.status).toBe("completed");
expect(job.coverageDone).toBe(5);
expect(typeof job.finalSummary).toBe("string");
expect((job.finalSummary as string).length).toBeGreaterThan(0);
const expectedCalls = 5 + reduceExpected(5, caps.reduceFanin);
expect(job.escrowRefs.length).toBe(expectedCalls);
// round-robin mixes 2M + 3M priced suppliers; cost is the sum actually paid.
expect(BigInt(job.view().running_cost_lovelace)).toBeGreaterThan(0n);
});
it("retries a failing chunk on a different supplier, then marks a gap", async () => {
const caps = loadPdfCaps({ PDF_RETRY_K: "1" });
const runCall: RunCallFn = async (sup, prompt) => {
// chunk index 1 → prompt contains "[chunk 2]" → always fail (both suppliers)
if (prompt.includes("[chunk 2]")) {
throw new Error("supplier exploded");
}
return {
response: `S`,
escrowRef: `${"f".repeat(64)}#${Math.floor(Math.random() * 1e6)}`,
supplierPkh: sup.supplierPkh,
model: sup.model,
receipt: {},
receiptSignature: "sig",
};
};
const store = new JobStore({
marketplace: MARKETPLACE,
chain: CHAIN,
walletKey: WALLET,
indexerUrl: "http://indexer",
caps,
runCall,
walletBalance: async () => 10_000_000_000n,
});
const job = store.createJob("book.pdf", 3, chunks(3));
await waitForDone(store, job);
expect(job.status).toBe("completed_with_gaps");
expect(job.coverageDone).toBe(2); // chunks 0 and 2 only
expect(job.failedCount).toBeGreaterThanOrEqual(1);
expect(job.chunkResults[1].status).toBe("gap");
expect(typeof job.finalSummary).toBe("string");
});
it("fails cleanly when no capable suppliers exist", async () => {
const emptyMarket = { discoverSuppliers: async () => [] } as unknown as Marketplace;
const store = new JobStore({
marketplace: emptyMarket,
chain: CHAIN,
walletKey: WALLET,
indexerUrl: "http://indexer",
caps: loadPdfCaps({}),
runCall: async () => {
throw new Error("should not be called");
},
walletBalance: async () => 10_000_000_000n,
});
const job = store.createJob("book.pdf", 1, chunks(2));
await waitForDone(store, job);
expect(job.status).toBe("failed");
});
});
/** Mirror of estimate.reduceCallCount for the expected value in assertions. */
function reduceExpected(n: number, fanin: number): number {
if (n <= 1) return 0;
let total = 0;
let level = n;
while (level > 1) {
level = Math.ceil(level / fanin);
total += level;
}
return total;
}

View File

@@ -0,0 +1,164 @@
/**
* buyer-pdf-routes.test.ts — the /v1/pdf-* HTTP surface, via supertest against
* createApp. Auth is unconfigured in these tests (no password/sessionSecret),
* so the routes are reachable directly — same construction shape as
* buyer-server-synth-speech.test.ts.
*/
import { describe, it, expect } from "vitest";
import { createApp } from "../../buyer/src/server.js";
import { JobStore, type RunCallFn } from "../../buyer/src/pdf/summarize-job.js";
import { loadPdfCaps } from "../../buyer/src/pdf/caps.js";
import type { Chunk } from "../../buyer/src/pdf/types.js";
import type { Marketplace } from "../../buyer/src/sdk/Marketplace.js";
import type { SupplierView } from "../../buyer/src/sdk/types.js";
import type { ChainProvider } from "@marketplace/shared/chain";
import type { WalletKey } from "@marketplace/shared/tx";
function supplierView(model: string, ref: string, price: string): SupplierView {
return {
utxo_ref: ref,
supplier_pkh: `pkh_${model}`,
capability_id: "llm.text.generate.v1",
model,
max_output_tokens: 512,
max_processing_ms: 60000,
price_lovelace: price,
supplier_bond_lovelace: "1000000",
buyer_bond_lovelace: "1000000",
endpoint_url: "http://supplier",
detail_uri: "",
detail_hash: "",
advertised_at: 0,
status: "active",
advert_status: "Active",
current_escrow_ref: null,
last_seen_iso: null,
created_slot: 0,
};
}
const MARKETPLACE = {
discoverSuppliers: async () => [supplierView("kimi-k2", `${"a".repeat(64)}#0`, "2000000")],
} as unknown as Marketplace;
const WALLET = { address: "addr_test", pubKeyHash: "pkh", pubKeyHex: "", privateKeyHex: "0".repeat(64) } as WalletKey;
const CHAIN = {} as ChainProvider;
function makeStore(opts?: { balance?: bigint; caps?: Record<string, string> }): JobStore {
let n = 0;
const runCall: RunCallFn = async (sup) => {
n += 1;
return {
response: `summary ${n}`,
escrowRef: `${"f".repeat(64)}#${n}`,
supplierPkh: sup.supplierPkh,
model: sup.model,
receipt: {},
receiptSignature: "sig",
};
};
return new JobStore({
marketplace: MARKETPLACE,
chain: CHAIN,
walletKey: WALLET,
indexerUrl: "http://indexer",
caps: loadPdfCaps(opts?.caps ?? {}),
runCall,
walletBalance: async () => opts?.balance ?? 10_000_000_000n,
});
}
function chunks(n: number): Chunk[] {
return Array.from({ length: n }, (_, i) => ({ index: i, text: `chunk ${i}`, tokenEstimate: 5 }));
}
describe("/v1/pdf-* routes", () => {
it("503s every pdf route when the feature is disabled (no jobStore)", async () => {
const app = createApp({});
const { default: request } = await import("supertest");
const up = await request(app).post("/v1/pdf-upload").send({});
expect(up.status).toBe(503);
const job = await request(app).get("/v1/pdf-jobs/whatever");
expect(job.status).toBe(503);
});
it("rejects an upload with no file (400 file_required)", async () => {
const app = createApp({ jobStore: makeStore(), pdfCaps: loadPdfCaps({}) });
const { default: request } = await import("supertest");
const res = await request(app).post("/v1/pdf-upload");
expect(res.status).toBe(400);
expect(res.body.error).toBe("file_required");
});
it("rejects a non-PDF upload (422)", async () => {
const app = createApp({ jobStore: makeStore(), pdfCaps: loadPdfCaps({}) });
const { default: request } = await import("supertest");
const res = await request(app)
.post("/v1/pdf-upload")
.attach("file", Buffer.from("this is not a pdf"), "junk.pdf");
expect(res.status).toBe(422);
expect(["pdf_parse_failed", "scanned_or_image_pdf", "no_text"]).toContain(res.body.error);
});
it("404s estimate for an unknown job", async () => {
const app = createApp({ jobStore: makeStore(), pdfCaps: loadPdfCaps({}) });
const { default: request } = await import("supertest");
const res = await request(app).get("/v1/pdf-jobs/nope/estimate");
expect(res.status).toBe(404);
expect(res.body.error).toBe("job_not_found");
});
it("estimate → confirm-gate → start → run → summary (seeded job)", async () => {
const store = makeStore();
const seeded = store.createJob("MyBook.pdf", 4, chunks(4));
const app = createApp({ jobStore: store, pdfCaps: loadPdfCaps({}) });
const { default: request } = await import("supertest");
// estimate
const est = await request(app).get(`/v1/pdf-jobs/${seeded.jobId}/estimate`);
expect(est.status).toBe(200);
expect(est.body.mapCalls).toBe(4);
expect(est.body.would_drop_below_floor).toBe(false);
// start without confirm → 400
const noConfirm = await request(app).post(`/v1/pdf-jobs/${seeded.jobId}/start`).send({});
expect(noConfirm.status).toBe(400);
expect(noConfirm.body.error).toBe("not_confirmed");
// start with confirm → 202
const start = await request(app).post(`/v1/pdf-jobs/${seeded.jobId}/start`).send({ confirm: true });
expect(start.status).toBe(202);
// poll until finished
let view = seeded.view();
for (let i = 0; i < 100 && view.status === "running"; i++) {
await new Promise((r) => setTimeout(r, 10));
const v = await request(app).get(`/v1/pdf-jobs/${seeded.jobId}`);
view = v.body;
}
expect(view.status).toBe("completed");
expect(view.coverage).toEqual({ done: 4, total: 4 });
expect(view.escrow_refs.length).toBeGreaterThanOrEqual(4);
// double-start now 409
const again = await request(app).post(`/v1/pdf-jobs/${seeded.jobId}/start`).send({ confirm: true });
expect(again.status).toBe(409);
// summary download
const md = await request(app).get(`/v1/pdf-jobs/${seeded.jobId}/summary.md`);
expect(md.status).toBe(200);
expect(md.headers["content-type"]).toMatch(/text\/markdown/);
expect(md.headers["content-disposition"]).toContain("MyBook-summary.md");
expect(md.text.length).toBeGreaterThan(0);
});
it("refuses to start when spend would drop below the wallet floor (402)", async () => {
const store = makeStore({ balance: 1_000_000n, caps: { PDF_WALLET_FLOOR_LOVELACE: "50000000" } });
const seeded = store.createJob("book.pdf", 1, chunks(3));
const app = createApp({ jobStore: store, pdfCaps: loadPdfCaps({ PDF_WALLET_FLOOR_LOVELACE: "50000000" }) });
const { default: request } = await import("supertest");
const res = await request(app).post(`/v1/pdf-jobs/${seeded.jobId}/start`).send({ confirm: true });
expect(res.status).toBe(402);
expect(res.body.error).toBe("below_wallet_floor");
});
});

View File

@@ -0,0 +1,108 @@
/**
* buyer-pdf-supplier-pool.test.ts — capability filtering (must drop the weak
* qwen model and non-parseable demo refs) + round-robin with failover.
*/
import { describe, it, expect } from "vitest";
import {
isCapableModel,
filterSuppliers,
SupplierPool,
} from "../../buyer/src/pdf/supplier-pool.js";
import { loadPdfCaps } from "../../buyer/src/pdf/caps.js";
import type { SupplierView } from "../../buyer/src/sdk/types.js";
const caps = loadPdfCaps({});
function view(p: Partial<SupplierView>): SupplierView {
return {
utxo_ref: `${"a".repeat(64)}#0`,
supplier_pkh: "pkh",
capability_id: "llm.text.generate.v1",
model: "kimi-k2",
max_output_tokens: 512,
max_processing_ms: 60000,
price_lovelace: "2000000",
supplier_bond_lovelace: "1000000",
buyer_bond_lovelace: "1000000",
endpoint_url: "http://supplier",
detail_uri: "",
detail_hash: "",
advertised_at: 0,
status: "active",
advert_status: "Active",
current_escrow_ref: null,
last_seen_iso: null,
created_slot: 0,
...p,
};
}
describe("isCapableModel", () => {
it("excludes deny-listed models, allows allow-listed", () => {
expect(isCapableModel("kimi-k2.6", ["kimi", "deepseek", "gpt"], ["qwen2.5:0.5b"])).toBe(true);
expect(isCapableModel("deepseek-chat", ["kimi", "deepseek", "gpt"], ["qwen2.5:0.5b"])).toBe(true);
expect(isCapableModel("qwen2.5:0.5b", ["kimi", "deepseek", "gpt"], ["qwen2.5:0.5b"])).toBe(false);
expect(isCapableModel("llama3", ["kimi", "deepseek", "gpt"], [])).toBe(false);
});
it("deny wins over allow", () => {
expect(isCapableModel("gpt-qwen2.5:0.5b", ["gpt"], ["qwen2.5:0.5b"])).toBe(false);
});
it("empty allowlist allows anything not denied", () => {
expect(isCapableModel("anything", [], ["bad"])).toBe(true);
});
});
describe("filterSuppliers", () => {
it("keeps capable suppliers and drops qwen + demo + wrong-capability", () => {
const views = [
view({ model: "kimi-k2.6", utxo_ref: `${"a".repeat(64)}#0` }),
view({ model: "deepseek-chat", utxo_ref: `${"b".repeat(64)}#1` }),
view({ model: "qwen2.5:0.5b", utxo_ref: `${"c".repeat(64)}#0` }),
view({ model: "kimi", utxo_ref: "demo:piper-tts" }), // unparseable ref
view({ model: "gpt-4o", capability_id: "audio.synthesize.piper.v1", utxo_ref: `${"d".repeat(64)}#0` }),
];
const out = filterSuppliers(views, caps);
expect(out.map((s) => s.model).sort()).toEqual(["deepseek-chat", "kimi-k2.6"]);
expect(out[0].priceLovelace).toBe(2_000_000n);
});
it("dedups by utxo_ref", () => {
const ref = `${"a".repeat(64)}#0`;
const out = filterSuppliers([view({ utxo_ref: ref }), view({ utxo_ref: ref })], caps);
expect(out).toHaveLength(1);
});
});
describe("SupplierPool.next", () => {
function pool(): SupplierPool {
return new SupplierPool(
filterSuppliers(
[
view({ model: "kimi", utxo_ref: `${"a".repeat(64)}#0` }),
view({ model: "deepseek", utxo_ref: `${"b".repeat(64)}#0` }),
view({ model: "gpt-4o", utxo_ref: `${"c".repeat(64)}#0` }),
],
caps,
),
);
}
it("round-robins through all suppliers", () => {
const p = pool();
const seen = [p.next()?.model, p.next()?.model, p.next()?.model, p.next()?.model];
expect(seen.slice(0, 3).sort()).toEqual(["deepseek", "gpt-4o", "kimi"]);
expect(seen[3]).toBe(seen[0]); // wrapped around
});
it("skips excluded suppliers and returns null when all excluded", () => {
const p = pool();
const all = p.all().map((s) => s.utxoRef);
const exclude = new Set(all);
expect(p.next(exclude)).toBeNull();
const excludeTwo = new Set(all.slice(0, 2));
expect(p.next(excludeTwo)?.utxoRef).toBe(all[2]);
});
});