Files
Jyotisha/frontend/tests/rectification-timeline-20260909.test.ts
T
Jesse_ChenandClaude Fable 5.1 662672c153 fix(web): 手机上时间轴读数不再被裁,「跳到最新」不再压住选项
375px 下读数第四项被 ellipsis 裁成「已…」、第五项整项不可见:compact
段仍加 44px 头像缩进(每侧 60px,可用内容只剩 255px),而 cfb41daf 又把
读数从四项变成五项,按 13px 逐字估宽需要 383px。compact 段改为只取
`space-4`、`column-gap` 收到 `space-2`、第五项 `已对照 N 件` 在 767px 以下
`display: none`(仍在 DOM 与轴 aria-label 里),四项约 292px / 可用 343px。
桌面段、条高 64/56px、滚动锚定逻辑均未动。

「跳到最新」浮层占 composer 上方 56px 带,而 `.message-list` 底部留白恰好
也是 56px,末条选项贴在浮层按钮正下方、正中间点不动。留白改为
`calc(clearance + space-3)`,静止时末条下方 84px = 56px 带 + 28px 空气;
`.conversation` 追加同高 `scroll-padding-block-end`。浮层位置、居中与可见
条件未动。

顺带记一条待产品裁决的口径冲突:DESIGN §10 禁列表写明「已对照 N 件经历」
不上条、仍归交付卡,`cfb41daf` 却把它加上且未改 DESIGN;本轮只在手机上把
它移出可见行,桌面保留原状。

BUG-918、BUG-919

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-17 00:53:21 +00:00

430 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.
// 原值 `0px` / 新值 `calc(32px + var(--space-3))`
// / 原因:产品在真机走查后要求把 agent 头像加回来(推翻 R3 的 D7);
// 这条缩进跟着头像走,时间轴仍与助手正文左边界对齐,规则本身没变。
assert.match(chatRule, /--assistant-content-inset: calc\(32px \+ var\(--space-3\)\);/);
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;/);
// 原值: padding-inline: calc(var(--space-4) + var(--assistant-content-inset));
// 新值: padding-inline: var(--space-4);
// 原因: BUG-918。真机 375px 下这 44px 头像沟槽让读数放不下,第四项被
// text-overflow 裁成「已…」。条不在消息列里,手机上对齐头像列没有
// 可见收益,页面边距才是诚实的边界。桌面那条规则不变(上一条测试)。
assert.match(mobile, /padding-inline: var\(--space-4\);/);
// 56px is the existing jump-clearance constant, not a new number.
assert.match(styles, /--rectification-jump-clearance: calc\(44px \+ var\(--space-3\)\);/);
});