Files
Jyotisha/frontend/tests/rectification-timeline-20260909.test.ts
T
Jesse_ChenandClaude Fable 5 ce91a0d23c feat(rectification): show the credible range as a persistent timeline
The range is the only quantity that expresses convergence, and it existed
only as a sentence in the transcript. The bar makes it permanent.

It is the chat grid's first row, outside the scroll container, so
scrollHeight is untouched and use-conversation-scroll-anchor needs no
change. Its height is fixed because nothing observes it: a growing bar
would alter clientHeight and let an anchored reader lose the tail.

The axis is the current search window, not the opening one, since
widening overruns the opening window. Marks are binary — in range or
excluded — because relative support between candidate minutes carries no
demonstrated minute-level discrimination (BUG-560, blocked).

Band and marks move by transform alone; globals.css bans width
transitions, and transform keeps the motion off the layout path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016P5RoqzmUQEbeC2qjAkeGr
2026-09-09 04:23:05 +00:00

279 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.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
buildRectificationTimeline,
formatClockMinutes,
parseClockMinutes,
timelineDurationLabel,
timelineTickStepMinutes,
} from "../src/lib/rectification-timeline-scale.ts";
const read = (relativePath: string) => readFileSync(new URL(relativePath, import.meta.url), "utf8");
const component = read("../src/components/rectification-timeline.tsx");
const scale = read("../src/lib/rectification-timeline-scale.ts");
const chat = read("../src/components/rectification-agentic-chat.tsx");
const anchor = read("../src/hooks/use-conversation-scroll-anchor.ts");
const styles = read("../src/app/globals.css");
/** ±15 around a declared 05:00, the most common opening window. */
const declaredWindow = ["04:45", "05:15"] as const;
test("clock parsing rejects anything that is not a real reading", () => {
assert.equal(parseClockMinutes("04:45"), 285);
assert.equal(parseClockMinutes("4:45"), 285);
assert.equal(parseClockMinutes("05:07:00"), 307);
assert.equal(parseClockMinutes("24:00"), null);
assert.equal(parseClockMinutes("05:60"), null);
assert.equal(parseClockMinutes(""), null);
assert.equal(parseClockMinutes(undefined), null);
assert.equal(formatClockMinutes(307), "05:07");
assert.equal(formatClockMinutes(1440), "00:00");
});
test("tick step coarsens as the window grows, and labels stay on round clock values", () => {
assert.equal(timelineTickStepMinutes(30), 5); // ±15
assert.equal(timelineTickStepMinutes(60), 10); // ±30
assert.equal(timelineTickStepMinutes(120), 20); // ±60
assert.equal(timelineTickStepMinutes(240), 60); // ±120
assert.equal(timelineTickStepMinutes(1439), 240); // whole day
const half = buildRectificationTimeline({
searchWindow: declaredWindow,
credibleRange: ["04:47", "05:14"],
candidateTimes: [],
stage: "minute",
});
assert.ok(half);
assert.deepEqual(half.ticks.map((tick) => tick.label), [
"04:45", "04:50", "04:55", "05:00", "05:05", "05:10", "05:15",
]);
const fourHours = buildRectificationTimeline({
searchWindow: ["03:00", "07:00"],
credibleRange: null,
candidateTimes: [],
stage: "block_scan",
});
assert.ok(fourHours);
assert.deepEqual(fourHours.ticks.map((tick) => tick.label), [
"03:00", "04:00", "05:00", "06:00", "07:00",
]);
const wholeDay = buildRectificationTimeline({
searchWindow: ["00:00", "23:59"],
credibleRange: null,
candidateTimes: [],
stage: "block_scan",
});
assert.ok(wholeDay);
assert.deepEqual(wholeDay.ticks.map((tick) => tick.label), [
"00:00", "04:00", "08:00", "12:00", "16:00", "20:00",
]);
});
test("the band is placed against the axis and never escapes it", () => {
const view = buildRectificationTimeline({
searchWindow: declaredWindow,
credibleRange: ["05:07", "05:09"],
candidateTimes: [],
stage: "minute",
});
assert.ok(view);
// 05:07 is 22 minutes into a 30-minute axis.
assert.equal(Math.round(view.bandStartPercent), 73);
assert.equal(Math.round(view.bandWidthPercent), 7);
assert.ok(view.bandStartPercent + view.bandWidthPercent <= 100);
assert.equal(view.rangeLabel, "05:0705:09");
assert.equal(view.widthLabel, "2 分钟");
// A range reported wider than the window is clamped rather than overflowing.
const overflowing = buildRectificationTimeline({
searchWindow: declaredWindow,
credibleRange: ["04:00", "06:00"],
candidateTimes: [],
stage: "minute",
});
assert.ok(overflowing);
assert.equal(overflowing.bandStartPercent, 0);
assert.equal(overflowing.bandWidthPercent, 100);
});
test("candidate marks are binary and the range boundary counts as inside", () => {
const view = buildRectificationTimeline({
searchWindow: declaredWindow,
credibleRange: ["05:07", "05:09"],
candidateTimes: ["05:06", "05:07", "05:08", "05:09", "05:10"],
stage: "minute",
});
assert.ok(view);
assert.deepEqual(view.marks.map((mark) => mark.state), ["out", "in", "in", "in", "out"]);
// Closed interval: both ends of the range are in, not on the fence.
assert.equal(view.marks[1]?.state, "in");
assert.equal(view.marks[3]?.state, "in");
// No third state exists to grade confidence with.
assert.equal(view.marks.every((mark) => mark.state === "in" || mark.state === "out"), true);
});
test("marks outside the window are dropped and duplicates collapse", () => {
const view = buildRectificationTimeline({
searchWindow: declaredWindow,
credibleRange: ["05:00", "05:10"],
candidateTimes: ["03:00", "05:02", "05:02", "06:30", "05:08"],
stage: "minute",
});
assert.ok(view);
assert.deepEqual(view.marks.map((mark) => mark.key), ["m302", "m308"]);
});
test("block scan draws the window as the range and no minute marks", () => {
const view = buildRectificationTimeline({
searchWindow: ["00:00", "23:59"],
credibleRange: null,
candidateTimes: ["05:07", "06:20"],
stage: "block_scan",
});
assert.ok(view);
// Saying "23 小时 59 分" here is the truth: nothing has been ruled out yet.
assert.equal(view.bandStartPercent, 0);
assert.equal(view.bandWidthPercent, 100);
assert.equal(view.rangeLabel, "00:0023:59");
assert.equal(view.widthLabel, "23 小时 59 分");
assert.deepEqual(view.marks, []);
});
test("minute stage draws marks where block scan draws none, from the same candidates", () => {
const shared = { searchWindow: ["06:20", "07:40"] as const, credibleRange: ["06:50", "07:06"] as const, candidateTimes: ["06:55", "07:02"] };
const minute = buildRectificationTimeline({ ...shared, stage: "minute" });
const block = buildRectificationTimeline({ ...shared, stage: "block_scan" });
assert.ok(minute);
assert.ok(block);
assert.equal(minute.marks.length, 2);
assert.equal(block.marks.length, 0);
});
test("widening the window rescales the axis and keeps the band inside it", () => {
// BUG-572: a low event fit rate near the window edge widens ±15 to ±30.
const before = buildRectificationTimeline({
searchWindow: declaredWindow,
credibleRange: ["05:01", "05:09"],
candidateTimes: ["05:02", "05:08"],
stage: "minute",
});
const after = buildRectificationTimeline({
searchWindow: ["04:30", "05:30"],
credibleRange: ["04:56", "05:20"],
candidateTimes: ["05:02", "05:08"],
stage: "minute",
});
assert.ok(before);
assert.ok(after);
// The same candidate minute sits at a different percentage once the axis grows.
assert.notEqual(before.marks[0]?.percent, after.marks[0]?.percent);
assert.equal(Math.round(before.marks[0]!.percent), 57);
assert.equal(Math.round(after.marks[0]!.percent), 53);
// The widened range still fits: an axis pinned to the opening window would not hold it.
assert.ok(after.bandStartPercent >= 0);
assert.ok(after.bandStartPercent + after.bandWidthPercent <= 100);
assert.equal(after.widthLabel, "24 分钟");
});
test("a window that crosses midnight stays monotonic", () => {
// late_night is 23:0003:59, which arrives with an end earlier than its start.
const view = buildRectificationTimeline({
searchWindow: ["23:00", "03:59"],
credibleRange: ["00:30", "01:30"],
candidateTimes: ["23:30", "01:00", "03:00"],
stage: "minute",
});
assert.ok(view);
assert.equal(view.axisStartLabel, "23:00");
assert.equal(view.axisEndLabel, "03:59");
assert.equal(view.rangeLabel, "00:3001:30");
assert.equal(view.widthLabel, "1 小时");
assert.deepEqual(view.marks.map((mark) => mark.state), ["out", "in", "out"]);
assert.ok(view.marks.every((mark) => mark.percent >= 0 && mark.percent <= 100));
});
test("duration reads as clock language, not raw minutes", () => {
assert.equal(timelineDurationLabel(2), "2 分钟");
assert.equal(timelineDurationLabel(59), "59 分钟");
assert.equal(timelineDurationLabel(60), "1 小时");
assert.equal(timelineDurationLabel(80), "1 小时 20 分");
assert.equal(timelineDurationLabel(240), "4 小时");
});
test("no window means no bar content; the caller still renders the same box", () => {
assert.equal(buildRectificationTimeline({ searchWindow: null, credibleRange: ["05:07", "05:09"], candidateTimes: [], stage: "minute" }), null);
assert.equal(buildRectificationTimeline({ searchWindow: ["bad", "05:15"], credibleRange: null, candidateTimes: [], stage: "minute" }), null);
// Same element, same height, no spinner and no loading copy.
assert.match(component, /if \(!view\) \{\s*return <div className="rectification-timeline" data-state="pending"/);
assert.doesNotMatch(component, /InlineSpinner|正在加载|骨架/);
});
test("the bar is read-only and carries no confidence grading", () => {
// BUG-575 banned hover-revealed detail on this surface; BUG-560 blocks grading.
assert.doesNotMatch(component, /onMouseEnter|onMouseOver|onClick|onFocus/);
assert.doesNotMatch(component, /<button|relativeSupport|probability/);
assert.doesNotMatch(scale, /relativeSupport|probability/);
// Two elements only: the readout and the axis.
assert.match(component, /rectification-timeline__readout/);
assert.match(component, /rectification-timeline__axis/);
// The cut list stays cut.
assert.doesNotMatch(component, /已对照|已排除|仍在范围内|范围从|只读|时段阶段|分钟阶段/);
assert.doesNotMatch(component, /rectification-step-state/);
});
test("the band and marks move by transform, never by animating layout", () => {
// globals.css bans transitions on width outright (sidebar-contract guards it);
// transform also keeps the motion off the layout path.
assert.match(component, /transform: `translateX\(\$\{view\.bandStartPercent\}%\) scaleX\(/);
assert.match(component, /transform: `translateX\(\$\{mark\.percent\}%\)`/);
// The two moving parts never take an inline left/width; ticks may, because a
// rescale rebuilds the whole tick set rather than sliding it.
assert.doesNotMatch(component, /__band"[\s\S]{0,120}(?:left|width): `/);
assert.doesNotMatch(component, /__mark-at"[\s\S]{0,120}(?:left|width): `/);
const barStyles = styles.slice(
styles.indexOf(".rectification-timeline__band"),
styles.indexOf(".rectification-timeline__tick"),
);
assert.match(barStyles, /transition: transform 420ms/);
assert.doesNotMatch(barStyles, /transition:[^;}]*\b(?:width|left|height)\b/);
});
test("nothing on the bar advances on a timer", () => {
// BUG-601's principle: never animate motion the backend has not made.
assert.doesNotMatch(component, /setInterval|setTimeout|requestAnimationFrame/);
assert.doesNotMatch(scale, /setInterval|setTimeout|requestAnimationFrame|Date\.now/);
});
test("the bar is its own grid row, outside the scroll container", () => {
// Rendered as a sibling before <section className="conversation">, not inside it.
assert.match(
chat,
/<RectificationTimeline view=\{timelineView\} \/>\s*<section\s*ref=\{conversation\}\s*className="conversation is-rectification"/,
);
assert.doesNotMatch(component, /position:\s*sticky|sticky/);
// The scroll anchor knows nothing about the timeline and needs no changes.
assert.doesNotMatch(anchor, /timeline|rectification-timeline/i);
});
test("the chat grid has three rows and the timeline row is a fixed height", () => {
const chatRule = styles.slice(styles.indexOf(".rectification-workspace__chat {"));
assert.match(chatRule, /grid-template-rows: var\(--rectification-timeline-height\) minmax\(0, 1fr\) auto;/);
assert.match(styles, /--rectification-timeline-height: 64px;/);
const barRule = styles.slice(styles.indexOf(".rectification-timeline {"), styles.indexOf(".rectification-timeline__readout"));
assert.match(barRule, /height: var\(--rectification-timeline-height\);/);
// Not min-height and not content-driven: a growing bar would silently break
// stick-to-bottom, since it sits outside the observed scroll container.
assert.doesNotMatch(barRule, /min-height|height:\s*auto/);
});
test("the mobile bar stays within the 44px touch rhythm", () => {
const mobile = styles.slice(styles.indexOf("@media (max-width: 767px) {\n .rectification-workspace__chat {"));
assert.match(mobile, /--rectification-timeline-height: 56px;/);
// 56px is the existing jump-clearance constant, not a new number.
assert.match(styles, /--rectification-jump-clearance: calc\(44px \+ var\(--space-3\)\);/);
});