b66929714f
Ревью #493 (HIGH): escapeLeadingBlockTrigger применялся к всему результату renderInlineChildren один раз, а регэкспы якорены на ^ без флага m → защищалась только ПЕРВАЯ строка. hardBreak сериализуется как ` \n`, поэтому триггер на строке-продолжении не экранировался и на git-sync round-trip re-парсился в другой блок; для setext/thematic `---` текст строки терялся ЦЕЛИКОМ ([text "a", hardBreak, text "---"] → heading, "---" пропадал). Правка: параграф теперь бьётся на `\n`-строки и escapeLeadingBlockTrigger применяется к КАЖДОЙ. Фаззер расширен: hardBreakThenTriggerArb вставляет триггер ПОСЛЕ hardBreak в inlineContentArb, так что P1/P2/P3 структурно покрывают подслучай (раньше триггер стоял только первым run'ом). Добавлен детерминированный пин на continuation-line триггеры, включая `a \n---` (текст «---» сохраняется, не setext). Mutation-verify: со старым single-line escape новый пин + P1/P2 краснеют. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
149 lines
5.5 KiB
TypeScript
149 lines
5.5 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
// Import DIRECTLY from src (NOT the docmost-client barrel, which pulls in
|
|
// collaboration.ts and mutates global DOM at import time).
|
|
import { convertProseMirrorToMarkdown } from "../src/lib/markdown-converter.js";
|
|
import { markdownToProseMirror } from "../src/lib/markdown-to-prosemirror.js";
|
|
|
|
/**
|
|
* #493 commit 1 — the paragraph serializer's leading-block-escape closes the
|
|
* data-loss class where a paragraph whose text opens at column 0 with a markdown
|
|
* block trigger (`#`/`-`/`*`/`+`/`>`, an ordered `N.`/`N)`, a code fence, a
|
|
* table `|`, a callout opener, or a thematic break) silently re-parsed into a
|
|
* heading / list / quote / code block / table / horizontalRule on the git-sync
|
|
* doc -> markdown -> doc cycle. The thematic-break case was the worst: a
|
|
* horizontalRule carries NO text, so the line's text was lost entirely.
|
|
*
|
|
* This is the deterministic PIN, one assertion per trigger, exercised through
|
|
* the REAL converter round-trip (not a mock): each bare trigger line now
|
|
* round-trips as a SINGLE paragraph with its text byte-preserved — proving the
|
|
* class is closed WITHOUT the former client-side ZWSP workaround (removed) or
|
|
* the generative suite's leading-word self-censorship (removed).
|
|
*/
|
|
|
|
const doc = (...nodes: any[]) => ({ type: "doc", content: nodes });
|
|
const para = (t: string) => ({
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: t }],
|
|
});
|
|
|
|
const roundtrip = async (text: string) => {
|
|
const md = convertProseMirrorToMarkdown(doc(para(text)));
|
|
const back = await markdownToProseMirror(md);
|
|
return back.content as any[];
|
|
};
|
|
|
|
describe("paragraph block-escape (git-sync round-trip)", () => {
|
|
// Every line here, at column 0, WOULD (pre-fix) re-parse into a non-paragraph
|
|
// block. Each is now block-escaped by the serializer and round-trips clean.
|
|
const triggerLines = [
|
|
"- dash",
|
|
"* star",
|
|
"+ plus",
|
|
"> quote",
|
|
"# hash",
|
|
"## two hash",
|
|
"###### six hash",
|
|
"1. one",
|
|
"1) one",
|
|
"> [!info] note",
|
|
"```js",
|
|
"~~~",
|
|
"| a | b |",
|
|
// Solid + spaced thematic breaks — the text-LOSING case pre-fix.
|
|
"---",
|
|
"***",
|
|
"___",
|
|
"- - -",
|
|
"_ _ _",
|
|
];
|
|
|
|
it("every bare trigger line round-trips as a single paragraph, text byte-preserved", async () => {
|
|
for (const line of triggerLines) {
|
|
const blocks = await roundtrip(line);
|
|
expect(blocks, `"${line}" should be one block`).toHaveLength(1);
|
|
expect(blocks[0].type, `"${line}" should stay a paragraph`).toBe(
|
|
"paragraph",
|
|
);
|
|
expect(
|
|
blocks[0].content?.[0]?.text,
|
|
`"${line}" text should survive byte-exact`,
|
|
).toBe(line);
|
|
}
|
|
});
|
|
|
|
it("emphasis / inline-code paragraphs are NOT escaped (no backslash churn)", async () => {
|
|
// These open with `*`/`` ` `` but are NOT block triggers; the serialized
|
|
// markdown must not gain a stray leading backslash, and they round-trip.
|
|
for (const [text, mark] of [
|
|
["bold", "bold"],
|
|
["italic", "italic"],
|
|
["code", "code"],
|
|
] as const) {
|
|
const node = doc({
|
|
type: "paragraph",
|
|
content: [{ type: "text", text, marks: [{ type: mark }] }],
|
|
});
|
|
const md = convertProseMirrorToMarkdown(node);
|
|
expect(md.startsWith("\\"), `${mark} must not be block-escaped`).toBe(
|
|
false,
|
|
);
|
|
const back = await markdownToProseMirror(md);
|
|
expect(back.content[0].type).toBe("paragraph");
|
|
expect(back.content[0].content[0].text).toBe(text);
|
|
expect(back.content[0].content[0].marks?.[0]?.type).toBe(mark);
|
|
}
|
|
});
|
|
|
|
it("a block trigger on a CONTINUATION line (after a hardBreak) is escaped too", async () => {
|
|
// A hardBreak serializes as ` \n`, so a trigger on the second line would,
|
|
// without a per-line escape, re-parse into another block. The worst case is
|
|
// `---`: a setext underline would turn the first line into a heading and LOSE
|
|
// the `---` text entirely. Each pair round-trips as ONE paragraph with the
|
|
// hardBreak and both texts preserved.
|
|
for (const [first, second] of [
|
|
["a", "# b"],
|
|
["a", "- b"],
|
|
["a", "> b"],
|
|
["a", "1. b"],
|
|
["a", "| b |"],
|
|
["a", "---"], // setext / thematic — the text-losing case
|
|
]) {
|
|
const d = doc({
|
|
type: "paragraph",
|
|
content: [
|
|
{ type: "text", text: first },
|
|
{ type: "hardBreak" },
|
|
{ type: "text", text: second },
|
|
],
|
|
});
|
|
const back = await markdownToProseMirror(convertProseMirrorToMarkdown(d));
|
|
expect(back.content, `"${first}⏎${second}" should be one block`).toHaveLength(1);
|
|
expect(back.content[0].type).toBe("paragraph");
|
|
const texts = (back.content[0].content as any[])
|
|
.filter((n) => n.type === "text")
|
|
.map((n) => n.text);
|
|
const hasBreak = (back.content[0].content as any[]).some(
|
|
(n) => n.type === "hardBreak",
|
|
);
|
|
expect(hasBreak, `"${first}⏎${second}" should keep the hardBreak`).toBe(true);
|
|
expect(texts, `"${first}⏎${second}" should preserve both line texts`).toEqual([
|
|
first,
|
|
second,
|
|
]);
|
|
}
|
|
});
|
|
|
|
it("normal host-prefixed lines round-trip byte-exact (unaffected)", async () => {
|
|
for (const line of [
|
|
"You: hello there",
|
|
"Speaker 1: - and then a dash mid-line",
|
|
"Speaker 2: 1. not a list",
|
|
]) {
|
|
const blocks = await roundtrip(line);
|
|
expect(blocks).toHaveLength(1);
|
|
expect(blocks[0].type).toBe("paragraph");
|
|
expect(blocks[0].content[0].text).toBe(line);
|
|
}
|
|
});
|
|
});
|