test(editor-ext): cover recreateTransform invariant, table move/selection, unique-id
recreateTransform: apply(diff)==target round-trip across text/mark/structural edits and complexSteps/wordDiffs options. moveRow/moveColumn drive real PM tables (reorder preserves content, self-move/no-table -> false, CellSelection on select). getSelectionRangeInColumn: single/multi-column + colspan + range guard. addUniqueIdsToDoc: only configured types, nested targets, idempotency. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>): 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();
|
||||
});
|
||||
});
|
||||
156
packages/editor-ext/src/lib/table/utils/move-column.test.ts
Normal file
156
packages/editor-ext/src/lib/table/utils/move-column.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
167
packages/editor-ext/src/lib/table/utils/move-row.test.ts
Normal file
167
packages/editor-ext/src/lib/table/utils/move-row.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user