Files
Jyotisha/frontend/tests/birth-time-dynamic-actions.test.ts
T
2026-07-19 08:25:33 +08:00

208 lines
8.1 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import { createBirthTimeJourneyService } from "../src/lib/birth-time-journey-service.ts";
import { StaleJourneyTurnError } from "../src/lib/birth-time-journey-turn-persistence.ts";
import { dynamicCase, ownerId, persistedQuestion } from "./birth-time-dynamic-persistence-fixture.ts";
import { memoryStore } from "./birth-time-journey-memory-store.ts";
import { dynamicJobStore } from "./birth-time-dynamic-job-memory-store.ts";
import { differenceBuild } from "./fixtures/birth-time-dynamic-question-fixture.ts";
import { createInitialDynamicState } from "../src/lib/birth-time-journey-dynamic-state.ts";
import { approximateAssessment, scanWithSigns } from "./birth-time-journey-test-support.ts";
const actionId = "9a921af8-ddcc-4d20-b4c8-fbbb3e6a814d";
const secondActionId = "700406ad-1ca6-437d-9f77-61354ba8e36a";
function dynamicFlow(initial = dynamicCase()) {
const memory = memoryStore(initial);
const jobs = dynamicJobStore(memory.store, () => {
const value = memory.savedCase();
return value?.journeyProtocol === "dynamic-choice-v2" ? value : null;
});
const service = createBirthTimeJourneyService({
store: jobs.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() { return differenceBuild; },
async scoreChoices() { throw new Error("unexpected choice score"); },
},
});
return { memory, service, jobs };
}
test("a primary click resolves private evidence and enters score_pending", async () => {
const flow = dynamicFlow();
const result = await flow.service.answerDynamicChoice(ownerId, {
caseId: dynamicCase().id,
actionId,
turnVersion: 7,
questionId: persistedQuestion.questionId,
optionId: persistedQuestion.options[0].optionId,
});
assert.equal(result.nextAction.kind, "score_pending");
const saved = flow.memory.savedCase();
assert.equal(saved?.journeyProtocol, "dynamic-choice-v2");
assert.equal(saved?.choiceAnswers.length, 1);
assert.equal(saved?.choiceEvidence[0]?.partitionId, "window-a");
assert.equal(saved?.dynamicControl.effectiveAnswerCount, 2);
assert.equal(flow.jobs.count(), 1);
});
test("new assessments return the persisted v2 generation turn", async () => {
const memory = memoryStore();
const service = createBirthTimeJourneyService({
store: {
...memory.store,
async saveAssessment(value) {
const initial = createInitialDynamicState(value.snapshot, "2026-07-18");
const fixture = dynamicCase();
memory.replaceCase({
...fixture,
userId: value.userId,
snapshot: value.snapshot,
questionnaire: value.questionnaire,
dynamicTurnState: initial.turn,
...initial.privateState,
});
return fixture.id;
},
},
engine: {
async scan() { return scanWithSigns(["Cancer", "Leo"]); },
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 result = await service.assess(ownerId, approximateAssessment);
assert.equal(result.journeyProtocol, "dynamic-choice-v2");
assert.equal(result.nextAction.kind, "generate_dynamic_question");
assert.equal(result.turnVersion, 0);
});
test("generation returns an engine packet and commits one persisted question", async () => {
const initial = {
...dynamicCase(),
eventContext: { birthDate: "1993-04-17", lat: 31.23, lon: 121.47, tz: 8 },
currentChoiceQuestion: null,
dynamicTurnState: {
...dynamicCase().dynamicTurnState,
nextAction: { kind: "generate_dynamic_question" as const },
},
};
const flow = dynamicFlow(initial);
const command = {
caseId: initial.id,
actionId,
turnVersion: 7,
unmatchedNote: null,
};
const build = await flow.service.generateDynamicQuestion(ownerId, command);
const nextQuestion = {
...persistedQuestion,
questionId: "af34edbf-b4b0-4ebf-9a07-5c177bc73add",
opportunityId: "next-opportunity",
questionFingerprint: "next-question-fingerprint",
candidatePartitionFingerprint: "next-partition-fingerprint",
};
const committed = await flow.service.commitDynamicQuestion(
ownerId,
command,
nextQuestion,
);
assert.equal(build.packet.caseId, differenceBuild.packet.caseId);
assert.equal(committed.nextAction.kind, "ask_dynamic_choice");
assert.equal(flow.memory.savedCase()?.currentChoiceQuestion?.questionId, nextQuestion.questionId);
assert.deepEqual(flow.memory.savedCase()?.dynamicControl?.questionFingerprints, [
persistedQuestion.questionFingerprint,
nextQuestion.questionFingerprint,
]);
});
test("unmatched context is trimmed separately and generates without scoring", async () => {
const flow = dynamicFlow();
const unmatched = persistedQuestion.options.find((option) => option.kind === "unmatched");
if (!unmatched) throw new Error("missing unmatched option");
const clarification = await flow.service.answerDynamicChoice(ownerId, {
caseId: dynamicCase().id,
actionId,
turnVersion: 7,
questionId: persistedQuestion.questionId,
optionId: unmatched.optionId,
});
const reframed = await flow.service.submitUnmatchedContext(ownerId, {
caseId: dynamicCase().id,
actionId: secondActionId,
turnVersion: clarification.turnVersion,
questionId: persistedQuestion.questionId,
note: " 更像是 2017 年 ",
});
assert.equal(reframed.nextAction.kind, "generate_dynamic_question");
assert.deepEqual(flow.memory.savedCase()?.agentContext, ["用户只记得大概阶段", "更像是 2017 年"]);
assert.equal(flow.memory.savedCase()?.currentChoiceQuestion, null);
assert.deepEqual(flow.memory.savedCase()?.choiceEvidence, []);
assert.equal(flow.jobs.count(), 0);
});
test("pause and resume restore the exact persisted question", async () => {
const flow = dynamicFlow();
const paused = await flow.service.pauseDynamic(ownerId, dynamicCase().id, actionId, 7);
const resumed = await flow.service.resumeDynamic(ownerId, dynamicCase().id);
assert.equal(paused.nextAction.kind, "paused");
assert.deepEqual(resumed.nextAction, dynamicCase().dynamicTurnState.nextAction);
assert.equal(flow.memory.savedCase()?.dynamicControl?.pausedAction, null);
assert.equal(resumed.turnVersion, 9);
});
test("a stale or forged option cannot affect private evidence", async () => {
const primary = persistedQuestion.options.find((option) => option.kind === "primary");
if (!primary) throw new Error("missing primary option");
for (const command of [
{ turnVersion: 6, optionId: primary.optionId },
{ turnVersion: 7, optionId: "forged-option" },
]) {
const flow = dynamicFlow();
await assert.rejects(
flow.service.answerDynamicChoice(ownerId, {
caseId: dynamicCase().id,
actionId,
questionId: persistedQuestion.questionId,
...command,
}),
StaleJourneyTurnError,
);
assert.deepEqual(flow.memory.savedCase()?.choiceEvidence, []);
}
});
test("unknown and unmatched increment only answered count and never score", async () => {
for (const kind of ["unknown", "unmatched"] as const) {
const flow = dynamicFlow();
const option = persistedQuestion.options.find((candidate) => candidate.kind === kind);
if (!option) throw new Error("missing special option");
const result = await flow.service.answerDynamicChoice(ownerId, {
caseId: dynamicCase().id,
actionId,
turnVersion: 7,
questionId: persistedQuestion.questionId,
optionId: option.optionId,
});
assert.equal(result.progress.answeredCount, 2);
assert.equal(result.progress.effectiveAnswerCount, 1);
assert.deepEqual(flow.memory.savedCase()?.choiceEvidence, []);
assert.equal(flow.jobs.count(), 0);
assert.equal(result.nextAction.kind, kind === "unknown"
? "generate_dynamic_question"
: "clarify_unmatched_answer");
}
});