import { test } from "node:test"; import assert from "node:assert/strict"; import { blockPlainText, replaceNodeById, deleteNodeById, assertUnambiguousMatch, insertNodeRelative, } from "../../build/lib/node-ops.js"; // 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 callout / table-cell wraps its children in `content`, just like any other // block, so recursion reaches a paragraph nested inside it. const callout = (id, ...children) => ({ type: "callout", attrs: { id, type: "info" }, content: children, }); const tableDoc = (innerPara) => doc({ type: "table", attrs: { id: "table-1" }, content: [ { type: "tableRow", attrs: { id: "row-1" }, content: [ { type: "tableCell", attrs: { id: "cell-1" }, content: [innerPara], }, ], }, ], }); // --------------------------------------------------------------------------- // blockPlainText // --------------------------------------------------------------------------- test("blockPlainText concatenates nested text", () => { const node = { type: "callout", content: [ para("p-1", textNode("Hello "), textNode("world")), para("p-2", textNode("!")), ], }; assert.equal(blockPlainText(node), "Hello world!"); }); test("blockPlainText returns '' for nullish / non-object", () => { assert.equal(blockPlainText(null), ""); assert.equal(blockPlainText(undefined), ""); assert.equal(blockPlainText("just a string"), ""); }); test("blockPlainText reads a bare text node", () => { assert.equal(blockPlainText(textNode("solo")), "solo"); }); // --------------------------------------------------------------------------- // replaceNodeById // --------------------------------------------------------------------------- test("replaceNodeById replaces the matching block and leaves others, count===1", () => { const input = doc( para("p-1", textNode("one")), para("p-2", textNode("two")), para("p-3", textNode("three")), ); const newNode = para("p-2", textNode("REPLACED")); const { doc: out, replaced } = replaceNodeById(input, "p-2", newNode); assert.equal(replaced, 1); // Target replaced. assert.equal(out.content[1].content[0].text, "REPLACED"); // Siblings untouched (text and ids). assert.equal(out.content[0].content[0].text, "one"); assert.equal(out.content[2].content[0].text, "three"); assert.deepEqual( out.content.map((n) => n.attrs.id), ["p-1", "p-2", "p-3"], ); }); test("replaceNodeById on no-match returns replaced===0 and does not throw", () => { const input = doc(para("p-1", textNode("one"))); const { doc: out, replaced } = replaceNodeById( input, "missing", para("x", textNode("x")), ); assert.equal(replaced, 0); // Document content is preserved. assert.equal(out.content[0].content[0].text, "one"); }); test("replaceNodeById replaces EVERY node sharing the id (count reflects all)", () => { const input = doc( para("dup", textNode("a")), para("dup", textNode("b")), para("keep", textNode("c")), ); const { doc: out, replaced } = replaceNodeById( input, "dup", para("dup", textNode("NEW")), ); assert.equal(replaced, 2); assert.equal(out.content[0].content[0].text, "NEW"); assert.equal(out.content[1].content[0].text, "NEW"); assert.equal(out.content[2].content[0].text, "c"); // The two replacements must not share a reference (deep clone per match). assert.notEqual(out.content[0], out.content[1]); }); test("replaceNodeById reaches a node nested inside a callout", () => { const input = doc(callout("c-1", para("inner", textNode("old")))); const { doc: out, replaced } = replaceNodeById( input, "inner", para("inner", textNode("new")), ); assert.equal(replaced, 1); assert.equal(out.content[0].content[0].content[0].text, "new"); }); test("replaceNodeById reaches a node nested inside a table cell", () => { const input = tableDoc(para("deep", textNode("before"))); const { doc: out, replaced } = replaceNodeById( input, "deep", para("deep", textNode("after")), ); assert.equal(replaced, 1); const cellPara = out.content[0].content[0].content[0].content[0]; assert.equal(cellPara.content[0].text, "after"); }); test("replaceNodeById does NOT mutate input (deep-equal snapshot)", () => { const input = doc( para("p-1", textNode("one")), callout("c-1", para("inner", textNode("old"))), ); const snap = snapshot(input); const { doc: out } = replaceNodeById( input, "inner", para("inner", textNode("changed")), ); assert.deepEqual(input, snap); assert.notEqual(out, input); }); // --------------------------------------------------------------------------- // deleteNodeById // --------------------------------------------------------------------------- test("deleteNodeById removes the block and reports deleted===1", () => { const input = doc( para("p-1", textNode("one")), para("p-2", textNode("two")), para("p-3", textNode("three")), ); const { doc: out, deleted } = deleteNodeById(input, "p-2"); assert.equal(deleted, 1); assert.deepEqual( out.content.map((n) => n.attrs.id), ["p-1", "p-3"], ); }); test("deleteNodeById on no-match returns deleted===0 and leaves content", () => { const input = doc(para("p-1", textNode("one"))); const { doc: out, deleted } = deleteNodeById(input, "missing"); assert.equal(deleted, 0); assert.equal(out.content.length, 1); }); test("deleteNodeById removes a node nested inside a callout", () => { const input = doc( callout("c-1", para("inner", textNode("x")), para("keep", textNode("y"))), ); const { doc: out, deleted } = deleteNodeById(input, "inner"); assert.equal(deleted, 1); assert.deepEqual( out.content[0].content.map((n) => n.attrs.id), ["keep"], ); }); test("deleteNodeById removes EVERY node sharing the id", () => { const input = doc( para("dup", textNode("a")), para("keep", textNode("b")), para("dup", textNode("c")), ); const { doc: out, deleted } = deleteNodeById(input, "dup"); assert.equal(deleted, 2); assert.deepEqual( out.content.map((n) => n.attrs.id), ["keep"], ); }); test("deleteNodeById does NOT mutate input (deep-equal snapshot)", () => { const input = doc(para("p-1", textNode("one")), para("p-2", textNode("two"))); const snap = snapshot(input); const { doc: out } = deleteNodeById(input, "p-2"); assert.deepEqual(input, snap); assert.notEqual(out, input); }); // --------------------------------------------------------------------------- // insertNodeRelative // --------------------------------------------------------------------------- test("insertNodeRelative before by anchorNodeId", () => { const input = doc(para("p-1", textNode("one")), para("p-2", textNode("two"))); const node = para("new", textNode("NEW")); const { doc: out, inserted } = insertNodeRelative(input, node, { position: "before", anchorNodeId: "p-2", }); assert.equal(inserted, true); assert.deepEqual( out.content.map((n) => n.attrs.id), ["p-1", "new", "p-2"], ); }); test("insertNodeRelative after by anchorNodeId", () => { const input = doc(para("p-1", textNode("one")), para("p-2", textNode("two"))); const node = para("new", textNode("NEW")); const { doc: out, inserted } = insertNodeRelative(input, node, { position: "after", anchorNodeId: "p-1", }); assert.equal(inserted, true); assert.deepEqual( out.content.map((n) => n.attrs.id), ["p-1", "new", "p-2"], ); }); test("insertNodeRelative before/after by anchorNodeId reaches a nested sibling", () => { const input = doc( callout("c-1", para("a", textNode("a")), para("b", textNode("b"))), ); const node = para("new", textNode("NEW")); const { doc: out, inserted } = insertNodeRelative(input, node, { position: "after", anchorNodeId: "a", }); assert.equal(inserted, true); // Inserted as a sibling inside the callout's content array. assert.deepEqual( out.content[0].content.map((n) => n.attrs.id), ["a", "new", "b"], ); }); test("insertNodeRelative before by anchorText (top-level)", () => { const input = doc( para("p-1", textNode("alpha")), para("p-2", textNode("beta")), ); const node = para("new", textNode("NEW")); const { doc: out, inserted } = insertNodeRelative(input, node, { position: "before", anchorText: "beta", }); assert.equal(inserted, true); assert.deepEqual( out.content.map((n) => n.attrs.id), ["p-1", "new", "p-2"], ); }); test("insertNodeRelative after by anchorText (top-level)", () => { const input = doc( para("p-1", textNode("alpha")), para("p-2", textNode("beta")), ); const node = para("new", textNode("NEW")); const { doc: out, inserted } = insertNodeRelative(input, node, { position: "after", anchorText: "alpha", }); assert.equal(inserted, true); assert.deepEqual( out.content.map((n) => n.attrs.id), ["p-1", "new", "p-2"], ); }); test("insertNodeRelative anchorText scans TOP-LEVEL blocks via recursive plain text", () => { // anchorText matches the FIRST top-level block whose (recursive) blockPlainText // includes the string. "deeptext" lives nested in a top-level callout, so the // callout itself is the matched top-level block and the node lands as its // sibling at the top level (not inside the callout). const input = doc( callout("c-1", para("inner", textNode("deeptext"))), para("p-2", textNode("tail")), ); const node = para("new", textNode("NEW")); const { doc: out, inserted } = insertNodeRelative(input, node, { position: "after", anchorText: "deeptext", }); assert.equal(inserted, true); assert.deepEqual( out.content.map((n) => n.attrs.id), ["c-1", "new", "p-2"], ); }); test("insertNodeRelative anchorText does NOT match text only present below top level when no top-level block contains it", () => { // The only block whose plain text includes "lonely" is a paragraph nested two // levels deep, but the top-level scan still sees it through the callout's // recursive plain text. To prove the scan is TOP-LEVEL (parent-array) only, // assert the insertion happens at the top level beside the callout, never // inside it. const input = doc(callout("c-1", para("inner", textNode("lonely word")))); const node = para("new", textNode("NEW")); const { doc: out, inserted } = insertNodeRelative(input, node, { position: "before", anchorText: "lonely", }); assert.equal(inserted, true); // Inserted at the top level (siblings of the callout), not into the callout. assert.deepEqual( out.content.map((n) => n.attrs.id), ["new", "c-1"], ); // The callout's own children are untouched. assert.deepEqual( out.content[1].content.map((n) => n.attrs.id), ["inner"], ); }); test("insertNodeRelative append pushes the node at the end of top-level content", () => { const input = doc(para("p-1", textNode("one")), para("p-2", textNode("two"))); const node = para("new", textNode("NEW")); const { doc: out, inserted } = insertNodeRelative(input, node, { position: "append", }); assert.equal(inserted, true); assert.deepEqual( out.content.map((n) => n.attrs.id), ["p-1", "p-2", "new"], ); }); test("insertNodeRelative inserted===false when anchorNodeId missing", () => { const input = doc(para("p-1", textNode("one"))); const node = para("new", textNode("NEW")); const { doc: out, inserted } = insertNodeRelative(input, node, { position: "after", anchorNodeId: "nope", }); assert.equal(inserted, false); assert.deepEqual(out, input); }); test("insertNodeRelative inserted===false when anchorText missing", () => { const input = doc(para("p-1", textNode("one"))); const node = para("new", textNode("NEW")); const { inserted } = insertNodeRelative(input, node, { position: "before", anchorText: "nomatch", }); assert.equal(inserted, false); }); test("insertNodeRelative does NOT mutate input (deep-equal snapshot)", () => { const input = doc(para("p-1", textNode("one")), para("p-2", textNode("two"))); const snap = snapshot(input); const node = para("new", textNode("NEW")); const { doc: out } = insertNodeRelative(input, node, { position: "after", anchorNodeId: "p-1", }); assert.deepEqual(input, snap); assert.notEqual(out, input); }); // --------------------------------------------------------------------------- // anchorText markdown-normalization fallback (locating only) // --------------------------------------------------------------------------- test("insertNodeRelative before by markdown-wrapped anchorText matches the plain block", () => { const input = doc( para("p-1", textNode("alpha")), para("p-2", textNode("beta")), ); const node = para("new", textNode("NEW")); // "**beta**" has no verbatim match; the stripped "beta" matches "p-2". const { doc: out, inserted } = insertNodeRelative(input, node, { position: "before", anchorText: "**beta**", }); assert.equal(inserted, true); assert.deepEqual( out.content.map((n) => n.attrs.id), ["p-1", "new", "p-2"], ); }); test("insertNodeRelative after by markdown-wrapped anchorText matches the plain block", () => { const input = doc( para("p-1", textNode("alpha")), para("p-2", textNode("beta")), ); const node = para("new", textNode("NEW")); const { doc: out, inserted } = insertNodeRelative(input, node, { position: "after", anchorText: "**alpha**", }); assert.equal(inserted, true); assert.deepEqual( out.content.map((n) => n.attrs.id), ["p-1", "new", "p-2"], ); }); test("insertNodeRelative anchorText with markdown AND a trailing emoji matches the plain block", () => { const input = doc( para("p-1", textNode("alpha")), para("p-2", textNode("beta")), ); const node = para("new", textNode("NEW")); const { doc: out, inserted } = insertNodeRelative(input, node, { position: "before", anchorText: "**beta** ✨", }); assert.equal(inserted, true); assert.deepEqual( out.content.map((n) => n.attrs.id), ["p-1", "new", "p-2"], ); }); test("insertNodeRelative exact anchorText still wins (no normalization)", () => { // A block literally contains "a*b"; the exact anchor must match it directly. const input = doc( para("p-1", textNode("a*b")), para("p-2", textNode("beta")), ); const node = para("new", textNode("NEW")); const { doc: out, inserted } = insertNodeRelative(input, node, { position: "after", anchorText: "a*b", }); assert.equal(inserted, true); assert.deepEqual( out.content.map((n) => n.attrs.id), ["p-1", "new", "p-2"], ); }); test("insertNodeRelative truly-missing anchor still returns inserted:false", () => { const input = doc( para("p-1", textNode("alpha")), para("p-2", textNode("beta")), ); const node = para("new", textNode("NEW")); const { inserted } = insertNodeRelative(input, node, { position: "before", anchorText: "**gamma**", }); assert.equal(inserted, false); }); // assertUnambiguousMatch (#159, #185 review pt 2): the patch_node/delete_node // guard. Docmost duplicates block ids on copy/paste, so a write by id that // matches >1 node must be REFUSED (the caller already skipped the write for any // count !== 1; this reports the error). The duplicate COUNT itself is covered by // the replaceNodeById/deleteNodeById tests above (count===2 for a 2-dup doc). test("assertUnambiguousMatch: count 0 throws 'no node found'", () => { assert.throws( () => assertUnambiguousMatch("patch_node", "replace", 0, "n1", "p1"), /patch_node: no node with id "n1" found on page p1/, ); }); test("assertUnambiguousMatch: count > 1 refuses with an 'ambiguous' error", () => { assert.throws( () => assertUnambiguousMatch("patch_node", "replace", 2, "dup", "p1"), /ambiguous.*Refusing to replace all of them; nothing was changed/, ); assert.throws( () => assertUnambiguousMatch("delete_node", "delete", 3, "dup", "p1"), /ambiguous.*Refusing to delete all of them; nothing was changed/, ); }); test("assertUnambiguousMatch: exactly one match does NOT throw", () => { assert.doesNotThrow(() => assertUnambiguousMatch("patch_node", "replace", 1, "n1", "p1"), ); assert.doesNotThrow(() => assertUnambiguousMatch("delete_node", "delete", 1, "n1", "p1"), ); });