// Tables: the string model under the live-preview table editor.
//
// Everything here exercises client/editor/tableModel.ts — the pure half of
// the table feature, split from tables.ts precisely so this file can load it
// under `node --test` (the CodeMirror/renderer half drags .css through its
// import chain, which node cannot parse).
//
// The invariants that matter, in order of how expensive their violation is:
// 1. A structural edit (column move, prettify) NEVER rewrites cell content —
// `\|` escapes and pipes inside inline code included. Splitting is the
// only operation allowed to look inside a cell.
// 2. A column move carries the alignment row with it. A move that shears
// the delimiter walks every column's alignment into its neighbour's,
// which corrupts silently — the table still parses, it just lies.
// 3. Formatting is idempotent and refuses non-tables: it runs unattended on
// every caret exit, so "almost a table" must round-trip untouched.
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
alignName,
cellEdit,
cellText,
colIndexAt,
columnCount,
deleteTableColumn,
deleteTableRow,
displayWidth,
duplicateTableRow,
emptyRowText,
escapeCellText,
formatTable,
insertTableColumn,
insertTableRow,
moveTableColumn,
moveTableRow,
parseTable,
rowCount,
rowIndexAt,
setColumnAlign,
sortTableRows,
splitRowCells,
tableSkeleton,
} from "../client/editor/tableModel.ts";
const TABLE = ["| Name | Qty | Price |", "| :--- | :-: | ----: |", "| Apple | 3 | $1.20 |", "| Pear | 12 | $0.90 |"].join("\n");
describe("splitRowCells", () => {
it("splits a piped row into trimmed cells with source offsets", () => {
const cells = splitRowCells("| a | bc |", 100);
assert.equal(cells.length, 2);
assert.deepEqual(cells.map((c) => c.text), ["a", "bc"]);
// "| a | bc |" — 'a' at index 2, 'bc' at 6.
assert.equal(cells[0].trimFrom, 102);
assert.equal(cells[0].trimTo, 103);
assert.equal(cells[1].trimFrom, 106);
assert.equal(cells[1].trimTo, 108);
});
it("handles rows without leading/trailing pipes", () => {
assert.deepEqual(splitRowCells("a | b").map((c) => c.text), ["a", "b"]);
assert.deepEqual(splitRowCells("| a | b").map((c) => c.text), ["a", "b"]);
assert.deepEqual(splitRowCells("a | b |").map((c) => c.text), ["a", "b"]);
});
it("keeps \\| inside a cell and lets \\\\| split (parity, not presence)", () => {
assert.deepEqual(splitRowCells("| a \\| b | c |").map((c) => c.text), ["a \\| b", "c"]);
// Backslash-backslash is a literal backslash; the pipe after it is real.
assert.deepEqual(splitRowCells("| a \\\\| b |").map((c) => c.text), ["a \\\\", "b"]);
});
it("collapses an empty cell's caret target one character in from the pipe", () => {
const cells = splitRowCells("| | |");
assert.equal(cells.length, 2);
// `| |` — raw segment starts at 1; the caret goes to 2, between the
// pads, not flush against the wall.
assert.equal(cells[0].trimFrom, 2);
assert.equal(cells[0].trimTo, 2);
});
it("keeps a trailing escaped pipe as content, not punctuation", () => {
assert.deepEqual(splitRowCells("| a | b \\|").map((c) => c.text), ["a", "b \\|"]);
});
});
describe("parseTable", () => {
it("reads header, delimiter, alignment and body", () => {
const shape = parseTable(TABLE);
assert.ok(shape);
assert.deepEqual(shape.header.cells.map((c) => c.text), ["Name", "Qty", "Price"]);
assert.equal(shape.body.length, 2);
assert.deepEqual(shape.aligns, [
{ left: true, right: false },
{ left: true, right: true },
{ left: false, right: true },
]);
});
it("refuses when the second line is not a delimiter row", () => {
assert.equal(parseTable("| a | b |\n| c | d |"), null);
assert.equal(parseTable("just prose"), null);
assert.equal(parseTable("| lone header |"), null);
});
it("maps the delimiter line to the header for navigation", () => {
const shape = parseTable(TABLE)!;
assert.equal(rowIndexAt(shape, shape.delimiter.from + 3), 0);
assert.equal(rowIndexAt(shape, shape.body[1].from + 2), 2);
assert.equal(rowIndexAt(shape, shape.to + 5), null);
});
it("assigns an offset on a pipe to the nearest unfinished cell", () => {
const shape = parseTable(TABLE)!;
// Offset 0 is the leading pipe of the header — cell 0's problem.
assert.equal(colIndexAt(shape.header, shape.from), 0);
assert.equal(colIndexAt(shape.header, shape.header.to), 2);
});
});
describe("displayWidth", () => {
it("counts CJK and emoji as two columns, Latin and Arabic as one", () => {
assert.equal(displayWidth("abc"), 3);
assert.equal(displayWidth("表格"), 4);
assert.equal(displayWidth("表a"), 3);
assert.equal(displayWidth("🙂"), 2);
assert.equal(displayWidth("جدول"), 4); // Arabic is cursive, not wide
});
it("clusters combining marks with their base", () => {
// e + combining acute is one grapheme, one column.
assert.equal(displayWidth("é"), 1);
});
});
describe("formatTable", () => {
it("pads every column to its widest cell and stretches the delimiter", () => {
const pretty = formatTable("| a | bbbb |\n| - | - |\n| cc | d |");
assert.equal(pretty, ["| a | bbbb |", "| --- | ---- |", "| cc | d |"].join("\n"));
});
it("is idempotent", () => {
const once = formatTable(TABLE);
assert.equal(formatTable(once), once);
});
it("keeps explicit alignment colons where the author put them", () => {
const pretty = formatTable(TABLE);
const delim = pretty.split("\n")[1];
assert.match(delim, /^\| :-+ \| :-+: \| -+: \|$/);
});
it("pads right- and center-aligned cells on the correct side", () => {
const pretty = formatTable("| head | mid |\n| ---: | :-: |\n| x | y |");
const row = pretty.split("\n")[2];
assert.equal(row, "| x | y |");
});
it("never rewrites cell content: escapes and code-span pipes survive", () => {
const src = "| a \\| b | `x` |\n| - | - |\n| `c \\| d` | e |";
const before = parseTable(src)!;
const after = parseTable(formatTable(src))!;
assert.deepEqual(
after.header.cells.map((c) => c.text),
before.header.cells.map((c) => c.text),
);
assert.deepEqual(
after.body[0].cells.map((c) => c.text),
before.body[0].cells.map((c) => c.text),
);
});
it("squares off short rows and adopts stray extra columns", () => {
const pretty = formatTable("| a | b |\n| - | - |\n| only |\n| x | y | extra |");
const lines = pretty.split("\n");
// Every line ends with the same number of cells.
for (const line of lines) {
assert.equal(splitRowCells(line).length, 3);
}
});
it("lines the pipes up even when a column holds CJK", () => {
const pretty = formatTable("| 表格 | b |\n| - | - |\n| x | 说明文字 |");
const widths = pretty.split("\n").map((l) => displayWidth(l));
assert.equal(new Set(widths).size, 1);
});
it("returns a non-table untouched", () => {
const notATable = "prose with | a pipe\nand another line";
assert.equal(formatTable(notATable), notATable);
});
});
describe("moveTableColumn", () => {
it("moves header, alignment row and every body row together", () => {
const res = moveTableColumn(TABLE, 0, 1);
assert.ok(res);
assert.equal(res.col, 1);
const shape = parseTable(res.src)!;
assert.deepEqual(shape.header.cells.map((c) => c.text), ["Qty", "Name", "Price"]);
assert.deepEqual(shape.body[0].cells.map((c) => c.text), ["3", "Apple", "$1.20"]);
// The alignment travelled with its column — Name is still :---, Qty :-:.
assert.deepEqual(shape.aligns[0], { left: true, right: true });
assert.deepEqual(shape.aligns[1], { left: true, right: false });
});
it("refuses at the edges instead of wrapping", () => {
assert.equal(moveTableColumn(TABLE, 0, -1), null);
assert.equal(moveTableColumn(TABLE, 2, 1), null);
});
it("pads a short row rather than shearing it", () => {
const src = "| a | b |\n| - | - |\n| only |";
const res = moveTableColumn(src, 0, 1)!;
const shape = parseTable(res.src)!;
assert.deepEqual(shape.body[0].cells.map((c) => c.text), ["", "only"]);
});
});
describe("moveTableRow", () => {
it("swaps adjacent body rows and reports the new index", () => {
const res = moveTableRow(TABLE, 0, 1);
assert.ok(res);
assert.equal(res.row, 1);
const shape = parseTable(res.src)!;
assert.deepEqual(shape.body.map((r) => r.cells[0].text), ["Pear", "Apple"]);
});
it("refuses at the edges — the header is not a destination", () => {
assert.equal(moveTableRow(TABLE, 0, -1), null);
assert.equal(moveTableRow(TABLE, 1, 1), null);
});
});
describe("emptyRowText", () => {
it("matches the column count Tab-in-the-last-cell needs", () => {
assert.equal(splitRowCells(emptyRowText(3)).length, 3);
assert.equal(splitRowCells(emptyRowText(1)).length, 1);
});
});
// ── The commands the table UI runs ──────────────────────────────────────────
//
// Two invariants run through every case below, and they are the ones a
// reader would notice the loss of before they noticed anything else:
//
// THE ALIGNMENT ROW TRAVELS WITH ITS COLUMN. Insert, delete and move all
// rewrite the delimiter in the same pass as the rows. A command that misses
// it corrupts silently: the table still parses, it just lies about which
// column is centred.
//
// BYTES NOBODY TOUCHED DO NOT MOVE. A cell edit is one range; every other
// cell in the block comes out of it identical, escapes and all. That is
// what makes the widget able to redraw one `
`, and what makes one
// committed cell one undo step.
/** Nav-row/column text as a plain grid, for asserting a whole table at once. */
const grid = (src: string): string[][] => {
const shape = parseTable(src)!;
const cols = columnCount(shape);
return [shape.header, ...shape.body].map((row) =>
Array.from({ length: cols }, (_v, j) => row.cells[j]?.text ?? ""),
);
};
describe("escapeCellText", () => {
it("escapes a typed pipe and leaves an escaped one alone", () => {
assert.equal(escapeCellText("a | b"), "a \\| b");
assert.equal(escapeCellText("a \\| b"), "a \\| b");
// `\\` is a literal backslash, so the pipe after it is a real separator
// and has to be escaped — parity, not presence.
assert.equal(escapeCellText("a \\\\| b"), "a \\\\\\| b");
});
it("flattens newlines and trims, because a row is a line", () => {
assert.equal(escapeCellText(" one\ntwo "), "one two");
assert.equal(escapeCellText("a b"), "a b");
});
});
describe("cellEdit", () => {
it("replaces one cell's raw segment and nothing else", () => {
const edit = cellEdit(TABLE, 2, 1, "7")!;
const out = TABLE.slice(0, edit.from) + edit.insert + TABLE.slice(edit.to);
assert.equal(grid(out)[2][1], "7");
// Every OTHER line is byte for byte what it was.
const before = TABLE.split("\n");
const after = out.split("\n");
assert.equal(after[0], before[0]);
assert.equal(after[1], before[1]);
assert.equal(after[2], before[2]);
});
it("escapes the pipes a typed cell brought with it", () => {
const edit = cellEdit(TABLE, 1, 0, "Apple | Pear")!;
const out = TABLE.slice(0, edit.from) + edit.insert + TABLE.slice(edit.to);
const shape = parseTable(out)!;
assert.equal(shape.body[0].cells.length, 3); // still three cells
assert.equal(shape.body[0].cells[0].text, "Apple \\| Pear");
});
it("grows a short row rather than refusing the cell", () => {
const src = "| a | b |\n| - | - |\n| only |";
const edit = cellEdit(src, 1, 1, "second")!;
const out = src.slice(0, edit.from) + edit.insert + src.slice(edit.to);
assert.deepEqual(grid(out)[1], ["only", "second"]);
});
it("refuses a column the table does not have", () => {
assert.equal(cellEdit(TABLE, 0, 9, "x"), null);
assert.equal(cellEdit("prose", 0, 0, "x"), null);
});
});
describe("insertTableRow", () => {
it("adds an empty row below, keeping the column count", () => {
const res = insertTableRow(TABLE, 1, "below")!;
assert.equal(res.row, 2);
const shape = parseTable(res.src)!;
assert.equal(rowCount(shape), 4);
assert.deepEqual(grid(res.src)[2], ["", "", ""]);
assert.deepEqual(grid(res.src)[3], ["Pear", "12", "$0.90"]);
});
it("adds above a body row and refuses above the header", () => {
const res = insertTableRow(TABLE, 1, "above")!;
assert.deepEqual(grid(res.src)[1], ["", "", ""]);
assert.equal(insertTableRow(TABLE, 0, "above"), null);
});
it("below the header lands in the first body position", () => {
const res = insertTableRow(TABLE, 0, "below")!;
assert.equal(res.row, 1);
assert.deepEqual(grid(res.src)[0], ["Name", "Qty", "Price"]);
assert.deepEqual(grid(res.src)[1], ["", "", ""]);
// The alignment row is still line 1 and still says what it said.
assert.equal(res.src.split("\n")[1], TABLE.split("\n")[1]);
});
});
describe("deleteTableRow / duplicateTableRow", () => {
it("deletes a body row and reports where the caret goes", () => {
const res = deleteTableRow(TABLE, 1)!;
assert.deepEqual(grid(res.src).map((r) => r[0]), ["Name", "Pear"]);
assert.equal(res.row, 1);
});
it("clamps to the last row when the last row is what went", () => {
const res = deleteTableRow(TABLE, 2)!;
assert.equal(res.row, 1);
assert.equal(parseTable(res.src)!.body.length, 1);
});
it("refuses the header — a table without one is prose full of pipes", () => {
assert.equal(deleteTableRow(TABLE, 0), null);
});
it("duplicates a row directly beneath itself, bytes and all", () => {
const res = duplicateTableRow(TABLE, 1)!;
assert.equal(res.row, 2);
const lines = res.src.split("\n");
assert.equal(lines[2], lines[3]);
assert.equal(parseTable(res.src)!.body.length, 3);
});
it("duplicates the HEADER into the first body row, not into a second header", () => {
const res = duplicateTableRow(TABLE, 0)!;
assert.equal(res.row, 1);
assert.deepEqual(grid(res.src)[0], ["Name", "Qty", "Price"]);
assert.deepEqual(grid(res.src)[1], ["Name", "Qty", "Price"]);
assert.equal(res.src.split("\n")[1], TABLE.split("\n")[1]);
});
});
describe("insertTableColumn / deleteTableColumn", () => {
it("inserts into the header, the alignment row and every body row", () => {
const res = insertTableColumn(TABLE, 1, "before")!;
assert.equal(res.col, 1);
const shape = parseTable(res.src)!;
assert.equal(columnCount(shape), 4);
assert.deepEqual(grid(res.src)[0], ["Name", "", "Qty", "Price"]);
assert.deepEqual(grid(res.src)[1], ["Apple", "", "3", "$1.20"]);
// The alignments that existed kept their columns, and the new one is bare.
assert.equal(alignName(shape, 0), "left");
assert.equal(alignName(shape, 1), "none");
assert.equal(alignName(shape, 2), "center");
assert.equal(alignName(shape, 3), "right");
});
it("inserts after the last column", () => {
const res = insertTableColumn(TABLE, 2, "after")!;
assert.equal(res.col, 3);
assert.deepEqual(grid(res.src)[0], ["Name", "Qty", "Price", ""]);
});
it("deletes a column out of every line, alignment included", () => {
const res = deleteTableColumn(TABLE, 1)!;
assert.equal(res.col, 1);
const shape = parseTable(res.src)!;
assert.equal(columnCount(shape), 2);
assert.deepEqual(grid(res.src)[0], ["Name", "Price"]);
assert.equal(alignName(shape, 0), "left");
assert.equal(alignName(shape, 1), "right");
});
it("refuses the last column", () => {
const one = "| a |\n| - |\n| b |";
assert.equal(deleteTableColumn(one, 0), null);
});
it("never rewrites a cell it is only moving past", () => {
const src = "| a \\| b | `x` |\n| - | - |\n| c | d |";
const res = insertTableColumn(src, 0, "after")!;
assert.deepEqual(grid(res.src)[0], ["a \\| b", "", "`x`"]);
assert.deepEqual(grid(res.src)[1], ["c", "", "d"]);
});
it("pads a RAGGED table's delimiter with a delimiter, not with a blank", () => {
// GFM lets a row carry more cells than the alignment row — an unescaped
// pipe inside a code span is one way to get there. Padding the delimiter
// with " " would write `| |` into it, which is not a delimiter cell: the
// block stops parsing as a table and every later command refuses on a
// table the reader can plainly see.
const src = "| a | `x|y` |\n| - | - |\n| c | d |";
assert.equal(parseTable(src)!.header.cells.length, 3);
for (const out of [
insertTableColumn(src, 0, "after")!.src,
deleteTableColumn(src, 0)!.src,
moveTableColumn(src, 0, 1)!.src,
]) {
assert.ok(parseTable(out), `still a table: ${JSON.stringify(out)}`);
}
});
});
describe("setColumnAlign", () => {
it("sets each alignment and keeps the delimiter's width", () => {
const before = TABLE.split("\n")[1];
for (const [want, re] of [
["left", /^\|.*\| :-+ \|/],
["right", /^\|.*\| -+: \|/],
["center", /^\|.*\| :-+: \|/],
["none", /^\|.*\| -+ \|/],
] as const) {
const out = setColumnAlign(TABLE, 1, want)!;
const delim = out.split("\n")[1];
assert.equal(delim.length, before.length, `${want} changed the width`);
assert.match(delim, re);
assert.equal(alignName(parseTable(out)!, 1), want === "none" ? "none" : want);
}
});
it("leaves the rows alone", () => {
const out = setColumnAlign(TABLE, 0, "center")!;
const a = TABLE.split("\n");
const b = out.split("\n");
assert.equal(b[0], a[0]);
assert.equal(b[2], a[2]);
assert.equal(b[3], a[3]);
});
it("refuses a column the alignment row does not have", () => {
assert.equal(setColumnAlign(TABLE, 7, "left"), null);
});
});
describe("sortTableRows", () => {
const T = [
"| Name | Qty |",
"| --- | --- |",
"| pear | 12 |",
"| Apple | 3 |",
"| fig | |",
].join("\n");
it("sorts A→Z case-insensitively and never moves the header", () => {
const out = sortTableRows(T, 0, "az")!;
assert.deepEqual(grid(out).map((r) => r[0]), ["Name", "Apple", "fig", "pear"]);
assert.equal(out.split("\n")[1], T.split("\n")[1]);
});
it("sorts Z→A", () => {
const out = sortTableRows(T, 0, "za")!;
assert.deepEqual(grid(out).map((r) => r[0]), ["Name", "pear", "fig", "Apple"]);
});
it("sorts numerically, and a cell with no number in it goes last", () => {
const out = sortTableRows(T, 1, "numeric")!;
assert.deepEqual(grid(out).map((r) => r[0]), ["Name", "Apple", "pear", "fig"]);
});
it("reads grouped and Arabic-Indic digits as the numbers they are", () => {
const src = ["| n |", "| - |", "| 1,204 |", "| ٩٨ |", "| 12 |"].join("\n");
const out = sortTableRows(src, 0, "numeric")!;
assert.deepEqual(grid(out).map((r) => r[0]), ["n", "12", "٩٨", "1,204"]);
});
it("is stable, so a second sort keeps the first one's order inside ties", () => {
const src = ["| k | v |", "| - | - |", "| a | 2 |", "| a | 1 |"].join("\n");
const out = sortTableRows(src, 0, "az")!;
assert.deepEqual(grid(out)[1], ["a", "2"]);
assert.deepEqual(grid(out)[2], ["a", "1"]);
});
it("refuses a column that is not there", () => {
assert.equal(sortTableRows(T, 5, "az"), null);
assert.equal(sortTableRows("prose", 0, "az"), null);
});
});
describe("tableSkeleton", () => {
it("counts the header among the rows the picker drew", () => {
const shape = parseTable(tableSkeleton(3, 4))!;
assert.equal(rowCount(shape), 3);
assert.equal(columnCount(shape), 4);
assert.equal(shape.body.length, 2);
});
it("is empty everywhere, and parses as a table", () => {
const src = tableSkeleton(2, 2);
assert.ok(parseTable(src));
for (const row of grid(src)) for (const cell of row) assert.equal(cell, "");
});
it("never produces a table with no row and no column", () => {
assert.ok(parseTable(tableSkeleton(0, 0)));
assert.equal(columnCount(parseTable(tableSkeleton(0, 0))!), 1);
});
});
describe("cellText / alignName / counts", () => {
it("hands back the source spelling, escapes intact", () => {
const shape = parseTable("| a \\| b |\n| - |\n| c |")!;
assert.equal(cellText(shape, 0, 0), "a \\| b");
assert.equal(cellText(shape, 1, 0), "c");
assert.equal(cellText(shape, 9, 0), "");
});
it("names every alignment the delimiter can spell", () => {
const shape = parseTable("| a | b | c | d |\n| --- | :-- | --: | :-: |\n| 1 | 2 | 3 | 4 |")!;
assert.equal(alignName(shape, 0), "none");
assert.equal(alignName(shape, 1), "left");
assert.equal(alignName(shape, 2), "right");
assert.equal(alignName(shape, 3), "center");
});
it("counts a stray extra column in, so a command can square it off", () => {
const shape = parseTable("| a | b |\n| - | - |\n| x | y | z |")!;
assert.equal(columnCount(shape), 3);
assert.equal(rowCount(shape), 2);
});
});
|