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
201 lines
9.7 KiB
TypeScript
201 lines
9.7 KiB
TypeScript
/**
|
||
* 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;/);
|
||
});
|