feat: orchestrate dynamic rectification turns

This commit is contained in:
Jesse_Chen
2026-07-19 08:25:33 +08:00
parent 45b63d5cef
commit 7aa800b846
20 changed files with 1946 additions and 28 deletions
@@ -0,0 +1,207 @@
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");
}
});
@@ -0,0 +1,79 @@
import type {
BirthTimeJourneyStore,
DynamicStoredRectificationCase,
} from "../src/lib/birth-time-journey-service.ts";
import type { DynamicScoringJobSpec } from "../src/lib/birth-time-scoring-job.ts";
import { BirthTimeScoringJobError } from "../src/lib/birth-time-scoring-job.ts";
import { StaleJourneyTurnError } from "../src/lib/birth-time-journey-turn-persistence.ts";
type DynamicMemoryJob = DynamicScoringJobSpec & {
readonly caseId: string;
readonly userId: string;
readonly status: "pending" | "processing" | "completed" | "failed";
};
export function dynamicJobStore(
base: BirthTimeJourneyStore,
read: () => DynamicStoredRectificationCase | null,
) {
const jobs = new Map<string, DynamicMemoryJob>();
const store: BirthTimeJourneyStore = {
...base,
async createDynamicScoringJob(value, expectedVersion, actionId, _questionId, spec) {
const current = read();
if (!current) throw new BirthTimeScoringJobError("unavailable");
const receipt = actionId.toLowerCase();
if (current.processedActionIds.includes(receipt)) return current;
if (current.turnVersion !== expectedVersion) {
throw new StaleJourneyTurnError(value.id, expectedVersion, current.turnVersion);
}
jobs.set(spec.jobId, {
...spec,
caseId: value.id,
userId: value.userId,
status: "pending",
});
return base.saveDynamicTurn(value, expectedVersion, receipt);
},
async claimDynamicScoringJob(identity) {
const job = jobs.get(identity.jobId);
if (!job || job.caseId !== identity.caseId || job.userId !== identity.userId
|| job.evidenceFingerprint !== identity.evidenceFingerprint) {
throw new BirthTimeScoringJobError("unavailable");
}
if (job.algorithmVersion !== identity.algorithmVersion) {
throw new BirthTimeScoringJobError("algorithm_mismatch");
}
if (job.status === "completed") {
return { kind: "completed", algorithmVersion: job.algorithmVersion };
}
if (job.status === "processing") {
return { kind: "processing", algorithmVersion: job.algorithmVersion };
}
jobs.set(job.jobId, { ...job, status: "processing" });
return { kind: "claimed", algorithmVersion: job.algorithmVersion };
},
async completeDynamicScoringJob(value, command) {
const job = jobs.get(command.jobId);
if (!job || job.status !== "processing") {
throw new BirthTimeScoringJobError("invalid_turn");
}
const saved = await base.completeDynamicScoringJob(value, command);
jobs.set(job.jobId, { ...job, status: "completed" });
return saved;
},
async failDynamicScoringJob(value, command) {
const job = jobs.get(command.jobId);
if (!job || job.status !== "processing") {
throw new BirthTimeScoringJobError("invalid_turn");
}
const saved = await base.failDynamicScoringJob(value, command);
jobs.set(job.jobId, { ...job, status: "failed" });
return saved;
},
};
return {
store,
count: () => jobs.size,
};
}
@@ -0,0 +1,104 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createDynamicScoringJobStore } from "../src/lib/birth-time-dynamic-scoring-job-store.ts";
import { createDynamicScoringJobSpec } from "../src/lib/birth-time-scoring-job.ts";
import { answerTransition } from "../src/lib/birth-time-dynamic-transitions.ts";
import type { DynamicStoredRectificationCase } from "../src/lib/birth-time-journey-service.ts";
import {
dynamicCase,
ownerId,
persistedQuestion,
} from "./birth-time-dynamic-persistence-fixture.ts";
const actionId = "ab2d936b-5ce7-45d8-a0fb-33f48f960f36";
function freshCase(): DynamicStoredRectificationCase {
const stored = dynamicCase();
return {
...stored,
eventContext: { birthDate: "1993-04-17", lat: 31.23, lon: 121.47, tz: 8 },
dynamicControl: {
...stored.dynamicControl,
answeredCount: 0,
effectiveAnswerCount: 0,
},
dynamicTurnState: {
...stored.dynamicTurnState,
progress: {
...stored.dynamicTurnState.progress,
answeredCount: 0,
effectiveAnswerCount: 0,
},
},
};
}
test("dynamic job store sends exact private create and typed claim RPCs", async () => {
const jobId = "85b22d7e-3adc-473d-81e1-6ad29e9b06f4";
const now = new Date("2026-07-18T08:00:00.000Z");
const option = persistedQuestion.options.find((candidate) => candidate.kind === "primary");
if (!option) throw new Error("missing primary option");
const pending = answerTransition({
stored: freshCase(),
option,
answeredAt: now.toISOString(),
jobId,
nextVersion: 8,
});
const spec = createDynamicScoringJobSpec(jobId, pending.choiceEvidence, now);
let loaded = freshCase();
const calls: { readonly name: string; readonly args: Readonly<Record<string, unknown>> }[] = [];
const store = createDynamicScoringJobStore({
async rpc(name, args) {
calls.push({ name, args });
if (name === "create_birth_time_dynamic_scoring_job") {
loaded = {
...pending,
turnVersion: 8,
dynamicTurnState: { ...pending.dynamicTurnState, turnVersion: 8 },
processedActionIds: [actionId],
};
return { data: 8, error: null };
}
return {
data: [{
claim_state: "claimed",
algorithm_version: "birth-time-choice-scoring-v2",
}],
error: null,
};
},
}, async () => loaded);
const created = await store.createDynamicScoringJob(
pending,
7,
actionId,
persistedQuestion.questionId,
spec,
);
const claim = await store.claimDynamicScoringJob({
userId: ownerId,
caseId: pending.id,
jobId,
evidenceFingerprint: spec.evidenceFingerprint,
algorithmVersion: spec.algorithmVersion,
now: now.toISOString(),
});
assert.equal(created.turnVersion, 8);
assert.deepEqual(claim, {
kind: "claimed",
algorithmVersion: "birth-time-choice-scoring-v2",
});
assert.deepEqual(calls.map((call) => call.name), [
"create_birth_time_dynamic_scoring_job",
"claim_birth_time_dynamic_scoring_job",
]);
assert.equal(calls[0]?.args.p_question_id, persistedQuestion.questionId);
assert.equal(JSON.stringify(calls[0]?.args.p_public_turn_state).includes("candidateScores"), false);
assert.deepEqual(
Reflect.get(calls[0]?.args.p_private_state ?? {}, "choiceEvidence"),
pending.choiceEvidence,
);
});
@@ -0,0 +1,229 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
completeDynamicScoreTransition,
} from "../src/lib/birth-time-dynamic-transitions.ts";
import type { CandidateResult } from "../src/lib/birth-time-evidence.ts";
import { createBirthTimeJourneyService } from "../src/lib/birth-time-journey-service.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";
const lowCandidate: CandidateResult = {
resultId: "11111111-1111-4111-8111-111111111111",
confidence: "low",
canApply: false,
winningSegment: null,
eventCount: 1,
domainCount: 1,
topScore: 10,
secondScore: 9,
marginPercent: 10,
reasons: ["close"],
evidence: [],
algorithmVersion: "birth-time-choice-scoring-v2",
};
const actionId = "ab2d936b-5ce7-45d8-a0fb-33f48f960f36";
function freshDynamicCase(candidateResult: CandidateResult | null = null) {
const stored = dynamicCase();
return {
...stored,
eventContext: {
birthDate: "1993-04-17",
lat: 31.23,
lon: 121.47,
tz: 8,
},
candidateResult,
dynamicControl: {
...stored.dynamicControl,
answeredCount: 0,
effectiveAnswerCount: 0,
plateauCount: candidateResult === null ? 0 : 1,
},
dynamicTurnState: {
...stored.dynamicTurnState,
progress: {
...stored.dynamicTurnState.progress,
answeredCount: 0,
effectiveAnswerCount: 0,
plateauCount: candidateResult === null ? 0 : 1,
},
},
};
}
function scoringFlow(input: {
readonly candidate?: CandidateResult;
readonly initialCandidate?: CandidateResult | null;
readonly failOnce?: boolean;
} = {}) {
const initial = freshDynamicCase(input.initialCandidate ?? null);
const memory = memoryStore(initial);
const jobs = dynamicJobStore(memory.store, () => {
const value = memory.savedCase();
return value?.journeyProtocol === "dynamic-choice-v2" ? value : null;
});
let scoreCalls = 0;
let shouldFail = input.failOnce ?? false;
const candidate = input.candidate ?? lowCandidate;
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 scoreChoices() {
scoreCalls += 1;
if (shouldFail) {
shouldFail = false;
throw new TypeError("offline");
}
return {
candidate,
evidenceMode: "dynamic_choice" as const,
effectiveAnswerCount: 1,
dimensionCount: 1,
};
},
async buildDifferencePacket(value) {
return {
packet: {
caseId: value.caseId,
scoringVersion: "birth-time-choice-scoring-v2" as const,
currentRange: { startTime: value.startTime, endTime: value.endTime },
opportunities: [{
opportunityId: "next-opportunity",
dimensionCode: "relocation_change",
neutralContext: "一次居住变化",
estimatedInformationGain: 0.5,
candidatePartitionFingerprint: "next-partition",
fallbackPrompt: "哪一段更接近一次居住变化?",
partitions: [
{ partitionId: "early", descriptor: "early", fallbackLabel: "较早" },
{ partitionId: "late", descriptor: "late", fallbackLabel: "较晚" },
],
}],
askedQuestionFingerprints: value.questionFingerprints,
candidatePartitionFingerprints: value.partitionFingerprints,
recentRangeHistory: value.recentRanges,
},
candidateModel: { version: "after-score" },
scoringPartitions: {},
};
},
},
});
return { memory, jobs, service, scoreCalls: () => scoreCalls };
}
test("score completion continues only when stop policy allows it", () => {
const stored = dynamicCase();
const result = completeDynamicScoreTransition({
stored: { ...stored, currentChoiceQuestion: null },
candidate: lowCandidate,
usefulOpportunityCount: 1,
repeatedOnly: false,
nextVersion: 8,
});
assert.equal(result.dynamicTurnState.nextAction.kind, "generate_dynamic_question");
assert.equal(result.dynamicControl.plateauCount, 0);
});
test("high confidence requires explicit confirmation without applying a time", () => {
const stored = dynamicCase();
const candidate = {
...lowCandidate,
resultId: "097b7b4c-60f3-4ed8-b290-64b2084182e7",
confidence: "high" as const,
canApply: true,
winningSegment: {
startTime: "05:10",
endTime: "05:12",
representativeTime: "05:11",
widthMinutes: 2,
},
};
const result = completeDynamicScoreTransition({
stored: { ...stored, currentChoiceQuestion: null },
candidate,
usefulOpportunityCount: 1,
repeatedOnly: false,
nextVersion: 8,
});
assert.deepEqual(result.dynamicTurnState.nextAction, {
kind: "request_candidate_confirmation",
resultId: candidate.resultId,
});
assert.equal(result.snapshot.activeTime, null);
assert.equal(result.dynamicTurnState.permissions.canConfirmCandidate, true);
});
test("dynamic scoring claims once, completes atomically, and replays", async () => {
const flow = scoringFlow();
const pending = await flow.service.answerDynamicChoice(ownerId, {
caseId: dynamicCase().id,
actionId,
turnVersion: 7,
questionId: persistedQuestion.questionId,
optionId: persistedQuestion.options[0].optionId,
});
if (pending.nextAction.kind !== "score_pending") throw new Error("expected pending score");
const first = await flow.service.pollDynamicScoringJob(ownerId, dynamicCase().id, pending.nextAction.jobId);
const replay = await flow.service.pollDynamicScoringJob(ownerId, dynamicCase().id, pending.nextAction.jobId);
assert.equal(first.nextAction.kind, "generate_dynamic_question");
assert.deepEqual(replay.nextAction, first.nextAction);
assert.equal(flow.scoreCalls(), 1);
assert.deepEqual(flow.memory.savedCase()?.candidateModel, { version: "after-score" });
});
test("dynamic scoring failure retries the same job without duplicating evidence", async () => {
const flow = scoringFlow({ failOnce: true });
const pending = await flow.service.answerDynamicChoice(ownerId, {
caseId: dynamicCase().id,
actionId,
turnVersion: 7,
questionId: persistedQuestion.questionId,
optionId: persistedQuestion.options[0].optionId,
});
if (pending.nextAction.kind !== "score_pending") throw new Error("expected pending score");
const jobId = pending.nextAction.jobId;
const failed = await flow.service.pollDynamicScoringJob(ownerId, dynamicCase().id, jobId);
const completed = await flow.service.pollDynamicScoringJob(ownerId, dynamicCase().id, jobId);
assert.deepEqual(failed.nextAction, { kind: "retry_scoring", jobId });
assert.equal(completed.nextAction.kind, "generate_dynamic_question");
assert.equal(flow.memory.savedCase()?.choiceEvidence?.length, 1);
assert.equal(flow.memory.savedCase()?.dynamicControl?.effectiveAnswerCount, 1);
assert.equal(flow.scoreCalls(), 2);
});
test("the second plateau is terminal and resume stays terminal", async () => {
const medium = { ...lowCandidate, confidence: "medium" as const };
const flow = scoringFlow({ initialCandidate: medium, candidate: medium });
const pending = await flow.service.answerDynamicChoice(ownerId, {
caseId: dynamicCase().id,
actionId,
turnVersion: 7,
questionId: persistedQuestion.questionId,
optionId: persistedQuestion.options[0].optionId,
});
if (pending.nextAction.kind !== "score_pending") throw new Error("expected pending score");
const terminal = await flow.service.pollDynamicScoringJob(ownerId, dynamicCase().id, pending.nextAction.jobId);
const resumed = await flow.service.resumeDynamic(ownerId, dynamicCase().id);
assert.equal(terminal.nextAction.kind, "present_medium_result");
assert.deepEqual(resumed.nextAction, terminal.nextAction);
});
@@ -0,0 +1,93 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createBirthTimeJourneyService } from "../src/lib/birth-time-journey-service.ts";
import { BirthTimeDynamicActionError } from "../src/lib/birth-time-dynamic-actions.ts";
import type { DynamicStoredRectificationCase } from "../src/lib/birth-time-journey-service.ts";
import { dynamicCase, ownerId, persistedQuestion } from "./birth-time-dynamic-persistence-fixture.ts";
import { memoryStore } from "./birth-time-journey-memory-store.ts";
const actionId = "38dd8315-7d6f-4af8-b2e4-a4062926f5ca";
function terminalCase() {
const stored = dynamicCase();
return {
...stored,
currentChoiceQuestion: null,
dynamicTurnState: {
...stored.dynamicTurnState,
nextAction: { kind: "present_medium_result" as const, resultId: "result-1" },
progress: { ...stored.dynamicTurnState.progress, phase: "result" as const },
},
};
}
function journeyFlow(initial: DynamicStoredRectificationCase = terminalCase()) {
const memory = memoryStore(initial);
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"); },
},
});
return { memory, service };
}
test("terminal resume returns the stored action byte-for-byte", async () => {
const flow = journeyFlow();
const resumed = await flow.service.resumeDynamic(ownerId, dynamicCase().id);
assert.deepEqual(resumed.nextAction, terminalCase().dynamicTurnState.nextAction);
assert.equal(flow.memory.committedTurnWrites(), 0);
});
test("terminal answer pause finish and generation commits are rejected", async () => {
const operations = [
(service: ReturnType<typeof journeyFlow>["service"]) => service.answerDynamicChoice(ownerId, {
caseId: dynamicCase().id,
actionId,
turnVersion: 7,
questionId: persistedQuestion.questionId,
optionId: persistedQuestion.options[0].optionId,
}),
(service: ReturnType<typeof journeyFlow>["service"]) => service.pauseDynamic(ownerId, dynamicCase().id, actionId, 7),
(service: ReturnType<typeof journeyFlow>["service"]) => service.finishDynamic(ownerId, dynamicCase().id, actionId, 7),
(service: ReturnType<typeof journeyFlow>["service"]) => service.generateDynamicQuestion(ownerId, {
caseId: dynamicCase().id,
actionId,
turnVersion: 7,
unmatchedNote: null,
}),
(service: ReturnType<typeof journeyFlow>["service"]) => service.commitDynamicQuestion(ownerId, {
caseId: dynamicCase().id,
actionId,
turnVersion: 7,
unmatchedNote: null,
}, persistedQuestion),
];
for (const operation of operations) {
const flow = journeyFlow();
await assert.rejects(operation(flow.service), BirthTimeDynamicActionError);
assert.equal(flow.memory.committedTurnWrites(), 0);
}
});
test("explicit finish preserves the current range and cannot restart on resume", async () => {
const initial = dynamicCase();
const flow = journeyFlow(initial);
const finished = await flow.service.finishDynamic(
ownerId,
initial.id,
actionId,
initial.turnVersion,
);
const resumed = await flow.service.resumeDynamic(ownerId, initial.id);
assert.deepEqual(finished.nextAction, { kind: "present_low_result", resultId: null });
assert.deepEqual(finished.progress.currentRange, initial.dynamicTurnState.progress.currentRange);
assert.deepEqual(resumed.nextAction, finished.nextAction);
assert.equal(flow.memory.committedTurnWrites(), 1);
});