Files
Jyotisha/frontend/tests/chat-stream-layout.test.ts
T
Jesse_ChenandClaude Opus 5 66d9f59d84
Independent Staging Quality Gate / validate (push) Failing after 9m26s
Independent Staging Quality Gate / publish (push) Skipped
feat(ui): 标题不再落宋体,强调色分阶换 coral,删输入框常驻底栏(BUG-737/738)
BUG-737:--font-display 以两个从未加载的 Anthropic 授权字体开头
(Tiempos Headline / StyreneB:无 @font-face、public/ 无字体、layout.tsx
只 vendor 了 Inter),于是每个中文标题都掉到 Songti SC / SimSun——20 条
规则宽,含助手回答正文的 h2/h3。display 与 body 合并为同一条无衬线栈,
层级改由字重承担(33 条 display 规则 400→500)。

拉丁衬线方案实测否决:Newsreader 拉丁子集 132 KB(Inter 的 2.7 倍),
只为给一个品牌字上衬线,且会把「D10 事业盘怎么读」劈成两种字形。

BUG-738:亮色 #85432f 与暗色 #d78064 不同源;--color-ring 在
@theme inline 里硬编码不跟随 :root。产品拍板换 Claude coral #cc785c,
但实测它作文字色只有 3.14:1,而 62 个调用点里 53 个是 color:。
按角色拆两阶:--color-action #a9583e(文字 4.85:1)、
--color-action-strong #cc785c(填充/导轨/焦点环,3:1 非文字阈值)。
--report-accent 刻意保持 #85432f(报告是纸面,不跟应用强调色)。

T1.3/T1.4:模型选择器进 ChatComposer 的 toolbar 插槽,删掉常驻 44px 的
.composer-footer;顶栏 68→46px,「分析对象:」改为标题旁静默 chip。

防复发:新增 font-stack-loadable-contract,断言字体栈里每个 family 要么
vendored 要么是系统字体,且 --font-display 不得触达任何 CJK 衬线。

测试 3346→3350,fail 仍 31 且清单与基线逐条一致;/ 仍 Static;
CSS gzip 41,095→41,096(+0.002%);快速门 pytest 段 798 passed / 0 failed。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-16 04:53:14 +00:00

204 lines
12 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { chatMessageViews } from "../src/lib/chat-message-view.ts";
import { homeSurface as pageSource } from "./home-surface.ts";
const transcriptSource = readFileSync(new URL("../src/components/chat-transcript.tsx", import.meta.url), "utf8");
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const messageRowSource = readFileSync(new URL("../src/components/chat-message-row.tsx", import.meta.url), "utf8");
const activitySource = readFileSync(new URL("../src/components/agent-activity-status.tsx", import.meta.url), "utf8");
const timelineSource = readFileSync(new URL("../src/components/consultation-run-timeline.tsx", import.meta.url), "utf8");
const reducerSource = readFileSync(new URL("../src/lib/consultation-run-timeline.ts", import.meta.url), "utf8");
const previousMessages = [
{ role: "user", text: "问题" },
] as const;
test("keeps the assistant render identity stable when streaming settles", () => {
// Given: one assistant answer exists first as a transient stream.
const streaming = chatMessageViews(previousMessages, true, "完整答案");
// When: the same answer becomes part of the persisted transcript.
const settled = chatMessageViews([
...previousMessages,
{ role: "assistant", text: "完整答案" },
], false, "");
// Then: React receives the same key and can reuse the existing message shell.
assert.equal(streaming.at(-1)?.renderKey, settled.at(-1)?.renderKey);
assert.equal(streaming.at(-1)?.state, "streaming");
assert.equal(settled.at(-1)?.state, "settled");
});
test("does not duplicate a completed assistant answer while loading state settles", () => {
// Given: the final answer has entered the transcript while request cleanup lags.
const completedMessages = [
...previousMessages,
{ role: "assistant", text: "完整答案" },
] as const;
// When: the render view is derived with the old loading flag still true.
const views = chatMessageViews(completedMessages, true, "完整答案");
// Then: only the persisted answer is rendered.
assert.equal(views.length, completedMessages.length);
assert.equal(views.at(-1)?.state, "settled");
});
test("shows honest agent activity states before and during streamed text", () => {
const activity = { phase: "chart-calculation", label: "正在计算本命盘…" } as const;
const view = chatMessageViews(previousMessages, true, "", activity).at(-1);
assert.deepEqual(view?.activity, activity);
const thinkingView = chatMessageViews(previousMessages, true, "", activity, "先看事业宫。").at(-1);
assert.equal(thinkingView?.thinkingText, "先看事业宫。");
assert.equal(thinkingView?.state, "thinking");
const sectionView = chatMessageViews(previousMessages, true, "", activity, undefined, [{
id: "foundation",
title: "先整理本盘的统一参数",
heading: "统一参数与原始结构",
steps: [{ id: "raw", label: "列出岁差、上升与宫位结构", status: "pending" }],
}]).at(-1);
assert.equal(sectionView?.thinkingSections?.[0]?.heading, "统一参数与原始结构");
assert.match(messageRowSource, /"loading-method": "searching"/);
assert.match(messageRowSource, /"chart-calculation": "solving"/);
assert.match(messageRowSource, /"evidence-validation": "working"/);
assert.match(messageRowSource, /"answer-composition": "composing"/);
assert.match(messageRowSource, /message\.activity\?\.label/);
assert.doesNotMatch(messageRowSource, /正在核对星盘信息/);
assert.match(messageRowSource, /正在处理…/);
assert.match(messageRowSource, /showActivity = message\.state !== "settled"/);
assert.match(messageRowSource, /showThinkingPanel &&/);
assert.match(messageRowSource, /showSpokenAnswer/);
assert.match(messageRowSource, /<ChatMessageContent[\s\S]*text=\{message\.text\}[\s\S]*auditRows=\{message\.agentExecutionReceipt\?\.techniqueAuditTable\}/);
assert.match(messageRowSource, /stackedThinkingAndAnswer/);
assert.match(messageRowSource, /aria-label="回复"/);
assert.match(messageRowSource, /className="message-stage-and-answer"/);
// 旧:vargaSentence 在气泡正文,折叠思考时才藏
// 新:正文不再渲染方法句;展开的活动时间线保留
// 原因:BUG-606 决策 3(a)
assert.doesNotMatch(messageRowSource, /vargaSentence=\{showThinkingPanel \? null : vargaSentence\}/);
assert.match(messageRowSource, /<ConsultationRunTimeline[\s\S]*vargaSentence=\{vargaSentence\}/);
assert.match(globalStyles, /\.message-stage-and-answer/);
assert.match(globalStyles, /\.message-stage-and-answer \{[\s\S]*gap: var\(--space-2\)/);
assert.match(globalStyles, /\.agent-activity-status \+ \.message-answer/);
// Former lock: `<ThinkingOrb aria-hidden="true" state={state} size={20}` — the canvas orb was a
// second live-marker vocabulary next to the timeline's InlineSpinner (BUG-476). One marker now.
assert.match(activitySource, /<InlineSpinner size=\{12\} \/>/);
assert.match(activitySource, /className="agent-activity-status__text"/);
assert.match(activitySource, /className="agent-activity-status__elapsed" aria-hidden="true"/);
assert.match(activitySource, /className="agent-thinking-timeline"/);
assert.match(activitySource, /role="status"/);
assert.match(globalStyles, /@keyframes agent-activity-shimmer/);
assert.match(globalStyles, /agent-activity-status-in 160ms ease-out/);
assert.match(globalStyles, /\.agent-thinking-step/);
assert.match(globalStyles, /\.agent-thinking-timeline/);
assert.match(globalStyles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.agent-activity-status__text[\s\S]*animation: none/);
assert.match(pageSource, /nextActivityView/);
assert.match(pageSource, /chartCalculationProgressLabel|CONSULTATION_CHART_CALCULATION_LABEL/);
assert.match(pageSource, /activityCompletedTrail\(\[CONSULTATION_DONE_SKILL_LABEL\]\)/);
assert.doesNotMatch(activitySource, /CircleCheck|回答已完成/);
assert.doesNotMatch(activitySource, /state === "completed"|: "completed"/);
assert.doesNotMatch(messageRowSource, /: "completed"/);
assert.doesNotMatch(globalStyles, /\.thinking\b/);
assert.match(pageSource, /application\/x-ndjson/);
assert.match(pageSource, /createNdjsonParser/);
assert.match(pageSource, /event.type === "thinking.section"/);
assert.match(pageSource, /event.type === "thinking.delta"/);
assert.match(pageSource, /activeStreamingSections/);
assert.match(pageSource, /streamedThinking/);
assert.match(messageRowSource, /showLiveActivity/);
// Former locks on `ConsultationThinkingReport`, `hasTrace`, `activityTrace={message.activityTrace}`,
// `function TraceStep`, `.agent-thinking-step.is-think:has(...)`, and the report/tree sources:
// those pinned the second and third thinking-render paths (sections report, trace panel) that
// no caller could reach once every reply carries a timeline (BUG-476). One path remains.
assert.doesNotMatch(messageRowSource, /ConsultationThinkingReport|showReport|hasTrace|activityTrace/);
assert.match(messageRowSource, /thinkingText=\{message\.thinkingText\}/);
assert.match(messageRowSource, /showLiveActivity \|\| \(consultTimeline === undefined && Boolean\(message\.thinkingText/);
assert.doesNotMatch(activitySource, /activityTrace|thinking-orbs/);
assert.match(globalStyles, /\.markdown-list/);
assert.match(activitySource, />思考</);
assert.match(pageSource, /event\.type === "run\.failed"/);
assert.match(pageSource, /event\.code === "answer_truncated"/);
assert.match(pageSource, /throw new ConsultationResponseError/);
assert.match(pageSource, /if \(!runCompleted && !truncatedFailure\) \{\s*throw new ConsultationResponseError/);
assert.match(pageSource, /agentExecutionReceipt = event\.receipt/);
assert.match(pageSource, /const failedSession: ChatSession/);
assert.match(pageSource, /\.\.\.\(thinkingSections\.length \? \{ thinkingSections \} : \{\}\)/);
assert.match(pageSource, /reduceConsultationTimeline/);
assert.match(pageSource, /timeline: timelineState\.rows/);
assert.match(pageSource, /activeStreamingTimeline/);
assert.match(messageRowSource, /ConsultationRunTimeline/);
assert.match(messageRowSource, /message\.timeline/);
assert.match(timelineSource, /BookOpen/);
assert.match(timelineSource, /Layers/);
assert.match(timelineSource, /ListTodo/);
assert.match(timelineSource, /PenLine/);
assert.match(timelineSource, /LoaderCircle/);
assert.match(timelineSource, /data-kind=\{row\.kind\}/);
assert.doesNotMatch(timelineSource, /thinking-orbs|Globe|Cloud|favicon/i);
assert.match(globalStyles, /\.consultation-run-timeline/);
assert.match(reducerSource, /kind: "method"/);
assert.match(reducerSource, /kind: "calculate"/);
assert.match(globalStyles, /\.consultation-report-analysis/);
assert.match(globalStyles, /\.consultation-thinking-report/);
// Former locks on `.consultation-thinking-report .message-thinking`, `consultation-step-tree__*`
// and the tree source: the step-tree report path is deleted (BUG-476), and with it its CSS.
assert.doesNotMatch(globalStyles, /consultation-step-tree/);
});
test("nothing sits between the transcript and the composer to shift height while streaming", () => {
// The follow-up chip row used to appear and disappear here, changing composer height
// mid-stream; the jump-to-latest overlay is the only remaining sibling and it is
// absolutely positioned, so it cannot reflow the composer.
const composerWrap = pageSource.slice(
pageSource.indexOf('composer-wrap ${starterHomeVisible'),
pageSource.indexOf("<ChatComposer"));
assert.doesNotMatch(composerWrap, /composer-suggestions/);
// Former lock: the inline `pointer-events-none absolute inset-x-0 bottom-full` wrapper. The
// control is now the shared JumpToLatestButton, positioned by `.jump-to-latest` (BUG-478).
assert.match(composerWrap, /<JumpToLatestButton/);
assert.match(globalStyles, /\.jump-to-latest \{[^}]*position: absolute[^}]*bottom: 100%/);
});
test("docks the composer inside the chat panel instead of floating over content", () => {
assert.match(pageSource, /<div className=\{`composer-wrap \$\{starterHomeVisible \? "composer-wrap-starter" : ""\}`\}>/);
assert.match(globalStyles, /\.chat-panel[^}]*grid-template-rows:\s*46px minmax\(0,\s*1fr\) auto/);
assert.match(globalStyles, /\.conversation[^}]*padding-bottom:\s*var\(--composer-reserve\)/);
assert.match(globalStyles, /\.composer-wrap[^}]*position:\s*sticky/);
assert.match(globalStyles, /\.composer-wrap[^}]*bottom:\s*0/);
assert.doesNotMatch(globalStyles, /\.composer-wrap[^}]*position:\s*fixed/);
});
test("ordinary consultation replies reuse the shared Agent action bar", () => {
const actionsSource = readFileSync(new URL("../src/components/chat-message-actions.tsx", import.meta.url), "utf8");
const rectificationChat = readFileSync(
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
"utf8",
);
for (const label of ["赞", "踩", "复制回答", "重新生成回答"]) {
assert.match(actionsSource, new RegExp(`aria-label="${label}"`));
}
assert.match(transcriptSource, /<ChatMessageActions/);
assert.match(pageSource, /toggleChatMessageFeedback/);
assert.match(pageSource, /function regenerateLatestAnswer\(renderKey: string\)/);
assert.match(pageSource, /messages: session\.messages\.slice\(0, -1\)/);
assert.match(pageSource, /restoreOnFailure: session/);
assert.doesNotMatch(rectificationChat, /appendActivityTraceThinking/);
assert.doesNotMatch(rectificationChat, /event\.type === "thinking\.delta"/);
assert.match(rectificationChat, /startActivityTraceStep/);
assert.match(rectificationChat, /completeActivityTraceStep/);
// 原值: 校正面容器源码含 `<ChatMessageActions`。
// 新值: 动作条随行组件抽出,容器仍 import 行组件。
// 原因: BUG-725。
const rectificationEntry = readFileSync(
new URL("../src/components/rectification-message-entry.tsx", import.meta.url),
"utf8",
);
assert.match(rectificationEntry, /<ChatMessageActions/);
assert.match(rectificationChat, /<RectificationMessageEntry/);
assert.match(globalStyles, /\.message-actions \{/);
assert.doesNotMatch(globalStyles, /\.rectification-message-actions \{/);
});