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>
120 lines
4.2 KiB
TypeScript
120 lines
4.2 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
replaceNodeByIdWithMany,
|
|
insertNodesRelative,
|
|
} from "../src/lib/node-ops.js";
|
|
|
|
// #413: the array-splice helpers used by the markdown patch/insert paths.
|
|
const p = (id: string, t: string): any => ({
|
|
type: "paragraph",
|
|
attrs: { id },
|
|
content: [{ type: "text", text: t }],
|
|
});
|
|
|
|
describe("replaceNodeByIdWithMany", () => {
|
|
it("splices N nodes in place of the first match, keeping neighbours byte-identical", () => {
|
|
const before = { type: "doc", content: [p("a", "A"), p("b", "B"), p("c", "C")] };
|
|
const snap = JSON.parse(JSON.stringify(before));
|
|
const { doc, replaced } = replaceNodeByIdWithMany(before, "b", [
|
|
p("b1", "B1"),
|
|
p("b2", "B2"),
|
|
]);
|
|
expect(replaced).toBe(1);
|
|
expect(doc.content.map((n: any) => n.attrs.id)).toEqual(["a", "b1", "b2", "c"]);
|
|
// Input never mutated.
|
|
expect(before).toEqual(snap);
|
|
// Neighbours byte-identical.
|
|
expect(doc.content[0]).toEqual(snap.content[0]);
|
|
expect(doc.content[3]).toEqual(snap.content[2]);
|
|
});
|
|
|
|
it("reaches a nested match (inside a callout) and splices there", () => {
|
|
const before = {
|
|
type: "doc",
|
|
content: [
|
|
{ type: "callout", attrs: { id: "co" }, content: [p("x", "X")] },
|
|
],
|
|
};
|
|
const { doc, replaced } = replaceNodeByIdWithMany(before, "x", [
|
|
p("x1", "X1"),
|
|
p("x2", "X2"),
|
|
]);
|
|
expect(replaced).toBe(1);
|
|
expect(doc.content[0].content.map((n: any) => n.attrs.id)).toEqual(["x1", "x2"]);
|
|
});
|
|
|
|
it("only touches the FIRST duplicate (caller guards ambiguity)", () => {
|
|
const before = { type: "doc", content: [p("d", "1"), p("d", "2")] };
|
|
const { doc, replaced } = replaceNodeByIdWithMany(before, "d", [p("n", "N")]);
|
|
expect(replaced).toBe(1);
|
|
// First replaced; the second duplicate survives untouched.
|
|
expect(doc.content.map((n: any) => n.attrs.id)).toEqual(["n", "d"]);
|
|
});
|
|
|
|
it("reports replaced:0 for no match, doc unchanged", () => {
|
|
const before = { type: "doc", content: [p("a", "A")] };
|
|
const { doc, replaced } = replaceNodeByIdWithMany(before, "zzz", [p("n", "N")]);
|
|
expect(replaced).toBe(0);
|
|
expect(doc).toEqual(before);
|
|
});
|
|
});
|
|
|
|
describe("insertNodesRelative", () => {
|
|
it("inserts an ordered array after an id anchor", () => {
|
|
const before = { type: "doc", content: [p("a", "A"), p("b", "B")] };
|
|
const { doc, inserted } = insertNodesRelative(
|
|
before,
|
|
[p("n1", "N1"), p("n2", "N2")],
|
|
{ position: "after", anchorNodeId: "a" },
|
|
);
|
|
expect(inserted).toBe(true);
|
|
expect(doc.content.map((n: any) => n.attrs.id)).toEqual(["a", "n1", "n2", "b"]);
|
|
});
|
|
|
|
it("inserts before an id anchor", () => {
|
|
const before = { type: "doc", content: [p("a", "A"), p("b", "B")] };
|
|
const { doc } = insertNodesRelative(before, [p("n", "N")], {
|
|
position: "before",
|
|
anchorNodeId: "b",
|
|
});
|
|
expect(doc.content.map((n: any) => n.attrs.id)).toEqual(["a", "n", "b"]);
|
|
});
|
|
|
|
it("appends an ordered array at the top level", () => {
|
|
const before = { type: "doc", content: [p("a", "A")] };
|
|
const { doc } = insertNodesRelative(before, [p("n1", "N1"), p("n2", "N2")], {
|
|
position: "append",
|
|
});
|
|
expect(doc.content.map((n: any) => n.attrs.id)).toEqual(["a", "n1", "n2"]);
|
|
});
|
|
|
|
it("resolves an anchor by top-level text", () => {
|
|
const before = { type: "doc", content: [p("a", "hello there"), p("b", "B")] };
|
|
const { doc, inserted } = insertNodesRelative(before, [p("n", "N")], {
|
|
position: "after",
|
|
anchorText: "hello",
|
|
});
|
|
expect(inserted).toBe(true);
|
|
expect(doc.content.map((n: any) => n.attrs.id)).toEqual(["a", "n", "b"]);
|
|
});
|
|
|
|
it("reports inserted:false when the anchor is missing", () => {
|
|
const before = { type: "doc", content: [p("a", "A")] };
|
|
const { doc, inserted } = insertNodesRelative(before, [p("n", "N")], {
|
|
position: "after",
|
|
anchorNodeId: "missing",
|
|
});
|
|
expect(inserted).toBe(false);
|
|
expect(doc).toEqual(before);
|
|
});
|
|
|
|
it("is a no-op for an empty node array", () => {
|
|
const before = { type: "doc", content: [p("a", "A")] };
|
|
const { doc, inserted } = insertNodesRelative(before, [], {
|
|
position: "append",
|
|
});
|
|
expect(inserted).toBe(false);
|
|
expect(doc).toEqual(before);
|
|
});
|
|
});
|