Files
Jyotisha/frontend/tests/rectification-timeline-20260909.test.ts
T
Jesse_ChenandClaude Opus 5 b3ea8c336e
Independent Staging Quality Gate / validate (push) Failing after 9m19s
Independent Staging Quality Gate / publish (push) Skipped
feat(ui): 侧栏三段合成一条平铺列表,助手消息去掉头像
侧栏此前在第一条会话之前压着三段:星盘名字 chip 组、收藏对话 <details>、
历史对话 <details>——两个折叠箭头,底下都没有值得折叠的东西。现在是一条
平铺列表:收藏作为首个标签组(带 13px 星标),其余沿用既有的日期分组。

星盘入口收敛到一处。这里与任务书 D6 的字面写法方向相反:D6 说留侧栏删
账户菜单,我做的是删侧栏留账户菜单。理由——D6 写于侧栏只有 2 个导航项时,
现在是 4 个;且 chip 点下去只是开弹窗(page.tsx 里三个回调都是
openAccountDialog("chart-library")),一组只会开弹窗的名字没有导航价值。
连带删 charts/onSelectChart/onAddChart props、SidebarChart 类型、
sidebarCharts 派生与一批 CSS。

助手头像删除,--assistant-content-inset 由 calc(32px + gap) 归零,
追问 chip 与运行时间轴改为与正文对齐。/jyotish-logo.png 文件保留。

顺带关闭 R2 留下的隐患:入口 pill 36px 低于 44px 触摸推荐值,补
::after inset -4px 热区扩展,并在 touch-target-contract 里接替
已删除的 .chart-nav-chip。

测试 3350 不变,fail 仍 31 且与基线逐条一致;/ 仍 Static;
CSS gzip 41,044→39,825(−3.0%),三轮累计 −3.1%。

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

422 lines
18 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 { parseRectificationCandidateResult } from "../src/lib/rectification-candidate-result.ts";
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");
// Inclusive of both ends: 05:07, 05:08, 05:09. Exclusive difference was "2 分钟".
assert.equal(view.widthLabel, "3 分钟");
// 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 follow inference status, not band position", () => {
const view = buildRectificationTimeline({
searchWindow: declaredWindow,
credibleRange: ["05:07", "05:09"],
candidateTimes: ["05:07", "05:08", "05:09"],
inferenceMarks: [
{ time: "05:06", eliminated: true },
{ time: "05:07", eliminated: false },
{ time: "05:08", eliminated: true },
{ time: "05:09", eliminated: false },
{ time: "05:10", eliminated: true },
],
stage: "minute",
});
assert.ok(view);
// 05:08 sits inside the band but is eliminated → hollow. Position vs band is not the judge.
assert.deepEqual(view.marks.map((mark) => [mark.key, mark.state]), [
["m306", "out"],
["m307", "in"],
["m308", "out"],
["m309", "in"],
["m310", "out"],
]);
assert.equal(view.marks.every((mark) => mark.state === "in" || mark.state === "out"), true);
});
test("without inference marks every engine candidate is solid", () => {
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), ["in", "in", "in", "in", "in"]);
});
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);
// Inclusive 00:0023:59 is a full day. Exclusive difference was "23 小时 59 分".
assert.equal(view.bandStartPercent, 0);
assert.equal(view.bandWidthPercent, 100);
assert.equal(view.rangeLabel, "00:0023:59");
assert.equal(view.widthLabel, "24 小时");
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);
// 04:5605:20 inclusive is 25 minutes. Exclusive difference was "24 分钟".
assert.equal(after.widthLabel, "25 分钟");
});
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");
// Inclusive 00:3001:30 is 61 minutes. Exclusive difference was "1 小时".
assert.equal(view.widthLabel, "1 小时 1 分");
assert.deepEqual(view.marks.map((mark) => mark.state), ["in", "in", "in"]);
assert.ok(view.marks.every((mark) => mark.percent >= 0 && mark.percent <= 100));
});
test("widthLabel counts both clock ends, matching the delivery-report inclusive minute shape", () => {
const label = (
searchWindow: readonly [string, string],
credibleRange: readonly [string, string] | null,
) => buildRectificationTimeline({
searchWindow,
credibleRange,
candidateTimes: [],
stage: credibleRange ? "minute" : "block_scan",
})?.widthLabel;
assert.equal(label(["04:45", "05:15"], ["04:51", "04:59"]), "9 分钟");
assert.equal(label(["04:45", "05:15"], ["05:07", "05:09"]), "3 分钟");
assert.equal(label(["04:45", "05:15"], ["04:51", "04:53"]), "3 分钟");
assert.equal(label(["04:45", "05:15"], ["05:07", "05:07"]), "1 分钟");
assert.equal(label(["00:00", "23:59"], null), "24 小时");
});
test("eliminated inference minutes stay as hollow dots and keep stable keys", () => {
const searchWindow = ["04:45", "05:15"] as const;
const credibleRange = ["04:51", "04:53"] as const;
const times = [
"04:45", "04:47", "04:49", "04:51", "04:52", "04:53", "04:55", "04:57", "04:59",
] as const;
const marksFor = (active: ReadonlySet<string>) => times.map((time) => ({
time,
eliminated: !active.has(time),
}));
const first = buildRectificationTimeline({
searchWindow,
credibleRange,
candidateTimes: ["04:51", "04:53"],
inferenceMarks: marksFor(new Set(["04:51", "04:53"])),
stage: "minute",
});
assert.ok(first);
assert.equal(first.marks.length, 9);
assert.equal(first.marks.filter((mark) => mark.state === "out").length, 7);
assert.equal(first.marks.filter((mark) => mark.state === "in").length, 2);
const firstKeys = first.marks.map((mark) => mark.key);
const second = buildRectificationTimeline({
searchWindow,
credibleRange: ["04:53", "04:53"],
candidateTimes: ["04:53"],
inferenceMarks: marksFor(new Set(["04:53"])),
stage: "minute",
});
assert.ok(second);
assert.equal(second.marks.length, 9);
assert.equal(second.marks.filter((mark) => mark.state === "out").length, 8);
assert.equal(second.marks.filter((mark) => mark.state === "in").length, 1);
assert.deepEqual(second.marks.map((mark) => mark.key), firstKeys);
});
test("timeline chat wiring reads parsed inferenceMarks and never the raw receipt", () => {
const timelineBlock = chat.slice(
chat.indexOf("const timelineView = buildRectificationTimeline"),
chat.indexOf("const persistedOfferKey"),
);
assert.match(timelineBlock, /inferenceMarks:\s*candidateResult\?\.inferenceMarks/);
assert.doesNotMatch(timelineBlock, /decisionReceipt|inference_state/);
});
test("parsed inference_state marks reach the timeline even when engine candidates are only the active minutes", () => {
const activeId = "88888888-8888-4888-8888-888888888881";
const secondId = "88888888-8888-4888-8888-888888888882";
const times = [
"04:45", "04:47", "04:49", "04:51", "04:52", "04:53", "04:55", "04:57", "04:59",
] as const;
const result = parseRectificationCandidateResult({
resultId: "11111111-1111-4111-8111-111111111111",
candidates: [
{ candidateId: activeId, rank: 1, time: "04:51", relativeSupport: 40, tiedMinuteCount: 3 },
{ candidateId: secondId, rank: 2, time: "04:53", relativeSupport: 38, tiedMinuteCount: 3 },
],
overallConfidence: "low",
selectionAllowed: false,
canAdopt: false,
confirmationAllowed: false,
representativeTime: "04:53",
selectedTime: null,
selectionKind: null,
credibleRange: ["04:51", "04:53"],
decisionReceipt: {
inference_state: {
candidates: times.map((time) => ({
time,
status: time === "04:51" || time === "04:53" ? "active" : "eliminated",
})),
},
},
});
assert.ok(result);
assert.ok(result.inferenceMarks);
assert.equal(result.candidates.length, 2);
assert.equal(result.inferenceMarks.length, 9);
assert.equal(result.inferenceMarks.filter((mark) => mark.eliminated).length, 7);
const view = buildRectificationTimeline({
searchWindow: declaredWindow,
credibleRange: result.credibleRange,
candidateTimes: result.candidates.map((candidate) => candidate.time),
inferenceMarks: result.inferenceMarks,
stage: "minute",
});
assert.ok(view);
assert.equal(view.marks.length, 9);
assert.equal(view.marks.filter((mark) => mark.state === "out").length, 7);
assert.equal(view.marks.filter((mark) => mark.state === "in").length, 2);
assert.equal(view.widthLabel, "3 分钟");
});
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/);
// Inner padding matches the assistant column, not the chat-panel edge.
assert.match(chatRule, /--assistant-content-inset: 0px;/);
assert.match(barRule, /padding: 0 calc\(var\(--space-8\) \+ var\(--assistant-content-inset\)\);/);
});
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;/);
assert.match(mobile, /padding-inline: calc\(var\(--space-4\) \+ var\(--assistant-content-inset\)\);/);
// 56px is the existing jump-clearance constant, not a new number.
assert.match(styles, /--rectification-jump-clearance: calc\(44px \+ var\(--space-3\)\);/);
});