/** * BUG-691 · 记录与校正区间冲突时:记录优先,分歧如实呈现。 * * 产品拍板(2026-09-14):默认仍按出生记录时间排盘,但校正结果必须并列呈现, * 系统不得宣布任何一方无效。本文件锁的是文案层与采用入口措辞,不涉及打分与采用逻辑。 */ import assert from "node:assert/strict"; import test from "node:test"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { FORBIDDEN_RECORD_VERDICT_PHRASE, SWITCH_TO_RECTIFIED_LABEL, recordRangeConflict, reportedTimeOffsetCopy, switchToRectifiedConfirmCopy, } from "../src/lib/rectification-agentic/birth-time-provenance.ts"; import { buildRangeDelivery } from "../src/lib/rectification-agentic/v9/divergence-panel.ts"; import { RectificationRangeDelivery } from "../src/components/rectification-range-delivery.tsx"; import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts"; import { INFERENCE_ALGORITHM_VERSION, type InferenceCandidate, type InferenceState } from "../src/lib/rectification-agentic/core/types.ts"; import type { RectificationCandidateResult } from "../src/lib/rectification-candidate-result.ts"; const RANGE = ["04:48", "05:07"] as const; /** Outside RANGE by 5 minutes — the conflict case. */ const RECORD_OUTSIDE = "05:12"; /** Inside RANGE — must keep the pre-BUG-691 wording. */ const RECORD_INSIDE = "05:00"; function cand(time: string, probability: number): InferenceCandidate { return { id: time, time, cluster_range: [RANGE[0], RANGE[1]], prior_score: probability * 100, posterior_score: probability * 100, probability, status: "active", rank: 1, strong_conflict_count: 0, }; } function inference(): InferenceState { return { algorithm_version: INFERENCE_ALGORITHM_VERSION, candidate_set_id: "set", revision: 1, phase: "discrimination", result_status: "credible_range", range_start: RANGE[0], range_end: RANGE[1], candidates: [cand("04:53", 0.4), cand("04:50", 0.35)], events: [], probes: [], answered_probes: [], rounds: [], entropy: 1, representative_time: "04:53", credible_range: [RANGE[0], RANGE[1]], }; } function delivery(reportedBirthTime: string, birthTimeSource: string) { return buildRangeDelivery({ inference: inference(), publicCandidates: [ { candidateId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1", time: "04:53" }, { candidateId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb2", time: "04:50" }, ], credibleRange: [RANGE[0], RANGE[1]], representativeTime: "04:53", reportedBirthTime, birthTimeSource, }); } function deliveryResult(built: ReturnType): RectificationCandidateResult { return { resultId: "result-record-conflict", candidates: built.columns.map((column, index) => ({ candidateId: column.candidate_id, rank: index + 1, time: column.time, relativeSupport: column.probability_percent, tiedMinuteCount: 1, })), overallConfidence: "medium", selectionAllowed: true, canAdopt: true, confirmationAllowed: false, decisionReceipt: null, representativeTime: built.representative_time, selectedTime: null, selectionKind: null, houseTable: null, houseTablesByTime: {}, natalRecast: null, techniqueAudit: [], windowTransitions: [], eventDashaLedger: [], dashaAgreement: null, lagnaContrast: null, nakshatraBoundary: null, precisionStage: null, oosBlindPrompts: [], confirmationGate: { confirmation_allowed: false } as RectificationCandidateResult["confirmationGate"], validated: false, completionStatus: null, sessionOutcome: "adopt_representative", credibleRange: built.range, rangeDelivery: built, verificationReportMarkdown: built.verification_markdown, }; } function html(reportedBirthTime: string, birthTimeSource: string): string { return renderToStaticMarkup(createElement(RectificationRangeDelivery, { result: deliveryResult(delivery(reportedBirthTime, birthTimeSource)), acceptingCandidateId: null, readonly: false, onAccept: () => undefined, })); } test("conflict is only a hospital record that falls outside the range", () => { assert.deepEqual( recordRangeConflict({ source: "hospital_record", reportedTime: RECORD_OUTSIDE, range: RANGE }), { reportedTime: RECORD_OUTSIDE, gapMinutes: 5 }, ); // Inside the range there is nothing to reconcile. assert.equal(recordRangeConflict({ source: "hospital_record", reportedTime: RECORD_INSIDE, range: RANGE }), null); // The other two sources are already declared approximate, so they cannot conflict. assert.equal(recordRangeConflict({ source: "approximate", reportedTime: RECORD_OUTSIDE, range: RANGE }), null); assert.equal(recordRangeConflict({ source: "period_only", reportedTime: RECORD_OUTSIDE, range: RANGE }), null); // Missing or unusable inputs are not a conflict. assert.equal(recordRangeConflict({ source: "hospital_record", reportedTime: RECORD_OUTSIDE, range: null }), null); assert.equal(recordRangeConflict({ source: "hospital_record", reportedTime: "", range: RANGE }), null); assert.equal(recordRangeConflict({ source: "hospital_record", reportedTime: "25:99", range: RANGE }), null); }); test("conflict copy states record-first default, the divergence, and the user's choice", () => { const copy = reportedTimeOffsetCopy({ source: "hospital_record", reportedTime: RECORD_OUTSIDE, range: RANGE, representativeTime: "04:53", }) ?? ""; // D2 ①: the chart still uses the record until the user says otherwise. assert.match(copy, /出生记录时间 05:12/); assert.match(copy, /默认仍按这个时间排盘/); // D2 ②: the evidence points somewhere else, and the gap is named. assert.match(copy, /指向另一段时间/); assert.match(copy, /相差 5 分钟/); // D2 ③: both doors stay open. assert.match(copy, /改用校正结果/); assert.match(copy, /继续用记录/); // D4: neither side may be declared invalid. assert.doesNotMatch(copy, FORBIDDEN_RECORD_VERDICT_PHRASE); // BUG-587: adoption is never described as a confirmed minute. assert.doesNotMatch(copy, /已确认|确定就是|唯一出生分钟是/); }); test("a record inside the range keeps the pre-BUG-691 wording", () => { const copy = reportedTimeOffsetCopy({ source: "hospital_record", reportedTime: RECORD_INSIDE, range: RANGE, representativeTime: "04:53", }) ?? ""; assert.equal(copy, "出生记录时间 05:00,落在目前范围内"); assert.doesNotMatch(copy, /改用校正结果/); assert.doesNotMatch(copy, FORBIDDEN_RECORD_VERDICT_PHRASE); }); test("conflict swaps the adopt entry wording and says which clock replaces which", () => { const conflict = html(RECORD_OUTSIDE, "hospital_record"); assert.ok(conflict.includes(SWITCH_TO_RECTIFIED_LABEL)); assert.equal(conflict.includes(RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis), false); // The confirmation line is per column, so each button names its own target minute. assert.ok(conflict.includes(switchToRectifiedConfirmCopy(RECORD_OUTSIDE, "04:53"))); assert.ok(conflict.includes(switchToRectifiedConfirmCopy(RECORD_OUTSIDE, "04:50"))); assert.doesNotMatch(conflict, FORBIDDEN_RECORD_VERDICT_PHRASE); }); test("non-conflict states keep 更像这个 and show no switch note", () => { for (const [reported, source] of [ [RECORD_INSIDE, "hospital_record"], [RECORD_OUTSIDE, "approximate"], [RECORD_OUTSIDE, "period_only"], ] as const) { const markup = html(reported, source); assert.ok(markup.includes(RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis), `${reported}/${source}`); assert.equal(markup.includes(SWITCH_TO_RECTIFIED_LABEL), false, `${reported}/${source}`); assert.equal(markup.includes("换成 04:53"), false, `${reported}/${source}`); } }); test("record_conflict survives the projection round trip and stays adopt-logic free", () => { const built = delivery(RECORD_OUTSIDE, "hospital_record"); assert.deepEqual(built.record_conflict, { reported_time: RECORD_OUTSIDE, gap_minutes: 5 }); assert.equal(delivery(RECORD_INSIDE, "hospital_record").record_conflict, null); // Adoption itself is untouched: the same candidates stay selectable either way. assert.deepEqual( built.columns.map((column) => column.candidate_id), delivery(RECORD_INSIDE, "hospital_record").columns.map((column) => column.candidate_id), ); assert.equal(built.representative_time, delivery(RECORD_INSIDE, "hospital_record").representative_time); });