/** * 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(/

([\s\S]*?)<\/p>/); assert.ok(readout, "readout paragraph not rendered"); return [...readout[1].matchAll(/([^<]*)<\/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> = { 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()); 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(); 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()); // `.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;/); });