208 lines
8.1 KiB
TypeScript
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");
|
|
}
|
|
});
|