Files
Jyotisha/frontend/tests/rectification-board-model.test.ts
T
Jesse_ChenandClaude Opus 5 ca0204984b
Independent Staging Quality Gate / validate (push) Canceled after 3m14s
Independent Staging Quality Gate / publish (push) Canceled after 0s
feat(rectification): 区间常驻条补代表分钟与已答题数,盘面五段折叠收成两段
R7 / 决策 D10。任务书 docs/tasks/TASK-cend-surfaces-claude-alignment-20260916.md。

T7.1 常驻条。任务书 E9 说区间只在盘面标题行,实际读代码:对话区顶部
已有常驻条 RectificationTimeline 且已显示区间与宽度,窄屏 .is-compact 下
也可见。真正只存在于 .rectification-board__clock 的是代表分钟——窄屏盘面
是默认关闭的 overlay,代表分钟就此看不见。所以没有另造第二条(会撞固定条
高这条正确性约束),而是在既有条上补齐缺的两项:

  05:07–05:09 · 3 分钟 · 代表分钟 05:08 · 已答 6 题

代表分钟与盘面标题行是同一次 workingRectificationTime(result) 调用,一个
值两处用,不是两条推导路径。已答题数逐字读服务端的
inference_state.answered_probe_count,投影没给就整项不显示,不补「已答 0 题」。
条上不写确认,边界句仍只在交付/采用旁白里出现一次。读数只增宽不增高:条高
在滚动容器外,变高会让贴底读者的内容无声滑出视野。

收窄进度未做,触发让步顺序第 5 条。没有这个字段(服务端只把这句话拼进旁白
正文),且前端自己减会算错——candidate_range 是当前窗口且会放宽(BUG-572),
拿它当「最初」相减会把一次放宽报成一次收窄。缺口与解除条件写进 BLOCKED.md。

T7.2 盘面折叠。实际有 8 个 details(任务书记的五处漏了交界节奏、错标了两块),
按内容归成「候选分钟」(换升时刻 + 次级候选)与「参数与口径」(宫位表、确认门、
本轮技法、两段本命上升、经历与大运对照含双轨一致性、交界节奏)。每块保留原有
渲染条件,engineMeaningToDisplayCopy() 仍是 5 处,逐分钟行的 is-changed /
当前时间 一字未改。共享组件 technique-audit-disclosure.tsx 未动。

顺带修掉一个静默失效的断言:rectification-agentic-entry 用
board.slice(indexOf(A), indexOf(B)),B 改名后 indexOf 返回 -1,slice(i, -1)
会悄悄退化成几乎整份文件,底下四条 doesNotMatch 照样绿。已换成带越界断言的
sliceBetween()。两处改动的断言都在测试里写了原值/新值/原因。

tsc 0 错;lint 0 error / 118 warning(同基线);npm test 3350 → 3360,失败
仍是 31 条无 Docker 用例且与基线逐条一致;next build exit 0,/ 仍 ○ Static;
产物 CSS gzip 39,017 → 38,973 B(−0.11%)。

窄屏 .is-compact 下常驻条的实际可见性无 Chrome 无法验证,已按任务书写成环境
缺口,未标通过;CSS 上已确认无任何规则在该状态下隐藏该条,并写成测试断言。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-16 06:44:49 +00:00

177 lines
6.7 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import {
changedHouseNumbers,
diffRectificationBoard,
groupWindowTransitions,
houseTableToNorthIndianChart,
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";
import { RectificationBoard } from "../src/components/rectification-board.tsx";
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, "补充经历后会在这里更新");
assert.equal(rectificationBoardPeekCopy(null, "05:10").detail, "大概 05:10");
assert.equal(rectificationBoardPeekCopy(null, "05:10", "hospital_record").detail, "出生记录 05:10");
const filled = rectificationBoardPeekCopy(snapshot({ time: "04:48", lagna: "金牛座" }));
assert.equal(filled.title, "当前盘面");
assert.equal(filled.detail, "04:48 · 上升金牛座");
});
test("board shows a north-indian chart and folds 换升时刻 by default", () => {
const result = snapshot({
time: "04:45",
lagna: "金牛座",
transitions: [transition("d9", "04:57"), transition("hora", "04:47")],
});
assert.ok(result);
const chart = houseTableToNorthIndianChart(result.houseTable!);
assert.equal(chart.houses.length, 12);
assert.equal(chart.houses[0]?.houseNumber, 1);
assert.equal(chart.houses[0]?.sign, "金牛座");
const html = renderToStaticMarkup(createElement(RectificationBoard, {
result,
declaredTime: "04:45",
savedStatus: null,
diff: diffRectificationBoard(null, result),
compact: false,
open: true,
boardId: "board",
titleId: "board-title",
onClose: () => undefined,
}));
assert.match(html, /role="img"/);
assert.match(html, /当前本命盘,上升金牛座/);
// 原值: assert.match(html, /换升时刻 · 2 个/)
// 新值: 「候选分钟」段的 summary 带 `2 个时刻`,`换升时刻` 变成段内组标题。
// 原因: D10 五段收敛成两段,换升时刻与次级候选并入「候选分钟」。计数仍是
// grouped.length,仍然默认折叠——只是标签换了归属,内容一条不丢。
assert.match(html, /候选分钟/);
assert.match(html, /2 个时刻/);
assert.match(html, /换升时刻/);
assert.match(html, /<details/);
assert.doesNotMatch(html, /<details[^>]*\sopen/);
assert.match(html, /本命宫位表/);
assert.match(html, /参数与口径/);
assert.doesNotMatch(html, /<section[^>]*aria-label="换升时刻"/);
});