Files
Cyrene_v2/frontend/web/src/lib/renderContent.render.test.ts
T
AskaEth f01fd7db20 feat(web): u 加回白名单 + 空气泡丢弃 + 非 chat 模式不再显示字面标签
主人 2026-10-07 三点裁定:「下划线确实可以保留」「多余的空气泡确实可以丢掉」
「非聊天模式 按照应当有的专业卡片和格式来显示」。

- 白名单回到六项(u 加回,s 仍在外)
- 空 p 块也丢(与 br 碎片同一口径);纯注记回复 ⇒ 零气泡
- 非 chat 模式(8 个单卡正文)给它自己的渲染口径 CARD_RENDER:
  br ⇒ 真换行元素(那些容器没有 whitespace-pre-wrap,文本换行会被 CSS 折叠)、
  行内白名单正常出样式;WorkMessageBody 的按行切清单、CodeMessageBody 的 pre
  等既有较强约定一律保留
- 🔴 发现并修了一个会炸的下游损失:空气泡原本是承载头像/思维链/工具卡/心情/
  「生成中…」的那一行,丢气泡会把这些用户可见内容一起丢 ⇒ 加 hasBubbleChrome,
  补一行无正文外壳
- 测试 99 -> 189 checks;6 个变异(含「外壳行摘掉 ⇒ 工具卡丢失」)
- 改掉一条既有断言:三个 p 中首段被注记剥空 ⇒ 气泡数由 3 改为 2(并补按行数断言)
2026-10-07 18:44:45 +08:00

452 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 行内白名单标签的渲染测试(主人 2026-10-07:「聊天消息里插入了 html/md 标签,能否
// 增加网页前端的基本渲染支持」)。
//
// 为什么需要它:昔涟产出的是**少量 HTML 标签**而不是 Markdown —— 段落 `<p>`(契约
// §4.16)、动作 `<action>`(气泡外灰斜体注记)此前都已处理,**行内**标签(实测 12 条
// assistant 消息里有 6 个 `<b>`)没有处理 ⇒ 用户直接看到字面的 `&lt;b&gt;`。
// 这个测试锁住「行内标签渲染」这一层,以及**它不能带来的风险**:产物是不可信文本
// (模型生成、用户可诱导),任何把它当 HTML 注入的写法都必须在这里变红。
//
// 分九组:
// ① 白名单标签渲染成真元素(不是字面量);白名单当天先收窄到五项(`u`/`s` 0 命中),
// 当日复核后又把 **`u` 加回**(主人「下划线确实可以保留」)⇒ 现为**六项**:`b`/`strong`/
// `i`/`em`/`code`/`u`。**`s` 仍在白名单外** ⇒ 从此按白名单外口径逐字原样;
// ② 白名单外(`<script>` / `<img onerror>` / `<div>` / `<a>`)⇒ **纯文本字面量**,
// 树里不出现这些元素、不出现任何来自产物的属性;
// ③ 未闭合 / 落单闭标签 ⇒ 整段降级为纯文本,不崩、字符一个不丢;
// ④ 交错标签(`<b>a<i>b</b>c</i>`)⇒ 同上,不崩、字符一个不丢;
// ⑤ 带属性 ⇒ 只出元素本身,属性既不进 markup 也不进 props;
// ⑥ 既有 `<p>` / `<action>` 行为(走真实的 SplitChatBubble 链路)—— 含 **2026-10-07 晚
// 的口径变更:空 `<p>` 块不再产出空气泡**(旧的「三个 `<p>` ⇒ 三个气泡」断言随之改为
// 两个气泡);
// ⑦ `<br>` = **气泡/消息分隔符**(主人 2026-10-07 口径「对消息内的 `<br>` 始终处理为
// 多条消息隔断」):与 `<p>` 同级切气泡、空气泡丢弃、**绝不**是气泡内换行;
// ⑧ 🆕 **纯动作 / 纯独白回复 ⇒ 零个气泡**(不崩、不画空气泡),且工具卡 / 思维链 / 心情
// 这些**用户可见内容**不能跟着被丢;
// ⑨ 🆕 **非 chat 单卡正文**(工作 / 代码 / 文档 / 首页 / 视觉 / 语音 / 智能体 / 设置):
// `<br>` ⇒ **换行**(那些界面没有「多条气泡」的概念,所以不切分)、行内白名单出样式、
// 白名单外仍逐字字面;chat 模式的 `<br>` 分隔口径不受影响(⑨ 里有一条回归断言)。
//
// 跑法(react-dom/server 的 CJS 构建需要 require,所以必须打成 cjs):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/lib/renderContent.render.test.ts \
// --bundle --platform=node --format=cjs \
// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \
// --outfile=/tmp/cyrene-inline-markup.test.cjs \
// && node /tmp/cyrene-inline-markup.test.cjs
//
// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内导入、
// 不 import node:*。
import { createElement, Fragment, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { renderContent } from "./renderContent";
import { SplitChatBubble } from "../components/chat";
import { modeBody } from "../components/messages";
import type { ChatMessage } from "./types";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function has(html: string, needle: string): boolean {
return html.includes(needle);
}
function count(html: string, needle: string): number {
return html.split(needle).length - 1;
}
/** 渲染一段(`<p>` 切分后的)段落文本,取静态 HTML。 */
function markup(text: string): string {
return renderToStaticMarkup(createElement(Fragment, null, renderContent(text)));
}
/** 还原成「用户实际看见的字」:先剥掉**真元素**的标记(含 React 的相邻文本分隔注释),
* 再还原实体。顺序不能反 —— 先解码再剥标签会把降级后的字面量 `<b>` 也当成标签误删。 */
function decode(html: string): string {
return html
.replace(/<[^>]*>/g, "")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#x27;/g, "'")
.replace(/&amp;/g, "&");
}
/** 渲染后**用户实际看见的字**。 */
function seen(text: string): string {
return decode(markup(text));
}
interface ElInfo {
type: string;
propKeys: string[];
}
/** 走一遍 `renderContent` 产出的 React 元素树,列出「元素类型 + 非 children 的属性名」。
* 这是「有没有不该出现的节点 / 属性」最直接的一层断言 —— 看的是树,不是字符串。 */
function tree(text: string): ElInfo[] {
const out: ElInfo[] = [];
const walk = (node: ReactNode): void => {
if (node === null || node === undefined || typeof node !== "object") return;
if (Array.isArray(node)) {
(node as ReactNode[]).forEach(walk);
return;
}
const el = node as unknown as { type: unknown; props?: Record<string, unknown> };
const kind = el.type;
const name =
typeof kind === "string"
? kind
: ((kind as { displayName?: string; name?: string } | null)?.displayName ??
(kind as { name?: string } | null)?.name ??
"Fragment");
const props = el.props ?? {};
out.push({ type: name, propKeys: Object.keys(props).filter((k) => k !== "children") });
walk(props.children as ReactNode);
};
walk(renderContent(text));
return out;
}
function elementTypes(text: string): string[] {
return tree(text).map((e) => e.type);
}
/** 除最外层 span 与承载纯文本的 Fragment 之外的节点类型 —— 应当为空,即「没有造出
* 任何额外节点」(Fragment 是纯文本的载体,不产生 DOM)。 */
function foreignTypes(text: string): string[] {
return elementTypes(text).filter((t) => t !== "span" && t !== "Fragment");
}
function render(message: ChatMessage): string {
return renderToStaticMarkup(createElement(SplitChatBubble, { message }));
}
const REAL_SAMPLE =
"<p><action>赶紧凑过来看你那边,手指在空气里划了下</action></p>\n" +
"<p>花溪呀——人家按贵阳花溪区算的哦,不对的话你得纠正人家♪</p>\n" +
"<p>今天那儿是晴,13~25℃,日落 <b>18:35</b>,比上海晚了整整一小时呢。</p>";
// ── ① 白名单标签渲染成真元素 ────────────────────────────────────────────────────
{
const html = markup("日落 <b>18:35</b> 了");
ok(has(html, "<b>18:35</b>"), "① `<b>` 渲染成粗体元素");
ok(!has(html, "&lt;b&gt;"), "① 不再把 `<b>` 当字面量显示");
ok(seen("日落 <b>18:35</b> 了") === "日落 18:35 了", "① 标签本身不作为文字出现(只剩正文)");
// 白名单 = **六项**(主人 2026-10-07 晚先收窄到五项,当日复核后把 `u` 加回:
// 「下划线确实可以保留」)⇒ `<u>` 从此渲染成下划线元素,不再是字面量。
const tags: [string, string][] = [
["<b>粗</b>", "<b>粗</b>"],
["<strong>粗</strong>", "<strong>粗</strong>"],
["<i>斜</i>", "<i>斜</i>"],
["<em>强调</em>", "<em>强调</em>"],
["<u>下划</u>", "<u>下划</u>"],
];
for (const [src, want] of tags) ok(has(markup(src), want), `① ${want} 正常渲染`);
ok(has(markup("<code>go build</code>"), "<code"), "① `<code>` 渲染成等宽元素");
ok(seen("<code>go build</code>") === "go build", "① code 内文本逐字保留");
ok(seen("<u>下划</u>") === "下划", "① `u` 的标签本身不作为文字出现(只剩正文)");
ok(foreignTypes("<u>下划</u>").join(",") === "u", "① `u` 是一个真元素(不是字面量)");
// 大小写混写:按 HTML 口径不敏感(`<B>` 就是 `<b>`)。
ok(has(markup("<B>大写</B>"), "<b>大写</b>"), "① `<B>` 大小写不敏感");
// 嵌套白名单标签(正常闭合)照常渲染。
const nested = markup("<b>外 <i>内</i></b>");
ok(has(nested, "<b>外 <i>内</i></b>"), "① 正常嵌套逐层渲染");
// 🔴 只有 `s` 出列(`u` 已加回白名单):它按**白名单外**口径逐字原样,不产出元素 ——
// 别顺手把 `s` 也加回来。
ok(seen("<s>划掉</s>") === "<s>划掉</s>", "① 出列的 `<s>` 逐字原样显示(字符不丢)");
ok(!has(markup("<s>划掉</s>"), "<s>"), "① 不再为 `s` 产出元素");
}
// ── ② 白名单外一律纯文本字面量,且不产生任何节点/属性 ──────────────────────────
{
const cases: [string, string, string][] = [
["<script>alert(1)</script>", "<script", "script"],
["<img src=x onerror=alert(1)>", "<img", "img"],
["<div>块级不做</div>", "<div", "div"],
["<ul><li>列表不做</li></ul>", "<ul", "ul"],
["<a href=\"javascript:alert(1)\">链接</a>", "<a ", "a"],
["<iframe src=\"https://x/\"></iframe>", "<iframe", "iframe"],
["<style>body{display:none}</style>", "<style", "style"],
];
for (const [src, forbidden, name] of cases) {
ok(!has(markup(src), forbidden), `② 不产出 <${name}> 元素`);
ok(seen(src) === src, `② 白名单外的 \`${name}\` 逐字原样显示(不丢字符)`);
}
ok(
seen("<script>alert(1)</script>") === "<script>alert(1)</script>",
"② `<script>` 显示为字面量文本,不是可执行节点"
);
ok(
foreignTypes("<script>alert(1)</script>").length === 0,
"② 元素树里除最外层 span 外没有任何节点"
);
// 「没有可执行的 DOM 属性」:树里除 children 外**不存在**任何属性位。
for (const e of tree("<img src=x onerror=alert(1)>")) {
ok(e.propKeys.length === 0, `② 元素 ${e.type} 上没有任何属性(含 onerror)`);
}
}
// ── ③ 未闭合 / 落单闭标签 ⇒ 整段降级为纯文本 ────────────────────────────────────
{
const broken = [
"<b>x",
"日落 <b>18:35 了",
"</b>孤立闭标签",
"<b>x <i>y</i>", // 开 b 未闭合(i 已闭合)⇒ 整段降级
"<b><i>x</b>", // 交错(i 未完)⇒ 整段降级
];
for (const src of broken) {
const html = markup(src);
ok(seen(src) === src, `③ 结构不平衡时整段降级为纯文本:${src}`);
ok(!has(html, "<b>") && !has(html, "<i>"), `③ 降级后不产出元素:${src}`);
ok(foreignTypes(src).length === 0, `③ 降级后没有多余节点:${src}`);
}
ok(seen("1 < 2") === "1 < 2", "③ §4.16 护栏:`1 < 2` 不是标签");
ok(seen("a<b") === "a<b", "③ §4.16 护栏:`a<b`(无 `>`)不是标签");
ok(seen("<p2>x</p2>") === "<p2>x</p2>", "③ §4.16 护栏:`<p2>` 不是标签");
ok(seen("文本 <p 无尖括号") === "文本 <p 无尖括号", "③ §4.16 护栏:`<p`(无 `>`)不是标签");
}
// ── ④ 交错标签:不崩、字符一个不丢 ──────────────────────────────────────────────
{
const interleaved = "<b>a<i>b</b>c</i>";
ok(seen(interleaved) === interleaved, "④ `<b>a<i>b</b>c</i>` 逐字保留,不崩");
ok(foreignTypes(interleaved).length === 0, "④ 交错时不产出元素(不猜结构)");
// 同一段里既有合法标签又有畸形标签:**整段降级**(不猜结构),因此连合法的 `<b>` 对
// 也一并以字面量显示 —— 代价是「这一段没有行内渲染」,换来的是「一个字符都不丢」。
const mixed = "日落 <b>18:35</b>,还有 <i>未闭合";
ok(seen(mixed) === mixed, "④ 畸形参与时整段降级为纯文本,字符不丢");
ok(foreignTypes(mixed).length === 0, "④ 整段降级时不产出任何元素");
}
// ── ⑤ 带属性 ⇒ 只出元素,属性不生效 ────────────────────────────────────────────
{
const html = markup("<b class=x onclick=y>z</b>");
ok(has(html, "<b>z</b>"), "⑤ 只出粗体元素本身");
ok(!has(html, "onclick") && !has(html, "class="), "⑤ 属性不进入 markup");
const bEl = tree("<b class=x onclick=y>z</b>").find((e) => e.type === "b");
ok(Boolean(bEl), "⑤ 树里存在 b 元素");
ok(bEl !== undefined && bEl.propKeys.length === 0, "⑤ b 元素上没有任何属性(class/onclick 被丢弃)");
ok(seen("<b class=x onclick=y>z</b>") === "z", "⑤ 属性被丢弃,正文逐字保留");
const codeHtml = markup("<code data-x=1 style=color:red>c</code>");
ok(has(codeHtml, "<code") && !has(codeHtml, "data-x") && !has(codeHtml, "color:red"), "⑤ code 上只保留自己的样式类");
ok(seen("<b title=\"点我\">提示</b>") === "提示", "⑤ 引号含空格的属性同样不生效");
}
// ── ⑥ 既有 `<p>` / `<action>` 行为无回归(真实气泡链路) ────────────────────────
{
const html = render({ id: "a1", role: "assistant" as const, content: REAL_SAMPLE, createdAt: 1 } as ChatMessage);
ok(has(html, "*动作:赶紧凑过来看你那边,手指在空气里划了下*"), "⑥ action 仍是气泡外的灰斜体注记");
// 🔴 **断言变更**(主人 2026-10-07 晚「多余的空气泡确实可以丢掉」):首段是
// `<p><action>…</action></p>`,注记被剥走后只剩空壳 `<p></p>` ⇒ 与 `<br>` 碎片同一套
// 口径,**不再产出空气泡**。原断言为 `count(…) === 3`,理由是「首段只有动作注记 ⇒
// 空气泡,与改动前逐字一致」;现为 **2**(就是正文那两段)。
ok(count(html, "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5") === 2, "⑥ 三个 <p> 里首段被注记剥空 ⇒ 只剩两个气泡(空气泡丢弃)");
// 光数「气泡正文」还不够 —— 空段若仍在,会留下一行「只有头像、没有正文」的空行
// (`ChatBody` 对空正文返回 null ⇒ 空气泡的**壳**没了,但那一行仍在)。所以按**行数**再锁一次。
ok(count(html, "mu-rise-in flex gap-3") === 2, "⑥ 首段空壳连「气泡行」一起消失(不是只把壳画没了)");
ok(has(html, "花溪呀——人家按贵阳花溪区算的哦,不对的话你得纠正人家♪"), "⑥ 第二段正文照常");
ok(has(html, "今天那儿是晴,13~25℃,日落 <b>18:35</b>,比上海晚了整整一小时呢。"), "⑥ 第三段正文照常,且行内 <b> 渲染为粗体");
ok(!has(html, "&lt;p&gt;") && !has(html, "<p>"), "⑥ `<p>` 一个都不进 DOM(段落机制不变)");
ok(has(html, "italic leading-tight"), "⑥ 注记样式类不变(text-[10px] italic leading-tight)");
// 纯 `<p>` 三段 ⇒ 三个气泡(首段 full、其余 compact)。
const three = render({ id: "a2", role: "assistant", content: "<p>第一段</p><p>第二段</p><p>第三段</p>", createdAt: 2 } as ChatMessage);
ok(count(three, "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5") === 3, "⑥ 纯 <p> 三段 ⇒ 三个气泡");
ok(has(three, "第一段") && has(three, "第二段") && has(three, "第三段"), "⑥ 三段正文都在");
// 🆕 **空 `<p>` 块也丢**(主人 2026-10-07 晚「多余的空气泡确实可以丢掉」):与 `<br>`
// 碎片同一套口径 —— 上一轮只丢了 `<br>` 碎片,空的 `<p>` 仍留一个空气泡,本轮改掉。
const emptyP = render({ id: "a5", role: "assistant", content: "<p></p><p>只有这段</p>", createdAt: 5 } as ChatMessage);
ok(count(emptyP, "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5") === 1, "⑥ 空 `<p></p>` 块丢弃 ⇒ 只剩正文那一个气泡");
ok(count(emptyP, "mu-rise-in flex gap-3") === 1, "⑥ 空 `<p></p>` 块不留「只有头像」的空行");
const blankP = render({ id: "a6", role: "assistant", content: "<p>甲</p><p> </p><p>乙</p>", createdAt: 6 } as ChatMessage);
ok(count(blankP, "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5") === 2, "⑥ `<p> </p>`(全空白)同样丢弃 ⇒ 两个气泡");
ok(count(blankP, "mu-rise-in flex gap-3") === 2, "⑥ `<p> </p>` 不留「只有头像」的空行");
const onlyEmptyP = render({ id: "a7", role: "assistant", content: "<p></p>", createdAt: 7 } as ChatMessage);
ok(count(onlyEmptyP, "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5") === 1 && !has(onlyEmptyP, "&lt;p&gt;"), "⑥ 正文只剩空 `<p>` ⇒ 不画字面量 `<p>`,只留空回复外壳(与只剩 `<br>` 同一处置)");
// 🔴 `<action>` 注记**不参与**行内渲染(注记文案是原样文本)—— 既有行为一个字没改。
const noteWithTag = render({ id: "a3", role: "assistant", content: "<action>她 <b>笑</b> 了</action><p>正文</p>", createdAt: 3 } as ChatMessage);
ok(has(noteWithTag, "*动作:她 &lt;b&gt;笑&lt;/b&gt; 了*"), "⑥ 注记内的标签保持字面量(不改注记行为)");
ok(has(noteWithTag, "正文"), "⑥ 注记后的正文照常渲染");
// 空正文 + streaming:仍是「生成中…」而不是空渲染。
const streaming = render({ id: "a4", role: "assistant", content: "", streaming: true, createdAt: 4 } as ChatMessage);
ok(has(streaming, "生成中…"), "⑥ 空正文 streaming 回落到「生成中…」");
// 用户侧同理(用户气泡也走 renderContent)。
const user = render({ id: "u1", role: "user", content: "帮我看看 <b>这个</b> 和 <script>x</script>", createdAt: 5 } as ChatMessage);
ok(has(user, "<b>这个</b>"), "⑥ 用户气泡同样渲染行内标签");
ok(!has(user, "<script") && has(user, "&lt;script&gt;x&lt;/script&gt;"), "⑥ 用户气泡里的白名单外标签仍是字面量");
}
// ── ⑦ `<br>` = 气泡/消息分隔(与 `<p>` 同级,**不是**气泡内换行) ────────────────
// 主人 2026-10-07 口径:「对消息内的 `<br>` 始终处理为多条消息隔断」。这一组全部走
// **真实链路**(`SplitChatBubble` → `splitBubble` → `splitParagraphs` → `renderContent`),
// 所以它同时是「上一轮把 `<br>` 当行内换行」那个错误实现的**反向断言**。
{
const BUBBLE = "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5";
const asst = (content: string) => render({ id: "br1", role: "assistant", content, createdAt: 1 } as ChatMessage);
// 主人给的四条口径。
const inP = asst("<p>甲<br>乙</p>");
ok(count(inP, BUBBLE) === 2, "⑦ `<p>甲<br>乙</p>` ⇒ 两个气泡");
ok(has(inP, "<span>甲</span>") && has(inP, "<span>乙</span>"), "⑦ 两个气泡的正文分别是 甲 / 乙");
ok(!has(inP, "<br/>") && !has(inP, "&lt;br"), "⑦ `<br>` 既不进 DOM、也不作为字面量出现在正文里");
const noP = asst("甲<br>乙");
ok(count(noP, BUBBLE) === 2, "⑦ 无 `<p>` 时 `甲<br>乙` ⇒ 两个气泡");
ok(has(noP, "<span>甲</span>") && has(noP, "<span>乙</span>"), "⑦ 无 `<p>` 时同样按 `<br>` 切开");
ok(count(asst("<p>甲<br><br>乙</p>"), BUBBLE) === 2, "⑦ `<p>甲<br><br>乙</p>` ⇒ 两个气泡(中间空气泡丢弃)");
ok(count(asst("<p>甲 <br> 乙</p>"), BUBBLE) === 2, "⑦ `<br>` 两侧空白 ⇒ 不产生多余空气泡");
ok(count(asst("<p>甲<br></p>"), BUBBLE) === 1, "⑦ 尾随 `<br>` 不留空气泡");
ok(count(asst("<p>甲<br> </p>"), BUBBLE) === 1, "⑦ `<br>` 后全空白不留空气泡");
// 正文只剩 `<br>`:气泡分隔把它切光 ⇒ 按空正文交下去(空气泡丢弃,别把字面量画出来)。
const onlyBr = asst("<br>");
ok(count(onlyBr, BUBBLE) === 1 && !has(onlyBr, "&lt;br"), "⑦ 正文只剩 `<br>` ⇒ 不产生字面量、只留空回复外壳");
// 三种写法等价(HTML5):`<br>` / `<br/>` / `</br>`;属性不解析(`<br class=x>` 照样切)。
for (const src of ["<p>甲<br>乙</p>", "<p>甲<br/>乙</p>", "<p>甲</br>乙</p>", "<p>甲<br class=\"x\">乙</p>"]) {
ok(count(asst(src), BUBBLE) === 2, `⑦ 等价写法都切成两个气泡:${src}`);
}
// 护栏:`<brow>` / `<br2>` 不是 br 标签(与 `1 < 2`、`a<b`、`<p2>` 同一条口径)。
const brow = asst("<p>甲<brow>乙</brow>丙</p>");
ok(count(brow, BUBBLE) === 1, "⑦ `<brow>` 不是 br ⇒ 不切气泡");
ok(has(brow, "&lt;brow&gt;"), "⑦ `<brow>` 按白名单外口径逐字原样");
ok(count(asst("<p>甲<br2>乙</br2>丙</p>"), BUBBLE) === 1, "⑦ `<br2>` / `</br2>` 同样不是 br");
// 流式半截 `<br`(还没等到 `>`)⇒ 不是分隔符,本段仍是一个气泡。
const partial = asst("<p>甲<br</p>");
ok(count(partial, BUBBLE) === 1, "⑦ 流式半截 `<br`(无 `>`)不切气泡");
ok(has(partial, "&lt;br"), "⑦ 半截标签按字面量显示(字符不丢)");
// 与行内标签混用:切开的两段各自照常做行内渲染。
const mixed = asst("<p>日落 <b>18:35</b><br>晚安</p>");
ok(count(mixed, BUBBLE) === 2, "⑦ 含行内标签的段落同样按 `<br>` 切开");
ok(has(mixed, "<b>18:35</b>") && has(mixed, "<span>晚安</span>"), "⑦ 两段各自渲染(第一段保留粗体)");
// 用户气泡走同一条链。
const userBr = render({ id: "u2", role: "user", content: "<p>甲<br>乙</p>", createdAt: 2 } as ChatMessage);
ok(count(userBr, "mu-rise-in flex flex-col items-end gap-0.5") === 2, "⑦ 用户气泡同样按 `<br>` 切成两个");
// `<action>` 注记不受 `<br>` 影响(注记文案原样、仍在气泡外),正文照常切。
const noteBr = asst("<action>她 笑 了</action><p>甲<br>乙</p>");
ok(count(noteBr, BUBBLE) === 2 && has(noteBr, "*动作:她 笑 了*"), "⑦ 注记照旧在气泡外,正文照常切成两段");
}
// ── ⑧ 纯动作 / 纯独白回复 ⇒ **零气泡**(空气泡丢弃的边界 + 下游核实) ──────────────
{
const BUBBLE = "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5";
const asst = (content: string, extra: Partial<ChatMessage> = {}) =>
render({ id: "z1", role: "assistant", content, createdAt: 1, ...extra } as ChatMessage);
// `<p>` 包的纯注记回复:注记被剥走后只剩空壳 `<p></p>` ⇒ **零气泡**
// (上一轮这里还是 1 个空气泡;本轮口径变更的边界就在这里)。
const pureP = asst("<p><action>她点点头</action></p>");
ok(count(pureP, BUBBLE) === 0, "⑧ `<p><action>…</action></p>` ⇒ 零个气泡");
ok(has(pureP, "*动作:她点点头*"), "⑧ 注记照常显示(没连注记一起丢)");
ok(!has(pureP, "&lt;p&gt;") && !has(pureP, "&lt;action&gt;"), "⑧ 零气泡时也不留任何字面标签");
// 不带 `<p>` 的纯注记回复(更早的形态):零气泡是**既有行为**,此处锁住不回归。
ok(count(asst("<action>她点点头</action>"), BUBBLE) === 0, "⑧ 无 `<p>` 的纯注记回复同样零气泡(既有行为不变)");
ok(count(asst("*她点点头*"), BUBBLE) === 0, "⑧ 全角星号形态的纯注记回复同样零气泡");
// 🔴 下游核实:零气泡不等于「什么都不画」—— 思维链 / 工具卡 / 心情是**用户可见内容**,
// 不能跟着空气泡一起消失,所以补一行**无正文**的外壳(正文本就是空的 ⇒ 没有空气泡)。
const withChrome = asst("<p><action>她查了下天气</action></p>", {
thinking: "先查花溪天气",
mood: "calm",
tools: [{ name: "web-search", summary: "花溪天气", ok: true, at: 1 }],
});
ok(count(withChrome, BUBBLE) === 0, "⑧ 带工具卡的纯注记回复依然零气泡(空气泡没有回来)");
ok(has(withChrome, "web-search") && has(withChrome, "花溪天气"), "⑧ 零气泡时工具卡仍渲染");
ok(has(withChrome, "思维链") && has(withChrome, "先查花溪天气"), "⑧ 零气泡时思维链仍渲染");
ok(has(withChrome, "心情"), "⑧ 零气泡时心情小字仍渲染");
// 流式中的零气泡回复:仍要给「生成中…」的等待反馈(这一条外壳是有内容的,不是空气泡)。
const streamingNotes = asst("<p><action>她正要说</action></p>", { streaming: true });
ok(count(streamingNotes, BUBBLE) === 1 && has(streamingNotes, "生成中…"), "⑧ 零气泡 + 流式中给「生成中…」反馈(外壳非空)");
// 零气泡 + 只有尾注记(注记在正文之后)同样不崩。
const tailOnly = asst("<p>先说这句</p><p><action>然后她笑了</action></p>");
ok(count(tailOnly, BUBBLE) === 1 && has(tailOnly, "*动作:然后她笑了*"), "⑧ 尾注记 + 空壳段 ⇒ 一个正文气泡 + 一条注记");
}
// ── ⑨ 非 chat 单卡正文:`<br>` ⇒ 换行、行内出样式、白名单外仍字面 ──────────────────
// 主人 2026-10-07 晚:「非聊天模式按照应当有的专业卡片和格式来显示」。这些界面是**单卡
// 正文**(没有「多条气泡」的概念)⇒ `<br>` 只换行、不切分;chat 模式的 `<br>` 分隔口径
// 不受影响(本组末尾有回归断言)。全部走真实的 `modeBody` 入口(8 个非 chat 模式一个不漏)。
{
const BUBBLE = "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5";
const CARD_MODES = ["work", "code", "docs", "home", "vision", "voice", "agents", "settings"] as const;
const src = "甲<br>日落 <b>18:35</b>、<u>下划</u>、<s>划掉</s>、<script>x</script>";
const card = (mode: (typeof CARD_MODES)[number], content: string) =>
renderToStaticMarkup(
createElement(Fragment, null, modeBody(mode, { id: `card-${mode}`, role: "assistant", content, createdAt: 1 } as ChatMessage))
);
for (const mode of CARD_MODES) {
const html = card(mode, src);
ok(count(html, "<br/>") === 1, `⑨ ${mode}:\`<br>\` ⇒ 一个真换行元素`);
ok(!has(html, "&lt;br"), `⑨ ${mode}:不再把 \`<br>\` 当字面量显示`);
ok(has(html, "<b>18:35</b>"), `⑨ ${mode}:行内 \`<b>\` 出粗体`);
ok(has(html, "<u>下划</u>"), `⑨ ${mode}:行内 \`<u>\` 出下划线`);
ok(!has(html, "<s>"), `⑨ ${mode}:\`<s>\` 仍在白名单外(不产出元素)`);
ok(has(html, "&lt;s&gt;划掉&lt;/s&gt;"), `⑨ ${mode}:白名单外的 \`<s>\` 仍逐字字面`);
ok(has(html, "&lt;script&gt;x&lt;/script&gt;"), `⑨ ${mode}:白名单外的 \`<script>\` 逐字字面(不执行、不丢字符)`);
}
// `<br>` 的三种写法在单卡正文里等价(与 chat 侧 BR_SEPARATOR_RE 同一条标签候选口径)。
for (const s of ["甲<br>乙", "甲<br/>乙", "甲</br>乙", "甲<br class=\"x\">乙"]) {
ok(count(card("settings", s), "<br/>") === 1, `⑨ 单卡正文里等价写法都换行:${s}`);
}
const one = card("docs", "甲<br>乙");
ok(has(one, "甲") && has(one, "乙"), "⑨ 换行两侧文本都在同一个正文节点里(不切分、不丢字)");
ok(count(one, "&lt;br") === 0, "⑨ 换行后不留任何字面量");
// 护栏:`<brow>` / `<br2>` 不是 br(与 chat 侧、与 `1 < 2` / `a<b` / `<p2>` 同一条口径)。
ok(count(card("settings", "甲<brow>乙</brow>丙"), "<br/>") === 0, "⑨ `<brow>` 不是 br ⇒ 不换行");
ok(has(card("settings", "甲<brow>乙</brow>丙"), "&lt;brow&gt;"), "⑨ `<brow>` 按白名单外口径逐字原样");
ok(count(card("settings", "甲<br2>乙</br2>丙"), "<br/>") === 0, "⑨ `<br2>` / `</br2>` 同样不是 br");
// 结构不平衡 ⇒ 仍按既有硬口径:整段降级为原文(连那个 `<br>` 也逐字保留,不换行)。
const broken = card("settings", "日落 <b>18:35 了<br>晚安");
ok(has(broken, "&lt;b&gt;18:35 了&lt;br&gt;晚安"), "⑨ 结构不平衡时整段降级为原文(字符一个不丢)");
ok(count(broken, "<br/>") === 0, "⑨ 降级段不产出任何元素(含换行)");
// 🔴 回归:chat 模式的 `<br>` 仍是**气泡分隔符**,绝不因本组而变成换行。
const chatBr = render({ id: "c1", role: "assistant", content: "<p>甲<br>乙</p>", createdAt: 1 } as ChatMessage);
ok(count(chatBr, BUBBLE) === 2, "⑨ 回归:chat 模式 `<p>甲<br>乙</p>` 仍是两个气泡");
ok(count(chatBr, "<br/>") === 0, "⑨ 回归:chat 模式不产出换行元素(`<br>` 是分隔符,不是换行)");
// 非 chat 模式走**真实入口**(SplitChatBubble → MessageBubble → modeBody)同样是换行。
const docsCard = render({ id: "c2", role: "assistant", mode: "docs", content: "甲<br>乙", createdAt: 2 } as ChatMessage);
ok(count(docsCard, "<br/>") === 1, "⑨ 非 chat 模式经 SplitChatBubble 也是换行");
ok(has(docsCard, "<span>甲<br/>乙</span>"), "⑨ …且两侧文本在同一个正文节点里(没有切成多气泡)");
ok(!has(docsCard, "&lt;br"), "⑨ 非 chat 模式的字面 `<br>` 彻底消失");
}
console.log(`inline markup render test: ${checks} checks passed`);