Ctrl K
ComunidadGuías…

tests/tableActions.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// TABLE ACTIONS: the palette's wire, the source-side command, and every
// action over one fixture table.
//
// Three layers, each tested where it can be loaded under `node --test`:
//   1. client/tableActions.ts — the palette says WHAT on a window event, the
//      mounted editor answers by flipping `handled`, and a row with no table
//      under the caret says so in a toast instead of doing nothing visible.
//      A few lines of fake window/document stand in for the browser.
//   2. tableModel.ts `sourceTableCommand` — what `runTableCommand` does when
//      the caret is IN the pipes: the edit, the prettify, and the caret's
//      landing cell (tables.ts itself drags CSS and CodeMirror's DOM in).
//   3. The whole vocabulary over one fixture table (escaped pipes, inline
//      code, alignment, Arabic): add and remove a row and a column, move
//      both ways, align, sort, duplicate, and the one-cell write — with the
//      invariants that make them safe to run unattended: the result still
//      parses, no other cell's text moves, and each action has its inverse.
//
// Markdown tables have no header toggle: GFM requires the header row and the
// model keeps it (row 0 is not deletable, nothing inserts above it) — tested
// below as the refusal it is.

import assert from "node:assert/strict";
import { after, before, beforeEach, describe, it } from "node:test";
import {
  alignName,
  cellEdit,
  cellText,
  columnCount,
  deleteTableColumn,
  deleteTableRow,
  duplicateTableRow,
  formatTable,
  insertTableColumn,
  insertTableRow,
  moveTableColumn,
  moveTableRow,
  navRows,
  parseTable,
  rowCount,
  setColumnAlign,
  sortTableRows,
  sourceTableCommand,
  type TableShape,
} from "../client/editor/tableModel.ts";

// ── 1. the palette's wire ───────────────────────────────────────────────────

/** Just enough of a document for client/toast.ts: elements with a class
 *  list, a dataset, children and a text. */
class FakeEl {
  className = "";
  textContent = "";
  dataset: Record<string, string> = {};
  children: FakeEl[] = [];
  parent: FakeEl | null = null;
  attrs: Record<string, string> = {};
  classList = {
    contains: (c: string) => this.className.split(/\s+/).includes(c),
    add: (c: string) => {
      if (!this.classList.contains(c)) this.className = `${this.className} ${c}`.trim();
    },
  };
  setAttribute(k: string, v: string): void {
    this.attrs[k] = v;
  }
  appendChild(el: FakeEl): FakeEl {
    el.parent = this;
    this.children.push(el);
    return el;
  }
  insertBefore(el: FakeEl, ref: FakeEl): FakeEl {
    el.parent = this;
    this.children.splice(this.children.indexOf(ref), 0, el);
    return el;
  }
  remove(): void {
    if (!this.parent) return;
    this.parent.children.splice(this.parent.children.indexOf(this), 1);
    this.parent = null;
  }
  querySelector(sel: string): FakeEl | null {
    return this.all().find((el) => matches(el, sel)) ?? null;
  }
  all(): FakeEl[] {
    return this.children.flatMap((c) => [c, ...c.all()]);
  }
}

const body = new FakeEl();
const matches = (el: FakeEl, sel: string): boolean => {
  // ".a" or ".a:not(.b)" — the two shapes toast.ts asks for.
  const m = /^\.([\w-]+)(?::not\(\.([\w-]+)\))?$/.exec(sel);
  if (!m) throw new Error(`selector the fake does not speak: ${sel}`);
  return el.classList.contains(m[1]) && !(m[2] && el.classList.contains(m[2]));
};
const frames: (() => void)[] = [];
const g = globalThis as Record<string, unknown>;
const saved: Record<string, unknown> = {};

before(() => {
  for (const k of ["window", "document", "requestAnimationFrame"]) saved[k] = g[k];
  const win = new EventTarget() as EventTarget & { setTimeout: typeof setTimeout };
  win.setTimeout = ((fn: () => void) => 0) as unknown as typeof setTimeout; // toasts never retire mid-test
  g.window = win;
  g.document = {
    body,
    createElement: () => new FakeEl(),
    querySelector: (sel: string) => body.all().find((el) => matches(el, sel)) ?? null,
    querySelectorAll: (sel: string) => body.all().filter((el) => matches(el, sel)),
  };
  g.requestAnimationFrame = (fn: () => void) => {
    frames.push(fn);
    return frames.length;
  };
});

after(() => {
  for (const [k, v] of Object.entries(saved)) {
    if (v === undefined) delete g[k];
    else g[k] = v;
  }
});

function flush(): void {
  while (frames.length) frames.shift()!();
}

const toasts = (): string[] => body.all().filter((el) => el.classList.contains("s-toast")).map((el) => el.textContent);

describe("tableCommand: the palette asks, the editor answers", () => {
  beforeEach(() => {
    body.children = [];
    frames.length = 0;
  });

  it("dispatches the command on the next frame, not before", async () => {
    const { tableCommand, TABLE_COMMAND_EVENT } = await import("../client/tableActions.ts");
    const heard: string[] = [];
    const listener = (e: Event) => {
      const d = (e as CustomEvent<{ cmd: string; handled: { value: boolean } }>).detail;
      heard.push(d.cmd);
      d.handled.value = true;
    };
    (g.window as EventTarget).addEventListener(TABLE_COMMAND_EVENT, listener);
    try {
      tableCommand("rowBelow");
      assert.deepEqual(heard, [], "the overlay is gone and focus is back before the command runs");
      flush();
      assert.deepEqual(heard, ["rowBelow"]);
      assert.deepEqual(toasts(), [], "a handled command says nothing");
    } finally {
      (g.window as EventTarget).removeEventListener(TABLE_COMMAND_EVENT, listener);
    }
  });

  it("says so when no editor had a table under the caret", async () => {
    const { tableCommand } = await import("../client/tableActions.ts");
    const { installDictionary, setLang } = await import("../client/i18n.ts");
    const en = (await import("../client/i18n/en.ts")).default;
    installDictionary("en", en);
    setLang("en");
    tableCommand("colAfter");
    flush(); // the command, then the toast's own fill
    flush();
    assert.deepEqual(toasts(), [en.tableNotHere]);
  });

  it("carries each palette row by name", async () => {
    const { tableCommand, TABLE_COMMAND_EVENT } = await import("../client/tableActions.ts");
    const heard: string[] = [];
    const listener = (e: Event) => {
      const d = (e as CustomEvent<{ cmd: string; handled: { value: boolean } }>).detail;
      heard.push(d.cmd);
      d.handled.value = true;
    };
    (g.window as EventTarget).addEventListener(TABLE_COMMAND_EVENT, listener);
    try {
      for (const cmd of ["rowAbove", "rowBelow", "colBefore", "colAfter", "editSource"] as const) tableCommand(cmd);
      flush();
      assert.deepEqual(heard, ["rowAbove", "rowBelow", "colBefore", "colAfter", "editSource"]);
    } finally {
      (g.window as EventTarget).removeEventListener(TABLE_COMMAND_EVENT, listener);
    }
  });
});

// ── the fixture ─────────────────────────────────────────────────────────────

const FIXTURE = [
  "| Name | Code | Qty | ملاحظة |",
  "| :--- | :-: | --: | --- |",
  "| Apple | `a\\|b` | 3 | حلو |",
  "| Pipe \\| cell | `x` | 12 | مرّ |",
  "| Pear |  | 7 | |",
].join("\n");

const grid = (shape: TableShape): string[][] => navRows(shape).map((_, r) => Array.from({ length: columnCount(shape) }, (_, c) => cellText(shape, r, c)));
const shapeOf = (src: string): TableShape => {
  const s = parseTable(src);
  assert.ok(s, `does not parse:\n${src}`);
  return s;
};
const gridOf = (src: string): string[][] => grid(shapeOf(src));
const BASE = gridOf(FIXTURE);

describe("the fixture itself", () => {
  it("parses as 4 columns × 1 header + 3 rows, with the escaped pipe and the code span inside their cells", () => {
    const s = shapeOf(FIXTURE);
    assert.equal(columnCount(s), 4);
    assert.equal(rowCount(s), 4);
    assert.equal(cellText(s, 1, 1), "`a\\|b`");
    assert.equal(cellText(s, 2, 0), "Pipe \\| cell");
    assert.deepEqual([0, 1, 2, 3].map((c) => alignName(s, c)), ["left", "center", "right", "none"]);
  });
});

// ── 2. the palette with the caret in the pipes ──────────────────────────────

describe("sourceTableCommand: the palette over the source", () => {
  const OFFSET = 40; // the block sits 40 characters into the note
  // The caret in "Apple" (row 1, col 0) and in "12" (row 2, col 2).
  const at = (needle: string): number => OFFSET + FIXTURE.indexOf(needle) + 1;

  it("adds a row below the caret's row and lands in its first cell", () => {
    const res = sourceTableCommand(FIXTURE, OFFSET, at("Apple"), "rowBelow");
    assert.ok(res);
    assert.equal(res.src, formatTable(res.src), "prettified in the same edit");
    const g2 = gridOf(res.src);
    assert.deepEqual(g2[2], ["", "", "", ""]);
    assert.deepEqual([g2[0], g2[1], ...g2.slice(3)], BASE);
    const landed = parseTable(res.src, OFFSET)!;
    assert.equal(res.anchor, navRows(landed)[2].cells[0].trimFrom);
  });

  it("adds a row above a body row, and refuses one above the header", () => {
    const res = sourceTableCommand(FIXTURE, OFFSET, at("12"), "rowAbove");
    assert.ok(res);
    assert.deepEqual(gridOf(res.src)[2], ["", "", "", ""]);
    assert.equal(sourceTableCommand(FIXTURE, OFFSET, at("Name"), "rowAbove"), null);
  });

  it("adds a column either side of the caret's column and lands in the new column, same row", () => {
    for (const [cmd, want] of [["colBefore", 2], ["colAfter", 3]] as const) {
      const res = sourceTableCommand(FIXTURE, OFFSET, at("12"), cmd);
      assert.ok(res, cmd);
      const s = parseTable(res.src, OFFSET)!;
      assert.equal(columnCount(s), 5);
      assert.deepEqual(grid(s).map((r) => r[want]), ["", "", "", ""], `${cmd}: the new column is empty`);
      assert.deepEqual(grid(s).map((r) => r.filter((_, i) => i !== want)), BASE, `${cmd}: nothing else moved`);
      assert.equal(res.anchor, navRows(s)[2].cells[want].trimFrom, `${cmd}: the caret is in the new cell`);
    }
  });

  it("answers null for something that is not a table", () => {
    assert.equal(sourceTableCommand("just a line\nand another", 0, 3, "rowBelow"), null);
  });
});

// ── 3. every action over the fixture ────────────────────────────────────────

describe("every table action over the fixture", () => {
  it("add and remove a row are inverses, anywhere in the body", () => {
    for (let row = 0; row < rowCount(shapeOf(FIXTURE)); row++) {
      const added = insertTableRow(FIXTURE, row, "below");
      assert.ok(added, `below ${row}`);
      assert.deepEqual(gridOf(added.src)[added.row], ["", "", "", ""]);
      const removed = deleteTableRow(added.src, added.row);
      assert.ok(removed);
      assert.equal(removed.src, FIXTURE, `row ${row}: add then remove is the original, byte for byte`);
    }
  });

  it("the header row cannot be removed or have a row put above it", () => {
    assert.equal(deleteTableRow(FIXTURE, 0), null);
    assert.equal(insertTableRow(FIXTURE, 0, "above"), null);
    assert.equal(insertTableRow(FIXTURE, 99, "below"), null);
  });

  it("add and remove a column are inverses, and the alignment row goes with them", () => {
    for (let col = 0; col < 4; col++) {
      for (const where of ["before", "after"] as const) {
        const added = insertTableColumn(FIXTURE, col, where);
        assert.ok(added, `${where} ${col}`);
        const s = shapeOf(added.src);
        assert.equal(columnCount(s), 5);
        assert.equal(s.aligns.length, 5, "the delimiter row grew with the table");
        const removed = deleteTableColumn(added.src, added.col);
        assert.ok(removed);
        assert.deepEqual(gridOf(removed.src), BASE, `${where} ${col}: the cells are back`);
        assert.deepEqual([0, 1, 2, 3].map((c) => alignName(shapeOf(removed.src), c)), ["left", "center", "right", "none"]);
      }
    }
  });

  it("a one-column table keeps its last column", () => {
    assert.equal(deleteTableColumn("| a |\n| - |\n| b |", 0), null);
  });

  it("moving a column there and back is the original, alignment included", () => {
    for (let col = 0; col < 3; col++) {
      const there = moveTableColumn(FIXTURE, col, 1);
      assert.ok(there);
      assert.equal(there.col, col + 1);
      const s = shapeOf(there.src);
      assert.deepEqual(grid(s).map((r) => r[col + 1]), BASE.map((r) => r[col]), "the column's cells travelled together");
      assert.equal(alignName(s, col + 1), alignName(shapeOf(FIXTURE), col), "and its alignment with them");
      const back = moveTableColumn(there.src, there.col, -1);
      assert.ok(back);
      assert.equal(back.src, FIXTURE);
    }
    assert.equal(moveTableColumn(FIXTURE, 0, -1), null, "nothing left of the first column");
    assert.equal(moveTableColumn(FIXTURE, 3, 1), null, "nothing right of the last");
  });

  it("moving a body row there and back is the original; the header is not a destination", () => {
    const there = moveTableRow(FIXTURE, 0, 1);
    assert.ok(there);
    assert.deepEqual(gridOf(there.src)[2], BASE[1]);
    const back = moveTableRow(there.src, there.row, -1);
    assert.ok(back);
    assert.equal(back.src, FIXTURE);
    assert.equal(moveTableRow(FIXTURE, 0, -1), null);
    assert.equal(moveTableRow(FIXTURE, 2, 1), null);
  });

  it("aligning a column changes that column's delimiter and no cell", () => {
    for (const align of ["left", "center", "right", "none"] as const) {
      const out = setColumnAlign(FIXTURE, 3, align);
      assert.ok(out, align);
      assert.equal(alignName(shapeOf(out), 3), align);
      assert.deepEqual([0, 1, 2].map((c) => alignName(shapeOf(out), c)), ["left", "center", "right"]);
      assert.deepEqual(gridOf(out), BASE);
    }
  });

  it("sorting reorders whole body rows and keeps the header", () => {
    const numeric = sortTableRows(FIXTURE, 2, "numeric");
    assert.ok(numeric);
    assert.deepEqual(gridOf(numeric).map((r) => r[2]), ["Qty", "3", "7", "12"]);
    const za = sortTableRows(FIXTURE, 0, "za");
    assert.ok(za);
    assert.deepEqual(gridOf(za).map((r) => r[0]), ["Name", "Pipe \\| cell", "Pear", "Apple"]);
    // a sort moves rows, never cells within a row
    const rows = new Set(BASE.slice(1).map((r) => r.join("\u0000")));
    for (const r of gridOf(za).slice(1)) assert.ok(rows.has(r.join("\u0000")));
  });

  it("duplicating a row copies it beneath itself", () => {
    const res = duplicateTableRow(FIXTURE, 2);
    assert.ok(res);
    assert.equal(res.row, 3);
    assert.deepEqual(gridOf(res.src)[3], BASE[2]);
  });

  it("prettifying is idempotent and changes no cell", () => {
    const once = formatTable(FIXTURE);
    assert.equal(formatTable(once), once);
    assert.deepEqual(gridOf(once), BASE);
  });
});

describe("the cell edit contract", () => {
  const apply = (src: string, e: { from: number; to: number; insert: string }): string => src.slice(0, e.from) + e.insert + src.slice(e.to);

  it("writes one cell and only that cell", () => {
    const e = cellEdit(FIXTURE, 1, 3, "حامض");
    assert.ok(e);
    const out = gridOf(apply(FIXTURE, e));
    assert.equal(out[1][3], "حامض");
    out[1][3] = BASE[1][3];
    assert.deepEqual(out, BASE);
  });

  it("escapes a typed pipe, keeps an escaped one, and folds a newline to a space", () => {
    const e = cellEdit(FIXTURE, 3, 0, "a | b \\| c\nd");
    assert.ok(e);
    const out = shapeOf(apply(FIXTURE, e));
    assert.equal(columnCount(out), 4, "a typed pipe did not split the cell");
    assert.equal(cellText(out, 3, 0), "a \\| b \\| c d");
  });

  it("fills a cell the row never wrote (a ragged row) without touching its neighbours", () => {
    const ragged = "| a | b | c |\n| - | - | - |\n| 1 |";
    const e = cellEdit(ragged, 1, 2, "three");
    assert.ok(e);
    const out = shapeOf(apply(ragged, e));
    assert.deepEqual(grid(out)[1], ["1", "", "three"]);
  });

  it("refuses a cell outside the table", () => {
    assert.equal(cellEdit(FIXTURE, 9, 0, "x"), null);
    assert.equal(cellEdit(FIXTURE, 1, 9, "x"), null);
    assert.equal(cellEdit("not a table", 0, 0, "x"), null);
  });
});