Files
Jyotisha/frontend/tests/rectification-block-scan-20260906.test.ts
T
Jesse_ChenandCursor 814c924e4a
Independent Staging Quality Gate / validate (push) Successful in 9m20s
Independent Staging Quality Gate / publish (push) Successful in 6m51s
fix(rectification): exhaustion exit, explain layer, range reading, unknown-time scan (BUG-565–568)
Keep askable cards after exhaustion, explain each probe, read the adopted credible range in reports and chat, and compare declared periods before the minute grid when the clock is unknown.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 09:10:37 +08:00

395 lines
13 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import { deriveRectificationOpenWindow } from "../src/lib/rectification-agentic/v9/case-service.ts";
import { decideAfterInferenceChange, decideFromDossier } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import { evidenceLedgerFingerprint, parseV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { applyRectificationChoice } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { CHOICE_ACTION } from "../src/lib/rectification-agentic/v9/choice-action.ts";
import { projectRectificationStepState } from "../src/lib/rectification-agentic/v9/step-state.ts";
import {
BLOCK_CHOICE_INTENT,
BLOCK_PERIOD_LABELS,
buildBlockChoiceFrame,
parseBlockScanPayload,
serializeBlockScanPayload,
windowFromBlockChoice,
} from "../src/lib/rectification-agentic/v9/block-scan.ts";
import {
CASE_ID,
FOCUS_ID,
SESSION_ID,
TURN_ID,
USER_ID,
activeFocusFixture,
computeFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
const BLOCKS = [
{ period: "early_morning", start_time: "04:00", end_time: "07:59", relative_support: 12 },
{ period: "morning", start_time: "08:00", end_time: "11:59", relative_support: 41 },
{ period: "afternoon", start_time: "12:00", end_time: "17:59", relative_support: 28 },
{ period: "evening", start_time: "18:00", end_time: "22:59", relative_support: 11 },
{ period: "late_night", start_time: "23:00", end_time: "03:59", relative_support: 8 },
] as const;
const EVIDENCE = [
{
id: "e-edu",
source_turn_id: TURN_ID,
subject: "self",
event_kind: "education_start",
domain: "education",
occurred_from: "2016-09-01",
occurred_to: "2016-09-30",
date_precision: "month",
summary: "education start",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-09-06T00:00:00.000Z",
},
{
id: "e-rel",
source_turn_id: TURN_ID,
subject: "self",
event_kind: "relationship_end",
domain: "relationship",
occurred_from: "2024-08-08",
occurred_to: "2024-08-08",
date_precision: "day",
summary: "relationship end",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-09-06T00:00:01.000Z",
},
{
id: "e-move",
source_turn_id: TURN_ID,
subject: "self",
event_kind: "relocation",
domain: "relocation",
occurred_from: "2023-07-01",
occurred_to: "2023-07-31",
date_precision: "month",
summary: "relocation",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-09-06T00:00:02.000Z",
},
];
function blockPayload(extra: Record<string, unknown> = {}) {
return {
evidence_ledger_fingerprint: "f".repeat(64),
algorithm_version: "test",
minute_step: 10,
blocks: [...BLOCKS],
...extra,
};
}
function blockSchema() {
const payload = parseBlockScanPayload(blockPayload());
assert.ok(payload);
const frame = buildBlockChoiceFrame(payload);
assert.ok(frame);
return {
choice: {
prompt: frame.prompt,
option_a: frame.option_a_hint,
option_b: frame.option_b_hint,
option_c: frame.neither_label,
option_d: frame.unsure_label,
options: [
{ key: "A", label: frame.option_a_hint, answer_class: frame.option_a_answer_class, role: "primary" },
{ key: "B", label: frame.option_b_hint, answer_class: frame.option_b_answer_class, role: "primary" },
{ key: "C", label: frame.neither_label, answer_class: frame.option_c_answer_class, role: "primary" },
{ key: "D", label: frame.unsure_label, answer_class: frame.option_d_answer_class, role: "primary" },
],
},
choice_kind: "block_choice",
scoring: false,
block_periods: {
A: BLOCKS[1],
B: BLOCKS[2],
C: BLOCKS[0],
},
};
}
function blockDossier(extra: {
declined?: boolean;
stage?: string;
range?: { start_time: string; end_time: string };
blockScan?: unknown;
} = {}) {
const schema = blockSchema();
return dossierFixture({
candidateRange: extra.range ?? { start_time: "00:00", end_time: "23:59" },
stage: extra.stage ?? "block_scan",
blockScan: extra.blockScan ?? (extra.declined
? blockPayload({ declined_at_fingerprint: "f".repeat(64), evidence_ledger_fingerprint: "f".repeat(64) })
: blockPayload()),
evidence: EVIDENCE,
conversationSummary: {
confirmed_evidence_summary: [],
pending_revisions: [],
active_focus: extra.stage === "minute"
? null
: activeFocusFixture({
intent: BLOCK_CHOICE_INTENT,
targetDomain: null,
targetKind: null,
questionId: "block_scan:choose_birth_block:holdout",
expectedAnswerSchema: schema,
}),
declined_skipped_topics: [],
candidate_divergence_summary: null,
missing_evidence_categories: [],
last_result_policy: null,
summary_version: 1,
updated_at: "2026-09-06T00:00:00.000Z",
},
});
}
test("unknown source opens a full-day block_scan window", () => {
assert.deepEqual(
deriveRectificationOpenWindow({
reportedTime: null,
source: "unknown",
period: null,
windowStart: null,
windowEnd: null,
}),
{
candidateRange: { start_time: "00:00", end_time: "23:59" },
stage: "block_scan",
},
);
assert.deepEqual(
deriveRectificationOpenWindow({
reportedTime: "05:00",
source: "unknown",
period: null,
windowStart: null,
windowEnd: null,
}).stage,
"minute",
);
assert.equal(
deriveRectificationOpenWindow({
reportedTime: "05:00",
source: "family_exact",
period: null,
windowStart: null,
windowEnd: null,
}).stage,
"minute",
);
});
test("three dated events in two domains ask a block choice and never adopt", () => {
const parsed = parseV9CaseDossier(blockDossier());
assert.ok(parsed);
const decision = decideFromDossier(parsed);
assert.equal(decision.nextAction, "ask_block_choice");
assert.equal(decision.canAdopt, false);
assert.equal(decision.selectionAllowed, false);
assert.equal(decision.representativeTime, null);
assert.equal(decision.separation.ranked.length, 0);
const step = projectRectificationStepState({ nextAction: decision.nextAction });
assert.equal(step.index, 1);
assert.equal(step.stage, "collect");
assert.match(step.headline, /比较时段/);
});
test("block choice copy names periods, not a winning minute", () => {
const payload = parseBlockScanPayload(blockPayload());
assert.ok(payload);
const frame = buildBlockChoiceFrame(payload);
assert.ok(frame);
assert.match(frame.prompt, /哪一段更像出生时段/);
assert.doesNotMatch(frame.prompt, /更像\s*\d/);
assert.match(frame.option_a_hint ?? "", new RegExp(BLOCK_PERIOD_LABELS.morning));
assert.equal(frame.scoring, false);
assert.equal(frame.choice_kind, "block_choice");
const schema = blockSchema();
assert.deepEqual(windowFromBlockChoice({ schema, optionId: "B" }), {
start_time: "12:00",
end_time: "17:59",
});
assert.equal(windowFromBlockChoice({ schema, optionId: "D" }), null);
});
test("declined block_scan fingerprint returns collect instead of another card", () => {
const parsed = parseV9CaseDossier(blockDossier());
assert.ok(parsed);
const fingerprint = evidenceLedgerFingerprint(parsed.evidence);
const declined = parseV9CaseDossier(blockDossier({
blockScan: {
...blockPayload(),
declined_at_fingerprint: fingerprint,
},
}));
assert.ok(declined);
assert.equal(declined.case.blockScan?.declinedAtFingerprint, fingerprint);
const decision = decideAfterInferenceChange({
dossier: declined,
state: null,
userStopped: false,
});
assert.equal(decision.nextAction, "ask_fact_collection");
assert.equal(decision.canAdopt, false);
});
test("choosing B advances the declared period and does not keep the 24h snapshot current", async () => {
let current = blockDossier();
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => current,
get_agentic_rectification_case_compute: () => computeFixture({
baselineBirthSnapshot: {
birth_date: "1998-03-15",
latitude: 39.9042,
longitude: 116.4074,
timezone_id: "Asia/Shanghai",
timezone_offset: 8,
birth_time_source: "unknown",
},
}),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
apply_agentic_rectification_choice_action: (_fn, args) => ({
action_id: args.p_action_id,
status: "applied",
idempotent: false,
question_id: args.p_question_id,
option_id: args.p_option_id,
revision: 1,
narration: args.p_narration,
focus_status: args.p_focus_status,
}),
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: {
id: FOCUS_ID,
case_id: CASE_ID,
question_id: args.p_question_id,
intent: args.p_intent,
expected_answer_schema: args.p_expected_answer_schema,
status: "active",
asked_at: "2026-09-06T00:00:00.000Z",
resolved_at: null,
},
idempotent: false,
}),
advance_agentic_rectification_case_from_block_scan: (_fn, args) => {
current = blockDossier({
stage: "minute",
range: { start_time: String(args.p_start_time), end_time: String(args.p_end_time) },
});
return {
case_id: CASE_ID,
stage: "minute",
candidate_range: { start_time: args.p_start_time, end_time: args.p_end_time },
};
},
persist_agentic_rectification_candidate_v2: () => {
throw new Error("must not persist the 24h minute snapshot");
},
});
const receipt = await applyRectificationChoice(accounting.client, {
userId: USER_ID,
caseId: CASE_ID,
sessionId: SESSION_ID,
actionId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1",
action: CHOICE_ACTION,
focusId: FOCUS_ID,
optionId: "B",
expectedRevision: 0,
});
assert.equal(receipt.snapshotCurrent, false);
assert.equal(receipt.nextAction.can_adopt, false);
const parsed = parseV9CaseDossier(current);
assert.equal(parsed?.case.stage, "minute");
assert.deepEqual(parsed?.case.candidateRange, { start_time: "12:00", end_time: "17:59" });
assert.ok(accounting.calls.some((item) => item.fn === "advance_agentic_rectification_case_from_block_scan"));
});
test("choosing D stays in block_scan and collects again", async () => {
let current = blockDossier();
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => current,
get_agentic_rectification_case_compute: () => computeFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
apply_agentic_rectification_choice_action: (_fn, args) => ({
action_id: args.p_action_id,
status: "applied",
idempotent: false,
question_id: args.p_question_id,
option_id: args.p_option_id,
revision: 1,
narration: args.p_narration,
focus_status: args.p_focus_status,
}),
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: {
id: FOCUS_ID,
case_id: CASE_ID,
question_id: args.p_question_id,
intent: args.p_intent,
expected_answer_schema: args.p_expected_answer_schema,
status: "active",
asked_at: "2026-09-06T00:00:00.000Z",
resolved_at: null,
},
idempotent: false,
}),
write_agentic_rectification_block_scan: (_fn, args) => {
current = blockDossier({ blockScan: args.p_block_scan });
return { case_id: CASE_ID, stage: "block_scan", block_scan: args.p_block_scan };
},
advance_agentic_rectification_case_from_block_scan: () => {
throw new Error("D must not advance the window");
},
});
const receipt = await applyRectificationChoice(accounting.client, {
userId: USER_ID,
caseId: CASE_ID,
sessionId: SESSION_ID,
actionId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2",
action: CHOICE_ACTION,
focusId: FOCUS_ID,
optionId: "D",
expectedRevision: 0,
});
const parsedAfter = parseV9CaseDossier(current);
assert.equal(parsedAfter?.case.stage, "block_scan");
assert.equal(
parsedAfter?.case.blockScan?.declinedAtFingerprint,
evidenceLedgerFingerprint(parsedAfter!.evidence),
);
assert.equal(receipt.nextAction.type, "ask_fact_collection");
assert.equal(receipt.nextAction.can_adopt, false);
assert.ok(accounting.calls.some((item) => item.fn === "write_agentic_rectification_block_scan"));
const payload = serializeBlockScanPayload(parseBlockScanPayload(blockPayload({
declined_at_fingerprint: "f".repeat(64),
}))!);
assert.equal(payload.declined_at_fingerprint, "f".repeat(64));
});
test("existing minute-stage cases keep the minute interview", () => {
const parsed = parseV9CaseDossier(dossierFixture({
evidence: EVIDENCE,
stage: "minute",
candidateRange: { start_time: "04:50", end_time: "05:10" },
}));
assert.ok(parsed);
const decision = decideFromDossier(parsed);
assert.notEqual(decision.nextAction, "ask_block_choice");
assert.notEqual(decision.sessionOutcome, "compare_blocks");
});