Files
Jyotisha/frontend/tests/chat-markdown-split.test.ts
T
jesse-ux 58ccafb67c
Independent Staging Quality Gate / validate (push) Failing after 6m18s
Independent Staging Quality Gate / publish (push) Skipped
fix(rectification): stop re-parsing settled messages on every stream frame
2026-09-16 07:13:12 +08:00

90 lines
4.7 KiB
TypeScript

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 { StableMarkdownPrefix, StreamingMarkdown } from "../src/components/chat-message-content.tsx";
import { splitStableMarkdown } from "../src/lib/chat-markdown-split.ts";
const contentSource = readFileSync(new URL("../src/components/chat-message-content.tsx", import.meta.url), "utf8");
const messageRowSource = readFileSync(new URL("../src/components/chat-message-row.tsx", import.meta.url), "utf8");
test("the stable prefix ends at the last completed paragraph and the tail keeps streaming", () => {
assert.deepEqual(splitStableMarkdown("只有一段还没写完"), { stable: "", tail: "只有一段还没写完" });
assert.deepEqual(splitStableMarkdown("第一段。\n\n第二段还在"), { stable: "第一段。", tail: "第二段还在" });
assert.deepEqual(
splitStableMarkdown("## 标题\n\n第一段。\n\n第二段。\n\n第三"),
{ stable: "## 标题\n\n第一段。\n\n第二段。", tail: "第三" },
);
});
test("the cut never lands inside a fence, between list items, or inside a table", () => {
const fenced = "前言。\n\n```txt\n第一行\n\n第二行";
assert.deepEqual(splitStableMarkdown(fenced), { stable: "前言。", tail: "```txt\n第一行\n\n第二行" });
const looseList = "- 甲\n\n- 乙\n\n- 丙还在";
assert.deepEqual(splitStableMarkdown(looseList), { stable: "", tail: looseList });
const listThenParagraph = "- 甲\n- 乙\n\n总结一下";
assert.deepEqual(splitStableMarkdown(listThenParagraph), { stable: "- 甲\n- 乙", tail: "总结一下" });
const orderedListContinues = "1. 甲\n\n2. 乙\n\n 缩进的补充";
assert.deepEqual(splitStableMarkdown(orderedListContinues), { stable: "", tail: orderedListContinues });
const table = "说明。\n\n| 技法 | 状态 |\n| --- | --- |\n| 甲 | 已执行 |\n\n> 引用";
assert.deepEqual(
splitStableMarkdown(table),
{ stable: "说明。\n\n| 技法 | 状态 |\n| --- | --- |\n| 甲 | 已执行 |", tail: "> 引用" },
);
});
test("growing the tail keeps the prefix string identical so the memoised prefix is not re-parsed", () => {
const before = splitStableMarkdown("第一段。\n\n第二段。\n\n第三段正在");
const after = splitStableMarkdown("第一段。\n\n第二段。\n\n第三段正在写,还没有换段");
assert.equal(before.stable, after.stable);
assert.notEqual(before.tail, after.tail);
// Only once a new paragraph completes does the prefix move forward.
const later = splitStableMarkdown("第一段。\n\n第二段。\n\n第三段写完了。\n\n第四");
assert.equal(later.stable, "第一段。\n\n第二段。\n\n第三段写完了。");
});
test("the streaming renderer parses the prefix and the tail as two separate documents", () => {
const calls: string[] = [];
const renderMarkdown = (text: string) => {
calls.push(text);
return createElement("p", null, text);
};
renderToString(createElement(StreamingMarkdown, {
text: "第一段。\n\n第二段。\n\n第三段还在",
renderMarkdown,
}));
// Server rendering evaluates the tail in the parent and the memoised prefix as a child,
// so compare as a set: what matters is that neither call sees the whole answer.
assert.deepEqual([...calls].sort(), ["第一段。\n\n第二段。", "第三段还在"].sort());
// The prefix component is memoised on its text, so an unchanged prefix costs no parse.
assert.match(contentSource, /const StableMarkdownPrefix = memo\(function StableMarkdownPrefix/);
assert.match(contentSource, /splitStableMarkdown\(text\)/);
assert.match(contentSource, /streaming\s*\?\s*<StreamingMarkdown/);
// 原值: `: renderProse(spoken, renderMarkdown)` 结算口语层每次重渲都全量 parse。
// 新值: 结算口语层走 `StableMarkdownPrefix`(按文本记忆 + 解析缓存)。
// 原因: BUG-725 流式帧不得重跑已结算 Markdown。
assert.match(contentSource, /: <StableMarkdownPrefix text=\{spoken\} renderMarkdown=\{renderMarkdown\} \/>/);
assert.match(messageRowSource, /streaming=\{message\.state !== "settled"\}/);
});
test("settled markdown parses the same text only once across consecutive renders", () => {
const calls: string[] = [];
const renderMarkdown = (text: string) => {
calls.push(text);
return createElement("p", null, text);
};
const text = "已结算的一段回答,对照了 D9 和 D10。";
renderToString(createElement(StableMarkdownPrefix, { text, renderMarkdown }));
renderToString(createElement(StableMarkdownPrefix, { text, renderMarkdown }));
assert.equal(calls.length, 1);
assert.deepEqual(calls, [text]);
});