Spoken and report layers each keep a message-markdown shell and both must call renderProse; login hard-jumps drop from 4 to 3 after the onboarding suggestion effect went away. No product code.
112 lines
6.5 KiB
TypeScript
112 lines
6.5 KiB
TypeScript
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");
|
||
|
||
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.
|
||
assert.match(contentSource, /import\("@\/components\/chat-markdown-view"\)/);
|
||
assert.match(contentSource, /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, /<p key=\{index\}>\{paragraph\}<\/p>/);
|
||
// 原值: assert.match(contentSource, /plainParagraphs\(spoken\) \?\? \[\]/)
|
||
// 新值: 断言 renderProse 内部的回退路径
|
||
// 原因: 口语层与报告层共用 renderProse,plainParagraphs 只在它内部调用一次。
|
||
assert.match(contentSource, /\(plainParagraphs\(text\) \?\? \[\]\)\.map/);
|
||
|
||
// 两层必须都走 renderProse,不得一层 markdown 一层裸文本。
|
||
// 口语层若改成 {spoken} / renderMarkdown(spoken)、报告层仍走 renderProse(或反过来),
|
||
// 下面的精确计数会从 2 掉到 1。不得改成 >= 1。
|
||
assert.match(contentSource, /renderProse\(spoken,\s*renderMarkdown\)/);
|
||
assert.match(contentSource, /renderProse\(report,\s*renderMarkdown\)/);
|
||
assert.equal(
|
||
contentSource.match(/renderProse\((?:spoken|report),\s*renderMarkdown\)/g)?.length,
|
||
2,
|
||
);
|
||
assert.doesNotMatch(contentSource, /\{renderMarkdown\((?:spoken|report)\)\}/);
|
||
assert.doesNotMatch(contentSource, />\{(?:spoken|report)\}<\/div>/);
|
||
});
|
||
|
||
test("gsap loads on demand while keeping the reduced-motion gate", () => {
|
||
// Given: the animation engine is no longer part of the static import graph.
|
||
assert.doesNotMatch(messageRowSource, /^import .*from "gsap"/m);
|
||
assert.doesNotMatch(messageRowSource, /@gsap\/react/);
|
||
assert.match(messageRowSource, /import\("gsap"\)/);
|
||
|
||
// Then: reduced-motion users never request it, and the tween contract is unchanged.
|
||
assert.match(messageRowSource, /prefers-reduced-motion: reduce/);
|
||
assert.match(messageRowSource, /prefers-reduced-motion: no-preference/);
|
||
assert.match(messageRowSource, /gsap\.matchMedia\(\)/);
|
||
// Former value: `duration: 0\.18`. DESIGN.md §6 gives the message entrance 160ms and the CSS
|
||
// keyframe that used to run alongside was 160ms too; 0.18 was the GSAP copy drifting.
|
||
assert.match(messageRowSource, /duration: 0\.16/);
|
||
assert.match(messageRowSource, /clearProps: "opacity,transform,visibility"/);
|
||
|
||
// And: a row that mounts before the chunk lands renders unanimated instead of flashing.
|
||
assert.match(messageRowSource, /if \(!gsapCore\) \{/);
|
||
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, /<InlineSpinner size=\{12\} \/>/);
|
||
|
||
// 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\)/);
|
||
assert.match(contentSource, /prefetchOnIdle\(loadMarkdownRenderer\)/);
|
||
assert.match(messageRowSource, /if \(motionPreferred\(\)\) prefetchOnIdle\(loadGsap\)/);
|
||
});
|