fix(rectification): 记录与校正区间冲突时记录优先、分歧并列(BUG-691)
Independent Staging Quality Gate / validate (push) Canceled after 3m23s
Independent Staging Quality Gate / publish (push) Canceled after 0s

医院记录那一分钟落在校正区间外时,交付卡以前只说一句「相差 N 分钟」:
没说默认按哪个时间排盘,采用按钮也仍写中性的「更像这个」,用户看不出
点下去会把之后的排盘换成另一分钟。

产品负责人 2026-09-14 拍板「记录优先,分歧如实呈现」:

- 冲突文案补满三层——默认仍按出生记录时间排盘、经历指向另一段时间相差
  N 分钟、两条路都可以走。
- 冲突态采用按钮改「改用校正结果」,按钮下按列写明
  「选它之后,排盘会从出生记录时间 hh:mm 换成 hh:mm」。
- 新增 FORBIDDEN_RECORD_VERDICT_PHRASE 锁住 D4:不得宣布记录不准,
  也不得宣布校正结果无效。

只改文案层与采用入口措辞。打分、判据、采用 RPC、置信度与确认门控、
数据库一律未动;不新增入口、不加确认弹窗;记录落在范围内或来源是
「大概时间 / 时间段」时卡片与今天完全一致。Skill 未 bump,只补
references §6.1。

验收:tsc 0 错;lint 0 error;npm test 3335 项 fail 31,失败清单与基线
37e6c519 逐条一致(全部是无 Docker / 无外网的既有环境缺口);next build
通过且 / 仍 ○ Static;首屏 chunks gzip +269 B(+0.019%)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
This commit is contained in:
Jesse_Chen
2026-09-16 02:02:03 +00:00
co-authored by Claude Opus 5
parent 63c419f375
commit 6df40c322c
16 changed files with 505 additions and 6 deletions
+2
View File
@@ -548,6 +548,8 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
卡顶保留范围与经历数;卡底边界句保留;「查看验证报告」默认收起。不预标「排盘用」。卡上没有「再答两道参考题」入口。风格参考题只在出卡前收集,出卡即结算。`tie_break_available` 仍由服务端决定要不要先问,不再驱动任何按钮。点「更像这个」走现有 accept RPC。采用过程中整张卡留在原处;已采用列按钮禁用。有活题时「更像这个」置灰。收尾句跟卡片同一列。
**出生记录与范围冲突时换措辞,不换形状。** 医院记录的那一分钟落在目前范围外时(`record_conflict`),来源小字写满三层:默认仍按记录排盘、经历指向另一段时间差 N 分钟、两条路都可以走;每列按钮从「更像这个」改成「改用校正结果」,按钮下方补一行同为 `--type-caption` 的灰字「选它之后,排盘会从出生记录时间 hh:mm 换成 hh:mm」(`.rectification-range-delivery__switch-note`,与来源小字同字号同行高)。不新增按钮、不加确认弹窗、不改列数与间距;记录落在范围内或来源是「大概时间 / 时间段」时,卡片与今天完全一致。已采用的那一列不再显示这行小字。
引擎 `event_dasha_ledger_by_time` / `prospective_windows_by_time` 的键是引擎 `candidate_times` 去重全集(上限 64),不是引擎分数前三。卡片仍只投影后验前三。若上一轮 64 分钟对照超过 3 秒,下一轮可以把推断层仍有效的分钟作为 `column_times` 传入。
## 12. 校正右栏盘面
+2
View File
@@ -49,6 +49,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
| 也可以再说一件你记得大概时间的事。 / 还差带月份的经历,领域不限。 | 训练门开后给区间卡,并写「如果还记得……范围还能再收一截」。材料不够时只写记下了哪几件、再来一件不是这类的、至少两个具体例子;输入框占位「再说一件带年月的事」。 | 训练门未开时永远给结果或精确缺口;不说「领域不限」「做不了」。交付后采集线关完的邀请见下一行。 |
| 能问的都问完了 | 还剩 2 个候选时:「这两分钟按现有信息分不开。」还剩 4 个时:「这几个候选按现有信息分不开。」候选数未知时只说「按现有信息分不开。」后面接:你要是还记得确切哪一天的事,不限领域,说出来我接着算——登记结婚那天、入职第一天、手术那天、孩子出生那天、拿到录取通知那天都成。记不得哪一天的,有年月也行,比如换专业、出国、打官司、创业。 | 固定七条线关完不等于校正结束。句首的「这两分钟 / 这几个候选」必须和同段话里的候选数一致,不得硬编码。并列时先请确切日期,再退年月;「不限领域」只出现在交付卡邀请里。不承诺「说了确切日期就能定到分钟」。 |
| 填报出生时间 05:10 / 与你的出生时间相差 7 分钟 | 医院记录:「出生记录时间 05:10」。家人记得:「你填的大概时间 05:10」「与你填的大概时间相差 7 分钟」。只知道时段:「你给的时间段」。 | 推算值不得称作「你的出生时间」。医院记录与目前范围不一致时只并列差值,不站队。 |
| 你的出生记录不准,按校正结果来 / 这些经历说明记录写错了 / 校正结果无效 | 出生记录时间 04:40,默认仍按这个时间排盘。你说的这些经历指向另一段时间,相差 8 分钟。你可以改用校正结果,也可以继续用记录。 | 记录与校正区间冲突时记录优先,但两者必须并列。不得宣布任何一方无效——不说记录不准,也不说校正结果不作数。 |
| (冲突时按钮仍写)更像这个 | 按钮写「改用校正结果」,按钮下面一行写「选它之后,排盘会从出生记录时间 04:40 换成 04:48」。 | 换掉的是什么要在动手的地方说清楚。不新增入口,仍是同一个采用按钮;采用仍不是确认。 |
| 口述采集没有停止按钮,范围小字不可点 | 选择题卡内有「先这样,先看当前范围」;生成中有「停止回答」。口述采集只有输入框,没有「没有」「记不清」芯片,也不挂在输入框上方。范围小字是状态句,不可点。 | 想跳过可以打字「没有」或「记不清」。 |
| 只报一件后立刻按家人/学业/财务轮着问,题干带「2020 年前后」 | 记下后问「还有吗?比如……」(只列还没提过的具体事)。答「没有了」后才从用户说过的年份追问,例如「2020 年毕业后第一份工作大概哪年开始?」 | 先等用户说完;年份只来自用户,不用生日推。 |
| 点选后旁白写「04:3104:39 这段领先,05:0005:07 这段落后」 | 已记录,范围收到 04:31–04:39。 | 旁白只说范围变没变。领先/落后留在卡内「答了会怎样」,不进气泡。 |
+6
View File
@@ -3338,6 +3338,12 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
font-size: var(--type-caption);
line-height: 1.5;
}
.rectification-range-delivery__switch-note {
margin: 0;
color: var(--color-ink-secondary);
font-size: var(--type-caption);
line-height: 1.5;
}
.rectification-range-delivery__boundary {
margin: 0;
color: var(--color-ink-secondary);
@@ -10,6 +10,10 @@ import {
rangeDeliveryShowsOpenCollectInvite,
} from "@/lib/rectification-agentic/user-copy";
import type { RangeDeliveryProjection } from "@/lib/rectification-agentic/v9/divergence-panel";
import {
SWITCH_TO_RECTIFIED_LABEL,
switchToRectifiedConfirmCopy,
} from "@/lib/rectification-agentic/birth-time-provenance";
import { RectificationVerificationReport } from "@/components/rectification-verification-report";
function columnHasTraits(column: RangeDeliveryProjection["columns"][number]): boolean {
@@ -48,6 +52,9 @@ export function RectificationRangeDelivery({
hint: delivery?.narrow_hint,
columns,
});
// BUG-691: while the birth record disagrees with the range the adopt entry says what
// it actually does — swap the charting clock — instead of the neutral 「更像这个」.
const recordConflict = delivery?.record_conflict ?? null;
const caption = showOpenCollectInvite || closedInvite
? rangeDeliveryCaptionWithoutClosedInvite(delivery?.narrow_hint)
: delivery?.narrow_hint ?? null;
@@ -129,8 +136,15 @@ export function RectificationRangeDelivery({
? RECTIFICATION_USER_COPY.rangeDeliveryAdopting
: adopted
? RECTIFICATION_USER_COPY.rangeDeliveryAdopted
: RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis}
: recordConflict
? SWITCH_TO_RECTIFIED_LABEL
: RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis}
</button>
{recordConflict && !adopted ? (
<p className="rectification-range-delivery__switch-note">
{switchToRectifiedConfirmCopy(recordConflict.reported_time, column.time)}
</p>
) : null}
</li>
);
})}
@@ -24,6 +24,17 @@ export const REPORTED_TIME_LABEL: Readonly<Record<ProvenanceSource, string>> = {
/** Approximate / period_only copy must not match this. Hospital uses 出生记录时间. */
export const FORBIDDEN_DECLARED_BIRTH_TIME_PHRASE = /你的出生时间/;
/**
* BUG-691 / D4: when the birth record and the rectified range disagree the product
* states both sides and the gap. It must never declare either side invalid not the
* record ("记录不准"), and not the rectification ("校正结果无效").
*/
export const FORBIDDEN_RECORD_VERDICT_PHRASE =
/记录不准|记录不对|记录错了|记录有误|记录写错|以记录为准|以证据为准|校正结果无效|校正结果不作数|应该信/;
/** Adopt entry wording while the record and the range disagree (BUG-691 / D3). */
export const SWITCH_TO_RECTIFIED_LABEL = "改用校正结果";
export function reportedTimeLabel(source: string | null | undefined): string {
return REPORTED_TIME_LABEL[normalizeBirthTimeSource(source)];
}
@@ -104,10 +115,42 @@ export function reportedTimeOffsetCopy(input: {
)
: offset;
const gap = Number.isFinite(edge) ? edge : offset;
return `出生记录时间 ${reported}。目前范围不含这一分钟,相差 ${gap} 分钟`;
// BUG-691 / D2: record first, disagreement stated in full. Three beats —
// what we chart by now, where the evidence points, and that the choice is the user's.
return `出生记录时间 ${reported},默认仍按这个时间排盘。你说的这些经历指向另一段时间,相差 ${gap} 分钟。你可以改用校正结果,也可以继续用记录`;
}
if (source === "period_only") {
return `与你给的时间段参照 ${reported} 相差 ${offset} 分钟`;
}
return `与你填的大概时间 ${reported} 相差 ${offset} 分钟`;
}
/**
* The declared birth record sits outside the current range (BUG-691).
* Only `hospital_record` can conflict: the other two sources are already stated as
* approximate, so there is nothing to overrule. Returns null when there is no conflict.
*/
export function recordRangeConflict(input: {
source?: string | null;
reportedTime?: string | null;
range?: readonly [string, string] | null;
}): Readonly<{ reportedTime: string; gapMinutes: number }> | null {
if (normalizeBirthTimeSource(input.source) !== "hospital_record") return null;
const reported = (input.reportedTime ?? "").trim().slice(0, 5);
if (!CLOCK.test(reported)) return null;
const range = input.range ?? null;
if (!range) return null;
if (clockInRange(reported, range)) return null;
const toStart = minutesBetweenClocks(reported, range[0]);
const toEnd = minutesBetweenClocks(reported, range[1]);
if (toStart == null || toEnd == null) return null;
return { reportedTime: reported, gapMinutes: Math.min(toStart, toEnd) };
}
/**
* Said again at the point of action (BUG-691 / D3), so the reader sees exactly which
* clock replaces which. Adoption stays a , never a confirmed minute.
*/
export function switchToRectifiedConfirmCopy(reportedTime: string, targetTime: string): string {
return `选它之后,排盘会从出生记录时间 ${reportedTime} 换成 ${targetTime}`;
}
@@ -12,6 +12,8 @@ import {
REPORTED_TIME_LABEL,
boardDeclaredTimeLine,
reportedTimeOffsetCopy,
SWITCH_TO_RECTIFIED_LABEL,
switchToRectifiedConfirmCopy,
} from "./birth-time-provenance.ts";
export const REPRESENTATIVE_MINUTE_DISCLAIMER = "这只是代表性候选,不是已确认的唯一出生分钟。";
@@ -685,6 +687,8 @@ export function listUserVisibleCopy(): string[] {
range: ["04:48", "05:07"],
representativeTime: "04:53",
}) ?? "",
SWITCH_TO_RECTIFIED_LABEL,
switchToRectifiedConfirmCopy("05:12", "04:53"),
];
return values;
}
@@ -18,7 +18,7 @@ import {
rangeDeliveryWindowLine,
sharedTraitLine,
} from "../user-copy.ts";
import { reportedTimeOffsetCopy } from "../birth-time-provenance.ts";
import { recordRangeConflict, reportedTimeOffsetCopy } from "../birth-time-provenance.ts";
import { previousInferenceFromReceipt } from "./inference-adapter.ts";
import {
rangeNarrowHint,
@@ -70,6 +70,11 @@ export type RangeDeliveryColumn = Readonly<{
window_line: string;
}>;
export type RangeDeliveryRecordConflict = Readonly<{
reported_time: string;
gap_minutes: number;
}>;
export type RangeDeliveryProjection = Readonly<{
range: readonly [string, string] | null;
representative_time: string | null;
@@ -86,6 +91,8 @@ export type RangeDeliveryProjection = Readonly<{
tie_break_available: boolean;
tie_break_note: string | null;
provenance_line: string | null;
/** Set only when a hospital record sits outside the range (BUG-691). */
record_conflict: RangeDeliveryRecordConflict | null;
}>;
export type PublicCandidateClock = Readonly<{
@@ -438,9 +445,29 @@ export function buildRangeDelivery(input: {
range: normalizedRange,
representativeTime,
}),
record_conflict: recordConflictProjection(recordRangeConflict({
source: input.birthTimeSource,
reportedTime: input.reportedBirthTime,
range: normalizedRange,
})),
};
}
function recordConflictProjection(
conflict: Readonly<{ reportedTime: string; gapMinutes: number }> | null,
): RangeDeliveryRecordConflict | null {
return conflict ? { reported_time: conflict.reportedTime, gap_minutes: conflict.gapMinutes } : null;
}
function recordConflictFromUnknown(value: unknown): RangeDeliveryRecordConflict | null {
if (!value || typeof value !== "object") return null;
const row = value as Record<string, unknown>;
const reported = clock(row.reported_time ?? row.reportedTime);
const gap = row.gap_minutes ?? row.gapMinutes;
if (!reported || typeof gap !== "number" || !Number.isFinite(gap)) return null;
return { reported_time: reported, gap_minutes: gap };
}
function publicCandidatesFromUnknown(value: unknown): PublicCandidateClock[] {
if (!Array.isArray(value)) return [];
const out: PublicCandidateClock[] = [];
@@ -688,5 +715,6 @@ export function parseRangeDelivery(value: unknown): RangeDeliveryProjection | nu
: typeof row.provenanceLine === "string" && row.provenanceLine.trim()
? row.provenanceLine.trim()
: null,
record_conflict: recordConflictFromUnknown(row.record_conflict ?? row.recordConflict),
};
}
@@ -20,6 +20,7 @@ import {
} from "../src/lib/rectification-agentic/user-copy.ts";
import { COLLECT_FLOW_BANNED_PHRASES, SPLIT_ENDPOINT_PHRASE } from "../src/lib/rectification-agentic/v9/collection-question-pool.ts";
import { MACHINE_VOICE_LEXICON } from "../src/lib/rectification-agentic/v9/agent-voice-lexicon.ts";
import { FORBIDDEN_RECORD_VERDICT_PHRASE } from "../src/lib/rectification-agentic/birth-time-provenance.ts";
import { attachQuestionsToTurns } from "../src/lib/rectification-agentic/v9/turn-question.ts";
import { CASE_ID, FOCUS_ID, TURN_ID } from "./rectification-v9-test-support.ts";
@@ -381,7 +382,18 @@ test("declared-time copy splits hospital records from family estimates", () => {
assert.match(visible, /你填的大概时间/);
assert.match(visible, /你给的时间段/);
assert.match(visible, /与你填的大概时间 05:00 相差 7 分钟/);
assert.match(visible, /出生记录时间 05:12。目前范围不含这一分钟,相差 5 分钟/);
// 原值: /出生记录时间 05:12。目前范围不含这一分钟,相差 5 分钟/
// 新值: 三层冲突文案(默认按记录排盘 / 指向另一段时间差 5 分钟 / 两条路都可以走)+ 采用入口措辞
// 原因: BUG-691 决策 D2、D3——冲突时只并列差值不够,必须说清默认按哪个排盘、改用会换掉什么
assert.match(visible, /出生记录时间 05:12,默认仍按这个时间排盘/);
assert.match(visible, /指向另一段时间,相差 5 分钟/);
assert.match(visible, /你可以改用校正结果,也可以继续用记录/);
assert.match(visible, /改用校正结果/);
assert.match(visible, /选它之后,排盘会从出生记录时间 05:12 换成 04:53/);
// D4: 并列不等于站队,任何一方都不得被宣布无效。
for (const item of listUserVisibleCopy()) {
assert.doesNotMatch(item, FORBIDDEN_RECORD_VERDICT_PHRASE, item);
}
const approximate = listUserVisibleCopy().filter((item) => item.includes("大概时间") || item.includes("时间段"));
for (const item of approximate) {
assert.doesNotMatch(item, /你的出生时间/);
@@ -6,6 +6,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import {
FORBIDDEN_DECLARED_BIRTH_TIME_PHRASE,
SWITCH_TO_RECTIFIED_LABEL,
boardDeclaredTimeLine,
normalizeBirthTimeSource,
reportedTimeOffsetCopy,
@@ -179,5 +180,11 @@ test("hospital_record copy names the record and states the offset without pickin
}));
assert.match(html, /出生记录时间 05:12/);
assert.match(html, /相差 5 分钟/);
assert.ok(html.includes(RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis));
// 原值: assert.ok(html.includes(RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis))
// 新值: 记录落在范围外时按钮写 SWITCH_TO_RECTIFIED_LABEL(「改用校正结果」)
// 原因: BUG-691 决策 D3——记录与范围冲突时采用入口要说明自己换掉的是什么;
// 05:12 对 04:48–05:07 正是冲突态。非冲突态仍走「更像这个」,
// 由 rectification-record-conflict-copy-20260916.test.ts 逐态锁住。
assert.ok(html.includes(SWITCH_TO_RECTIFIED_LABEL));
assert.equal(html.includes(RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis), false);
});
@@ -0,0 +1,210 @@
/**
* 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<typeof buildRangeDelivery>): 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);
});