feat: rectification screen — timeline-only minute, board on demand, result card (issues #146, #147)

- The working minute is written once, on the timeline; the board's title row
  no longer repeats it.
- 「看盘面」 sits in the header at every width; the board is closed by default
  and opens as a right column on desktop (bottom sheet on phones).

- A distinct result (D4) leads with 最可能的出生时间 + minute + Lagna; the range
  drops to the next line. Indistinct results keep the range headline (D3).
- The most likely column adopts with 「采用 HH:MM 并打开星盘」 and goes to /chart
  after the server saved it; other columns stay adoptable as 「改用这个」 under
  还不能排除. No confidence label. Record-conflict wording unchanged.
- 「我说错了,改一条」: confirmed events (from the case read's existing evidence
  list) prefill the composer; nothing is sent by the pick. No 「再补一件事」:
  the card must not invite more events (BUG-1084, issue #148 decision).
- The segment card keeps "which chart" (BUG-1151): no minute lead, same
  adopt label, same actions row.

Docs: DESIGN §10/§11/§12, VOICE, CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jesse
2026-10-28 18:23:04 +08:00
co-authored by Claude Opus 5.5
parent f80f3b75f8
commit 8e932959b5
22 changed files with 601 additions and 71 deletions
+7
View File
@@ -1,5 +1,12 @@
# 印度占星 Skill 更新日志
## 2026-10-28 — 生时校正的结果卡和盘面
- 结果分得出先后时,卡片最上面直接写最可能的出生时间和上升,按钮「采用 这个时间 并打开星盘」,采用后直接去星盘页;还不能排除的时间留在下面,仍然可以改用。
- 卡上可以点「我说错了,改一条」:选一条记下的事,输入框里先写好开头,补完发出就会改掉那一条。
- 代表时刻只在对话上方的时间轴写一次;当前盘面改成点「看盘面」再打开。
- Skill 版本不变(issue #146、#147)。
## 2026-10-28 — 两处分盘敏感点按来源修正
- Sarpa Drekkana 从三段补成书上的五段:巨蟹第 3 段、天蝎第 2 段以前漏掉,现在会标出来;正好落在 10 度边界上的盘不再划到上一段。
+12
View File
@@ -918,6 +918,8 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
**「已对照 N 件」在手机上不在可见行里,桌面上目前仍在——这是一处未裁决的口径冲突。** 下面的禁列表写明这一项归交付卡、不上条,而它后来(cfb41daf)被加到了条上且没有改本文件。BUG-918 只把它在手机上移出可见行;桌面是否也撤掉需要产品裁决,见 `docs/BUG_HISTORY.md` BUG-918。
**代表分钟只写在条上(2026-10-28,issue #146)。** 盘面标题行不再写时刻,「看盘面」按钮只写这三个字(读屏名称仍带时刻和上升)。下面 D10 那段里「条上与盘面标题行同一次调用」从此只剩条这一处。
**代表分钟上条是 D10(2026-09-16)的产品决定,推翻了「条上只有区间」的原设计。** 理由是窄屏:`.is-compact` 下右栏盘面整体变成默认关闭的 overlay,代表分钟此前只在 `.rectification-board__clock` 出现,于是用户最该一直看着的那个数字恰恰看不见。条上的取值必须与盘面标题行**同一次调用**——`workingRectificationTime(result)`,由 `rectification-chat-view.ts` 的 `deriveRectificationChatView` 算一次(容器 `rectification-agentic-chat.tsx` 每次渲染调用它一次;2026-09-26 代码拆分只搬位置)、两处共用,不允许第二条推导路径。已采用时写「已采用 HH:MM」,否则写「代表分钟 HH:MM」;**采用不是确认**,完整边界句仍只在交付 / 采用旁白里出现一次,条上不写、也不得暗示某一分钟已确认。
**条上不得出现收窄进度(「已从 30 分钟收到 7 分钟」)。** 开场窗口确实在线上,但没有任何投影把这个对比作为字段给出,而 VOICE.md 第 2 条要求进度数字来自服务端。前端自己减还会算错:轴是**当前**窗口且会放宽(BUG-572),相减会把一次放宽报成进度。这句话继续由服务端拼进旁白。缺的字段见 `BLOCKED.md`。
@@ -955,6 +957,14 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
## 11. 区间交付卡
**卡头与按钮(2026-10-28,issue #147,产品同意 https://git.copse.top/root/Jyotisha/issues/147#issuecomment-36056 四条)。** 下文 D3、D4 与「更像这个」按以下改:
- 区分得开(D4:前两列差 ≥ 5 个百分点)且有代表分钟时,卡头是 `.rectification-result-lead`:小字「最可能的出生时间」、30px 衬线时刻、「上升X」(有该分钟的宫位表才写);「目前范围 …(对照了 N 件经历)」降为下一行 `.rectification-range-delivery__range-line`。区分不开时 D3 原样:范围是 `<strong>` 卡头,加「区分不开」那句,不写最可能。
- 区分得开时最可能那列排第一,按钮是实心「采用 HH:MM 并打开星盘」,采用成功(服务端已保存)后去 `/chart`;其余列在小字「还不能排除」下面,按钮是描边「改用这个」,采用后不跳转。区分不开时每列仍是「更像这个」。出生记录冲突时一律「改用校正结果」,不跳转。
- 不显示把握(低 / 中 / 高):引擎的这一档没有校准,BUG-560 同理。
- 可以采用时,卡上多一个文字按钮「我说错了,改一条」,展开用户确认过的事件(与对话里的复述同一说法),点一条只在输入框里写好「我说错了:「…」应该是」,由用户补完发出,现有改记录工具来改;不自动发送、不另扣点。没有确认过的事件就不出这个按钮。不放「再补一件事」:训练门后补打字经历几乎缩不了范围,卡上不得邀请再补(BUG-1084,issue #148 产品 2026-10-28 定)。
- 盘型交付卡(「用哪张盘解读」)不加时刻卡头(BUG-1151:盘型产品不排分钟),按钮文案不变;平常采用后同样去 `/chart`,也有上面那一行。
交付对象是区间里并排的候选分钟,不是支持度数字,也不是逐行空差异。
**相同的句子只写一次。** 两列或三列共有的 D9 / D10 / 月宿性格句提到卡片顶部(「三个时间的事业盘都在巨蟹座:做事以照顾人为主」)。列内只留有差别的句子;某列没有差别句就不显示性格块,不写「同上」。单列不提升共享句。
@@ -973,6 +983,8 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
## 12. 校正右栏盘面
**默认关着(2026-10-28,issue #146)。** 所有宽度上,头部都有「看盘面」按钮(`.rectification-board-peek`),盘面默认不出。桌面点开后在右侧分出一栏(`minmax(18rem, 22.5rem)`,没有候选时 16–18rem),280ms 从右淡入,标题行右侧是关闭按钮;手机仍是底部抽屉。标题行不写时刻。
右栏回答「当前按哪一分钟在看盘」,不回答「选哪一分钟」。顶部不加任何新说明文字。
**本命宫位改北印星盘。** 用 `VedicChartSvg` 渲染代表分钟的 `house_table`。宫位表退到星盘下方的 `<details>`(读屏与复制仍可用)。换分钟时只高亮变化的宫(`changedHouses` / `lagnaChanged`),不做动画。星盘组件在校正面 `dynamic()` 分包里,不进首页首屏。
+6
View File
@@ -63,6 +63,12 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
报告末尾「对哪一段想再问清楚?」列出前八节(含关系时间线,不含共同练习)。点进去是一条新对话,输入框预先写好这句,用户自己发送。中文预填是「合盘报告里「节名」这一段,在我们两人的盘里依据是什么?」英文报告用英文问同一件事。对话里可以说星曜、宫位、九分盘、大运,但只能引用这份报告已经写下的计算。材料里没有的,说「这份合盘没算到」。对不上的盘面,说「这一点在这份合盘里没有算到,我不能确定」。报告删了之后,输入框写「这份合盘已删除」,不能再发送。英文还没写好时只说「英文版即将提供」。选人页不写副标题。按钮是「开始合盘」,上面不写价格。点了先确认「会用掉 N 点,余额 M 点。」,按钮是「取消 / 开始」。余额不够时说「需要 N 点,余额只有 M 点。」,主按钮是「去补点数」。N 从计价表读,M 从账户读。计价读不到时不弹确认,只说「计费配置不可用」。这两人已经有写好的合盘、且没有一份正在生成时,按钮下面加「这两人已经合过 N 次,再生成会另扣 X 点」,N、X 都读实际数字。正在生成时不写这句。下拉占位是「选择一个人」。资料不齐写「资料未齐」,已经选在另一边写「已在另一边」。档案满了写「档案已满(最多 20 人)」,旁边是「去星盘档案删一个」。新增星盘卡片用「称呼」「性别」「出生日期」「出生时间」「出生地」,按钮是「取消」和「保存并选中」。产品关着时说「合盘服务暂未开放」。开关读不到时说「合盘服务暂时连不上,请稍后再试」。开关结果回来之前,选人页不出现两个人的选择框,也不出现「开始合盘」。英文次数用尽时说「英文版暂时无法再试」。生成中说「正在写 甲 × 乙 的合盘报告…」,四步是「算两张完整命盘」「整理两人之间的证据」「写报告」「检查报告」,下面是「大约要几分钟。可以先离开,写好后在「之前的合盘」和「报告」里都能找到。」英文等待说「正在写英文版…」和「第一次切到英文时才写,不扣点,大约一两分钟。」报告标题下是「已保存,再次打开不扣点」。
## 生时校正界面:收束卡与盘面(2026-10-28,issue #146、#147)
- 头部按钮「看盘面」;盘面标题「当前盘面」,不写时刻。
- 收束卡区分得开时:「最可能的出生时间」+ 时刻 + 「上升X」,下一行仍是「目前范围 …(对照了 N 件经历)」。最可能那列按钮「采用 HH:MM 并打开星盘」;其余列上方小字「还不能排除」,按钮「改用这个」。区分不开时不变(「更像这个」、「这几个时刻按现在的方法区分不开。」)。不写「把握」。
- 卡上一个文字按钮「我说错了,改一条」(不放「再补一件事」,BUG-1084);展开后一句「选一条要改的,输入框里会先写好开头:」,点一条后输入框写「我说错了:「事件」应该是」。
## 生时校正独立入口(2026-10-28,issue #145)
产品 2026-10-28 定:校正有自己的入口,不再从对话里开案例。下面这节「生时校正从对话进入」里的卡片文案和「明确要求则直接进入」由本节取代,其余(何时提议、做不了必须说一句、正文怎么提、回来)不变。
+94 -2
View File
@@ -2827,14 +2827,26 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
min-height: 0;
height: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(18rem, 22.5rem);
grid-template-columns: minmax(0, 1fr);
background: var(--color-canvas);
}
/* Issue #146: the board is closed by default; 「看盘面」 in the header opens it
as a right column on wide screens. */
.rectification-workspace.is-board-open:not(.is-compact) {
grid-template-columns: minmax(0, 1fr) minmax(18rem, 22.5rem);
}
/* No candidate yet: the board only carries the declared time, so it takes less
width until there is a house table to show. */
.rectification-workspace.is-board-empty {
.rectification-workspace.is-board-empty.is-board-open:not(.is-compact) {
grid-template-columns: minmax(0, 1fr) minmax(16rem, 18rem);
}
.rectification-workspace:not(.is-compact) .rectification-board {
animation: rectification-board-enter 280ms cubic-bezier(.22, 1, .36, 1) both;
}
@keyframes rectification-board-enter {
from { opacity: 0; transform: translateX(16px); }
to { opacity: 1; transform: none; }
}
.rectification-workspace.is-compact {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
@@ -7201,3 +7213,83 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
.starter-rectify-link:hover {
background: var(--color-canvas-muted);
}
/* Result card (issue #147). The most likely minute leads only when the card
says the columns can be told apart (D4); the range drops to the line under
it. Candidates that cannot be ruled out keep their own adopt button. */
.rectification-result-lead {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--space-1) var(--space-3);
}
.rectification-result-lead__label {
color: var(--color-ink-tertiary);
font-size: var(--type-body-sm);
}
.rectification-result-lead__time {
color: var(--color-ink);
font-family: var(--font-display);
font-size: 30px;
font-weight: 500;
line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.rectification-result-lead__lagna {
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
}
.rectification-range-delivery__range-line {
margin: 0;
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
font-variant-numeric: tabular-nums;
}
.rectification-range-delivery__others {
margin: 0 0 var(--space-2);
padding-top: var(--space-3);
border-top: 1px solid var(--color-canvas-muted);
color: var(--color-ink-tertiary);
font-size: var(--type-caption);
}
.rectification-result-actions {
display: grid;
gap: var(--space-2);
}
.rectification-result-actions__row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
}
.rectification-result-actions__fix {
color: var(--color-action);
text-decoration: underline;
text-underline-offset: 3px;
}
.rectification-result-actions__list {
display: grid;
gap: var(--space-2);
padding: var(--space-3);
border-radius: var(--radius-lg);
background: var(--color-canvas-soft);
animation: rectification-result-list-enter 260ms cubic-bezier(.22, 1, .36, 1) both;
}
.rectification-result-actions__list p {
margin: 0;
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
}
.rectification-result-actions__items {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.rectification-result-actions__items button {
white-space: normal;
text-align: start;
}
@keyframes rectification-result-list-enter {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
@@ -7,6 +7,8 @@ import { createPortal } from "react-dom";
import type { ChatMessage } from "@/lib/chat-message-view";
import { canShowRectificationSelectionCards } from "@/lib/rectification-candidate-result";
import { RectificationRangeDelivery } from "@/components/rectification-range-delivery";
import { useOpenChartAfterAdopt } from "@/hooks/use-open-chart-after-adopt";
import { fixEvidenceDraft } from "@/lib/rectification-fix-evidence";
import { livingDisplayFromReceipt } from "@/lib/rectification-agentic/core/candidate-window";
import {
RECTIFICATION_EMPTY_ACTION_LABEL,
@@ -170,7 +172,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
currentQuestionRef,
applyCaseSnapshot,
loadCaseSnapshot,
fixableEvidence,
} = useRectificationCaseSnapshot({ caseId, sessionId, initialSnapshot });
const { arm: armChartAfterAdopt, onSaved: savedThenChart } = useOpenChartAfterAdopt(onSaved);
const readonly = historyReadonly || Boolean(resultNotice);
const conversationReadonly = historyReadonly || (Boolean(resultNotice) && !canRecompare);
const [messages, setMessages] = useState<RenderMessage[]>(() => messagesFromTurns(initialTurns));
@@ -365,30 +369,33 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
void send("opening", "");
}, [initialTurns.length, onOpeningConsumed, readonly, send, shouldStartOpening]);
const acceptCandidate = useCallback(async (candidateId: string) => runRectificationCandidateAccept({
caseId,
sessionId,
busy,
readonly,
candidateResult,
acceptingCandidateId,
keyCounter,
runAbort,
lastRunOutcome,
conversationAnchor,
setError,
setMessages,
setAcceptingCandidateId,
setCandidateResult,
setSavedTime,
setSavedStatus,
setPending,
beginLiveRun,
loadCaseSnapshot,
send,
onCompleted,
onSaved,
}, candidateId), [acceptingCandidateId, beginLiveRun, busy, candidateResult, caseId, loadCaseSnapshot, onCompleted, onSaved, readonly, send, sessionId, setPending]);
const acceptCandidate = useCallback(async (candidateId: string, openChart = false) => {
armChartAfterAdopt(openChart);
return runRectificationCandidateAccept({
caseId,
sessionId,
busy,
readonly,
candidateResult,
acceptingCandidateId,
keyCounter,
runAbort,
lastRunOutcome,
conversationAnchor,
setError,
setMessages,
setAcceptingCandidateId,
setCandidateResult,
setSavedTime,
setSavedStatus,
setPending,
beginLiveRun,
loadCaseSnapshot,
send,
onCompleted,
onSaved: savedThenChart,
}, candidateId);
}, [acceptingCandidateId, armChartAfterAdopt, savedThenChart, beginLiveRun, busy, candidateResult, caseId, loadCaseSnapshot, onCompleted, readonly, send, sessionId, setPending]);
async function copyMessage(message: RenderMessage) {
await copyRectificationMessage({ setCopiedMessageKey }, message);
@@ -533,7 +540,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
};
});
const boardPeek = compactBoard && !boardOpen ? (
// Issue #146: 「看盘面」 is in the header at every width. On a phone the open
// sheet covers it; on a desktop it stays as the toggle beside the open board.
const boardPeek = !compactBoard || !boardOpen ? (
<RectificationBoardPeek
result={candidateResult}
declaredTime={declaredTime}
@@ -598,8 +607,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
result={candidateResult}
acceptingCandidateId={acceptingCandidateId}
readonly={readonly || (busy && !acceptingCandidateId)}
onAccept={(candidateId) => void acceptCandidate(candidateId)}
onAccept={(candidateId, openChart) => void acceptCandidate(candidateId, openChart)}
adoptLocked={liveInterviewBlockingAdopt}
fixableEvidence={fixableEvidence}
onFixEvidence={(line) => {
setDraft(fixEvidenceDraft(line));
composer.current?.focus({ preventScroll: true });
}}
/>
{verifiedIdleCopy && (
<p className="rectification-pending-note" role="status">
+10 -10
View File
@@ -94,12 +94,9 @@ function RectificationBoardBody({
<header className="rectification-board__header">
<div className="rectification-board__title-row">
<h2 id={titleId}>当前盘面</h2>
{workingTime
? <span className="rectification-board__clock">{workingTime}</span>
: emptyCopy.clock
? <span className="rectification-board__clock is-declared">{emptyCopy.clock}</span>
: null}
{compact && (
{/* Issue #146: the working minute is written once, on the timeline
above the conversation. The board no longer repeats it. */}
{(
<Button
className="rectification-board__close"
type="button"
@@ -288,7 +285,8 @@ export function RectificationBoard({
return () => window.removeEventListener("keydown", handleKey);
}, [compact, open, onClose]);
if (compact && !open) return null;
// Issue #146: closed by default at every width; 「看盘面」 opens it.
if (!open) return null;
const sheet = (
<aside
@@ -323,6 +321,9 @@ export function RectificationBoard({
);
}
/** Issue #146: the header button says what it does; the time lives on the timeline. */
export const RECTIFICATION_BOARD_OPEN_LABEL = "看盘面";
export function RectificationBoardPeek({
result,
declaredTime = null,
@@ -344,13 +345,12 @@ export function RectificationBoardPeek({
className="rectification-board-peek"
type="button"
variant="ghost"
aria-label={peek.detail ? `${peek.title},${peek.detail}` : peek.title}
aria-label={peek.detail ? `${RECTIFICATION_BOARD_OPEN_LABEL},${peek.detail}` : RECTIFICATION_BOARD_OPEN_LABEL}
aria-expanded={expanded}
aria-controls={boardId}
onClick={onOpen}
>
<strong>{peek.title}</strong>
<span>{peek.detail}</span>
<strong>{RECTIFICATION_BOARD_OPEN_LABEL}</strong>
</Button>
);
}
@@ -18,6 +18,12 @@ import { RectificationVerificationReport } from "@/components/rectification-veri
import { RectificationSegmentDelivery } from "@/components/rectification-segment-delivery";
import { sessionOutcomeAllowsAdopt } from "@/lib/rectification-agentic/core/rectification-decision";
import { Button } from "@/components/ui/button";
import { RectificationResultActions, RectificationResultLead } from "@/components/rectification-result-parts";
import {
RECTIFICATION_OTHER_CANDIDATES_LABEL,
RECTIFICATION_SWITCH_TO_THIS_LABEL,
adoptAndOpenChartLabel,
} from "@/lib/rectification-fix-evidence";
import { deliveryWindowFromReceipt, protectVargaReport } from "@/lib/rectification-agentic/v9/delivery-window";
@@ -31,12 +37,17 @@ export function RectificationRangeDelivery({
readonly,
onAccept,
adoptLocked,
fixableEvidence = [],
onFixEvidence,
}: Readonly<{
result: RectificationCandidateResult;
acceptingCandidateId: string | null;
readonly: boolean;
onAccept: (candidateId: string) => void;
/** `openChart`: the most likely column's 「采用 HH:MM 并打开星盘」 (issue #147). */
onAccept: (candidateId: string, openChart?: boolean) => void;
adoptLocked?: boolean;
fixableEvidence?: readonly string[];
onFixEvidence?: (line: string) => void;
}>) {
const delivery: RangeDeliveryProjection | null = result.rangeDelivery;
const range = delivery?.range ?? result.credibleRange;
@@ -71,24 +82,48 @@ export function RectificationRangeDelivery({
// D4: per-column numbers only when the first column leads by ≥ 5 points.
const showPercents = rangeDeliveryShowsPercents(columns);
const indistinct = columns.length >= 2 && !showPercents;
// Issue #147: lead with the most likely minute only when D4 says the columns
// can be told apart; otherwise the range stays the headline (D3).
const primaryColumn = mostLikely && !indistinct
? columns.find((column) => column.candidate_id === delivery?.representative_candidate_id)
?? columns.find((column) => column.time === mostLikely)
?? null
: null;
const primaryLagna = primaryColumn
? result.houseTablesByTime[primaryColumn.time]?.lagna
?? (result.houseTable?.time === primaryColumn.time ? result.houseTable.lagna : null)
?? null
: null;
const orderedColumns = primaryColumn
? [primaryColumn, ...columns.filter((column) => column !== primaryColumn)]
: columns;
const showActions = adoptAllowed && !adoptLocked && !readonly;
if (delivery && (delivery.product_delivery || delivery.segment_summary)) {
return <RectificationSegmentDelivery summary={delivery.segment_summary ?? null} delivery={delivery} rangeLabel={rangeLabel}
selection={result.segmentSelection} resultId={result.resultId} busy={Boolean(busy)} accepting={Boolean(acceptingCandidateId)} onAccept={onAccept}
adoptLocked={adoptLocked} markdown={markdown} canAdopt={adoptAllowed}
candidateDate={result.candidates.find((candidate) => candidate.candidateId === delivery.representative_candidate_id)?.candidate_date} />;
candidateDate={result.candidates.find((candidate) => candidate.candidateId === delivery.representative_candidate_id)?.candidate_date}
fixableEvidence={fixableEvidence} onFixEvidence={onFixEvidence} readonly={readonly} />;
}
return (
<section className="rectification-candidates rectification-range-delivery" aria-label="生时校正区间">
<div className="rectification-candidates-heading">
<strong>{title}</strong>
{mostLikely && !indistinct ? (
<span className="rectification-range-delivery__most-likely">
{rangeDeliveryMostLikely(mostLikely)}
</span>
) : null}
</div>
{primaryColumn ? (
<>
<RectificationResultLead time={primaryColumn.time} lagna={primaryLagna} />
<p className="rectification-range-delivery__range-line">{title}</p>
</>
) : (
<div className="rectification-candidates-heading">
<strong>{title}</strong>
{mostLikely && !indistinct ? (
<span className="rectification-range-delivery__most-likely">
{rangeDeliveryMostLikely(mostLikely)}
</span>
) : null}
</div>
)}
{vargaBlocked ? (
<p className="rectification-range-delivery__narrow">{windowMinutes === null ? "完整窗口范围暂不可用;暂不推荐分盘解读。" : "分盘解读需要更准的出生时间"}</p>
) : caption ? (
@@ -118,7 +153,8 @@ export function RectificationRangeDelivery({
</ul>
) : null}
<ul className="rectification-range-delivery__columns">
{columns.map((column) => {
{orderedColumns.map((column, index) => {
const isPrimary = column === primaryColumn;
const candidateDate = result.candidates.find((candidate) => candidate.candidateId === column.candidate_id)?.candidate_date;
const relativeDay = candidateDate && result.declaredBirthDate && candidateDate !== result.declaredBirthDate
? candidateDate < result.declaredBirthDate ? "(前一天)" : "(后一天)" : "";
@@ -128,8 +164,11 @@ export function RectificationRangeDelivery({
return (
<li
key={column.candidate_id}
className={`rectification-range-delivery__column${adopted ? " is-adopted" : ""}`}
className={`rectification-range-delivery__column${adopted ? " is-adopted" : ""}${isPrimary ? " is-primary" : ""}`}
>
{primaryColumn && index === 1 ? (
<p className="rectification-range-delivery__others">{RECTIFICATION_OTHER_CANDIDATES_LABEL}</p>
) : null}
<div className="rectification-range-delivery__time">{candidateDate ? `${candidateDate}${relativeDay} ${column.time}` : column.time}</div>
{adoptAllowed && !adoptLocked && candidateDate ? <p className="rectification-range-delivery__muted">采用后,排盘使用这一日期和时间;原填报日期保留。</p> : null}
{showPercents ? (
@@ -162,11 +201,11 @@ export function RectificationRangeDelivery({
<>
<Button
type="button"
variant="outline"
variant={isPrimary ? "default" : "outline"}
className="rectification-range-delivery__accept"
aria-pressed={adopted}
disabled={busy || adopted}
onClick={() => onAccept(column.candidate_id)}
onClick={() => onAccept(column.candidate_id, isPrimary && !recordConflict)}
>
{adopting
? RECTIFICATION_USER_COPY.rangeDeliveryAdopting
@@ -174,7 +213,11 @@ export function RectificationRangeDelivery({
? RECTIFICATION_USER_COPY.rangeDeliveryAdopted
: recordConflict
? SWITCH_TO_RECTIFIED_LABEL
: RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis}
: isPrimary
? adoptAndOpenChartLabel(column.time)
: primaryColumn
? RECTIFICATION_SWITCH_TO_THIS_LABEL
: RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis}
</Button>
{recordConflict && !adopted ? (
<p className="rectification-range-delivery__switch-note">
@@ -189,6 +232,13 @@ export function RectificationRangeDelivery({
);
})}
</ul>
{showActions ? (
<RectificationResultActions
evidence={fixableEvidence}
disabled={Boolean(busy)}
onFixEvidence={onFixEvidence}
/>
) : null}
{delivery?.more_label ? (
<p className="rectification-range-delivery__more">{delivery.more_label}</p>
) : null}
@@ -0,0 +1,80 @@
"use client";
import { useId, useState } from "react";
import { Button } from "@/components/ui/button";
import { RECTIFICATION_FIX_COPY, RECTIFICATION_MOST_LIKELY_LABEL } from "@/lib/rectification-fix-evidence";
/**
* Issue #147: the result card leads with the most likely minute when the
* server says the candidates can be told apart (D4). The card decides that;
* this only draws it.
*/
export function RectificationResultLead({ time, lagna }: Readonly<{ time: string; lagna: string | null }>) {
return (
<div className="rectification-result-lead">
<span className="rectification-result-lead__label">{RECTIFICATION_MOST_LIKELY_LABEL}</span>
<strong className="rectification-result-lead__time">{time}</strong>
{lagna ? <span className="rectification-result-lead__lagna">上升{lagna}</span> : null}
</div>
);
}
/**
* 「我说错了,改一条」 lists the confirmed events and fills the composer with
* the chosen one. Nothing is sent here. There is deliberately no 「再补一件事」:
* typed events barely narrow the range after the training gate, so the card
* must not invite more (BUG-1084, issue #148 decision 2026-10-28).
*/
export function RectificationResultActions({
evidence,
disabled,
onFixEvidence,
}: Readonly<{
evidence: readonly string[];
disabled: boolean;
onFixEvidence?: (line: string) => void;
}>) {
const [fixOpen, setFixOpen] = useState(false);
const listId = useId();
if (!onFixEvidence || evidence.length === 0) return null;
return (
<div className="rectification-result-actions">
<div className="rectification-result-actions__row">
<Button
type="button"
variant="link"
className="rectification-result-actions__fix"
disabled={disabled}
aria-expanded={fixOpen}
aria-controls={listId}
onClick={() => setFixOpen((open) => !open)}
>
{RECTIFICATION_FIX_COPY.fixOne}
</Button>
</div>
{fixOpen ? (
<div id={listId} className="rectification-result-actions__list" role="group" aria-label={RECTIFICATION_FIX_COPY.fixOne}>
<p>{RECTIFICATION_FIX_COPY.pickHint}</p>
<div className="rectification-result-actions__items">
{evidence.map((line) => (
<Button
key={line}
type="button"
variant="outline"
size="sm"
disabled={disabled}
onClick={() => {
setFixOpen(false);
onFixEvidence(line);
}}
>
{line}
</Button>
))}
</div>
</div>
) : null}
</div>
);
}
@@ -8,8 +8,10 @@ import { RectificationVerificationReport } from "@/components/rectification-veri
import { protectVargaReport } from "@/lib/rectification-agentic/v9/delivery-window";
import { unknownProductDeliveryCopy } from "@/lib/rectification-agentic/v9/product-delivery";
import { Button } from "@/components/ui/button";
import { RectificationResultActions } from "@/components/rectification-result-parts";
export function RectificationSegmentDelivery({ summary, delivery, rangeLabel, selection, resultId, busy, accepting, onAccept, adoptLocked, markdown, candidateDate, canAdopt = false }: Readonly<{
export function RectificationSegmentDelivery({ summary, delivery, rangeLabel, selection, resultId, busy, accepting, onAccept, adoptLocked, markdown, candidateDate, canAdopt = false,
fixableEvidence = [], onFixEvidence, readonly = false }: Readonly<{
summary: SegmentSummary | null;
delivery: RangeDeliveryProjection;
rangeLabel: string | null;
@@ -17,11 +19,15 @@ export function RectificationSegmentDelivery({ summary, delivery, rangeLabel, se
resultId: string;
busy: boolean;
accepting: boolean;
onAccept: (candidateId: string) => void;
/** `openChart`: 「采用 HH:MM 并打开星盘」 (issue #147). */
onAccept: (candidateId: string, openChart?: boolean) => void;
adoptLocked?: boolean;
markdown?: string | null;
candidateDate?: string;
canAdopt?: boolean;
fixableEvidence?: readonly string[];
onFixEvidence?: (line: string) => void;
readonly?: boolean;
}>) {
const adoption = summary?.adoption_minute;
const candidateId = delivery.representative_candidate_id;
@@ -36,6 +42,10 @@ export function RectificationSegmentDelivery({ summary, delivery, rangeLabel, se
const blocked = summary?.charts.some((chart) => chart.tier === "blocked") === true;
const reportBlocked = !summary || blocked;
const showAdopt = Boolean(canAdopt && adoption && candidateId && !adoptLocked);
// Issue #147: no minute lead here. The segment product answers "which chart",
// never "which minute" (BUG-1151, product 10-01 option A). A plain adopt still
// opens the chart afterwards; re-adopt and record-conflict adopts do not.
const plainAdopt = !(readopt && priorAdoption) && !delivery.record_conflict;
return (
<section className="rectification-candidates rectification-range-delivery" aria-label="生时校正盘型">
<div className="rectification-candidates-heading"><strong>用哪张盘解读</strong></div>
@@ -58,8 +68,8 @@ export function RectificationSegmentDelivery({ summary, delivery, rangeLabel, se
{showAdopt && adoption && candidateId ? (
<>
<p className="rectification-range-delivery__muted">采用后按 {adoption.date ? `${adoption.date} ` : ""}{adoption.time} 排盘;原填报资料保留。</p>
<Button type="button" variant="outline" className="rectification-range-delivery__accept" disabled={busy || adopted}
aria-pressed={adopted} onClick={() => onAccept(candidateId)}>
<Button type="button" variant={plainAdopt ? "default" : "outline"} className="rectification-range-delivery__accept" disabled={busy || adopted}
aria-pressed={adopted} onClick={() => onAccept(candidateId, plainAdopt)}>
{accepting ? RECTIFICATION_USER_COPY.rangeDeliveryAdopting : adopted ? RECTIFICATION_USER_COPY.rangeDeliveryAdopted
: readopt && priorAdoption ? `改用 ${readopt}`
: delivery.record_conflict ? SWITCH_TO_RECTIFIED_LABEL : `改用 ${charts.map((chart) => chart.chart).join(" + ")} 盘解读`}
@@ -76,6 +86,9 @@ export function RectificationSegmentDelivery({ summary, delivery, rangeLabel, se
) : adopted ? (
<p className="rectification-range-delivery__muted">{RECTIFICATION_USER_COPY.rangeDeliveryAdopted}</p>
) : null}
{showAdopt && !readonly ? (
<RectificationResultActions evidence={fixableEvidence} disabled={busy} onFixEvidence={onFixEvidence} />
) : null}
<p className="rectification-range-delivery__boundary">{delivery.boundary || REPRESENTATIVE_MINUTE_DISCLAIMER}</p>
<RectificationVerificationReport markdown={protectVargaReport(markdown === undefined ? delivery.verification_markdown : markdown, reportBlocked, delivery.product_delivery?.targets ?? summary?.targets,
!summary ? "完整窗口扫描暂不可用;不能提供分盘解读推荐。" : undefined)} />
@@ -0,0 +1,26 @@
"use client";
import { useCallback, useRef } from "react";
import { navigateInApp } from "@/lib/app-navigation";
/**
* 「采用 HH:MM 并打开星盘」 (issue #147). The adopt itself is the existing
* accept run; this only goes to /chart once the server has saved the time.
* Armed per click, so a failed adopt or a plain 「改用这个」 never navigates.
*/
export function useOpenChartAfterAdopt(
onSaved: ((time: string, status: "accepted" | "confirmed") => void) | undefined,
) {
const openAfterSave = useRef(false);
const arm = useCallback((open: boolean) => {
openAfterSave.current = open;
}, []);
const saved = useCallback((time: string, status: "accepted" | "confirmed") => {
onSaved?.(time, status);
if (!openAfterSave.current) return;
openAfterSave.current = false;
navigateInApp("/chart");
}, [onSaved]);
return { arm, onSaved: saved };
}
@@ -25,6 +25,7 @@ import {
type CurrentQuestionModel,
} from "@/lib/rectification-chat-snapshot";
import type { CandidateResult } from "@/lib/rectification-chat-snapshot";
import { fixableEvidenceLines } from "@/lib/rectification-fix-evidence";
/**
* Case snapshot sync for the rectification chat surface: the state that the
@@ -63,6 +64,8 @@ export function useRectificationCaseSnapshot({
const [questionRepairAttempts, setQuestionRepairAttempts] = useState(0);
const [questionRepairing, setQuestionRepairing] = useState(false);
const [snapshotFailure, setSnapshotFailure] = useState<RectificationSnapshotFailure | null>(null);
// Issue #147: the confirmed events 「我说错了,改一条」 lists.
const [fixableEvidence, setFixableEvidence] = useState<readonly string[]>(() => fixableEvidenceLines(initialSnapshot?.evidence));
const snapshotAbort = useRef<AbortController | null>(null);
const currentQuestionRef = useRef(currentQuestion);
@@ -93,6 +96,7 @@ export function useRectificationCaseSnapshot({
const snapshot = payload as RectificationCaseSnapshotPayload;
setResultNotice(snapshot.case?.result_notice ?? null);
setCanRecompare(snapshot.case?.result_identity?.can_recompare === true);
if (Array.isArray(snapshot.evidence)) setFixableEvidence(fixableEvidenceLines(snapshot.evidence));
const nextCandidate = parseRectificationCandidateResult(payload.latest_result);
const nextQuestion = currentQuestionFromSnapshot(payload.current_question);
const nextChoice = parseRectificationChoiceCard(payload.choice_card);
@@ -174,6 +178,7 @@ export function useRectificationCaseSnapshot({
}, [applyCaseSnapshot, caseId, sessionId]);
return {
fixableEvidence,
resultNotice,
canRecompare,
savedTime,
+2 -3
View File
@@ -118,9 +118,8 @@ export function deriveRectificationChatView(input: RectificationChatViewInput) {
candidateTimes: candidateResult?.candidates.map((candidate) => candidate.time) ?? [],
inferenceMarks: candidateResult?.inferenceMarks ?? [],
stage: caseStage,
// The same call the board's title-row clock makes, on the same result, so
// the bar and `.rectification-board__clock` are one number by construction
// rather than by two code paths that happen to agree today.
// The one place the working minute is shown (issue #146 took it off the
// board's title row). The board's peek label reads the same call.
workingTime: workingRectificationTime(candidateResult),
workingAdopted: Boolean(candidateResult?.selectedTime),
segmentSummary: candidateResult?.rangeDelivery?.segment_summary,
@@ -0,0 +1,51 @@
/**
* 「我说错了,改一条」 on the result card (issue #147). The list is the case's
* confirmed events, worded with the same recap line the conversation already
* shows. Picking one only fills the composer; the user finishes the sentence
* and sends it, and the agent revises that event with its existing tool. No
* new route, nothing sent or charged by the pick itself.
*/
import { displayDateLabel, eventPhraseFromSummary } from "./rectification-agentic/v9/evidence-model.ts";
import { evidenceRecapLine } from "./rectification-agentic/v9/host-fallback.ts";
export const RECTIFICATION_FIX_COPY = {
fixOne: "我说错了,改一条",
pickHint: "选一条要改的,输入框里会先写好开头:",
} as const;
/** Confirmed events only, oldest first as stored, each once. */
export function fixableEvidenceLines(value: unknown): string[] {
if (!Array.isArray(value)) return [];
const lines: string[] = [];
for (const item of value) {
if (!item || typeof item !== "object") continue;
const row = item as Record<string, unknown>;
if (row.status !== "confirmed") continue;
const summary = typeof row.summary === "string" ? row.summary : "";
if (!summary.trim()) continue;
const precision = typeof row.datePrecision === "string" ? row.datePrecision : "unknown";
const from = typeof row.occurredFrom === "string" ? row.occurredFrom : null;
const to = typeof row.occurredTo === "string" ? row.occurredTo : null;
const line = evidenceRecapLine({
display_date_label: displayDateLabel(precision, from, to),
event_phrase: eventPhraseFromSummary(summary),
}).trim();
if (line && !lines.includes(line)) lines.push(line);
}
return lines;
}
/** The composer's opening words; the user writes what it should be. */
export function fixEvidenceDraft(line: string): string {
return `我说错了:「${line}」应该是`;
}
/** The primary adopt label on the most likely column. */
export function adoptAndOpenChartLabel(time: string): string {
return `采用 ${time} 并打开星盘`;
}
export const RECTIFICATION_OTHER_CANDIDATES_LABEL = "还不能排除";
export const RECTIFICATION_SWITCH_TO_THIS_LABEL = "改用这个";
export const RECTIFICATION_MOST_LIKELY_LABEL = "最可能的出生时间";
@@ -112,6 +112,8 @@ export type RectificationCaseSnapshotPayload = Readonly<{
birth_time_source?: unknown;
}>;
turns?: unknown;
/** The case's evidence rows; the result card lists the confirmed ones (issue #147). */
evidence?: unknown;
}>;
/** `candidate_range` as `[start, end]`; null when absent or malformed. */
@@ -635,7 +635,10 @@ test("compact board overlays chat as a bottom sheet above the composer", () => {
assert.match(styles, /\.rectification-board__close \{[\s\S]*width: 44px/);
assert.match(styles, /\.rectification-board__close \{[\s\S]*min-height: 44px/);
assert.match(board, /<X aria-hidden="true"/);
assert.match(chat, /compactBoard && !boardOpen \? \(/);
// 原值:只在手机且盘面没打开时出「看盘面」入口(compactBoard && !boardOpen)。
// 新值:桌面上也出,手机上盘面打开时才收起。
// 原因:issue #146(看盘面按钮在所有宽度都有,盘面默认关着,代表分钟只在时间轴上写一次)。
assert.match(chat, /!compactBoard \|\| !boardOpen \? \(/);
assert.match(chat, /headerSlot && boardPeek \? createPortal\(boardPeek, headerSlot\)/);
assert.doesNotMatch(chat, /querySelector\(/);
assert.doesNotMatch(chat, /setHeaderSlot/);
@@ -553,7 +553,8 @@ test("the minute card hides the adopt button and the chart-clock line when adopt
const allowed = renderRange(cardResult("adopt_representative", true, minuteDelivery()));
assert.match(allowed, /<button/);
assert.match(allowed, /采用后,排盘使用这一日期和时间/);
assert.match(allowed, /更像这个/);
// 原值:/更像这个/。新值:区分得开时最可能那列是「采用 HH:MM 并打开星盘」。原因:issue #147。
assert.match(allowed, /更像这个|采用 \d{2}:\d{2} 并打开星盘/);
});
test("a refused adoption refreshes the snapshot and says so in Chinese", async () => {
@@ -734,3 +735,21 @@ test("answering widen A with a failed rescore never delivers or adopts on the ol
assert.notEqual(receipt.nextAction.type, "ready_to_adopt");
assert.notEqual(receipt.nextAction.type, "complete_with_range");
});
// Issue #147: the segment card answers "which chart" (BUG-1151), so it gets the
// actions row but never a minute lead, and keeps its own adopt label.
test("the segment card gets 改一条 but no minute lead and no 再补一件事", () => {
const html = renderToStaticMarkup(createElement(RectificationRangeDelivery, {
result: cardResult("adopt_representative", true, segmentDelivery()),
acceptingCandidateId: null,
readonly: false,
onAccept: () => {},
fixableEvidence: ["2016-09 升学"],
onFixEvidence() {},
}));
assert.match(html, /用哪张盘解读/);
assert.doesNotMatch(html, /再补一件事/, "BUG-1084: the card does not invite more events");
assert.match(html, /我说错了,改一条/);
assert.doesNotMatch(html, /最可能|rectification-result-lead|并打开星盘/);
assert.match(html, /盘解读<\/button>/);
});
@@ -146,10 +146,17 @@ test("D4 threshold is 5 points between the first and second column", () => {
});
test("D3 range is the title and the representative minute is the subtitle", () => {
// 原值:前两列差距 ≥ 5 时仍以「目前范围 …」为 <strong> 卡头,「最可能 HH:MM」作副标题。
// 新值:区分得开时卡头改为「最可能的出生时间 HH:MM」,范围降为下一行;区分不开时仍以范围为卡头(D3 只在这时成立)。
// 原因:issue #147(产品 2026-10-28 同意:区分得开才以最可能时刻为卡头)。
const html = render([0.45, 0.3, 0.25]);
assert.match(html, /<strong>目前范围 05:00–05:15(对照了 2 件经历)<\/strong>/);
assert.match(html, /rectification-range-delivery__most-likely">最可能 05:07</);
assert.ok(html.indexOf("最可能 05:07") < html.indexOf("rectification-range-delivery__columns"));
assert.match(html, /rectification-result-lead__time">05:07</);
assert.match(html, /rectification-range-delivery__range-line">目前范围 05:00–05:15(对照了 2 件经历)</);
assert.ok(html.indexOf("rectification-result-lead") < html.indexOf("rectification-range-delivery__range-line"));
assert.ok(html.indexOf("rectification-range-delivery__range-line") < html.indexOf("rectification-range-delivery__columns"));
const flat = render([0.28, 0.26, 0.24]);
assert.match(flat, /<strong>目前范围 05:00–05:15(对照了 2 件经历)<\/strong>/);
assert.doesNotMatch(flat, /rectification-result-lead/);
assert.equal(rangeDeliveryMostLikely("05:07"), "最可能 05:07");
});
@@ -315,3 +322,30 @@ test("D2 a delivered card does not carry an unasked guided window as the next qu
const collecting = buildMethodFollowupPlan({ ...base, sessionOutcome: "collect_evidence" });
assert.doesNotMatch(collecting.next_followup?.collection_key ?? "", /^collect:guided:window:/);
});
// Issue #147: a distinct result leads with the most likely minute; its column
// adopts and opens the chart, the others stay adoptable as 「改用这个」.
test("a distinct result puts the most likely column first with 采用并打开星盘 and keeps the others adoptable", () => {
const calls: Array<[string, boolean | undefined]> = [];
const html = renderToStaticMarkup(createElement(RectificationRangeDelivery, {
result: result([0.45, 0.3, 0.25]),
acceptingCandidateId: null,
readonly: false,
onAccept: (candidateId: string, openChart?: boolean) => { calls.push([candidateId, openChart]); },
fixableEvidence: ["2016-09 升学"],
onFixEvidence() {},
}));
assert.match(html, /采用 05:07 并打开星盘/);
assert.equal([...html.matchAll(/改用这个/g)].length, 2);
assert.match(html, /还不能排除/);
assert.ok(html.indexOf("采用 05:07 并打开星盘") < html.indexOf("还不能排除"));
assert.doesNotMatch(html, /再补一件事/, "BUG-1084: the card does not invite more events");
assert.match(html, /我说错了,改一条/);
assert.doesNotMatch(html, /把握/, "no confidence label (issue #147, BUG-560)");
});
test("an indistinct result keeps 更像这个 on every column and no lead", () => {
const html = render([0.28, 0.26, 0.24]);
assert.doesNotMatch(html, /并打开星盘|改用这个|还不能排除|rectification-result-lead/);
assert.match(html, /更像这个/);
});
@@ -58,7 +58,10 @@ test("the bar's working minute is the board clock's own value, not a second deri
// passes the result of that same call into the bar, so the two surfaces
// cannot drift apart — there is one expression, not two that agree today.
assert.match(board, /const workingTime = workingRectificationTime\(result\);/);
assert.match(board, /className="rectification-board__clock">\{workingTime\}/);
// 原值:盘面标题行渲染 rectification-board__clock,与时间轴同一个值。
// 新值:盘面不再写时刻,代表分钟只在时间轴上出现一次。
// 原因:issue #146(看盘面按钮在所有宽度都有,盘面默认关着,代表分钟只在时间轴上写一次)。
assert.doesNotMatch(board, /className="rectification-board__clock"/);
assert.match(chat, /workingTime: workingRectificationTime\(candidateResult\)/);
const result = candidateSnapshot();
@@ -395,7 +395,8 @@ test("range-delivery DOM is one button per row and adopted is aria-pressed", ()
assert.doesNotMatch(html, /说不好/);
assert.doesNotMatch(html, /排盘用/);
const source = readFileSync(new URL("../src/components/rectification-range-delivery.tsx", import.meta.url), "utf8");
assert.match(source, /onAccept\(column\.candidate_id\)/);
// 原值:onAccept(column.candidate_id)。新值:带第二个参数,最可能那列(且无记录冲突)采用后打开星盘。原因:issue #147。
assert.match(source, /onAccept\(column\.candidate_id, isPrimary && !recordConflict\)/);
assert.match(source, /aria-pressed=\{adopted\}/);
});
@@ -191,7 +191,13 @@ test("non-conflict states keep 更像这个 and show no switch note", () => {
[RECORD_OUTSIDE, "period_only"],
] as const) {
const markup = html(reported, source);
assert.ok(markup.includes(RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis), `${reported}/${source}`);
// 原值:每列都是「更像这个」。
// 新值:区分得开时最可能那列是「采用 HH:MM 并打开星盘」、其余「改用这个」;区分不开时仍是「更像这个」。
// 原因:issue #147。无冲突时仍然不出现「改用校正结果」和换时间小字。
assert.ok(
markup.includes(RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis) || /采用 \d{2}:\d{2} 并打开星盘/.test(markup),
`${reported}/${source}`,
);
assert.equal(markup.includes(SWITCH_TO_RECTIFIED_LABEL), false, `${reported}/${source}`);
assert.equal(markup.includes("换成 04:53"), false, `${reported}/${source}`);
}
@@ -0,0 +1,105 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { RECTIFICATION_BOARD_OPEN_LABEL, RectificationBoard, RectificationBoardPeek } from "../src/components/rectification-board.tsx";
import { RectificationResultActions, RectificationResultLead } from "../src/components/rectification-result-parts.tsx";
import { diffRectificationBoard } from "../src/lib/rectification-board-model.ts";
import {
RECTIFICATION_FIX_COPY,
adoptAndOpenChartLabel,
fixEvidenceDraft,
fixableEvidenceLines,
} from "../src/lib/rectification-fix-evidence.ts";
// Issues #146 and #147: one working minute on the timeline, the board behind
// 「看盘面」, and the result card's lead and actions. Fictional data only.
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
test("only confirmed events are listed to fix, once each, in the conversation's recap wording", () => {
const lines = fixableEvidenceLines([
{ status: "confirmed", summary: "升学", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null },
{ status: "confirmed", summary: "升学", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null },
{ status: "pending_confirmation", summary: "搬家", datePrecision: "month", occurredFrom: "2023-08-01", occurredTo: null },
{ status: "superseded", summary: "入职", datePrecision: "day", occurredFrom: "2024-04-07", occurredTo: null },
{ status: "confirmed", summary: "入职", datePrecision: "day", occurredFrom: "2024-04-07", occurredTo: null },
{ status: "confirmed", summary: " ", datePrecision: "day", occurredFrom: "2024-04-07", occurredTo: null },
null,
"x",
]);
assert.equal(lines.length, 2);
assert.match(lines[0]!, /2016-09/);
assert.match(lines[1]!, /2024-04-07/);
assert.deepEqual(fixableEvidenceLines(undefined), []);
assert.equal(fixEvidenceDraft("2016-09 升学"), "我说错了:「2016-09 升学」应该是");
assert.equal(adoptAndOpenChartLabel("07:24"), "采用 07:24 并打开星盘");
});
test("the actions row offers only 改一条, and only with a callback and a confirmed event", () => {
assert.equal(renderToStaticMarkup(createElement(RectificationResultActions, { evidence: ["2016-09 升学"], disabled: false })), "");
const html = renderToStaticMarkup(createElement(RectificationResultActions, {
evidence: ["2016-09 升学"],
disabled: false,
onFixEvidence() {},
}));
assert.match(html, new RegExp(RECTIFICATION_FIX_COPY.fixOne));
assert.doesNotMatch(html, /再补一件事/, "BUG-1084: no invitation to add more events");
assert.match(html, /aria-expanded="false"/);
assert.doesNotMatch(html, /2016-09 升学/, "the event list opens on click");
const none = renderToStaticMarkup(createElement(RectificationResultActions, {
evidence: [],
disabled: false,
onFixEvidence() {},
}));
assert.equal(none, "", "nothing to fix without a confirmed event");
});
test("the lead shows the minute and the Lagna when known", () => {
assert.match(renderToStaticMarkup(createElement(RectificationResultLead, { time: "07:24", lagna: "巨蟹座" })), /最可能的出生时间[\s\S]*07:24[\s\S]*上升巨蟹座/);
assert.doesNotMatch(renderToStaticMarkup(createElement(RectificationResultLead, { time: "07:24", lagna: null })), /上升/);
});
test("the board is closed by default at every width and has no clock in its title row", () => {
const props = {
result: null,
declaredTime: "05:10",
savedStatus: null,
diff: diffRectificationBoard(null, null),
boardId: "board",
titleId: "title",
onClose() {},
};
assert.equal(renderToStaticMarkup(createElement(RectificationBoard, { ...props, compact: false, open: false })), "");
assert.equal(renderToStaticMarkup(createElement(RectificationBoard, { ...props, compact: true, open: false })), "");
const open = renderToStaticMarkup(createElement(RectificationBoard, { ...props, compact: false, open: true }));
assert.match(open, /当前盘面/);
assert.doesNotMatch(open, /rectification-board__clock/);
assert.match(open, /aria-label="关闭盘面"/, "the desktop column closes too");
});
test("看盘面 says what it does; the time stays in its accessible name only", () => {
const html = renderToStaticMarkup(createElement(RectificationBoardPeek, {
result: null,
declaredTime: "05:10",
expanded: false,
boardId: "board",
onOpen() {},
}));
assert.equal(RECTIFICATION_BOARD_OPEN_LABEL, "看盘面");
assert.match(html, /<strong>看盘面<\/strong>/);
assert.doesNotMatch(html, /<span>/);
assert.match(html, /aria-label="看盘面,[^"]*05:10"/);
});
test("adopting from the most likely column opens the chart only after the server saved it", () => {
const hook = read("../src/hooks/use-open-chart-after-adopt.ts");
assert.match(hook, /onSaved\?\.\(time, status\);\s*if \(!openAfterSave\.current\) return;\s*openAfterSave\.current = false;\s*navigateInApp\("\/chart"\);/);
const chat = read("../src/components/rectification-agentic-chat.tsx");
assert.match(chat, /armChartAfterAdopt\(openChart\);/);
assert.match(chat, /onSaved: savedThenChart,/);
assert.match(chat, /onFixEvidence=\{\(line\) => \{\s*setDraft\(fixEvidenceDraft\(line\)\);/);
const segment = read("../src/components/rectification-segment-delivery.tsx");
assert.doesNotMatch(segment, /RectificationResultLead/, "the segment product never leads with a minute (BUG-1151)");
});
@@ -226,7 +226,9 @@ test("the choice card confirms the tap and the board's first state shows the dec
assert.match(board, /rectificationBoardPeekCopy\(result, declaredTime, birthTimeSource\)/);
assert.doesNotMatch(board, /补充经历后,这里会显示当前本命宫位和换升时刻/);
assert.match(chat, /\$\{candidateResult \? "" : " is-board-empty"\}/);
assert.match(styles, /\.rectification-workspace\.is-board-empty \{\s*grid-template-columns: minmax\(0, 1fr\) minmax\(16rem, 18rem\);/);
// The empty modifier is declared before `.is-compact`, so the single-column layout still wins.
assert.ok(styles.indexOf(".rectification-workspace.is-board-empty {") < styles.indexOf(".rectification-workspace.is-compact {"));
// 原值:.rectification-workspace.is-board-empty 常驻两栏(右栏 16–18rem)。
// 新值:只有盘面打开且不是手机布局时才分出右栏,空盘面仍用窄的那档。
// 原因:issue #146(看盘面按钮在所有宽度都有,盘面默认关着,代表分钟只在时间轴上写一次)。
assert.match(styles, /\.rectification-workspace\.is-board-empty\.is-board-open:not\(\.is-compact\) \{\s*grid-template-columns: minmax\(0, 1fr\) minmax\(16rem, 18rem\);/);
assert.match(styles, /\.rectification-workspace \{[^}]*grid-template-columns: minmax\(0, 1fr\);/);
});