d269bd9efe
Канонический конвертер (#293/#345/#351) зрел для блочного уровня: markdown становится дефолтом чтения/записи ОДНОГО блока, PM JSON — опция «для тонких работ». Новых тулов нет, поверхность не растёт. Имена уже camelCase (стоит на #412). - getNode(pageId, nodeId, format='markdown'): дефолт markdown — обёртка {type:doc,content:[node]} → convertProseMirrorToMarkdown, comment-якоря (ВКЛЮЧАЯ resolved) сохранены (это чтение под редактирование, не getPage). format:'json' — сырой сабтри. Авто-фолбэк не-топ-левел типов (tableRow/Cell/ Header по #<index>) в JSON через canBeDocChild = docmostSchema.nodes.doc. contentMatch.matchType (не рукописный список); поле format на каждом ответе. - patchNode/insertNode: XOR-вход {markdown?|node?} (оба optional в схеме, XOR на рантайме). markdown → импорт фрагмента → 1→N сплайс: первый блок наследует id цели, остальные свежие; dry replaceNodeById для #159-ambiguity ДО сплайса; соседние блоки byte-identical. Guard findUnrepresentableTableAttrs: цель со span/colwidth/backgroundColor → отказ с указанием на table-тулы/node-JSON. insertNode — insertNodesRelative (N блоков по порядку); голый tableRow/Cell JSON-only. - Сноски: ^[...] во фрагменте → канон-импортёр; importMarkdownFragment делит блоки от footnotesList, РЕМАПИТ id сносок фрагмента в свежие uuid (fn-1 фрагмента не коллизит с fn-1 страницы), mergeFootnoteDefinitions добавляет через ту же машинерию appendDefinition→normalizeAndMergeFootnotes→ canonicalizeFootnotes, что insertFootnote. Сырые JSON-пути не тронуты. - node-ops: replaceNodeByIdWithMany/insertNodesRelative (сплайс массива) в prosemirror-markdown/node-ops (канон после #414) + barrel. - ROUTING_PROSE (READ/EDIT), compile-time client-call contract, CHANGELOG (getNode default→markdown, breaking для внешних клиентов, в окне #411/#412). Тесты: сходимость (patchNode(markdown) блок docsCanonicallyEqual полному импорту — нет «второго канона»); id-нить 1→N (первый наследует, остальные свежие, соседи byte-identical); XOR; getNode markdown/json/non-top-level-fallback; сохранение comment-якорей (active+resolved); ^[...]→хвостовой список+перенумерация; guard. mcp node --test 697/697; pmd vitest 736; tsc чисто; server jest 273. Третий линк breaking-окна, стоит на #412 (#411→#412→ЭТОТ→#415). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
122 lines
4.1 KiB
JavaScript
122 lines
4.1 KiB
JavaScript
// #413: unit tests for the markdown-fragment helpers used by patchNode/insertNode.
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import {
|
|
importMarkdownFragment,
|
|
canBeDocChild,
|
|
findUnrepresentableTableAttrs,
|
|
} from "../../build/lib/markdown-fragment.js";
|
|
|
|
function findAll(node, type, acc = []) {
|
|
if (!node || typeof node !== "object") return acc;
|
|
if (node.type === type) acc.push(node);
|
|
if (Array.isArray(node.content))
|
|
for (const c of node.content) findAll(c, type, acc);
|
|
return acc;
|
|
}
|
|
|
|
test("importMarkdownFragment: plain markdown -> blocks, no definitions", async () => {
|
|
const { blocks, definitions } = await importMarkdownFragment(
|
|
"first\n\nsecond",
|
|
);
|
|
assert.equal(blocks.length, 2);
|
|
assert.equal(definitions.length, 0);
|
|
assert.equal(blocks[0].type, "paragraph");
|
|
});
|
|
|
|
test("importMarkdownFragment: `^[...]` footnote -> a definition + a remapped ref", async () => {
|
|
const { blocks, definitions } = await importMarkdownFragment(
|
|
"a claim^[the note]",
|
|
);
|
|
assert.equal(definitions.length, 1);
|
|
const refs = findAll({ type: "doc", content: blocks }, "footnoteReference");
|
|
assert.equal(refs.length, 1);
|
|
// The reference id must match the (remapped) definition id.
|
|
assert.equal(refs[0].attrs.id, definitions[0].attrs.id);
|
|
// The id is NOT the importer's sequential "fn-1" — it was remapped to a fresh
|
|
// uuid so it cannot collide with a page footnote of the same number.
|
|
assert.notEqual(refs[0].attrs.id, "fn-1");
|
|
});
|
|
|
|
test("importMarkdownFragment: whitespace markdown imports to a single empty paragraph", async () => {
|
|
// The importer yields one empty paragraph for whitespace-only input (not zero
|
|
// blocks), so the fragment path returns that block. The client's XOR guard
|
|
// (markdown.trim() !== "") is what rejects an empty-string patch up front, so
|
|
// importMarkdownFragment never sees a truly empty string via patch/insert.
|
|
const { blocks, definitions } = await importMarkdownFragment(" \n ");
|
|
assert.equal(blocks.length, 1);
|
|
assert.equal(blocks[0].type, "paragraph");
|
|
assert.equal(definitions.length, 0);
|
|
});
|
|
|
|
test("canBeDocChild: paragraph/heading/table are doc children; tableRow/cell are not", () => {
|
|
assert.equal(canBeDocChild("paragraph"), true);
|
|
assert.equal(canBeDocChild("heading"), true);
|
|
assert.equal(canBeDocChild("table"), true);
|
|
assert.equal(canBeDocChild("tableRow"), false);
|
|
assert.equal(canBeDocChild("tableCell"), false);
|
|
assert.equal(canBeDocChild("tableHeader"), false);
|
|
assert.equal(canBeDocChild("text"), false);
|
|
assert.equal(canBeDocChild(undefined), false);
|
|
assert.equal(canBeDocChild("notARealType"), false);
|
|
});
|
|
|
|
const cell = (attrs, text) => ({
|
|
type: "tableCell",
|
|
attrs,
|
|
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
|
|
});
|
|
|
|
test("findUnrepresentableTableAttrs: null for a plain paragraph and a simple table", () => {
|
|
assert.equal(
|
|
findUnrepresentableTableAttrs({
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "x" }],
|
|
}),
|
|
null,
|
|
);
|
|
const simpleTable = {
|
|
type: "table",
|
|
content: [
|
|
{
|
|
type: "tableRow",
|
|
content: [cell({ colspan: 1, rowspan: 1 }, "a")],
|
|
},
|
|
],
|
|
};
|
|
assert.equal(findUnrepresentableTableAttrs(simpleTable), null);
|
|
});
|
|
|
|
test("findUnrepresentableTableAttrs: flags colspan/rowspan/colwidth/backgroundColor", () => {
|
|
const mk = (attrs) => ({
|
|
type: "table",
|
|
content: [{ type: "tableRow", content: [cell(attrs, "a")] }],
|
|
});
|
|
assert.match(findUnrepresentableTableAttrs(mk({ colspan: 2 })), /colspan/);
|
|
assert.match(findUnrepresentableTableAttrs(mk({ rowspan: 2 })), /rowspan/);
|
|
assert.match(
|
|
findUnrepresentableTableAttrs(mk({ colwidth: [120] })),
|
|
/colwidth/,
|
|
);
|
|
assert.match(
|
|
findUnrepresentableTableAttrs(mk({ backgroundColor: "#eee" })),
|
|
/backgroundColor/,
|
|
);
|
|
});
|
|
|
|
test("findUnrepresentableTableAttrs: finds a span nested deep (table inside a callout)", () => {
|
|
const doc = {
|
|
type: "callout",
|
|
content: [
|
|
{
|
|
type: "table",
|
|
content: [
|
|
{ type: "tableRow", content: [cell({ colspan: 3 }, "wide")] },
|
|
],
|
|
},
|
|
],
|
|
};
|
|
assert.match(findUnrepresentableTableAttrs(doc), /colspan/);
|
|
});
|