Files
Jyotisha/frontend/tests/birth-time-agent-flow-e2e.test.ts
2026-07-20 14:58:57 +08:00

269 lines
13 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import { createJourneyTelemetry } from "../src/lib/birth-time-journey-telemetry.ts";
import { createBirthTimeJourneyService } from "../src/lib/birth-time-journey-service.ts";
import { StaleJourneyTurnError } from "../src/lib/birth-time-journey-turn-persistence.ts";
import {
actionIds,
assertLegalTurn,
candidate,
confirmDirectEvidence,
createHarness,
guidedCase,
journeyCaseId,
reachScoring,
} from "./birth-time-agent-flow-test-support.ts";
import { actionId as dynamicActionId, dynamicCase, ownerId } from "./birth-time-dynamic-persistence-fixture.ts";
import { memoryStore } from "./birth-time-journey-memory-store.ts";
test("dynamic candidate confirmation remains ready after resume", async () => {
const result = candidate("high", "90000000-0000-4000-8000-000000000001");
const current = dynamicCase();
const memory = memoryStore({
...current,
candidateResult: result,
snapshot: {
...current.snapshot,
state: "confirming",
assistantIntent: "confirm_candidate_time",
input: "candidate_confirmation",
confidence: "high",
canApply: true,
},
currentChoiceQuestion: null,
dynamicTurnState: {
...current.dynamicTurnState,
nextAction: { kind: "request_candidate_confirmation", resultId: result.resultId },
progress: { ...current.dynamicTurnState.progress, phase: "result" },
permissions: { canConfirmCandidate: true },
},
});
const service = createBirthTimeJourneyService({
store: memory.store,
engine: {
async scan() { throw new Error("unexpected scan"); },
async score() { throw new Error("unexpected score"); },
async scoreEvents() { throw new Error("unexpected event score"); },
async buildDifferencePacket() { throw new Error("unexpected packet"); },
async scoreChoices() { throw new Error("unexpected choice score"); },
},
});
const confirmation = await service.confirmDynamicCandidate({
userId: ownerId,
caseId: current.id,
actionId: dynamicActionId,
expectedVersion: current.turnVersion,
resultId: result.resultId,
time: result.winningSegment?.representativeTime ?? "",
});
const confirmationResume = await service.resume(ownerId, current.id);
assert.equal(confirmation.nextAction.kind, "ready");
assert.equal(confirmationResume.nextAction.kind, "ready");
assert.equal(confirmation.snapshot.activeTime, "14:24");
assert.equal(confirmationResume.snapshot.activeTime, "14:24");
});
test("fake Agent and engine complete baseline, adaptive, low, and no-apply flow", async () => {
const harness = createHarness({ initial: guidedCase(), result: candidate("low", "10000000-0000-4000-8000-000000000001") });
let turn = await reachScoring({ harness, useGuide: true });
assert.equal(harness.scoreEventsCalls(), 0, "confirming evidence only creates a pending job");
turn = await harness.service.pollScoringJob(
"user-1", journeyCaseId, turn.nextAction.kind === "score_pending" ? turn.nextAction.jobId : "",
);
assertLegalTurn(turn);
assert.equal(turn.nextAction.kind, "ask_adaptive_evidence");
assert.equal(turn.snapshot.activeTime, null);
let skipAction: (typeof actionIds)[number] = actionIds[6];
while (turn.nextAction.kind === "ask_adaptive_evidence") {
turn = await harness.service.skipEvidenceQuestion("user-1", journeyCaseId, skipAction, turn.turnVersion);
assertLegalTurn(turn);
skipAction = actionIds[actionIds.indexOf(skipAction) + 1] ?? actionIds[7];
}
assert.equal(turn.nextAction.kind, "present_low_result");
assert.equal(turn.candidateResult?.confidence, "low");
assert.equal(turn.snapshot.activeTime, null);
assert.equal(harness.memory.savedCase()?.snapshot.activeTime, null);
await assert.rejects(harness.candidateActions.confirm({
userId: "user-1", caseId: journeyCaseId, actionId: actionIds[10], expectedVersion: turn.turnVersion,
resultId: turn.candidateResult?.resultId ?? "", time: "14:24",
}));
assert.equal(harness.memory.savedCase()?.snapshot.activeTime, null);
});
test("medium result is save-only and never applies an active minute", async () => {
const medium = candidate("medium", "20000000-0000-4000-8000-000000000001");
const harness = createHarness({ initial: guidedCase(), result: medium });
const pending = await reachScoring({ harness });
const result = await harness.service.pollScoringJob("user-1", journeyCaseId, pending.nextAction.kind === "score_pending" ? pending.nextAction.jobId : "");
assertLegalTurn(result);
assert.equal(result.nextAction.kind, "present_medium_result");
assert.equal(result.permissions.canConfirmCandidate, false);
await assert.rejects(harness.candidateActions.confirm({
userId: "user-1", caseId: journeyCaseId, actionId: actionIds[8], expectedVersion: result.turnVersion,
resultId: medium.resultId, time: "14:24",
}));
assert.equal(harness.memory.savedCase()?.snapshot.activeTime, null);
const saved = await harness.candidateActions.save({
userId: "user-1", caseId: journeyCaseId, actionId: actionIds[6], expectedVersion: result.turnVersion, resultId: medium.resultId,
});
assertLegalTurn(saved);
assert.equal(saved.nextAction.kind, "candidate_saved");
assert.equal(saved.snapshot.activeTime, null);
});
test("high result applies only after explicit matching representative-time confirmation", async () => {
const high = candidate("high", "30000000-0000-4000-8000-000000000001");
const harness = createHarness({
initial: guidedCase(),
result: candidate("low", "30000000-0000-4000-8000-000000000002"),
resultAfterAdaptive: high,
});
const pending = await reachScoring({ harness });
const adaptive = await harness.service.pollScoringJob("user-1", journeyCaseId, pending.nextAction.kind === "score_pending" ? pending.nextAction.jobId : "");
assert.equal(adaptive.nextAction.kind, "ask_adaptive_evidence");
const nextPending = await confirmDirectEvidence({
harness,
turn: adaptive,
proposalActionId: actionIds[6],
confirmActionId: actionIds[7],
date: "2013",
});
const result = await harness.service.pollScoringJob("user-1", journeyCaseId, nextPending.nextAction.kind === "score_pending" ? nextPending.nextAction.jobId : "");
assertLegalTurn(result);
assert.equal(result.nextAction.kind, "request_candidate_confirmation");
assert.equal(result.permissions.canConfirmCandidate, true);
await assert.rejects(harness.candidateActions.confirm({
userId: "user-1", caseId: journeyCaseId, actionId: actionIds[8], expectedVersion: result.turnVersion,
resultId: high.resultId, time: "14:23",
}));
assert.equal(harness.memory.savedCase()?.snapshot.activeTime, null);
const ready = await harness.candidateActions.confirm({
userId: "user-1", caseId: journeyCaseId, actionId: actionIds[9], expectedVersion: result.turnVersion,
resultId: high.resultId, time: high.winningSegment?.representativeTime ?? "",
});
assertLegalTurn(ready);
assert.equal(ready.nextAction.kind, "ready");
assert.equal(ready.snapshot.activeTime, "14:24");
});
test("a scorer cannot forge four events and three domains over three persisted events", async () => {
const declaredHigh = {
...candidate("high", "31000000-0000-4000-8000-000000000001"),
domainCount: 3,
};
const harness = createHarness({ initial: guidedCase(), result: declaredHigh });
const pending = await reachScoring({ harness });
const result = await harness.service.pollScoringJob(
"user-1",
journeyCaseId,
pending.nextAction.kind === "score_pending" ? pending.nextAction.jobId : "",
);
assertLegalTurn(result);
assert.equal(result.nextAction.kind, "retry_scoring");
assert.equal(result.candidateResult, null);
assert.equal(harness.memory.savedCase()?.snapshot.activeTime, null);
});
test("resume preserves ask, draft, score-pending, and confirmation states", async () => {
const ask = createHarness({ initial: guidedCase(), result: candidate("low", "71000000-0000-4000-8000-000000000001") });
const askTurn = await ask.service.resume("user-1", journeyCaseId);
assertLegalTurn(askTurn);
assert.equal(askTurn.nextAction.kind, "ask_baseline_evidence");
const draft = createHarness({ initial: guidedCase(), result: candidate("low", "72000000-0000-4000-8000-000000000001") });
const draftStart = await draft.service.resume("user-1", journeyCaseId);
assert.equal(draftStart.nextAction.kind, "ask_baseline_evidence");
await draft.service.proposeEvidenceDraft("user-1", journeyCaseId, actionIds[0], draftStart.turnVersion, {
domain: draftStart.nextAction.question.domain, precision: "year", date: "2010",
});
const draftResume = await draft.service.resume("user-1", journeyCaseId);
assertLegalTurn(draftResume);
assert.equal(draftResume.nextAction.kind, "review_evidence_draft");
const scoring = createHarness({ initial: guidedCase(), result: candidate("medium", "73000000-0000-4000-8000-000000000001") });
const pending = await reachScoring({ harness: scoring });
const pendingResume = await scoring.service.resume("user-1", journeyCaseId);
assertLegalTurn(pendingResume);
assert.equal(pendingResume.nextAction.kind, "score_pending");
assert.equal(pendingResume.nextAction.jobId, pending.nextAction.kind === "score_pending" ? pending.nextAction.jobId : "");
const confirming = createHarness({
initial: guidedCase(),
result: candidate("low", "74000000-0000-4000-8000-000000000002"),
resultAfterAdaptive: candidate("high", "74000000-0000-4000-8000-000000000001"),
});
const confirmPending = await reachScoring({ harness: confirming });
const confirmAdaptive = await confirming.service.pollScoringJob("user-1", journeyCaseId, confirmPending.nextAction.kind === "score_pending" ? confirmPending.nextAction.jobId : "");
const confirmNextPending = await confirmDirectEvidence({
harness: confirming,
turn: confirmAdaptive,
proposalActionId: actionIds[6],
confirmActionId: actionIds[7],
date: "2013",
});
const confirmation = await confirming.service.pollScoringJob("user-1", journeyCaseId, confirmNextPending.nextAction.kind === "score_pending" ? confirmNextPending.nextAction.jobId : "");
assert.equal(confirmation.nextAction.kind, "request_candidate_confirmation");
const confirmationResume = await confirming.service.resume("user-1", journeyCaseId);
assertLegalTurn(confirmationResume);
assert.equal(confirmationResume.nextAction.kind, "request_candidate_confirmation");
});
test("scoring failure is retryable and resume does not invoke the engine", async () => {
const medium = candidate("medium", "40000000-0000-4000-8000-000000000001");
const harness = createHarness({ initial: guidedCase(), result: medium, failFirstScore: true });
const pending = await reachScoring({ harness });
const jobId = pending.nextAction.kind === "score_pending" ? pending.nextAction.jobId : "";
const failed = await harness.service.pollScoringJob("user-1", journeyCaseId, jobId);
assertLegalTurn(failed);
assert.equal(failed.nextAction.kind, "retry_scoring");
const callsAfterFailure = harness.scoreEventsCalls();
const resumed = await harness.service.resume("user-1", journeyCaseId);
assertLegalTurn(resumed);
assert.deepEqual(resumed.nextAction, failed.nextAction);
assert.equal(harness.scoreEventsCalls(), callsAfterFailure);
const recovered = await harness.service.pollScoringJob("user-1", journeyCaseId, jobId);
assertLegalTurn(recovered);
assert.equal(recovered.nextAction.kind, "present_medium_result");
});
test("duplicate actions replay once and stale versions cannot overwrite a turn", async () => {
const harness = createHarness({ initial: guidedCase(), result: candidate("low", "50000000-0000-4000-8000-000000000001") });
const initial = await harness.service.resume("user-1", journeyCaseId);
assertLegalTurn(initial);
assert.equal(initial.nextAction.kind, "ask_baseline_evidence");
const first = await harness.service.proposeEvidenceDraft("user-1", journeyCaseId, actionIds[0], initial.turnVersion, {
domain: initial.nextAction.question.domain, precision: "year", date: "2010",
});
assertLegalTurn(first);
assert.equal(first.nextAction.kind, "review_evidence_draft");
const duplicate = await harness.service.proposeEvidenceDraft("user-1", journeyCaseId, actionIds[0], initial.turnVersion, {
domain: initial.nextAction.question.domain, precision: "year", date: "2010",
});
assert.deepEqual(duplicate.nextAction, first.nextAction);
assert.equal(harness.memory.committedTurnWrites(), 1);
await assert.rejects(harness.service.skipEvidenceQuestion("user-1", journeyCaseId, actionIds[2], initial.turnVersion), StaleJourneyTurnError);
assert.equal(harness.memory.savedCase()?.turnVersion, first.turnVersion);
});
test("telemetry-facing flow state contains no personal fields", async () => {
const harness = createHarness({ initial: guidedCase(), result: candidate("low", "60000000-0000-4000-8000-000000000001") });
const turn = await harness.service.resume("user-1", journeyCaseId);
assertLegalTurn(turn);
const emitted: unknown[] = [];
const record = createJourneyTelemetry((payload) => emitted.push(payload));
record("turn_advanced", { phase: turn.nextAction.kind === "ask_baseline_evidence" ? "baseline" : "result" });
const payload = emitted[0];
assert.ok(payload !== null && typeof payload === "object");
assert.deepEqual(Object.keys(payload), ["name", "phase"]);
assert.equal("caseId" in payload, false);
assert.equal("userId" in payload, false);
assert.equal("birthDate" in payload, false);
assert.equal("coordinates" in payload, false);
assert.equal("message" in payload, false);
});