Files
Jyotisha/frontend/tests/rectification-range-bar-20260916.test.ts
T
jesseandClaude Opus 5.5 8e932959b5 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>
2026-10-28 18:23:04 +08:00

265 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
datedEventLabel,
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";
import { rectificationChatSurface } from "./rectification-chat-surface.ts";
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");
// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。
const chat = rectificationChatSurface;
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\);/);
// 原值:盘面标题行渲染 rectification-board__clock,与时间轴同一个值。
// 新值:盘面不再写时刻,代表分钟只在时间轴上出现一次。
// 原因:issue #146(看盘面按钮在所有宽度都有,盘面默认关着,代表分钟只在时间轴上写一次)。
assert.doesNotMatch(board, /className="rectification-board__clock"/);
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);
assert.equal(view?.datedEventLabel, 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);
assert.equal(datedEventLabel(3), "已对照 3 件");
assert.equal(datedEventLabel(null), null);
assert.equal(datedEventLabel(0), 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}`);
});