Files
Jyotisha/frontend/tests/birth-time-guided-polling.test.ts
T
2026-07-20 11:44:56 +08:00

149 lines
4.6 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import { runBirthTimeScoringPoll } from "../src/lib/birth-time-guided-polling.ts";
import { claimMutation, publishCurrentJourney } from "../src/lib/birth-time-guided-effect-coordinator.ts";
import { storedDynamicJourneyResponse } from "../src/lib/birth-time-journey-response.ts";
import { dynamicCase } from "./birth-time-dynamic-persistence-fixture.ts";
import { parseJourneyResponse } from "../src/lib/birth-time-journey-client.ts";
import { highConfirmationTurn } from "./birth-time-journey-client-test-support.ts";
const completedTurn = parseJourneyResponse(highConfirmationTurn);
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
const promise = new Promise<T>((settle) => {
resolve = settle;
});
return { promise, resolve };
}
const pendingTurn = parseJourneyResponse({
...highConfirmationTurn,
snapshot: {
...highConfirmationTurn.snapshot,
state: "rectifying",
assistantIntent: "collect_dated_life_events",
input: "life_events",
confidence: null,
canApply: false,
},
candidateResult: null,
nextAction: { kind: "score_pending", jobId: "c70ea014-f8b4-41f2-9305-e4ae60c0d4d1" },
progress: { ...highConfirmationTurn.progress, phase: "scoring" },
permissions: { canConfirmCandidate: false },
});
test("polling is sequential and returns the first completed turn", async () => {
let active = 0;
let peak = 0;
let calls = 0;
const entered = Array.from({ length: 3 }, () => deferred<void>());
const gates = Array.from(
{ length: 3 },
() => deferred<typeof pendingTurn | typeof completedTurn>(),
);
const running = runBirthTimeScoringPoll({
initial: pendingTurn,
maxAttempts: 4,
signal: new AbortController().signal,
delay: async () => undefined,
poll: async () => {
const call = calls;
calls += 1;
active += 1;
peak = Math.max(peak, active);
entered[call]?.resolve();
const turn = await gates[call].promise;
active -= 1;
return turn;
},
});
await entered[0].promise;
assert.equal(calls, 1, "a second poll must not start while the first is unresolved");
gates[0].resolve(pendingTurn);
await entered[1].promise;
assert.equal(active, 1);
assert.equal(calls, 2, "only one deferred poll may be active at a time");
gates[1].resolve(pendingTurn);
await entered[2].promise;
assert.equal(active, 1);
assert.equal(calls, 3);
gates[2].resolve(completedTurn);
const result = await running;
assert.equal(calls, 3);
assert.equal(peak, 1);
assert.equal(result.kind, "completed");
});
test("polling stops on cancellation and ignores later work", async () => {
const controller = new AbortController();
let calls = 0;
const result = await runBirthTimeScoringPoll({
initial: pendingTurn,
maxAttempts: 5,
signal: controller.signal,
delay: async () => { controller.abort(); },
poll: async () => { calls += 1; return pendingTurn; },
});
assert.equal(calls, 1);
assert.equal(result.kind, "cancelled");
});
test("bounded polling preserves the pending turn instead of inventing completion", async () => {
const result = await runBirthTimeScoringPoll({
initial: pendingTurn,
maxAttempts: 2,
signal: new AbortController().signal,
delay: async () => undefined,
poll: async () => pendingTurn,
});
assert.equal(result.kind, "exhausted");
assert.equal(result.turn.nextAction.kind, "score_pending");
});
test("duplicate option clicks publish one advanced turn", async () => {
const gate = deferred<typeof completedTurn>();
const sent: string[] = [];
const published: typeof completedTurn[] = [];
const lock = { current: false };
const select = async (optionId: string) => {
const release = claimMutation(lock);
if (release === null) return;
sent.push(optionId);
const turn = await gate.promise;
published.push(turn);
release();
};
const first = select("primary-option");
const duplicate = select("primary-option");
gate.resolve(completedTurn);
await Promise.all([first, duplicate]);
assert.deepEqual(sent, ["primary-option"]);
assert.deepEqual(published, [completedTurn]);
});
test("a stale generated question cannot replace a newer turn", () => {
const expected = parseJourneyResponse(storedDynamicJourneyResponse(dynamicCase()));
const current = parseJourneyResponse({ ...expected, turnVersion: expected.turnVersion + 1 });
let published = 0;
const accepted = publishCurrentJourney({
expected,
current,
next: expected,
publish: () => { published += 1; },
});
assert.equal(accepted, false);
assert.equal(current.turnVersion, 8);
assert.equal(published, 0);
});