// 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); }); });