fix(rectification): show natal houses and lagna changes in a live right rail

The board was live data sitting under chat, so each new event looked like nothing changed. Keep candidate cards in the transcript and group 换升 by minute for the side panel.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-20 14:24:05 +08:00
parent 19de0bab36
commit e07d1bada4
10 changed files with 985 additions and 130 deletions
+20 -4
View File
@@ -4678,8 +4678,8 @@
- 根因:表来自 Case 快照、挂在消息列表末尾,却按聊天气泡的起始线排版,标题也只说“本命宫位”。
- 修复:宫位表单独放在消息列表后的 `rectification-snapshot`,表面与结果卡同类。标题改为“当前本命宫位”,并写明补充经历后会按新线索重算。时间选择卡不再进这个快照,见 BUG-312。
- 验证:`frontend/tests/rectification-agentic-entry.test.ts` 锁定快照容器在 `ChatMessageRow` 之外、不含候选卡、`aria-live`、重算文案,以及成功回合后 `loadCandidate`
- 防复发:宫位表必须在消息气泡外;文案必须说明随新线索重算。列对齐用 snapshot 容器的 `--assistant-content-inset`,不得再用表上的 `padding-inline-start` 把它扮成 Agent 正文
- 相关记录:BUG-307、BUG-310、BUG-312
- 防复发:宫位表必须在消息气泡外,并作为聊天右侧工作台原地刷新,不得再挂在消息列表底部。文案必须说明随新线索重算。候选卡仍用 `--assistant-content-inset` 对齐 Agent 正文;宫位表不再用 inset 冒充气泡
- 相关记录:BUG-307、BUG-310、BUG-312、BUG-322
- 复发自:BUG-310(对齐 Agent 正文后更像一条聊天)
- 修复版本:`4d872c68`
@@ -4726,8 +4726,8 @@
- 根因:宫位表改成消息外的 snapshot 卡后,容器不再吃 `--assistant-content-inset`,仍按整列 `message-list` 铺宽;Agent 正文在头像加 gap 之后。
- 修复:snapshot 与候选卡同一列:`width: calc(100% - var(--assistant-content-inset))``margin-inline-start` 用同一 inset。表本身保持卡片描边和内边距,不用 `padding-inline-start` 冒充气泡。
- 验证:`frontend/tests/rectification-agentic-entry.test.ts` 锁定 snapshot 与候选卡共用 inset 宽度和起始线,且 `.rectification-house-table` 不写 inset。
- 防复发:宫位表左缘和宽度必须跟 Agent 正文列一致;inset 只加在 snapshot 容器上
- 相关记录:BUG-310、BUG-311
- 防复发:宫位表在右侧工作台内,不再与 Agent 正文同列对齐。候选时间卡仍用 `--assistant-content-inset` 对齐气泡。不得把工作台再缩进成消息列
- 相关记录:BUG-310、BUG-311、BUG-322
- 复发自:BUG-311(去掉消息缩进后未收回正文列)
- 修复版本:`a732ff4b`
@@ -4842,3 +4842,19 @@
- 相关记录:BUG-309、BUG-315、BUG-320
- 复发自:BUG-309validate 跳过 `next build`;日志末尾 Import traces 掩盖类型检查失败)
- 修复版本:0eebf971
## BUG-322 | 生时纠正宫位表和换升观察仍埋在消息列表底部
- 状态:resolved
- 首次发现:2026-08-20
- 最近更新:2026-08-20
- 影响面:生时纠正对话布局、`rectification-snapshot`、换升时刻
- 用户现象:本命宫位和换升时刻跟在消息列表下面。每次补充经历后表会重算,但要滚过整段对话才能看见变化。
- 触发条件:校正已有 Candidate Snapshot,继续在对话里补充经历。
- 根因:BUG-311 把宫位表移出气泡,但仍放在 `.message-list` 末尾。换升时刻按图层一行一条平铺,不适合侧栏。
- 修复:生时纠正单独做成聊天 | 工作台分栏。宫位表、按分钟合并的换升、确认门和大运对照放在右侧并原地刷新。候选时间卡和“用这个时间看盘”仍留在对话里。窄屏用“当前盘面”打开对话框,不挤第二列。普通咨询 session 不改。
- 验证:`frontend/tests/rectification-agentic-entry.test.ts``frontend/tests/rectification-board-model.test.ts`
- 防复发:`rectification-snapshot` 不得回到消息循环。换升不得再按 `user_meaning` 一层一行平铺。`.conversation:not(.is-rectification)` 的 session 列宽不得套到校正对话。
- 相关记录:BUG-311、BUG-312、BUG-314
- 复发自:BUG-311(移出气泡后仍挂在消息列表底部)
- 修复版本:待提交
+222 -3
View File
@@ -474,6 +474,7 @@ button:disabled { cursor: default; opacity: .45; }
}
.chat-panel { height: 100%; min-width: 0; min-height: 0; overflow: hidden; display: grid; background: var(--color-canvas); grid-template-rows: 68px minmax(0, 1fr) auto; }
.chat-panel.is-rectification { grid-template-rows: 68px minmax(0, 1fr); }
.chat-header { z-index: 2; min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 20px; border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); padding: 0 var(--space-8); background: var(--color-frosted); backdrop-filter: saturate(130%) blur(20px); text-align: left; }
.chat-header > div { min-width: 0; }
.chat-header strong { max-width: min(560px, 62vw); overflow: hidden; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 400; letter-spacing: -.3px; }
@@ -1069,6 +1070,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.sidebar-scrim { position: fixed; z-index: 11; inset: 0; display: block; border: 0; background: var(--color-scrim); opacity: 1; visibility: visible; }
[data-sidebar="rail"] { display: none; }
.chat-panel { grid-template-rows: calc(64px + env(safe-area-inset-top)) minmax(0, 1fr) auto; }
.chat-panel.is-rectification { grid-template-rows: calc(64px + env(safe-area-inset-top)) minmax(0, 1fr); }
.chat-header { gap: 10px; padding: max(0px, env(safe-area-inset-top)) var(--space-3) 0; }
.conversation.is-empty {
display: block;
@@ -1122,6 +1124,17 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.paywall-footer > a, .paywall-footer > button { width: 100%; }
.dialog-actions { flex-direction: column-reverse; }
.dialog-actions > button { width: 100%; }
dialog.rectification-board {
width: 100%;
height: min(86dvh, 100%);
margin-block-start: auto;
margin-inline: 0;
border-inline-start: 0;
border-block-start: 1px solid var(--color-border);
border-start-start-radius: var(--radius-lg);
border-start-end-radius: var(--radius-lg);
padding-bottom: env(safe-area-inset-bottom);
}
.auth-story { min-height: 0; padding: var(--space-5); }
.auth-story h2 { font-size: var(--type-title-lg); }
.auth-panel { padding: var(--space-6) var(--space-5) max(var(--space-6), env(safe-area-inset-bottom)); }
@@ -1847,12 +1860,218 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.birth-time-clock-menu.select-content { width: 108px; min-width: 108px; }
.birth-time-clock-menu .select-item { justify-content: flex-start; }
.rectification-workspace {
min-width: 0;
min-height: 0;
height: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(18rem, 22.5rem);
background: var(--color-canvas);
}
.rectification-workspace.is-compact {
grid-template-columns: minmax(0, 1fr);
}
.rectification-workspace__chat {
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
}
.rectification-workspace__chat .conversation {
padding-bottom: var(--space-4);
}
.rectification-workspace__chat .message-list {
width: 100%;
max-width: 720px;
}
.rectification-workspace__chat .composer-wrap {
position: relative;
z-index: 2;
}
.rectification-board {
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
border-inline-start: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
background: var(--color-canvas-soft);
color: var(--color-ink);
}
dialog.rectification-board {
width: min(22.5rem, 100%);
height: 100dvh;
max-width: none;
max-height: none;
margin: 0;
margin-inline-start: auto;
padding: 0;
border: 0;
border-inline-start: 1px solid var(--color-border);
background: var(--color-canvas-soft);
color: var(--color-ink);
}
dialog.rectification-board::backdrop {
background: var(--color-scrim);
}
.rectification-board__header {
position: sticky;
top: 0;
z-index: 1;
display: grid;
gap: var(--space-2);
padding: var(--space-4) var(--space-4) var(--space-3);
background: var(--color-canvas-soft);
}
.rectification-board__title-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2) var(--space-3);
}
.rectification-board__header h2 {
margin: 0;
font-family: var(--font-display);
font-size: var(--type-title-sm);
font-weight: 600;
letter-spacing: -.2px;
}
.rectification-board__clock {
color: var(--color-ink);
font-variant-numeric: tabular-nums;
font-size: var(--type-body-sm);
font-weight: 600;
}
.rectification-board__close {
min-height: 44px;
margin-inline-start: auto;
padding: 0 var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-canvas);
color: var(--color-ink);
cursor: pointer;
}
.rectification-board__close:focus-visible,
.rectification-board-peek:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
.rectification-board__stage,
.rectification-board__empty,
.rectification-board__disclosure {
margin: 0;
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
line-height: 1.55;
}
.rectification-board__body {
min-height: 0;
display: grid;
align-content: start;
gap: var(--space-6);
overflow-y: auto;
overscroll-behavior: contain;
padding: 0 var(--space-4) var(--space-6);
}
.rectification-board .rectification-snapshot {
display: grid;
gap: var(--space-4);
width: auto;
margin: 0;
}
.rectification-board .rectification-house-table {
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
}
.rectification-board .technique-audit {
margin-top: 0;
padding-top: 0;
border-top: 0;
}
.rectification-board__section {
display: grid;
gap: var(--space-3);
}
.rectification-board__section h3 {
margin: 0;
font-family: var(--font-display);
font-size: var(--type-title-sm);
font-weight: 600;
letter-spacing: -.2px;
}
.rectification-board__minutes {
display: grid;
gap: var(--space-3);
margin: 0;
padding: 0;
list-style: none;
}
.rectification-board__minute {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px 10px;
}
.rectification-board__minute time {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.rectification-board__minute-note {
color: var(--color-ink-secondary);
font-size: var(--type-caption);
}
.rectification-board__chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
flex: 1 1 100%;
margin: 0;
padding: 0;
list-style: none;
}
.rectification-board__chip {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 8px;
border-radius: 999px;
background: var(--color-canvas);
color: var(--color-ink);
font-size: var(--type-caption);
}
.rectification-board-peek {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
width: 100%;
min-height: 44px;
margin: 0 0 var(--space-2);
padding: var(--space-2) var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-canvas-soft);
color: var(--color-ink);
text-align: start;
cursor: pointer;
}
.rectification-board-peek strong { font-size: var(--type-body-sm); }
.rectification-board-peek span {
color: var(--color-ink-secondary);
font-size: var(--type-caption);
}
.rectification-house-table__changed {
display: inline;
margin-inline-start: .4em;
color: var(--color-ink-secondary);
font-size: var(--type-caption);
}
.rectification-snapshot {
display: grid;
gap: var(--space-3);
width: calc(100% - var(--assistant-content-inset));
margin: 8px 0 16px;
margin-inline-start: var(--assistant-content-inset);
}
.rectification-candidates {
display: grid;
+1 -1
View File
@@ -3552,7 +3552,7 @@ export default function Home() {
</section>
</div>
) : null}
<SidebarInset className="chat-panel" inert={modalOpen}>
<SidebarInset className={`chat-panel${rectificationSurfaceOpen ? " is-rectification" : ""}`} inert={modalOpen}>
<header className="chat-header">
<SidebarTrigger placement="inset" />
<div>
@@ -1,7 +1,7 @@
"use client";
import { ArrowUp } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { parseAgentReply } from "@/lib/agent-reply";
import type { ChatMessage, ChatMessageView } from "@/lib/chat-message-view";
import {
@@ -17,6 +17,10 @@ import {
workingRectificationHouseTable,
type RectificationCandidateResult,
} from "@/lib/rectification-candidate-result";
import {
diffRectificationBoard,
RECTIFICATION_BOARD_SPLIT_MIN_PX,
} from "@/lib/rectification-board-model";
import { membershipHref } from "@/lib/membership";
import { vargaSentenceFromMethods } from "@/lib/rectification-varga-sentence";
import {
@@ -30,8 +34,7 @@ import {
toggleChatMessageFeedback,
} from "./chat-message-actions";
import { ModelSelector } from "./model-selector";
import { RectificationHouseTableView } from "./rectification-house-table";
import { TechniqueAuditDisclosure, ConfirmationGateDisclosure } from "./technique-audit-disclosure";
import { RectificationBoard, RectificationBoardPeek } from "./rectification-board";
import { Button } from "./ui/button";
import { Textarea } from "./ui/textarea";
@@ -104,75 +107,6 @@ function RectificationCandidateCards({
);
}
function RectificationRefinementPanel({
result,
}: Readonly<{ result: RectificationCandidateResult }>) {
const transitions = result.windowTransitions;
const ledger = result.eventDashaLedger;
const agreement = result.dashaAgreement;
const contrast = result.lagnaContrast;
const nakshatra = result.nakshatraBoundary;
const stage = result.precisionStage;
if (
transitions.length === 0
&& ledger.length === 0
&& !agreement
&& !contrast
&& !nakshatra?.near_boundary
&& !stage
) return null;
return (
<section className="rectification-refinement" aria-label="校正收窄观察">
{stage && (
<p className="rectification-refinement__stage">{stage.user_meaning}</p>
)}
{transitions.length > 0 && (
<div>
<strong></strong>
<ul>
{transitions.map((item) => (
<li key={`${item.layer}-${item.at}`}>{item.user_meaning}</li>
))}
</ul>
</div>
)}
{contrast && (
<div>
<strong></strong>
<p>{contrast.user_meaning}</p>
<ul>
{contrast.intervals.map((interval) => (
<li key={`${interval.start}-${interval.end}`}>
{interval.start}-{interval.end}{interval.lagna}
{interval.lords.l1 ? `;一/四/七/十宫主 ${[interval.lords.l1, interval.lords.l4, interval.lords.l7, interval.lords.l10].filter(Boolean).join("、")}` : ""}
</li>
))}
</ul>
</div>
)}
{ledger.length > 0 && (
<div>
<strong></strong>
<ul>
{ledger.map((item) => (
<li key={item.summary}>{item.user_meaning}</li>
))}
</ul>
</div>
)}
{agreement && (
<p>{agreement.user_meaning}</p>
)}
{nakshatra?.near_boundary && nakshatra.user_meaning && (
<div>
<strong></strong>
<p>{nakshatra.user_meaning}</p>
</div>
)}
</section>
);
}
function RectificationOosPanel({
prompts,
}: Readonly<{ prompts: RectificationCandidateResult["oosBlindPrompts"] }>) {
@@ -319,9 +253,37 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
const [regeneratingMessageKey, setRegeneratingMessageKey] = useState<string | null>(null);
const conversation = useRef<HTMLElement>(null);
const workspace = useRef<HTMLDivElement>(null);
const composer = useRef<HTMLTextAreaElement>(null);
const keyCounter = useRef(0);
const openingStarted = useRef(false);
const previousBoardResult = useRef<CandidateResult>(null);
const [compactBoard, setCompactBoard] = useState(false);
const [boardOpen, setBoardOpen] = useState(false);
const [boardDiff, setBoardDiff] = useState(() => diffRectificationBoard(null, null));
const boardId = useId();
const boardTitleId = useId();
useLayoutEffect(() => {
const node = workspace.current;
if (!node) return;
const update = () => {
const nextCompact = node.clientWidth < RECTIFICATION_BOARD_SPLIT_MIN_PX;
setCompactBoard(nextCompact);
if (!nextCompact) setBoardOpen(false);
};
update();
const observer = new ResizeObserver(update);
observer.observe(node);
return () => observer.disconnect();
}, []);
useEffect(() => {
setBoardDiff(diffRectificationBoard(previousBoardResult.current, candidateResult));
previousBoardResult.current = candidateResult;
}, [candidateResult]);
const closeBoard = useCallback(() => setBoardOpen(false), []);
const setPending = useCallback((value: boolean) => {
setBusy(value);
@@ -684,9 +646,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const canSend = !busy && !readonly && !regeneratingMessageKey;
return (
<>
<section ref={conversation} className="conversation" aria-label="生时校正对话" aria-busy={busy || regeneratingMessageKey !== null}>
<div className="message-list" aria-live="polite">
<div
ref={workspace}
className={`rectification-workspace${compactBoard ? " is-compact" : ""}${boardOpen ? " is-board-open" : ""}`}
>
<div className="rectification-workspace__chat" inert={compactBoard && boardOpen ? true : undefined}>
<section ref={conversation} className="conversation is-rectification" aria-label="生时校正对话" aria-busy={busy || regeneratingMessageKey !== null}>
<div className="message-list">
{pendingConsultationQuestion?.trim() && (
<p className="rectification-pending-note">
{pendingConsultationQuestion.trim()}
@@ -746,19 +712,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
</div>
);
})}
{candidateResult?.houseTable && (
<div className="rectification-snapshot">
{candidateResult.natalRecast && (
<p className="rectification-refinement__stage">{candidateResult.natalRecast.user_meaning}</p>
)}
<RectificationHouseTableView table={candidateResult.houseTable} />
<ConfirmationGateDisclosure gate={candidateResult.confirmationGate} />
{savedStatus === "accepted" && candidateResult.techniqueAudit.length > 0 && (
<TechniqueAuditDisclosure rows={candidateResult.techniqueAudit} />
)}
</div>
)}
{candidateResult && <RectificationRefinementPanel result={candidateResult} />}
{savedTime && (
<div className="rectification-saved-wrap">
<p className="rectification-saved" role="status">
@@ -786,8 +739,16 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
</div>
</section>
<div className="composer-wrap">
<form className="composer" onSubmit={submit}>
<div className="composer-wrap">
{compactBoard && (
<RectificationBoardPeek
result={candidateResult}
expanded={boardOpen}
boardId={boardId}
onOpen={() => setBoardOpen(true)}
/>
)}
<form className="composer" onSubmit={submit}>
<Textarea
ref={composer}
aria-label={readonly ? "该校正已结束,只能查看历史" : "继续描述你的经历或回答"}
@@ -817,7 +778,18 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onSelect={onSelectModel}
/>
</div>
</div>
</div>
</>
<RectificationBoard
result={candidateResult}
savedStatus={savedStatus}
diff={boardDiff}
compact={compactBoard}
open={boardOpen}
boardId={boardId}
titleId={boardTitleId}
onClose={closeBoard}
/>
</div>
);
}
@@ -0,0 +1,282 @@
"use client";
import { useEffect, useRef } from "react";
import {
groupWindowTransitions,
rectificationBoardPeekCopy,
workingRectificationTime,
type RectificationBoardDiff,
} from "@/lib/rectification-board-model";
import {
workingRectificationHouseTable,
type RectificationCandidateResult,
} from "@/lib/rectification-candidate-result";
import {
windowScanLayerLabel,
type WindowScanLayer,
} from "@/lib/rectification-agentic/v9/refinement-packet";
import { RectificationHouseTableView } from "./rectification-house-table";
import { ConfirmationGateDisclosure, TechniqueAuditDisclosure } from "./technique-audit-disclosure";
function LayerChips({ layers }: Readonly<{ layers: readonly WindowScanLayer[] }>) {
if (layers.length === 0) return null;
return (
<ul className="rectification-board__chips">
{layers.map((layer) => (
<li key={layer}>
<span className="rectification-board__chip">{windowScanLayerLabel(layer)}</span>
</li>
))}
</ul>
);
}
function RectificationBoardBody({
result,
savedStatus,
diff,
compact,
titleId,
onClose,
}: Readonly<{
result: RectificationCandidateResult | null;
savedStatus: "accepted" | "confirmed" | null;
diff: RectificationBoardDiff;
compact: boolean;
titleId: string;
onClose: () => void;
}>) {
const table = result ? workingRectificationHouseTable(result) : null;
const workingTime = workingRectificationTime(result);
const grouped = result ? groupWindowTransitions(result.windowTransitions) : [];
const scoringMinutes = grouped.filter((row) => row.scoringLayers.length > 0);
const displayMinutes = grouped.filter((row) => row.displayLayers.length > 0);
const changedHouses = new Set(diff.houseNumbers);
const changedMinutes = new Set(diff.transitionMinutes);
const stage = result?.precisionStage?.user_meaning
?? result?.natalRecast?.user_meaning
?? null;
const ledger = result?.eventDashaLedger ?? [];
const contrast = result?.lagnaContrast ?? null;
const agreement = result?.dashaAgreement ?? null;
const nakshatra = result?.nakshatraBoundary ?? null;
return (
<>
<header className="rectification-board__header">
<div className="rectification-board__title-row">
<h2 id={titleId}></h2>
{workingTime ? <span className="rectification-board__clock">{workingTime}</span> : null}
{compact && (
<button
className="rectification-board__close"
type="button"
onClick={onClose}
>
</button>
)}
</div>
{stage ? <p className="rectification-board__stage">{stage}</p> : null}
</header>
<div className="rectification-board__body">
<p className="sr-only" role="status" aria-live="polite" aria-atomic="true">{diff.liveMessage}</p>
{!table && (
<p className="rectification-board__empty">
</p>
)}
{table && (
<div className="rectification-snapshot">
<RectificationHouseTableView
table={table}
changedHouses={changedHouses}
lagnaChanged={diff.lagnaChanged}
/>
{result && <ConfirmationGateDisclosure gate={result.confirmationGate} />}
{savedStatus === "accepted" && result && result.techniqueAudit.length > 0 && (
<TechniqueAuditDisclosure rows={result.techniqueAudit} />
)}
</div>
)}
{(scoringMinutes.length > 0 || displayMinutes.length > 0) && (
<section className="rectification-board__section" aria-label="换升时刻">
<h3></h3>
{scoringMinutes.length > 0 && (
<ol className="rectification-board__minutes">
{scoringMinutes.map((row) => {
const changed = changedMinutes.has(row.at);
const working = row.at === workingTime;
return (
<li
key={row.at}
className={[
"rectification-board__minute",
changed ? "is-changed" : "",
working ? "is-working" : "",
].filter(Boolean).join(" ")}
>
<time dateTime={row.at}>{row.at}</time>
{working ? <span className="rectification-board__minute-note"></span> : null}
{changed ? <span className="rectification-board__minute-note"></span> : null}
<LayerChips layers={row.scoringLayers} />
</li>
);
})}
</ol>
)}
{displayMinutes.length > 0 && (
<details className="technique-audit">
<summary>
<small>{displayMinutes.length} </small>
</summary>
<ol className="rectification-board__minutes">
{displayMinutes.map((row) => (
<li
key={`display-${row.at}`}
className={changedMinutes.has(row.at) ? "rectification-board__minute is-changed" : "rectification-board__minute"}
>
<time dateTime={row.at}>{row.at}</time>
{changedMinutes.has(row.at) ? <span className="rectification-board__minute-note"></span> : null}
<LayerChips layers={row.displayLayers} />
</li>
))}
</ol>
</details>
)}
</section>
)}
{contrast && (
<details className="technique-audit">
<summary></summary>
<div className="rectification-board__disclosure">
<p>{contrast.user_meaning}</p>
<ul>
{contrast.intervals.map((interval) => (
<li key={`${interval.start}-${interval.end}`}>
{interval.start}-{interval.end}{interval.lagna}
{interval.lords.l1 ? `;一/四/七/十宫主 ${[interval.lords.l1, interval.lords.l4, interval.lords.l7, interval.lords.l10].filter(Boolean).join("、")}` : ""}
</li>
))}
</ul>
</div>
</details>
)}
{ledger.length > 0 && (
<details className="technique-audit">
<summary>
<small>{ledger.length} </small>
</summary>
<ul className="rectification-board__disclosure">
{ledger.map((item) => (
<li key={item.summary}>{item.user_meaning}</li>
))}
</ul>
{agreement ? <p className="rectification-board__disclosure">{agreement.user_meaning}</p> : null}
</details>
)}
{nakshatra?.near_boundary && nakshatra.user_meaning && (
<details className="technique-audit">
<summary></summary>
<p className="rectification-board__disclosure">{nakshatra.user_meaning}</p>
</details>
)}
</div>
</>
);
}
export function RectificationBoard({
result,
savedStatus,
diff,
compact,
open,
boardId,
titleId,
onClose,
}: Readonly<{
result: RectificationCandidateResult | null;
savedStatus: "accepted" | "confirmed" | null;
diff: RectificationBoardDiff;
compact: boolean;
open: boolean;
boardId: string;
titleId: string;
onClose: () => void;
}>) {
const dialogRef = useRef<HTMLDialogElement>(null);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
const body = (
<RectificationBoardBody
result={result}
savedStatus={savedStatus}
diff={diff}
compact={compact}
titleId={titleId}
onClose={onClose}
/>
);
useEffect(() => {
const node = dialogRef.current;
if (!compact || !node) return;
if (open) {
if (!node.open) node.showModal();
} else if (node.open) {
node.close();
}
}, [compact, open]);
useEffect(() => {
if (!compact) return;
const node = dialogRef.current;
if (!node) return;
const handleClose = () => onCloseRef.current();
node.addEventListener("close", handleClose);
return () => node.removeEventListener("close", handleClose);
}, [compact]);
if (compact) {
return (
<dialog ref={dialogRef} id={boardId} className="rectification-board" aria-labelledby={titleId}>
{body}
</dialog>
);
}
return (
<aside id={boardId} className="rectification-board" aria-labelledby={titleId}>
{body}
</aside>
);
}
export function RectificationBoardPeek({
result,
expanded,
boardId,
onOpen,
}: Readonly<{
result: RectificationCandidateResult | null;
expanded: boolean;
boardId: string;
onOpen: () => void;
}>) {
const peek = rectificationBoardPeekCopy(result);
return (
<button
className="rectification-board-peek"
type="button"
aria-expanded={expanded}
aria-controls={boardId}
onClick={onOpen}
>
<strong>{peek.title}</strong>
<span>{peek.detail}</span>
</button>
);
}
@@ -4,13 +4,21 @@ import type { RectificationHouseTable } from "@/lib/rectification-candidate-resu
export function RectificationHouseTableView({
table,
}: Readonly<{ table: RectificationHouseTable }>) {
changedHouses,
lagnaChanged,
}: Readonly<{
table: RectificationHouseTable;
changedHouses?: ReadonlySet<number>;
lagnaChanged?: boolean;
}>) {
const changed = changedHouses ?? new Set<number>();
return (
<section className="rectification-house-table" aria-label="当前本命宫位" aria-live="polite">
<section className="rectification-house-table" aria-label="当前本命宫位">
<div className="rectification-house-table__heading">
<strong></strong>
<span>
{table.time} {table.lagna}线
{table.time} {table.lagna}
{lagnaChanged ? "(已更新)" : ""}线
</span>
<span></span>
</div>
@@ -24,13 +32,19 @@ export function RectificationHouseTableView({
</tr>
</thead>
<tbody>
{table.houses.map((house) => (
<tr key={house.house}>
<th scope="row">{house.house}</th>
<td>{house.sign}</td>
<td>{house.occupants.length > 0 ? house.occupants.join("、") : "—"}</td>
</tr>
))}
{table.houses.map((house) => {
const houseChanged = changed.has(house.house);
return (
<tr key={house.house} data-changed={houseChanged ? "true" : undefined}>
<th scope="row">{house.house}</th>
<td>
{house.sign}
{houseChanged ? <span className="rectification-house-table__changed"></span> : null}
</td>
<td>{house.occupants.length > 0 ? house.occupants.join("、") : "—"}</td>
</tr>
);
})}
</tbody>
</table>
</div>
@@ -27,6 +27,36 @@ const LAYER_LABEL: Record<WindowScanLayer, string> = {
pranapada: "Pranapada Lagna",
};
export const WINDOW_SCAN_SCORING_LAYER_ORDER = [
"d1",
"d9",
"d10",
"d4",
"d5",
"d7",
"d12",
"d24",
"d2",
"d11",
"d30",
] as const satisfies readonly WindowScanLayer[];
export const WINDOW_SCAN_DISPLAY_LAYER_ORDER = [
"pada",
"hora",
"ghati",
"bhava",
"pranapada",
] as const satisfies readonly WindowScanLayer[];
export function windowScanLayerLabel(layer: WindowScanLayer): string {
return LAYER_LABEL[layer];
}
export function isWindowScanDisplayLayer(layer: WindowScanLayer): boolean {
return (WINDOW_SCAN_DISPLAY_LAYER_ORDER as readonly WindowScanLayer[]).includes(layer);
}
export type WindowScanLayer =
| "d1"
| "d9"
@@ -0,0 +1,177 @@
import {
isWindowScanDisplayLayer,
WINDOW_SCAN_DISPLAY_LAYER_ORDER,
WINDOW_SCAN_SCORING_LAYER_ORDER,
type WindowScanLayer,
type WindowScanTransition,
} from "./rectification-agentic/v9/refinement-packet";
import {
workingRectificationHouseTable,
type RectificationCandidateResult,
type RectificationHouseTable,
} from "./rectification-candidate-result";
export const RECTIFICATION_BOARD_SPLIT_MIN_PX = 832;
export type GroupedWindowMinute = Readonly<{
at: string;
scoringLayers: readonly WindowScanLayer[];
displayLayers: readonly WindowScanLayer[];
}>;
export type RectificationBoardDiff = Readonly<{
houseNumbers: readonly number[];
lagnaChanged: boolean;
timeChanged: boolean;
transitionMinutes: readonly string[];
liveMessage: string;
}>;
const SCORING_RANK = new Map<WindowScanLayer, number>(
WINDOW_SCAN_SCORING_LAYER_ORDER.map((layer, index) => [layer, index]),
);
const DISPLAY_RANK = new Map<WindowScanLayer, number>(
WINDOW_SCAN_DISPLAY_LAYER_ORDER.map((layer, index) => [layer, index]),
);
const EMPTY_DIFF: RectificationBoardDiff = {
houseNumbers: [],
lagnaChanged: false,
timeChanged: false,
transitionMinutes: [],
liveMessage: "",
};
function sortLayers(
layers: readonly WindowScanLayer[],
rank: ReadonlyMap<WindowScanLayer, number>,
): readonly WindowScanLayer[] {
return [...layers].sort((left, right) => (rank.get(left) ?? 99) - (rank.get(right) ?? 99));
}
export function groupWindowTransitions(
transitions: readonly WindowScanTransition[],
): readonly GroupedWindowMinute[] {
const byMinute = new Map<string, { scoring: Set<WindowScanLayer>; display: Set<WindowScanLayer> }>();
for (const item of transitions) {
const bucket = byMinute.get(item.at) ?? { scoring: new Set<WindowScanLayer>(), display: new Set<WindowScanLayer>() };
if (isWindowScanDisplayLayer(item.layer)) bucket.display.add(item.layer);
else bucket.scoring.add(item.layer);
byMinute.set(item.at, bucket);
}
return [...byMinute.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([at, bucket]) => ({
at,
scoringLayers: sortLayers([...bucket.scoring], SCORING_RANK),
displayLayers: sortLayers([...bucket.display], DISPLAY_RANK),
}));
}
export function changedHouseNumbers(
previous: RectificationHouseTable | null,
next: RectificationHouseTable | null,
): readonly number[] {
if (!previous || !next) return [];
const changed: number[] = [];
for (let index = 0; index < 12; index += 1) {
const before = previous.houses[index];
const after = next.houses[index];
if (!before || !after) continue;
if (before.sign !== after.sign || before.occupants.join("\0") !== after.occupants.join("\0")) {
changed.push(after.house);
}
}
return changed;
}
function groupedMinuteKey(row: GroupedWindowMinute): string {
return `${row.at}|${row.scoringLayers.join(",")}|${row.displayLayers.join(",")}`;
}
export function changedTransitionMinutes(
previous: readonly WindowScanTransition[],
next: readonly WindowScanTransition[],
): readonly string[] {
const before = new Map(groupWindowTransitions(previous).map((row) => [row.at, groupedMinuteKey(row)]));
const changed: string[] = [];
for (const row of groupWindowTransitions(next)) {
if (before.get(row.at) !== groupedMinuteKey(row)) changed.push(row.at);
}
return changed;
}
function joinHouses(houses: readonly number[]): string {
return houses.map((house) => `${house}`).join("、");
}
export function rectificationBoardLiveMessage(input: Readonly<{
previous: RectificationCandidateResult | null;
next: RectificationCandidateResult | null;
houseNumbers: readonly number[];
lagnaChanged: boolean;
timeChanged: boolean;
transitionMinutes: readonly string[];
}>): string {
const nextTable = input.next ? workingRectificationHouseTable(input.next) : null;
if (!nextTable) return "";
if (!input.previous) {
return `当前本命宫位已按 ${nextTable.time} 排出,上升${nextTable.lagna}`;
}
const parts: string[] = [];
if (input.timeChanged || input.lagnaChanged) {
parts.push(`本命宫位已按 ${nextTable.time} 重算(上升 ${nextTable.lagna})。`);
}
if (input.houseNumbers.length > 0 && !(input.timeChanged && input.houseNumbers.length === 12)) {
parts.push(`${joinHouses(input.houseNumbers)}已更新。`);
}
if (input.transitionMinutes.length > 0) {
parts.push("换升时刻已更新。");
}
return parts.join("");
}
export function diffRectificationBoard(
previous: RectificationCandidateResult | null,
next: RectificationCandidateResult | null,
): RectificationBoardDiff {
if (!next) return EMPTY_DIFF;
const previousTable = previous ? workingRectificationHouseTable(previous) : null;
const nextTable = workingRectificationHouseTable(next);
const houseNumbers = changedHouseNumbers(previousTable, nextTable);
const lagnaChanged = Boolean(previousTable && nextTable && previousTable.lagna !== nextTable.lagna);
const timeChanged = Boolean(previousTable && nextTable && previousTable.time !== nextTable.time);
const transitionMinutes = previous
? changedTransitionMinutes(previous.windowTransitions, next.windowTransitions)
: [];
return {
houseNumbers,
lagnaChanged,
timeChanged,
transitionMinutes,
liveMessage: rectificationBoardLiveMessage({
previous,
next,
houseNumbers,
lagnaChanged,
timeChanged,
transitionMinutes,
}),
};
}
export function rectificationBoardPeekCopy(result: RectificationCandidateResult | null): Readonly<{
title: string;
detail: string | null;
}> {
const table = result ? workingRectificationHouseTable(result) : null;
if (!table) {
return { title: "当前盘面", detail: "补充经历后会在这里更新" };
}
return { title: "当前盘面", detail: `${table.time} · 上升${table.lagna}` };
}
export function workingRectificationTime(result: RectificationCandidateResult | null): string | null {
if (!result) return null;
return result.selectedTime ?? result.representativeTime ?? workingRectificationHouseTable(result)?.time ?? null;
}
@@ -24,6 +24,14 @@ const agent = readFileSync(
"utf8",
);
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const board = readFileSync(
new URL("../src/components/rectification-board.tsx", import.meta.url),
"utf8",
);
const houseTable = readFileSync(
new URL("../src/components/rectification-house-table.tsx", import.meta.url),
"utf8",
);
const completedActivityReceipt = readFileSync(
new URL("../src/components/completed-activity-receipt.tsx", import.meta.url),
"utf8",
@@ -224,7 +232,7 @@ test("rectification uses one case-level entitlement and the session-pinned model
test("Agentic rectification scrolls the conversation container as streamed messages grow", () => {
assert.match(chat, /const conversation = useRef<HTMLElement>\(null\)/);
assert.match(chat, /<section ref=\{conversation\} className="conversation"/);
assert.match(chat, /<section ref=\{conversation\} className="conversation is-rectification"/);
assert.match(chat, /const container = conversation\.current/);
assert.match(chat, /top: container\.scrollHeight/);
assert.match(chat, /\}, \[busy, candidateResult, error, messages, savedTime\]\);/);
@@ -245,7 +253,7 @@ test("rectification keeps receipts for the varga sentence and hides Activity fro
assert.match(chat, /showActivity=\{displayedMessage\.state === "thinking"\}/);
assert.match(chat, /vargaSentenceFromMethods/);
assert.match(chat, /vargaSentence=\{vargaSentence\}/);
assert.match(chat, /RectificationHouseTableView/);
assert.match(board, /RectificationHouseTableView/);
assert.doesNotMatch(chat, /activeActivity/);
assert.doesNotMatch(chat, /正在读取校正记录/);
assert.doesNotMatch(chat, /<CompletedActivityReceipt/);
@@ -259,9 +267,8 @@ test("rectification keeps receipts for the varga sentence and hides Activity fro
chat.indexOf("{savedTime &&"),
);
const replyIndex = messageRender.indexOf("<ChatMessageRow");
const houseIndex = messageRender.indexOf("<RectificationHouseTableView");
assert.ok(replyIndex >= 0);
assert.ok(houseIndex > replyIndex);
assert.doesNotMatch(messageRender, /<RectificationHouseTableView/);
assert.match(completedActivityReceipt, /<details className=/);
assert.doesNotMatch(completedActivityReceipt, /<details[^>]*\sopen/);
assert.match(completedActivityReceipt, /本轮完成 · \$\{stepLabels\.length\} 个步骤 · \$\{receipt\.methods\.length\} 项计算依据/);
@@ -314,34 +321,33 @@ test("candidate state renders from the snapshot API and never from sentinels", (
assert.match(chat, /isRecommendedRectificationCandidate/);
assert.match(chat, /已采用/);
assert.match(chat, /正在采用…/);
assert.match(chat, /RectificationHouseTableView/);
assert.match(chat, /className="rectification-snapshot"/);
assert.match(chat, /RectificationBoard/);
assert.match(board, /className="rectification-snapshot"/);
assert.match(styles, /\.rectification-snapshot \{/);
assert.match(styles, /\.rectification-house-table \{[^}]*border:\s*1px solid var\(--color-border\)/);
assert.match(styles, /\.rectification-house-table \{[^}]*background:\s*var\(--color-canvas-soft\)/);
assert.match(styles, /\.message-list \{[\s\S]*--assistant-content-inset: calc\(32px \+ var\(--space-3\)\)/);
assert.match(styles, /\.rectification-message-wrap \.rectification-candidates \{[\s\S]*width: calc\(100% - var\(--assistant-content-inset\)\)/);
assert.match(styles, /\.rectification-message-wrap \.rectification-candidates \{[\s\S]*margin-inline-start: var\(--assistant-content-inset\)/);
assert.match(styles, /\.rectification-snapshot \{[\s\S]*width: calc\(100% - var\(--assistant-content-inset\)\)/);
assert.match(styles, /\.rectification-snapshot \{[\s\S]*margin-inline-start: var\(--assistant-content-inset\)/);
assert.match(styles, /\.rectification-workspace \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) minmax\(18rem, 22\.5rem\)/);
assert.match(styles, /\.rectification-workspace\.is-compact \{[\s\S]*grid-template-columns: minmax\(0, 1fr\)/);
assert.doesNotMatch(styles, /\.rectification-snapshot \{[^}]*--assistant-content-inset/);
assert.doesNotMatch(styles, /\.rectification-house-table \{[^}]*padding-inline-start:\s*var\(--assistant-content-inset\)/);
assert.doesNotMatch(styles, /\.rectification-house-table \{[^}]*--assistant-content-inset/);
assert.doesNotMatch(page, /rectification-workspace/);
assert.match(page, /chat-panel\$\{rectificationSurfaceOpen \? " is-rectification" : ""\}/);
});
test("the natal house table is a live snapshot card outside the agent bubble", () => {
const houseTable = readFileSync(
new URL("../src/components/rectification-house-table.tsx", import.meta.url),
"utf8",
);
test("the natal house table is a live board beside the chat, not a message", () => {
const messageLoop = chat.slice(
chat.indexOf("{messages.map((message) => {"),
chat.indexOf("{candidateResult?.houseTable &&"),
);
const snapshot = chat.slice(
chat.indexOf("{candidateResult?.houseTable &&"),
chat.indexOf("{savedTime &&"),
);
const snapshot = board.slice(
board.indexOf("className=\"rectification-snapshot\""),
board.indexOf("{scoringMinutes.length > 0 || displayMinutes.length > 0"),
);
const rowIndex = messageLoop.indexOf("<ChatMessageRow");
const snapshotIndex = snapshot.indexOf("className=\"rectification-snapshot\"");
const houseIndex = snapshot.indexOf("<RectificationHouseTableView");
@@ -349,8 +355,15 @@ test("the natal house table is a live snapshot card outside the agent bubble", (
assert.ok(snapshotIndex >= 0 && houseIndex > snapshotIndex);
assert.doesNotMatch(snapshot, /<RectificationCandidateCards/);
assert.doesNotMatch(snapshot, /当前可能的出生时间/);
assert.doesNotMatch(messageLoop, /className="rectification-snapshot"/);
assert.doesNotMatch(messageLoop, /<RectificationHouseTableView/);
assert.match(chat, /className="conversation is-rectification"/);
assert.match(chat, /<RectificationBoard/);
assert.match(chat, /await loadCandidate\(\)\.then\(\(result\) => \{/);
assert.match(houseTable, /aria-live="polite"/);
assert.match(board, /aria-live="polite"/);
assert.match(board, /groupWindowTransitions/);
assert.match(board, /<dialog/);
assert.match(chat, /RectificationBoardPeek/);
assert.match(houseTable, /当前本命宫位/);
assert.match(houseTable, /补充经历后会按新线索重算/);
assert.doesNotMatch(houseTable, /<ChatMessageRow/);
@@ -359,7 +372,7 @@ test("the natal house table is a live snapshot card outside the agent bubble", (
test("time-selection cards appear under the latest settled agent bubble only after offer-candidates", () => {
const messageLoop = chat.slice(
chat.indexOf("{messages.map((message) => {"),
chat.indexOf("{candidateResult?.houseTable &&"),
chat.indexOf("{savedTime &&"),
);
const actionsIndex = messageLoop.indexOf("<ChatMessageActions");
const cardsIndex = messageLoop.indexOf("<RectificationCandidateCards");
@@ -437,8 +450,8 @@ test("the Agent prompt cannot offer candidates while asking for more evidence",
test("adopted time offers a consultation handoff without unique-minute copy", () => {
assert.match(chat, /用这个时间看盘/);
assert.match(chat, /onStartConsultation/);
assert.match(chat, /换升时刻/);
assert.match(chat, /经历与大运对照/);
assert.match(board, /换升时刻/);
assert.match(board, /经历与大运对照/);
assert.match(chat, /盘外对照/);
assert.match(page, /startConsultationAfterRectification/);
assert.match(page, /createSession\(modelCatalog\.defaultModelId\)/);
@@ -0,0 +1,132 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
changedHouseNumbers,
diffRectificationBoard,
groupWindowTransitions,
rectificationBoardPeekCopy,
} from "../src/lib/rectification-board-model.ts";
import { parseRectificationCandidateResult } from "../src/lib/rectification-candidate-result.ts";
import type { WindowScanTransition } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
const CANDIDATE_ID = "88888888-8888-4888-8888-888888888881";
const SECOND_CANDIDATE_ID = "88888888-8888-4888-8888-888888888882";
const THIRD_CANDIDATE_ID = "88888888-8888-4888-8888-888888888883";
function transition(layer: WindowScanTransition["layer"], at: string): WindowScanTransition {
return { layer, at, user_meaning: `${layer}${at} 发生变化` };
}
function houses(lagna: string, seventh = lagna) {
return Array.from({ length: 12 }, (_, index) => ({
house: index + 1,
sign: index === 6 ? seventh : lagna,
occupants: index === 0 ? ["太阳"] : [],
}));
}
function snapshot(input: Readonly<{
time: string;
lagna: string;
seventh?: string;
transitions?: readonly WindowScanTransition[];
}>) {
return parseRectificationCandidateResult({
resultId: "11111111-1111-4111-8111-111111111111",
candidates: [
{ candidateId: CANDIDATE_ID, rank: 1, time: input.time, relativeSupport: 34, tiedMinuteCount: 1 },
{ candidateId: SECOND_CANDIDATE_ID, rank: 2, time: "05:08", relativeSupport: 33, tiedMinuteCount: 1 },
{ candidateId: THIRD_CANDIDATE_ID, rank: 3, time: "05:09", relativeSupport: 33, tiedMinuteCount: 1 },
],
overallConfidence: "low",
selectionAllowed: true,
confirmationAllowed: false,
representativeTime: input.time,
selectedTime: null,
selectionKind: null,
decisionReceipt: {
house_table: {
time: input.time,
lagna: input.lagna,
houses: houses(input.lagna, input.seventh ?? input.lagna),
},
window_scan: {
scanned: true,
d9_lagna_count: 2,
d10_lagna_count: 1,
transitions: input.transitions ?? [],
},
},
});
}
test("groups 换升时刻 by clock minute and splits scoring layers from display layers", () => {
const grouped = groupWindowTransitions([
transition("d11", "04:47"),
transition("hora", "04:47"),
transition("d24", "04:47"),
transition("d10", "04:48"),
transition("pranapada", "05:14"),
transition("d9", "04:57"),
]);
assert.deepEqual(grouped.map((row) => row.at), ["04:47", "04:48", "04:57", "05:14"]);
assert.deepEqual(grouped[0], {
at: "04:47",
scoringLayers: ["d24", "d11"],
displayLayers: ["hora"],
});
assert.deepEqual(grouped[1]?.scoringLayers, ["d10"]);
assert.deepEqual(grouped[3], {
at: "05:14",
scoringLayers: [],
displayLayers: ["pranapada"],
});
});
test("marks only houses whose sign or occupants actually changed", () => {
const before = snapshot({ time: "04:45", lagna: "金牛座" })?.houseTable ?? null;
const after = snapshot({ time: "04:48", lagna: "金牛座", seventh: "天秤座" })?.houseTable ?? null;
assert.deepEqual(changedHouseNumbers(before, after), [7]);
assert.deepEqual(changedHouseNumbers(null, after), []);
});
test("announces the first board fill and later house or 换升 changes without dumping the table", () => {
const first = snapshot({
time: "04:45",
lagna: "金牛座",
transitions: [transition("d9", "04:57")],
});
const second = snapshot({
time: "04:48",
lagna: "金牛座",
seventh: "天秤座",
transitions: [transition("d9", "04:57"), transition("d10", "04:48")],
});
assert.ok(first && second);
const opened = diffRectificationBoard(null, first);
assert.equal(opened.liveMessage, "当前本命宫位已按 04:45 排出,上升金牛座。");
assert.deepEqual(opened.houseNumbers, []);
assert.equal(opened.timeChanged, false);
const updated = diffRectificationBoard(first, second);
assert.equal(updated.timeChanged, true);
assert.deepEqual(updated.houseNumbers, [7]);
assert.deepEqual(updated.transitionMinutes, ["04:48"]);
assert.match(updated.liveMessage, /本命宫位已按 04:48 重算(上升 金牛座)。/);
assert.match(updated.liveMessage, /第7宫已更新/);
assert.match(updated.liveMessage, /换升时刻已更新/);
assert.doesNotMatch(updated.liveMessage, /太阳|第1宫|第2宫|下面是本轮/);
});
test("peek copy stays a control label, not a second dump of the house table", () => {
const empty = rectificationBoardPeekCopy(null);
assert.equal(empty.title, "当前盘面");
assert.equal(empty.detail, "补充经历后会在这里更新");
const filled = rectificationBoardPeekCopy(snapshot({ time: "04:48", lagna: "金牛座" }));
assert.equal(filled.title, "当前盘面");
assert.equal(filled.detail, "04:48 · 上升金牛座");
});