Files
gitmost/packages/mcp/test/unit/node-ops-table.test.mjs
T
agent_coder 4b2af3d34a refactor(mcp): дедупликация конвертер-смежных хелперов (node-ops форк, footnote-*, parse-node-arg)
Аудит при подготовке #413 нашёл дрейфующие дубли между packages/mcp и
packages/prosemirror-markdown. Четыре дедупа (поведение тулов не меняется):

1. node-ops: форк ~960 строк сведён в ОДНУ копию в prosemirror-markdown (живая
   mcp-версия — строгое надмножество замороженного #293-seed'а пакета; сверено по
   git-истории, новая пакет-копия байт-в-байт == прежней mcp-копии). Barrel-экспорт
   полной поверхности; mcp/client.ts/page-search.ts/transforms.ts/collaboration.ts
   импортируют из пакета; тесты переехали. node-ops тянет stripInlineMarkdown ->
   пакет-локальная text-normalize.ts несёт только этот примитив (mcp-версия —
   домен #408; заголовок документирует дубликацию + источник истины).
2. footnote-lex/footnote-analyze (vestigial legacy [^id]: диагностика): сведены к
   одному fence-aware предупреждению 'reference-style footnotes -> use ^[...]'
   (полезно для класса #410); footnote-lex удалён.
3. footnote-authoring -> примитивы (footnoteContentKey/makeFootnoteDefinition/
   generateFootnoteId) перенесены в пакетный footnote.ts, одна реализация конвенции.
4. parse-node-arg -> перенесён в prosemirror-markdown (не mcp: сервер CommonJS не
   импортирует ESM-only @docmost/mcp, но нативно импортирует пакет), обе копии
   удалены, консьюмеры перенаправлены.

canonicalizeFootnotes/ENFORCEMENT RULE #228 и comment-anchor/json-edit/text-normalize
(mcp) не тронуты. API-поверхность node-ops оставлена чистой для #409/#413.

closes #414

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 01:06:31 +03:00

302 lines
9.2 KiB
JavaScript

import { test } from "node:test";
import assert from "node:assert/strict";
import {
insertNodeRelative,
sanitizeForYjs,
findUnstorableAttr,
} from "@docmost/prosemirror-markdown";
// ProseMirror builders. Blocks carry a stable id in attrs.id.
const textNode = (text) => ({ type: "text", text });
const para = (id, ...children) => ({
type: "paragraph",
attrs: { id },
content: children,
});
const doc = (...children) => ({ type: "doc", content: children });
const snapshot = (v) => JSON.parse(JSON.stringify(v));
// A table cell holding a single paragraph.
const cell = (id, innerPara) => ({
type: "tableCell",
attrs: { id },
content: [innerPara],
});
const row = (id, ...cells) => ({
type: "tableRow",
attrs: { id },
content: cells,
});
const table = (id, ...rows) => ({
type: "table",
attrs: { id },
content: rows,
});
// A 2x2 table: rows r1/r2, cells c1..c4, each cell holds a paragraph p1..p4.
const make2x2Table = () =>
doc(
table(
"t1",
row("r1", cell("c1", para("p1", textNode("A1"))), cell("c2", para("p2", textNode("A2")))),
row("r2", cell("c3", para("p3", textNode("B1"))), cell("c4", para("p4", textNode("B2")))),
),
);
const freshRow = () => row("rNEW", cell("cNEW", para("pNEW", textNode("NEW"))));
const freshCell = () => cell("cNEW", para("pNEW", textNode("NEW")));
// ---------------------------------------------------------------------------
// sanitizeForYjs
// ---------------------------------------------------------------------------
test("sanitizeForYjs strips undefined node-attr keys, preserves null/false/0/''", () => {
const input = doc({
type: "paragraph",
attrs: {
id: "p-1",
gone: undefined,
keptNull: null,
keptFalse: false,
keptZero: 0,
keptEmpty: "",
},
content: [textNode("x")],
});
const out = sanitizeForYjs(input);
const attrs = out.content[0].attrs;
assert.equal("gone" in attrs, false);
assert.equal("keptNull" in attrs, true);
assert.equal(attrs.keptNull, null);
assert.equal(attrs.keptFalse, false);
assert.equal(attrs.keptZero, 0);
assert.equal(attrs.keptEmpty, "");
// Input must not be mutated.
assert.equal("gone" in input.content[0].attrs, true);
});
test("sanitizeForYjs strips undefined mark-attr keys, preserves falsy values", () => {
const input = doc({
type: "paragraph",
attrs: { id: "p-1" },
content: [
{
type: "text",
text: "x",
marks: [
{
type: "link",
attrs: { href: "", target: undefined, rel: null },
},
],
},
],
});
const out = sanitizeForYjs(input);
const markAttrs = out.content[0].content[0].marks[0].attrs;
assert.equal("target" in markAttrs, false);
assert.equal(markAttrs.href, "");
assert.equal(markAttrs.rel, null);
});
// ---------------------------------------------------------------------------
// findUnstorableAttr
// ---------------------------------------------------------------------------
test("findUnstorableAttr returns a path for an undefined node attr", () => {
const input = doc(
para("p-0", textNode("ok")),
{
type: "paragraph",
attrs: { id: "p-1", indent: undefined },
content: [textNode("y")],
},
);
const hit = findUnstorableAttr(input);
assert.equal(hit, "content[1].attrs.indent (undefined)");
});
test("findUnstorableAttr finds an unstorable mark attr", () => {
const input = doc({
type: "paragraph",
attrs: { id: "p-1" },
content: [
{
type: "text",
text: "x",
marks: [{ type: "link", attrs: { href: () => {} } }],
},
],
});
const hit = findUnstorableAttr(input);
assert.equal(hit, "content[0].content[0].marks[0].attrs.href (function)");
});
test("findUnstorableAttr returns null for a clean doc", () => {
const input = doc(para("p-1", textNode("clean")));
assert.equal(findUnstorableAttr(input), null);
});
// ---------------------------------------------------------------------------
// insertNodeRelative — table-structure-aware
// ---------------------------------------------------------------------------
test("insertNodeRelative inserting a tableRow anchored on a paragraph INSIDE a cell appends a sibling row to the table", () => {
const input = make2x2Table();
const { doc: out, inserted } = insertNodeRelative(input, freshRow(), {
position: "after",
anchorNodeId: "p4", // paragraph inside last cell of the last row
});
assert.equal(inserted, true);
const tbl = out.content[0];
// table.content length +1 (the row is a direct child of the table).
assert.equal(tbl.content.length, 3);
// The new row is a direct child of the table, NOT nested inside a cell.
const newRow = tbl.content[2];
assert.equal(newRow.type, "tableRow");
assert.equal(newRow.attrs.id, "rNEW");
// Existing rows' cells are intact.
assert.deepEqual(
tbl.content[0].content.map((c) => c.attrs.id),
["c1", "c2"],
);
assert.deepEqual(
tbl.content[1].content.map((c) => c.attrs.id),
["c3", "c4"],
);
// Assert the new row is NOT nested inside any existing cell.
for (const r of [tbl.content[0], tbl.content[1]]) {
for (const c of r.content) {
const ids = (c.content || []).map((n) => n.attrs?.id);
assert.equal(ids.includes("rNEW"), false);
}
}
});
test("insertNodeRelative before/after place the new row at the correct index relative to the enclosing row", () => {
// "before" the first row.
{
const input = make2x2Table();
const { doc: out } = insertNodeRelative(input, freshRow(), {
position: "before",
anchorNodeId: "p1", // paragraph in first row
});
assert.deepEqual(
out.content[0].content.map((r) => r.attrs.id),
["rNEW", "r1", "r2"],
);
}
// "after" the first row.
{
const input = make2x2Table();
const { doc: out } = insertNodeRelative(input, freshRow(), {
position: "after",
anchorNodeId: "p1", // paragraph in first row
});
assert.deepEqual(
out.content[0].content.map((r) => r.attrs.id),
["r1", "rNEW", "r2"],
);
}
});
test("insertNodeRelative inserting a tableCell anchored inside a cell adds it to the enclosing row", () => {
const input = make2x2Table();
const { doc: out, inserted } = insertNodeRelative(input, freshCell(), {
position: "after",
anchorNodeId: "p1", // paragraph inside first cell of first row
});
assert.equal(inserted, true);
// The cell is spliced into the enclosing row (r1) after c1.
assert.deepEqual(
out.content[0].content[0].content.map((c) => c.attrs.id),
["c1", "cNEW", "c2"],
);
// The other row is untouched.
assert.deepEqual(
out.content[0].content[1].content.map((c) => c.attrs.id),
["c3", "c4"],
);
});
test("insertNodeRelative inserting a tableRow with an anchor NOT inside a table throws", () => {
const input = doc(para("p-1", textNode("plain")));
assert.throws(
() =>
insertNodeRelative(input, freshRow(), {
position: "after",
anchorNodeId: "p-1",
}),
/not inside a table/,
);
});
test("insertNodeRelative append + tableRow throws", () => {
const input = make2x2Table();
assert.throws(
() => insertNodeRelative(input, freshRow(), { position: "append" }),
/cannot append a tableRow at the top level/,
);
});
test("insertNodeRelative structural insert with unresolved anchor returns inserted:false (no throw)", () => {
const input = make2x2Table();
const { doc: out, inserted } = insertNodeRelative(input, freshRow(), {
position: "after",
anchorNodeId: "does-not-exist",
});
assert.equal(inserted, false);
assert.deepEqual(out, input);
});
test("insertNodeRelative tableRow by anchorText resolving to the table block appends within the table", () => {
const input = make2x2Table();
// anchorText "A1" lives in the first cell; the matched top-level block is the
// table itself, so the row appends at the end of the table.
const { doc: out, inserted } = insertNodeRelative(input, freshRow(), {
position: "after",
anchorText: "A1",
});
assert.equal(inserted, true);
assert.deepEqual(
out.content[0].content.map((r) => r.attrs.id),
["r1", "r2", "rNEW"],
);
});
// ---------------------------------------------------------------------------
// Regression: a normal (non-structural) paragraph insert is unchanged.
// ---------------------------------------------------------------------------
test("insertNodeRelative regression: normal paragraph before/after a top-level block behaves as before", () => {
const before = doc(para("p-1", textNode("one")), para("p-2", textNode("two")));
{
const { doc: out, inserted } = insertNodeRelative(
before,
para("new", textNode("NEW")),
{ position: "before", anchorNodeId: "p-2" },
);
assert.equal(inserted, true);
assert.deepEqual(
out.content.map((n) => n.attrs.id),
["p-1", "new", "p-2"],
);
}
{
const snap = snapshot(before);
const { doc: out, inserted } = insertNodeRelative(
before,
para("new", textNode("NEW")),
{ position: "after", anchorNodeId: "p-1" },
);
assert.equal(inserted, true);
assert.deepEqual(
out.content.map((n) => n.attrs.id),
["p-1", "new", "p-2"],
);
// Input not mutated.
assert.deepEqual(before, snap);
}
});