diff --git a/packages/editor-ext/src/lib/recreate-transform/recreateTransform.test.ts b/packages/editor-ext/src/lib/recreate-transform/recreateTransform.test.ts new file mode 100644 index 00000000..a30dc3d2 --- /dev/null +++ b/packages/editor-ext/src/lib/recreate-transform/recreateTransform.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect } from "vitest"; +import { schema } from "@tiptap/pm/schema-basic"; +import type { Node as PMNode } from "@tiptap/pm/model"; +import { Transform } from "@tiptap/pm/transform"; +import { recreateTransform } from "./recreateTransform"; + +/** + * recreateTransform diffs two documents and produces ProseMirror steps that turn + * `fromDoc` into `toDoc`. It is the backbone of collaborative/version diffing, so + * THE invariant that matters is: replaying the produced steps on `fromDoc` must + * reproduce `toDoc` exactly. Every test below re-applies the steps onto a fresh + * Transform seeded from `fromDoc` (not just trusting `tr.doc`) and asserts node + * equality with `.eq()`. If a regression makes any step wrong, the round-trip + * breaks and the test fails. + */ + +// Real ProseMirror schema (the standard basic schema) with paragraph/heading + +// strong/em marks — the same primitives the editor diffs in production. +const doc = (...c: PMNode[]) => schema.node("doc", null, c); +const p = (...c: PMNode[]) => + schema.node("paragraph", null, c.length ? c : undefined); +const h = (level: number, ...c: PMNode[]) => + schema.node("heading", { level }, c); +const t = (text: string, ...marks: any[]) => + schema.text(text, marks.length ? marks : undefined); +const strong = schema.marks.strong.create(); +const em = schema.marks.em.create(); + +// Replay the diff's steps onto a fresh Transform built from `fromDoc`. This is +// the faithful "apply(diff) == target" check — it exercises the actual Step +// objects rather than the transform's internal accumulated doc. +function applyDiff(fromDoc: PMNode, toDoc: PMNode, options?: any): PMNode { + const tr = recreateTransform(fromDoc, toDoc, options); + const replay = new Transform(fromDoc); + tr.steps.forEach((s) => { + const result = replay.maybeStep(s); + if (result.failed) throw new Error(`step failed: ${result.failed}`); + }); + return replay.doc; +} + +describe("recreateTransform round-trip (apply(diff) == target)", () => { + it("reconstructs the target on plain text insertion", () => { + // Inserting " world" must yield exactly the target paragraph. + const from = doc(p(t("hello"))); + const to = doc(p(t("hello world"))); + expect(applyDiff(from, to).eq(to)).toBe(true); + }); + + it("reconstructs the target on text deletion", () => { + // Deleting a trailing word is the inverse of insertion and must round-trip. + const from = doc(p(t("hello world"))); + const to = doc(p(t("hello"))); + expect(applyDiff(from, to).eq(to)).toBe(true); + }); + + it("reconstructs the target when a word is replaced mid-string", () => { + // A char-level replace in the middle must not corrupt the surrounding text. + const from = doc(p(t("the quick brown fox"))); + const to = doc(p(t("the slow brown fox"))); + expect(applyDiff(from, to).eq(to)).toBe(true); + }); + + it("reconstructs the target when a mark is added (complexSteps path)", () => { + // Mark-only changes are diffed in a separate pass; the bolded run must match. + const from = doc(p(t("hello"))); + const to = doc(p(t("hello", strong))); + const out = applyDiff(from, to); + expect(out.eq(to)).toBe(true); + // Sanity: the produced doc actually carries the strong mark. + expect(out.firstChild!.firstChild!.marks.length).toBe(1); + }); + + it("reconstructs the target when a mark is removed", () => { + // Removing the only mark must leave the same text with no marks. + const from = doc(p(t("hello", strong))); + const to = doc(p(t("hello"))); + const out = applyDiff(from, to); + expect(out.eq(to)).toBe(true); + expect(out.firstChild!.firstChild!.marks.length).toBe(0); + }); + + it("reconstructs the target on a paragraph split into two blocks", () => { + // Structural change (one block -> two) must replay as valid replace steps. + const from = doc(p(t("hello world"))); + const to = doc(p(t("hello")), p(t("world"))); + const out = applyDiff(from, to); + expect(out.eq(to)).toBe(true); + expect(out.childCount).toBe(2); + }); + + it("reconstructs the target on a node-type change (paragraph -> heading)", () => { + // Type/attrs changes drive the setNodeMarkup branch; the node must become a + // heading while keeping its text. + const from = doc(p(t("hello"))); + const to = doc(h(1, t("hello"))); + const out = applyDiff(from, to); + expect(out.eq(to)).toBe(true); + expect(out.firstChild!.type.name).toBe("heading"); + }); + + it("reconstructs a combined structural + mark change", () => { + // Several diff kinds at once (new block + italic run) still round-trips. + const from = doc(p(t("alpha"))); + const to = doc(p(t("alpha")), p(t("beta", em))); + const out = applyDiff(from, to); + expect(out.eq(to)).toBe(true); + }); + + it("produces an empty step list for identical documents", () => { + // No diff => no work; spurious steps would mean wasted/incorrect history. + const from = doc(p(t("same"))); + const to = doc(p(t("same"))); + const tr = recreateTransform(from, to); + expect(tr.steps.length).toBe(0); + expect(tr.doc.eq(to)).toBe(true); + }); + + it("round-trips with complexSteps:false (marks diffed as replaces)", () => { + // With complexSteps off, mark changes are folded into replace steps rather + // than dedicated mark steps — the result must still equal the target. + const from = doc(p(t("hello"))); + const to = doc(p(t("hello", strong))); + expect(applyDiff(from, to, { complexSteps: false }).eq(to)).toBe(true); + }); + + it("round-trips with wordDiffs:true (whole-word text diffing)", () => { + // wordDiffs changes the granularity of the text diff, not the outcome. + const from = doc(p(t("the quick brown fox"))); + const to = doc(p(t("the quick red fox"))); + expect(applyDiff(from, to, { wordDiffs: true }).eq(to)).toBe(true); + }); +}); diff --git a/packages/editor-ext/src/lib/table/utils/get-selection-range-in-column.test.ts b/packages/editor-ext/src/lib/table/utils/get-selection-range-in-column.test.ts new file mode 100644 index 00000000..7e623c60 --- /dev/null +++ b/packages/editor-ext/src/lib/table/utils/get-selection-range-in-column.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from "vitest"; +import { Schema } from "@tiptap/pm/model"; +import type { Node as PMNode } from "@tiptap/pm/model"; +import { tableNodes } from "@tiptap/pm/tables"; +import { EditorState, Selection } from "@tiptap/pm/state"; +import { getSelectionRangeInColumn } from "./get-selection-range-in-column"; + +/** + * getSelectionRangeInColumn computes the rectangular column range (the set of + * column indexes, plus anchor/head cell positions) that a drag-reorder or + * column-select operation should act on, accounting for merged (colspan) cells. + * It keys off the table found from the current selection, so we drive it with a + * real EditorState whose selection sits inside the table. + */ + +// Real ProseMirror table schema (same primitives the editor uses) so TableMap / +// cellsInRect behave exactly as in production. +const tNodes = tableNodes({ + tableGroup: "block", + cellContent: "inline*", + cellAttributes: {}, +}); +const schema = new Schema({ + nodes: { + doc: { content: "block+" }, + paragraph: { group: "block", content: "inline*", toDOM: () => ["p", 0] }, + text: { group: "inline" }, + ...tNodes, + }, + marks: {}, +}); +const cell = (txt: string, attrs?: Record): PMNode => + schema.nodes.table_cell.createChecked(attrs ?? null, schema.text(txt)); +const row = (...cells: PMNode[]): PMNode => + schema.nodes.table_row.createChecked(null, cells); +const table = (...rows: PMNode[]): PMNode => + schema.nodes.table.createChecked(null, rows); +const doc = (...content: PMNode[]): PMNode => + schema.nodes.doc.createChecked(null, content); + +// Build a transaction whose selection is inside the table (the function locates +// the table via `tr.selection.$from`). +const trFor = (d: PMNode) => + EditorState.create({ doc: d, selection: Selection.atStart(d) }).tr; + +// A 2-row x 3-col grid; each column is identifiable by its top-row letter. +const grid3x2 = () => + doc( + table( + row(cell("a"), cell("b"), cell("c")), + row(cell("d"), cell("e"), cell("f")), + ), + ); + +describe("getSelectionRangeInColumn", () => { + it("returns a single-column range for a single index", () => { + // Asking for column 1 yields exactly indexes [1]. + const tr = trFor(grid3x2()); + const range = getSelectionRangeInColumn(tr, 1); + expect(range).toBeTruthy(); + expect(range!.indexes).toEqual([1]); + }); + + it("anchor/head resolve to the top and bottom cells OF the requested column", () => { + // $head must point at the column's first (top) cell and $anchor at its last + // (bottom) cell — pinning that the returned positions belong to column 1, + // not some other column. + const tr = trFor(grid3x2()); + const range = getSelectionRangeInColumn(tr, 1)!; + expect(tr.doc.nodeAt(range.$head.pos)?.textContent).toBe("b"); // top of col 1 + expect(tr.doc.nodeAt(range.$anchor.pos)?.textContent).toBe("e"); // bottom of col 1 + }); + + it("returns the inclusive span of columns for a multi-column request", () => { + // A 0..2 request must enumerate every covered column, in order. + const tr = trFor(grid3x2()); + const range = getSelectionRangeInColumn(tr, 0, 2); + expect(range!.indexes).toEqual([0, 1, 2]); + }); + + it("returns a two-column span for an adjacent pair", () => { + const tr = trFor(grid3x2()); + const range = getSelectionRangeInColumn(tr, 1, 2); + expect(range!.indexes).toEqual([1, 2]); + }); + + it("expands the range to cover a horizontally merged (colspan) cell", () => { + // Row 0 col 0 spans 2 columns. Requesting just column 0 must pull column 1 + // into the range because they are merged together in the top row. + const d = doc( + table( + row(cell("ab", { colspan: 2 }), cell("c")), + row(cell("d"), cell("e"), cell("f")), + ), + ); + const tr = trFor(d); + const range = getSelectionRangeInColumn(tr, 0); + expect(range!.indexes).toEqual([0, 1]); + }); + + it("throws when the requested column is entirely out of range", () => { + // No cells exist at column 5 of a 3-wide table, so the function cannot pick + // an anchor cell and dereferences undefined — pin this as the current + // (caller-guarded) contract so a silent behavior change is caught. + const tr = trFor(grid3x2()); + expect(() => getSelectionRangeInColumn(tr, 5)).toThrow(); + }); +}); diff --git a/packages/editor-ext/src/lib/table/utils/move-column.test.ts b/packages/editor-ext/src/lib/table/utils/move-column.test.ts new file mode 100644 index 00000000..5b2d60e5 --- /dev/null +++ b/packages/editor-ext/src/lib/table/utils/move-column.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect } from "vitest"; +import { Schema } from "@tiptap/pm/model"; +import type { Node as PMNode } from "@tiptap/pm/model"; +import { tableNodes, CellSelection } from "@tiptap/pm/tables"; +import { EditorState, Selection } from "@tiptap/pm/state"; +import { moveColumn } from "./move-column"; +import { convertTableNodeToArrayOfRows } from "./convert-table-node-to-array-of-rows"; +import { findTable } from "./query"; + +/** + * moveColumn reorders whole columns of a real ProseMirror table by mutating a + * Transaction (transpose -> move row -> transpose back -> replace). The invariant + * is that after the call each column appears at its new position with every + * cell's content preserved and nothing dropped or duplicated. + */ + +const tNodes = tableNodes({ + tableGroup: "block", + cellContent: "inline*", + cellAttributes: {}, +}); +const schema = new Schema({ + nodes: { + doc: { content: "block+" }, + paragraph: { group: "block", content: "inline*", toDOM: () => ["p", 0] }, + text: { group: "inline" }, + ...tNodes, + }, + marks: {}, +}); +const cell = (txt: string): PMNode => + schema.nodes.table_cell.createChecked(null, schema.text(txt)); +const row = (...cells: PMNode[]): PMNode => + schema.nodes.table_row.createChecked(null, cells); +const table = (...rows: PMNode[]): PMNode => + schema.nodes.table.createChecked(null, rows); +const doc = (...content: PMNode[]): PMNode => + schema.nodes.doc.createChecked(null, content); + +const grid = (tr: any): string[][] => { + const t = findTable(tr.doc.resolve(tr.selection.from))!; + return convertTableNodeToArrayOfRows(t.node).map((r) => + r.map((c) => (c ? c.textContent : "")), + ); +}; + +// 2-row x 3-col table; column k is (rowX-col-k). Columns: 0=(a,d) 1=(b,e) 2=(c,f). +const grid3x2 = () => + doc( + table( + row(cell("a"), cell("b"), cell("c")), + row(cell("d"), cell("e"), cell("f")), + ), + ); + +const stateFor = (d: PMNode) => + EditorState.create({ doc: d, selection: Selection.atStart(d) }); + +describe("moveColumn", () => { + it("moves the first column to the last index, preserving column content", () => { + // origin 0 -> target 2 sends column (a,d) to the right: cols become 1,2,0. + const state = stateFor(grid3x2()); + const tr = state.tr; + const ok = moveColumn({ + tr, + originIndex: 0, + targetIndex: 2, + select: false, + pos: state.selection.from, + }); + expect(ok).toBe(true); + expect(grid(tr)).toEqual([ + ["b", "c", "a"], + ["e", "f", "d"], + ]); + }); + + it("moves a later column to the first index", () => { + // origin 2 -> target 0 pulls column (c,f) to the front: cols become 2,0,1. + const state = stateFor(grid3x2()); + const tr = state.tr; + const ok = moveColumn({ + tr, + originIndex: 2, + targetIndex: 0, + select: false, + pos: state.selection.from, + }); + expect(ok).toBe(true); + expect(grid(tr)).toEqual([ + ["c", "a", "b"], + ["f", "d", "e"], + ]); + }); + + it("never drops or duplicates cells when reordering columns", () => { + const state = stateFor(grid3x2()); + const tr = state.tr; + moveColumn({ + tr, + originIndex: 1, + targetIndex: 2, + select: false, + pos: state.selection.from, + }); + expect(grid(tr).flat().sort()).toEqual( + ["a", "b", "c", "d", "e", "f"].sort(), + ); + expect(grid(tr)[0].length).toBe(3); + }); + + it("returns false (no-op) when target equals origin", () => { + const state = stateFor(grid3x2()); + const tr = state.tr; + const before = grid(tr); + const ok = moveColumn({ + tr, + originIndex: 1, + targetIndex: 1, + select: false, + pos: state.selection.from, + }); + expect(ok).toBe(false); + expect(grid(tr)).toEqual(before); + }); + + it("returns false when pos is not inside a table", () => { + const d = doc( + schema.nodes.paragraph.createChecked(null, schema.text("plain")), + ); + const state = stateFor(d); + const tr = state.tr; + const ok = moveColumn({ + tr, + originIndex: 0, + targetIndex: 1, + select: false, + pos: state.selection.from, + }); + expect(ok).toBe(false); + }); + + it("installs a CellSelection on the moved column when select is true", () => { + const state = stateFor(grid3x2()); + const tr = state.tr; + const ok = moveColumn({ + tr, + originIndex: 0, + targetIndex: 2, + select: true, + pos: state.selection.from, + }); + expect(ok).toBe(true); + expect(tr.selection instanceof CellSelection).toBe(true); + }); +}); diff --git a/packages/editor-ext/src/lib/table/utils/move-row.test.ts b/packages/editor-ext/src/lib/table/utils/move-row.test.ts new file mode 100644 index 00000000..3a0c481a --- /dev/null +++ b/packages/editor-ext/src/lib/table/utils/move-row.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect } from "vitest"; +import { Schema } from "@tiptap/pm/model"; +import type { Node as PMNode } from "@tiptap/pm/model"; +import { tableNodes, CellSelection } from "@tiptap/pm/tables"; +import { EditorState, Selection } from "@tiptap/pm/state"; +import { moveRow } from "./move-row"; +import { convertTableNodeToArrayOfRows } from "./convert-table-node-to-array-of-rows"; +import { findTable } from "./query"; + +/** + * moveRow reorders whole rows of a real ProseMirror table by mutating a + * Transaction: it locates the table, computes origin/target row ranges, rebuilds + * the table with rows reordered, and replaces it in the doc. The invariant is + * that after the call the table's rows appear in the new order with every cell's + * content preserved, and no rows are dropped or duplicated. + */ + +const tNodes = tableNodes({ + tableGroup: "block", + cellContent: "inline*", + cellAttributes: {}, +}); +const schema = new Schema({ + nodes: { + doc: { content: "block+" }, + paragraph: { group: "block", content: "inline*", toDOM: () => ["p", 0] }, + text: { group: "inline" }, + ...tNodes, + }, + marks: {}, +}); +const cell = (txt: string): PMNode => + schema.nodes.table_cell.createChecked(null, schema.text(txt)); +const row = (...cells: PMNode[]): PMNode => + schema.nodes.table_row.createChecked(null, cells); +const table = (...rows: PMNode[]): PMNode => + schema.nodes.table.createChecked(null, rows); +const doc = (...content: PMNode[]): PMNode => + schema.nodes.doc.createChecked(null, content); + +// Read the table's content as a grid of cell texts (rows x cols) from whatever +// table currently lives in `tr.doc`. +const grid = (tr: any): string[][] => { + const t = findTable(tr.doc.resolve(tr.selection.from))!; + return convertTableNodeToArrayOfRows(t.node).map((r) => + r.map((c) => (c ? c.textContent : "")), + ); +}; + +// 3-row x 2-col table; each row identifiable by its cells. +const grid2x3 = () => + doc( + table( + row(cell("r0a"), cell("r0b")), + row(cell("r1a"), cell("r1b")), + row(cell("r2a"), cell("r2b")), + ), + ); + +const stateFor = (d: PMNode) => + EditorState.create({ doc: d, selection: Selection.atStart(d) }); + +describe("moveRow", () => { + it("moves the first row down to the last index, preserving content", () => { + // origin 0 -> target 2 makes row 0 land after the other rows: [r1, r2, r0]. + const state = stateFor(grid2x3()); + const tr = state.tr; + const ok = moveRow({ + tr, + originIndex: 0, + targetIndex: 2, + select: false, + pos: state.selection.from, + }); + expect(ok).toBe(true); + expect(grid(tr)).toEqual([ + ["r1a", "r1b"], + ["r2a", "r2b"], + ["r0a", "r0b"], + ]); + }); + + it("moves a lower row up to an earlier index", () => { + // origin 2 -> target 0 lifts the last row above the rest: [r2, r0, r1]. + const state = stateFor(grid2x3()); + const tr = state.tr; + const ok = moveRow({ + tr, + originIndex: 2, + targetIndex: 0, + select: false, + pos: state.selection.from, + }); + expect(ok).toBe(true); + expect(grid(tr)).toEqual([ + ["r2a", "r2b"], + ["r0a", "r0b"], + ["r1a", "r1b"], + ]); + }); + + it("never drops or duplicates rows when reordering", () => { + // The full multiset of cell texts is invariant under any valid move. + const state = stateFor(grid2x3()); + const tr = state.tr; + moveRow({ + tr, + originIndex: 1, + targetIndex: 2, + select: false, + pos: state.selection.from, + }); + const flat = grid(tr).flat().sort(); + expect(flat).toEqual( + ["r0a", "r0b", "r1a", "r1b", "r2a", "r2b"].sort(), + ); + expect(grid(tr).length).toBe(3); + }); + + it("returns false (no-op) when target equals origin", () => { + // Moving a row onto itself is rejected and leaves the table unchanged. + const state = stateFor(grid2x3()); + const tr = state.tr; + const before = grid(tr); + const ok = moveRow({ + tr, + originIndex: 1, + targetIndex: 1, + select: false, + pos: state.selection.from, + }); + expect(ok).toBe(false); + expect(grid(tr)).toEqual(before); + }); + + it("returns false when pos is not inside a table", () => { + // Without a table at `pos`, the function bails out instead of throwing. + const d = doc( + schema.nodes.paragraph.createChecked(null, schema.text("plain")), + ); + const state = stateFor(d); + const tr = state.tr; + const ok = moveRow({ + tr, + originIndex: 0, + targetIndex: 1, + select: false, + pos: state.selection.from, + }); + expect(ok).toBe(false); + }); + + it("installs a CellSelection on the moved row when select is true", () => { + // With select:true the moved row at the target index is selected. + const state = stateFor(grid2x3()); + const tr = state.tr; + const ok = moveRow({ + tr, + originIndex: 0, + targetIndex: 2, + select: true, + pos: state.selection.from, + }); + expect(ok).toBe(true); + expect(tr.selection instanceof CellSelection).toBe(true); + }); +}); diff --git a/packages/editor-ext/src/lib/unique-id/unique-id.util.test.ts b/packages/editor-ext/src/lib/unique-id/unique-id.util.test.ts index 24d30408..64603b5b 100644 --- a/packages/editor-ext/src/lib/unique-id/unique-id.util.test.ts +++ b/packages/editor-ext/src/lib/unique-id/unique-id.util.test.ts @@ -100,4 +100,51 @@ describe("addUniqueIdsToDoc", () => { const [id] = ids(out); expect(id).toBeTruthy(); }); + + it("only assigns ids to configured node types, not to others", () => { + // `types` is ["heading","paragraph"]; a codeBlock is NOT addressed, so it + // must come back without an id while the sibling paragraph is filled. (The + // UniqueID attribute only exists on configured types in the schema.) + const doc = { + type: "doc", + content: [ + { type: "codeBlock", content: [{ type: "text", text: "x = 1" }] }, + para(undefined, "after"), + ], + }; + const out = addUniqueIdsToDoc(doc, extensions); + const [codeId, paraId] = ids(out); + expect(codeId).toBeUndefined(); + expect(paraId).toBeTruthy(); + }); + + it("assigns ids to target nodes nested inside non-target containers", () => { + // findChildren walks the whole tree: a paragraph inside a blockquote still + // gets an id, while the (non-target) blockquote wrapper does not. + const doc = { + type: "doc", + content: [ + { type: "blockquote", content: [para(undefined, "quoted")] }, + ], + }; + const out = addUniqueIdsToDoc(doc, extensions) as any; + const blockquote = out.content[0]; + const nestedPara = blockquote.content[0]; + expect(blockquote.attrs?.id).toBeUndefined(); + expect(nestedPara.attrs.id).toBeTruthy(); + }); + + it("is idempotent: a second pass keeps every already-unique id unchanged", () => { + // Once ids are assigned and unique, re-running must be a fixed point — no + // churn that would invalidate stored MCP anchors on every save. + const doc = { + type: "doc", + content: [para(undefined, "a"), para(undefined, "b"), para(undefined, "c")], + }; + const once = addUniqueIdsToDoc(doc, extensions); + const twice = addUniqueIdsToDoc(once, extensions); + expect(ids(twice)).toEqual(ids(once)); + // And all three are distinct, so the second pass had real ids to preserve. + expect(new Set(ids(once)).size).toBe(3); + }); });