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
255 lines
11 KiB
TypeScript
255 lines
11 KiB
TypeScript
/**
|
||
* D10 (2026-09-16): the credible range is promoted to a bar that is on screen at
|
||
* every width, and the board's five folds collapse to two.
|
||
*
|
||
* What these tests are really guarding is the honesty rule, not the layout.
|
||
* Every figure on that bar has to be one the server already computed — see
|
||
* frontend/docs/VOICE.md rule 2. The narrowing-progress readout the brief asked
|
||
* for is deliberately absent because no projection carries it; the assertions
|
||
* below keep it absent rather than letting someone add the arithmetic later.
|
||
*/
|
||
|
||
import assert from "node:assert/strict";
|
||
import test from "node:test";
|
||
import { readFileSync } from "node:fs";
|
||
import { createElement } from "react";
|
||
import { renderToStaticMarkup } from "react-dom/server";
|
||
|
||
import {
|
||
answeredProbeLabel,
|
||
buildRectificationTimeline,
|
||
workingMinuteLabel,
|
||
} from "../src/lib/rectification-timeline-scale.ts";
|
||
import { parseRectificationCandidateResult } from "../src/lib/rectification-candidate-result.ts";
|
||
import { workingRectificationTime } from "../src/lib/rectification-board-model.ts";
|
||
import { RectificationTimeline } from "../src/components/rectification-timeline.tsx";
|
||
|
||
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
|
||
const scale = read("../src/lib/rectification-timeline-scale.ts");
|
||
const bar = read("../src/components/rectification-timeline.tsx");
|
||
const board = read("../src/components/rectification-board.tsx");
|
||
const chat = read("../src/components/rectification-agentic-chat.tsx");
|
||
const styles = read("../src/app/globals.css");
|
||
|
||
const RESULT_ID = "77777777-7777-4777-8777-777777777770";
|
||
const CANDIDATE_ID = "77777777-7777-4777-8777-777777777771";
|
||
|
||
function candidateSnapshot(extra: Record<string, unknown> = {}) {
|
||
return parseRectificationCandidateResult({
|
||
resultId: RESULT_ID,
|
||
candidates: [
|
||
{ candidateId: CANDIDATE_ID, rank: 1, time: "05:08", relativeSupport: 16, tiedMinuteCount: 1 },
|
||
],
|
||
overallConfidence: "low",
|
||
selectionAllowed: true,
|
||
confirmationAllowed: false,
|
||
representativeTime: "05:08",
|
||
selectedTime: null,
|
||
credibleRange: ["05:07", "05:09"],
|
||
...extra,
|
||
});
|
||
}
|
||
|
||
test("the bar's working minute is the board clock's own value, not a second derivation", () => {
|
||
// The board title row renders `workingRectificationTime(result)`. The chat
|
||
// 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\}/);
|
||
assert.match(chat, /workingTime: workingRectificationTime\(candidateResult\)/);
|
||
|
||
const result = candidateSnapshot();
|
||
assert.ok(result);
|
||
const view = buildRectificationTimeline({
|
||
searchWindow: ["04:45", "05:15"],
|
||
credibleRange: result.credibleRange,
|
||
candidateTimes: ["05:08"],
|
||
stage: "minute",
|
||
workingTime: workingRectificationTime(result),
|
||
workingAdopted: Boolean(result.selectedTime),
|
||
});
|
||
assert.equal(view?.workingLabel, "代表分钟 05:08");
|
||
assert.equal(view?.rangeLabel, "05:07–05:09");
|
||
});
|
||
|
||
test("an adopted minute is labelled adopted, and adoption is still not confirmation", () => {
|
||
const adopted = candidateSnapshot({ selectedTime: "05:08" });
|
||
assert.ok(adopted);
|
||
assert.equal(workingRectificationTime(adopted), "05:08");
|
||
assert.equal(workingMinuteLabel("05:08", true), "已采用 05:08");
|
||
assert.equal(workingMinuteLabel("05:08", false), "代表分钟 05:08");
|
||
// The bar states which minute is in use. It must never assert the birth
|
||
// minute is settled — that boundary lives in the delivery/adopt narration,
|
||
// and AGENTS.md Part B4 bans a confirmed candidate outright.
|
||
assert.doesNotMatch(bar, /已确认|确认的唯一|精确到分钟|confirmed/);
|
||
assert.doesNotMatch(scale, /已确认|确认的唯一/);
|
||
});
|
||
|
||
test("an unparseable or absent minute yields no label rather than a guess", () => {
|
||
assert.equal(workingMinuteLabel(null, false), null);
|
||
assert.equal(workingMinuteLabel(undefined, false), null);
|
||
assert.equal(workingMinuteLabel("", false), null);
|
||
assert.equal(workingMinuteLabel("25:99", false), null);
|
||
const view = buildRectificationTimeline({
|
||
searchWindow: ["04:45", "05:15"],
|
||
credibleRange: ["05:07", "05:09"],
|
||
candidateTimes: [],
|
||
stage: "minute",
|
||
});
|
||
assert.equal(view?.workingLabel, null);
|
||
assert.equal(view?.answeredLabel, null);
|
||
});
|
||
|
||
test("the answered count is read off the projection, never counted here", () => {
|
||
// `inference_state.answered_probe_count` is the server's own figure.
|
||
const withCount = candidateSnapshot({
|
||
decisionReceipt: { inference_state: { answered_probe_count: 6 } },
|
||
});
|
||
assert.equal(withCount?.answeredProbeCount, 6);
|
||
assert.equal(answeredProbeLabel(6), "已答 6 题");
|
||
|
||
// No count on the wire means no readout. A zero invented here would be a
|
||
// number the projection never said.
|
||
const withoutCount = candidateSnapshot();
|
||
assert.equal(withoutCount?.answeredProbeCount, null);
|
||
assert.equal(answeredProbeLabel(null), null);
|
||
assert.equal(answeredProbeLabel(0), null);
|
||
assert.equal(answeredProbeLabel(1.5), null);
|
||
|
||
// The label echoes its argument. Nothing in its body counts, adds or walks a
|
||
// transcript to arrive at the number.
|
||
const body = scale.slice(
|
||
scale.indexOf("export function answeredProbeLabel"),
|
||
scale.indexOf("export function answeredProbeLabel") + 400,
|
||
);
|
||
assert.match(body, /return `已答 \$\{count\} 题`;/);
|
||
assert.doesNotMatch(body, /\.length|\+\+|answered_probes|\.filter\(|\.reduce\(/);
|
||
});
|
||
|
||
test("the bar shows no narrowing progress, because no projection carries it", () => {
|
||
// Concession 5 of TASK-cend-surfaces-claude-alignment-20260916: without an
|
||
// "已从 N 分钟收到 M 分钟" field, the bar shows the range and its width and
|
||
// stops. The opening window is on the wire, but comparing it here would both
|
||
// break VOICE.md rule 2 and misreport: the search window *widens* (BUG-572),
|
||
// so the subtraction would announce progress through a widening.
|
||
assert.doesNotMatch(bar, /已从|收到|最初|进度/);
|
||
assert.doesNotMatch(scale, /已从|收到|最初|进度|openingRange|openingWidth/);
|
||
const view = buildRectificationTimeline({
|
||
searchWindow: ["04:45", "05:15"],
|
||
credibleRange: ["05:07", "05:09"],
|
||
candidateTimes: ["05:08"],
|
||
stage: "minute",
|
||
workingTime: "05:08",
|
||
answeredProbeCount: 6,
|
||
});
|
||
assert.equal(view?.widthLabel, "3 分钟");
|
||
assert.deepEqual(
|
||
Object.keys(view ?? {}).filter((key) => /progress|opening|initial/i.test(key)),
|
||
[],
|
||
);
|
||
});
|
||
|
||
test("the bar renders all four readings on one line and stays a fixed height", () => {
|
||
const html = renderToStaticMarkup(createElement(RectificationTimeline, {
|
||
view: buildRectificationTimeline({
|
||
searchWindow: ["04:45", "05:15"],
|
||
credibleRange: ["05:07", "05:09"],
|
||
candidateTimes: ["05:08"],
|
||
stage: "minute",
|
||
workingTime: "05:08",
|
||
answeredProbeCount: 6,
|
||
}),
|
||
}));
|
||
assert.match(html, /05:07–05:09/);
|
||
assert.match(html, /3 分钟/);
|
||
assert.match(html, /代表分钟 05:08/);
|
||
assert.match(html, /已答 6 题/);
|
||
// All four are in the single readout paragraph, and the assistive label for
|
||
// the axis says the same things in the same order.
|
||
assert.match(html, /rectification-timeline__readout[\s\S]{0,400}已答 6 题<\/span><\/p>/);
|
||
assert.match(html, /aria-label="[^"]*当前区间 05:07–05:09[^"]*代表分钟 05:08[^"]*已答 6 题"/);
|
||
|
||
// Height is correctness here: the bar sits outside the scroll container, so a
|
||
// taller bar would move content with nothing observing it. The row grew wider
|
||
// and must not be allowed to wrap.
|
||
const readout = styles.slice(
|
||
styles.indexOf(".rectification-timeline__readout {"),
|
||
styles.indexOf(".rectification-timeline__range {"),
|
||
);
|
||
assert.match(readout, /flex-wrap: nowrap/);
|
||
assert.match(readout, /white-space: nowrap/);
|
||
assert.match(styles, /--rectification-timeline-height: 64px;/);
|
||
assert.match(styles, /--rectification-timeline-height: 56px;/);
|
||
});
|
||
|
||
test("nothing hides the bar on a compact viewport — that is the whole point", () => {
|
||
// On a narrow screen the board becomes an overlay that is closed by default,
|
||
// so this bar is the only place the range and the working minute appear. The
|
||
// chat column keeps its three grid rows in the compact cell.
|
||
assert.match(
|
||
styles,
|
||
/\.rectification-workspace\.is-compact \.rectification-workspace__chat,\s*\n\.rectification-workspace\.is-compact \.rectification-board-overlay \{\s*\n\s*grid-area: 1 \/ 1;/,
|
||
);
|
||
// No rule anywhere hides or collapses the bar under `.is-compact`.
|
||
for (const match of styles.matchAll(/\.is-compact[^{}]*\.rectification-timeline[^{}]*\{([^}]*)\}/g)) {
|
||
assert.doesNotMatch(match[1], /display:\s*none|visibility:\s*hidden|height:\s*0/);
|
||
}
|
||
// Nor does the timeline hide itself at any width.
|
||
const barRule = styles.slice(
|
||
styles.indexOf(".rectification-timeline {"),
|
||
styles.indexOf(".rectification-timeline__readout"),
|
||
);
|
||
assert.doesNotMatch(barRule, /display:\s*none|visibility:\s*hidden/);
|
||
});
|
||
|
||
test("the board folds to two sections and loses none of the five blocks", () => {
|
||
// Exactly two top-level disclosures.
|
||
assert.equal(board.match(/aria-label="候选分钟"/g)?.length, 1);
|
||
assert.equal(board.match(/aria-label="参数与口径"/g)?.length, 1);
|
||
|
||
const minutes = board.slice(
|
||
board.indexOf("{hasMinuteDetail && ("),
|
||
board.indexOf("{hasParameterDetail && ("),
|
||
);
|
||
const parameters = board.slice(board.indexOf("{hasParameterDetail && ("));
|
||
|
||
// 候选分钟 carries the transition minutes and the secondary candidates.
|
||
assert.match(minutes, /换升时刻/);
|
||
assert.match(minutes, /次级候选/);
|
||
assert.match(minutes, /scoringMinutes\.map/);
|
||
assert.match(minutes, /displayMinutes\.map/);
|
||
|
||
// 参数与口径 carries the rest: house table, the confirmation gate, the
|
||
// technique audit, the two-lagna split, the ledger with its dual-track
|
||
// agreement line, and the nakshatra note.
|
||
assert.match(parameters, /<RectificationHouseTableView/);
|
||
assert.match(parameters, /<ConfirmationGateDisclosure/);
|
||
assert.match(parameters, /<TechniqueAuditDisclosure/);
|
||
assert.match(parameters, /两段本命上升/);
|
||
assert.match(parameters, /经历与大运对照/);
|
||
assert.match(parameters, /交界节奏/);
|
||
assert.match(parameters, /agreement \? <p/);
|
||
|
||
// Every engine string still goes through the consumer-side display layer.
|
||
assert.equal(board.match(/engineMeaningToDisplayCopy\(/g)?.length, 5);
|
||
});
|
||
|
||
test("regrouping changed no minute-row behaviour", () => {
|
||
assert.match(board, /working \? <span className="rectification-board__minute-note">当前时间<\/span>/);
|
||
assert.match(board, /changed \? "is-changed" : ""/);
|
||
assert.match(board, /working \? "is-working" : ""/);
|
||
assert.match(
|
||
board,
|
||
/changedMinutes\.has\(row\.at\) \? "rectification-board__minute is-changed" : "rectification-board__minute"/,
|
||
);
|
||
});
|
||
|
||
test("both sections stay closed by default and every summary is still reachable", () => {
|
||
// Keyboard access must be no worse than before: nesting adds a level, so each
|
||
// disclosure has to remain a real <summary>, never a div with a click handler.
|
||
assert.doesNotMatch(board, /<details[^>]*\sopen[\s>]/);
|
||
assert.doesNotMatch(board, /role="button"|onClick=\{[^}]*toggle/);
|
||
const summaries = board.match(/<summary>/g)?.length ?? 0;
|
||
assert.ok(summaries >= 6, `expected every block to keep its summary, saw ${summaries}`);
|
||
});
|