Files
Jyotisha/frontend/tests/rectification-range-bar-20260916.test.ts
T
jesse-ux cfb41daf3d
Independent Staging Quality Gate / validate (push) Failing after 6m28s
Independent Staging Quality Gate / publish (push) Skipped
feat(rectification): 出卡加精度门槛,补经历改成系统点名
宽度超过 10 分钟或头名并列时不再出交付卡,改为按大运边界逐条问、
用类型芯片和年/月选择器录入。跳过的线换问法再问一次;答「这类事
都没有过」的不再问。用户说「没有了」仍立刻给目前范围。Skill 10.0.27。

BUG-740~743
2026-09-16 18:35:27 +08:00

260 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";
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:0705: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:0705: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:0705: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}`);
});