Files
Jyotisha/frontend/tests/rectification-mobile-timeline-readout-20260917.test.tsx
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

201 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
/**
* 2026-09-17, iPhone on staging: the rectification timeline readout came through
* as `04:48–05:07 20 分钟 代表分钟 04:53 已…` (BUG-918), and the floating
* 跳到最新 chip sat on top of the last tappable option of a choice card
* (BUG-919).
*
* What this file can and cannot check, stated plainly: there is no jsdom or
* happy-dom in this suite, so `renderToStaticMarkup` is the only render path and
* `scrollWidth` / `clientWidth` do not exist to compare. Every geometric claim
* below is therefore **arithmetic over the rendered text**, using deliberately
* generous per-glyph widths (see `readoutWidthPx`), plus CSS declaration
* assertions of the kind `chart-page-view.test.tsx` uses. A real-device pass at
* 375 and 390 CSS px is still required and lives in
* `docs/testing/rectification-mobile-timeline-readout-20260917.md`.
*/
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import test from "node:test";
import { RectificationTimeline } from "../src/components/rectification-timeline.tsx";
import { buildRectificationTimeline } from "../src/lib/rectification-timeline-scale.ts";
Object.assign(globalThis, { React });
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
/** Comments carry prose that would otherwise satisfy declaration assertions. */
const declarations = styles.replace(/\/\*[\s\S]*?\*\//g, " ");
/** The `@media (max-width: 767px)` block that owns the timeline, and only it. */
function compactTimelineBlock(source: string): string {
const start = source.indexOf("@media (max-width: 767px) {\n .rectification-workspace__chat {\n --rectification-timeline-height: 56px;");
assert.notEqual(start, -1, "compact timeline media block not found");
let depth = 0;
for (let at = start; at < source.length; at += 1) {
if (source[at] === "{") depth += 1;
else if (source[at] === "}") {
depth -= 1;
if (depth === 0) return source.slice(start, at + 1);
}
}
assert.fail("compact timeline media block is unbalanced");
}
function ruleBody(source: string, selector: string): string {
const at = source.indexOf(`${selector} {`);
assert.notEqual(at, -1, `missing rule: ${selector}`);
const end = source.indexOf("}", at);
assert.notEqual(end, -1, `unterminated rule: ${selector}`);
return source.slice(at + selector.length + 2, end);
}
/** The readout's five spans in document order, text only. */
function readoutItems(markup: string): string[] {
const readout = markup.match(/<p class="rectification-timeline__readout">([\s\S]*?)<\/p>/);
assert.ok(readout, "readout paragraph not rendered");
return [...readout[1].matchAll(/<span class="rectification-timeline__[a-z-]+">([^<]*)<\/span>/g)]
.map((match) => match[1]);
}
/**
* Upper bound on a readout item's rendered width at 13px, the size the bar is
* pinned to. Every figure here is rounded **up** from the system sans stacks the
* app ships (digits measure about 0.556em in -apple-system / Helvetica and the
* readout asks for tabular-nums, so 0.6em is slack, not a guess). An assertion
* that passes against these numbers passes against the real font.
*/
const GLYPH_EM: Readonly<Record<string, number>> = {
digit: 0.6,
colon: 0.33,
space: 0.28,
dash: 0.5,
cjk: 1,
other: 0.6,
};
function readoutWidthPx(text: string, fontSizePx = 13): number {
let em = 0;
for (const character of text) {
if (/\d/.test(character)) em += GLYPH_EM.digit;
else if (character === ":") em += GLYPH_EM.colon;
else if (character === " ") em += GLYPH_EM.space;
else if (character === "–" || character === "-") em += GLYPH_EM.dash;
else if (/[ -鿿豈-﫿]/.test(character)) em += GLYPH_EM.cjk;
else em += GLYPH_EM.other;
}
return em * fontSizePx;
}
/** The worst realistic readout: every optional item present, widest labels. */
const fullView = buildRectificationTimeline({
searchWindow: ["04:30", "05:30"],
credibleRange: ["04:48", "05:07"],
candidateTimes: ["04:48", "04:53", "05:07"],
stage: "minute",
workingTime: "04:53",
answeredProbeCount: 6,
datedEventCount: 3,
});
test("the timeline renders every readout figure the projection carries", () => {
assert.ok(fullView, "the fixture window has to produce a view");
const items = readoutItems(renderToStaticMarkup(<RectificationTimeline view={fullView} />));
assert.deepEqual(items, ["04:48–05:07", "20 分钟", "代表分钟 04:53", "已答 6 题", "已对照 3 件"]);
// BUG-918's fix is CSS, not omission: the compact viewport hides one item, it
// never drops it from the markup, so the accessible axis description below
// reads the same on every viewport.
const markup = renderToStaticMarkup(<RectificationTimeline view={fullView} />);
const axisLabel = markup.match(/aria-label="([^"]*)"/)?.[1] ?? "";
for (const item of items) {
assert.ok(axisLabel.includes(item), `axis label is missing ${item}`);
}
});
test("the four items a 375px phone shows fit the compact content box", () => {
assert.ok(fullView);
const items = readoutItems(renderToStaticMarkup(<RectificationTimeline view={fullView} />));
// `.rectification-timeline__dated` is display:none below 768px, so the visible
// row is the first four.
const visible = items.slice(0, 4);
const columnGapPx = 8; // --space-2, the compact column-gap
const required = visible.reduce((total, item) => total + readoutWidthPx(item), 0)
+ columnGapPx * (visible.length - 1);
// 375 CSS px (iPhone SE 2/3, 13 mini) minus the compact padding-inline,
// var(--space-4) on each side. The 44px avatar inset that used to be added
// here is what pushed the row over (BUG-918).
const available375 = 375 - 2 * 16;
assert.ok(
required <= available375,
`readout needs ${required.toFixed(0)}px, ${available375}px available at 375px`,
);
// 390 CSS px (iPhone 12–16 base) has to hold too.
assert.ok(required <= 390 - 2 * 16);
// And the shape of the old bug: with the inset and all five items it did not
// fit, which is why the fourth item arrived as 「已…」.
const oldRequired = items.reduce((total, item) => total + readoutWidthPx(item), 0) + 12 * (items.length - 1);
assert.ok(oldRequired > 375 - 2 * (16 + 44));
});
test("the compact timeline drops the avatar inset and the fifth figure", () => {
const compact = compactTimelineBlock(declarations);
// Page margin only — no --assistant-content-inset on a phone.
assert.match(ruleBody(compact, ".rectification-timeline"), /padding-inline: var\(--space-4\);/);
assert.doesNotMatch(ruleBody(compact, ".rectification-timeline"), /--assistant-content-inset/);
assert.match(ruleBody(compact, ".rectification-timeline__readout"), /column-gap: var\(--space-2\);/);
assert.match(ruleBody(compact, ".rectification-timeline__dated"), /display: none;/);
// The range and the working minute are the two figures the product requires
// to stay visible at every width, so neither may be hidden or shrunk here.
assert.doesNotMatch(compact, /\.rectification-timeline__(range|width|working)\b/);
// Still one line, still a fixed-height bar: the compact block must not undo
// either, or the bar would resize outside anything observing it.
assert.doesNotMatch(compact, /flex-wrap: wrap|white-space: normal/);
assert.match(compact, /--rectification-timeline-height: 56px;/);
});
test("the desktop readout keeps all five figures and the assistant-column inset", () => {
const desktop = ruleBody(declarations, ".rectification-timeline");
assert.match(desktop, /padding: 0 calc\(var\(--space-8\) \+ var\(--assistant-content-inset\)\);/);
assert.match(declarations, /\.rectification-timeline__readout \{[^}]*white-space: nowrap;/);
assert.match(declarations, /\.rectification-timeline__readout \{[^}]*flex-wrap: nowrap;/);
assert.match(declarations, /\.rectification-timeline__readout \{[^}]*font-size: 13px;/);
});
test("the transcript reserves the jump-to-latest band plus air below it", () => {
// BUG-919: reserving exactly the chip band left the last option flush under
// the chip. The band is the chip's 44px target plus the space-3 it holds
// under itself; the list now reserves that plus one more space-3.
assert.match(
declarations,
/\.rectification-workspace__chat \.conversation \{[^}]*--rectification-jump-clearance: calc\(44px \+ var\(--space-3\)\);/,
);
assert.match(
declarations,
/\.rectification-workspace__chat \.message-list \{[^}]*padding-bottom: calc\(var\(--rectification-jump-clearance\) \+ var\(--space-3\)\);/,
);
// Anything scrolled into view lands above the band too.
assert.match(
declarations,
/\.rectification-workspace__chat \.conversation \{[^}]*scroll-padding-block-end: var\(--rectification-jump-clearance\);/,
);
});
test("the jump-to-latest overlay itself is untouched by the clearance change", () => {
// Its placement and visibility rule belong to BUG-478; this round only widened
// the transcript's reservation.
assert.match(declarations, /\.jump-to-latest \{[^}]*position: absolute;/);
assert.match(declarations, /\.jump-to-latest \{[^}]*bottom: 100%;/);
assert.match(declarations, /\.jump-to-latest \{[^}]*justify-content: center;/);
assert.match(declarations, /\.jump-to-latest \{[^}]*padding-bottom: var\(--space-3\);/);
assert.match(declarations, /\.jump-to-latest \{[^}]*pointer-events: none;/);
assert.match(declarations, /\.jump-to-latest__button \{[^}]*min-height: 44px;/);
// No second scroll-follow and no reflowing layout for the chip.
const anchor = readFileSync(new URL("../src/hooks/use-conversation-scroll-anchor.ts", import.meta.url), "utf8");
assert.doesNotMatch(anchor, /scroll-padding|rectification-jump-clearance/);
assert.match(anchor, /export const conversationAnchorThreshold = 96;/);
});