/**
* 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;/);
});