import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { createElement } from "react"; import { renderToString } from "react-dom/server"; import { ConsultationRunTimeline, timelineSummaryLabel } from "../src/components/consultation-run-timeline.tsx"; import { latestAssistantView, type ChatMessage } from "../src/lib/chat-message-view.ts"; const read = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8"); const transcriptSource = read("src/components/chat-transcript.tsx"); const timelineSource = read("src/components/consultation-run-timeline.tsx"); const messageRowSource = read("src/components/chat-message-row.tsx"); const globalStyles = read("src/app/globals.css"); test("the trailing assistant reply keeps one view identity from first token to settlement", () => { const history: ChatMessage[] = [{ role: "user", text: "问题" }]; const streaming = latestAssistantView(history, true, "完整答案"); const settled = latestAssistantView([...history, { role: "assistant", text: "完整答案" }], false, ""); assert.ok(streaming && settled); assert.equal(streaming.view.renderKey, settled.view.renderKey); assert.equal(streaming.view.state, "streaming"); assert.equal(settled.view.state, "settled"); assert.equal(streaming.views.length, 2); assert.equal(settled.views.length, 2); // A trailing user message means nothing is pending and nothing is latest. assert.equal(latestAssistantView(history, false, ""), undefined); // A settled reply still in the loading gap is rendered once, not duplicated. const lagging = latestAssistantView([...history, { role: "assistant", text: "完整答案" }], true, "完整答案"); assert.equal(lagging?.view.state, "settled"); }); test("one component renders the latest reply in both states; nothing remounts on settle", () => { assert.match(transcriptSource, /export const LatestAssistantEntry = memo\(/); assert.match(transcriptSource, / { assert.match(messageRowSource, /duration: 0\.16/); assert.doesNotMatch(globalStyles, /message-enter/); const messageRule = globalStyles.match(/\n\.message \{[^}]*\}/)?.[0] ?? ""; assert.ok(messageRule, "the .message rule exists"); assert.doesNotMatch(messageRule, /animation/); }); test("the timeline collapses in place with a 180ms height transition and honours the reader's toggle", () => { assert.doesNotMatch(timelineSource, /key=\{live/); assert.match(timelineSource, /const open = userOpen \?\? live/); assert.match(timelineSource, /aria-expanded=\{open\}/); assert.match(timelineSource, /inert=\{open \? undefined : true\}/); assert.match(timelineSource, /className="consultation-run-timeline__summary"/); assert.match(timelineSource, /agent-activity-status__text/); assert.match(globalStyles, /\.consultation-run-timeline__body-wrap \{[^}]*grid-template-rows: 0fr/); assert.match(globalStyles, /\.consultation-run-timeline__body-wrap \{[^}]*transition: grid-template-rows 180ms var\(--ease-out\)/); assert.match(globalStyles, /\.consultation-run-timeline\.is-open > \.consultation-run-timeline__body-wrap \{[^}]*grid-template-rows: 1fr/); assert.match(globalStyles, /\.consultation-run-timeline__summary-label \{[^}]*animation: agent-activity-status-in 120ms/); assert.match(globalStyles, /@media \(prefers-reduced-motion: reduce\) \{\s*\.consultation-run-timeline__summary-label,\s*\.consultation-run-timeline__body-wrap,/); assert.equal(timelineSummaryLabel([], true), "正在分析"); assert.equal(timelineSummaryLabel([{ id: "method", kind: "method", status: "done", label: "已加载方法" }], false), "已完成 1 步"); const live = renderToString(createElement(ConsultationRunTimeline, { rows: [], live: true })); assert.match(live, /aria-expanded="true"/); assert.match(live, /正在处理…/); assert.match(live, /inline-spinner/); const settled = renderToString(createElement(ConsultationRunTimeline, { rows: [{ id: "method", kind: "method", status: "done", label: "已加载方法" }], live: false, })); assert.match(settled, /aria-expanded="false"/); assert.match(settled, /inert=""/); assert.match(settled, /已完成 1 步/); assert.equal(renderToString(createElement(ConsultationRunTimeline, { rows: [], live: false })), ""); });