e1b8ef5b8b
Строка параграфа, начинающаяся с блочного триггера (`#`/`-`/`*`/`+`/`>`, упорядоченного `N.`/`N)`, фенса ```/~~~, таблицы `|` или тематического разрыва `---`/`***`/`___`), на round-trip doc->markdown->doc молча превращалась в heading/list/quote/code block/table/horizontalRule. Худший случай — тематический разрыв: horizontalRule не несёт текста, и строка теряла его целиком. Сериализатор параграфа теперь backslash-экранирует ведущий блочный триггер (escapeLeadingBlockTrigger): экранируется только ПЕРВЫЙ значащий символ, токенизатор CommonMark декодирует `\` обратно в литерал И снимает блочную интерпретацию, так что строка round-trip'ится байт-в-байт как параграф. Emphasis `**x**`, inline-code и обычная проза триггерами не являются и не трогаются (нет мусорных backslash). Класс раньше не чинили, а ОБХОДИЛИ; обход убран у обоих потребителей: - клиентский мост (gitmost-recording.ts) больше не подставляет ZWSP-хак; - генеративный корпус (text-arbitraries.ts) снял самоцензуру — добавлен blockTriggerLeadRunArb, параграф теперь МОЖЕТ открываться триггером, и P1/P2/P3 сами доказывают закрытие класса. Пины на каждый триггер — детерминированные round-trip через реальный конвертер (gitmost-transcript-neutralization.test.ts). Обновлён документировавший старую потерю gap-тест (spec 13). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
110 lines
4.0 KiB
TypeScript
110 lines
4.0 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("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);
|
|
}
|
|
});
|
|
});
|