import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { plainParagraphs } from "../src/components/chat-message-paragraphs.ts"; const readProjectFile = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8"); const contentSource = readProjectFile("src/components/chat-message-content.tsx"); const markdownViewSource = readProjectFile("src/components/chat-markdown-view.tsx"); const messageRowSource = readProjectFile("src/components/chat-message-row.tsx"); const activitySource = readProjectFile("src/components/agent-activity-status.tsx"); const prefetchSource = readProjectFile("src/components/chat-chunk-prefetch.ts"); const markdownLoaderSource = readProjectFile("src/lib/chat-markdown-loader.ts"); test("the markdown pipeline is split out of the initial chat bundle", () => { // Given: the chat surface statically imports the message content component. assert.doesNotMatch(contentSource, /^import .*"react-markdown"/m); assert.doesNotMatch(contentSource, /^import .*"remark-gfm"/m); // Then: the renderer only arrives through a dynamic import. // 原值:chat-message-content.tsx 里直接 import("@/components/chat-markdown-view") // 新值:动态 import 挪到 lib/chat-markdown-loader.ts,组件和首页启动共用这一份 // 原因:issue #124 首页启动要在揭幕前等 Markdown(`?c=` 打开带消息的会话) assert.doesNotMatch(contentSource, /^import .*"@\/components\/chat-markdown-view"/m); assert.match(markdownLoaderSource, /import\("@\/components\/chat-markdown-view"\)/); assert.match(markdownLoaderSource, /markdownRenderer = module\.renderChatMarkdown/); assert.match(markdownViewSource, /^import ReactMarkdown, \{ type Components \} from "react-markdown";$/m); assert.match(markdownViewSource, /^import remarkGfm from "remark-gfm";$/m); assert.match(markdownViewSource, /remarkPlugins=\{\[remarkGfm\]\}/); assert.match(markdownViewSource, /skipHtml/); }); test("the pre-markdown fallback renders prose but never raw markdown", () => { // Given: a reply that carries markdown syntax. assert.equal(plainParagraphs("**重点**在这里"), null); assert.equal(plainParagraphs("## 阶段判断"), null); assert.equal(plainParagraphs("- 第一点"), null); assert.equal(plainParagraphs("1. 第一点"), null); assert.equal(plainParagraphs("| 宫位 | 行星 |"), null); assert.equal(plainParagraphs("`Vimshottari`"), null); assert.equal(plainParagraphs("参见 https://example.com"), null); assert.equal(plainParagraphs("> 引用"), null); assert.equal(plainParagraphs("[链接](/x)"), null); assert.equal(plainParagraphs("行尾软换行 \n下一行"), null); // Then: only plain prose takes the fallback path, split exactly like CommonMark. assert.deepEqual(plainParagraphs("第一段。\n\n第二段。"), ["第一段。", "第二段。"]); assert.deepEqual(plainParagraphs("一行\n下一行"), ["一行\n下一行"]); assert.deepEqual(plainParagraphs("只有一段。"), ["只有一段。"]); // And: one stable wrapper serves both paths, so no styling or caret is lost on upgrade. // 原值: contentSource.match(/className="message-markdown"/g)?.length === 1 // 新值: 2 // 原因: 回答改为两层可见性(口语层 + 折叠的完整分析),两层各有一个包裹。 // 这条仍然数包裹个数,不得放宽成 >= 1——它防的是"升级到 markdown 时换了壳"。 assert.equal(contentSource.match(/className="message-markdown"/g)?.length, 2); assert.match(contentSource, /className="message-answer"/); assert.match(contentSource, /TechniqueAuditDisclosure/); assert.match(contentSource, /

\{paragraph\}<\/p>/); // 原值: assert.match(contentSource, /plainParagraphs\(spoken\) \?\? \[\]/) // 新值: 断言 renderProse 内部的回退路径 // 原因: 口语层与报告层共用 renderProse,plainParagraphs 只在它内部调用一次。 assert.match(contentSource, /\(plainParagraphs\(text\) \?\? \[\]\)\.map/); // 原值: 口语层与报告层都直接 `renderProse(spoken/report, renderMarkdown)`,计数为 2。 // 新值: 结算两层都走 `StableMarkdownPrefix`;流式尾块仍走 `renderProse(split.tail)`。 // 原因: BUG-725 结算态按文本记忆化。计数仍锁死为 2,不得放宽成 >= 1。 assert.match(contentSource, //); assert.match(contentSource, //); assert.equal( contentSource.match(//g)?.length, 2, ); assert.match(contentSource, /renderProse\(split\.tail, renderMarkdown\)/); assert.doesNotMatch(contentSource, /\{renderMarkdown\((?:spoken|report)\)\}/); assert.doesNotMatch(contentSource, />\{(?:spoken|report)\}<\/div>/); }); test("message motion loads on demand while keeping the reduced-motion gate", () => { // 原值: 静态图里没有 gsap,动态 import("gsap"),gsap.matchMedia(),if (!gsapCore) // 新值: 静态图里没有 motion/react,动态 import("motion"),matchMedia(no-preference),if (!messageMotion) // 原因: 入场从 GSAP 换成 Motion,仍按需加载,模块没到就不要补播。时长、曲线、清属性不变。 assert.doesNotMatch(messageRowSource, /gsap|@gsap\/react/); assert.doesNotMatch(messageRowSource, /^import .*from ["']motion/m); assert.match(messageRowSource, /import\("motion"\)/); assert.match(messageRowSource, /prefers-reduced-motion: reduce/); assert.match(messageRowSource, /prefers-reduced-motion: no-preference/); assert.match(messageRowSource, /matchMedia\("\(prefers-reduced-motion: no-preference\)"\)/); assert.match(messageRowSource, /duration: 0\.16/); assert.match(messageRowSource, /clearProps: "opacity,transform,visibility"/); assert.match(messageRowSource, /if \(!messageMotion\) \{/); assert.match(messageRowSource, /useEntryEffect\(\(\) => \{/); }); test("the live marker is the shared inline spinner, with no on-demand orb chunk", () => { // Former test: "the thinking orb loads on demand behind a same-size placeholder". The canvas // orb was a second live-marker vocabulary beside the timeline's InlineSpinner, undocumented in // DESIGN.md §9, and clipped inside the rectification surface's 14px marker (BUG-476). Removed. assert.doesNotMatch(activitySource, /thinking-orbs|ThinkingOrb|next\/dynamic/); assert.match(activitySource, //); // And: the live region and the decorative marking survive. assert.match(activitySource, /role="status"/); assert.match(activitySource, /className="agent-thinking-marker is-live-marker" aria-hidden="true"/); }); test("deferred chat chunks are requested during idle time after first paint", () => { assert.match(prefetchSource, /if \(typeof window === "undefined"\) return;/); assert.match(prefetchSource, /window\.requestIdleCallback\(request, \{ timeout: 2_000 \}\)/); assert.match(prefetchSource, /window\.setTimeout\(request, 300\)/); // 原值:prefetchOnIdle(loadMarkdownRenderer);新值与原因同上(issue #124) assert.match(contentSource, /prefetchOnIdle\(loadChatMarkdown\)/); // 原值: prefetchOnIdle(loadGsap) / 新值: prefetchOnIdle(loadMessageMotion) // 原因: 同上,空闲预取的是 Motion 而不是 GSAP。 assert.match(messageRowSource, /if \(motionPreferred\(\)\) prefetchOnIdle\(loadMessageMotion\)/); });