Additive test coverage across server, editor-ext, client and mcp. #192 — AiChatService.stream integration (Section 3, against real Postgres): - new apps/server/test/integration/ai-chat-stream.int-spec.ts drives the real streamText through a seeded ai/test MockLanguageModelV3 and a real Node ServerResponse, covering: onError persists an assistant error record (status 'error' + partial answer + provider cause in metadata); external MCP client closed exactly once on BOTH onFinish and onError; anti-tamper — history is rebuilt from the DB transcript, not from body.messages. #206 — red-team findings (most already fixed+tested in #212): - mdrt-2 (UNFIXED, data loss): turndown.dataloss.test.ts documents that pageBreak / transclusionReference / mention are silently dropped on Markdown export (characterization + it.fails for the desired survive-export contract). - persist-6 (UNFIXED, data loss): persistence-store.spec.ts adds an it.failing documenting that a momentarily-empty live doc overwrites non-empty content (left unfixed — a store-side empty-guard is a behaviour change). #204 — test-strategy plan, highest-priority subset: - Phase 1: mcp-clients.lease.spec.ts covers the external MCP client lease/refcount/eviction lifecycle (leak / premature-close / double-close). - Phase 2 data-integrity pure functions: editor-ext table-utils (transpose/moveRow/convert round-trip) and math tokenizer false-positive guard; client emoji-menu (+ it.fails for the unguarded localStorage JSON.parse bug), sort-cells, normalizeTableColumnWidths; mcp htmlEmbed/ pageBreak markdown data-loss + footnote-diff; server export getInternalLinkPageName extensionless-path bug — FIXED (small/clear) + tested. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
174 lines
6.3 KiB
TypeScript
174 lines
6.3 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { Schema } from "@tiptap/pm/model";
|
|
import type { Node as PMNode } from "@tiptap/pm/model";
|
|
import { tableNodes, TableMap } from "@tiptap/pm/tables";
|
|
import { transpose } from "./transpose";
|
|
import { moveRowInArrayOfRows } from "./move-row-in-array-of-rows";
|
|
import { convertTableNodeToArrayOfRows } from "./convert-table-node-to-array-of-rows";
|
|
import { convertArrayOfRowsToTableNode } from "./convert-array-of-rows-to-table-node";
|
|
|
|
/**
|
|
* Unit tests for the pure table data-transformation utilities. These functions
|
|
* drive every drag-to-reorder row/column operation, so a regression here
|
|
* silently corrupts table content. We test them in isolation against a real
|
|
* ProseMirror table schema (the same primitives the editor uses).
|
|
*/
|
|
|
|
// Minimal schema containing real ProseMirror table nodes so TableMap behaves
|
|
// exactly as it does in the editor (merged cells, colspan, etc.).
|
|
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);
|
|
|
|
// Read the text content of each (non-null) cell so we can compare structure
|
|
// without depending on ProseMirror node identity.
|
|
const textGrid = (rows: (PMNode | null)[][]): (string | null)[][] =>
|
|
rows.map((r) => r.map((c) => (c ? c.textContent : null)));
|
|
|
|
const tableTextGrid = (t: PMNode): (string | null)[][] =>
|
|
textGrid(convertTableNodeToArrayOfRows(t));
|
|
|
|
describe("transpose", () => {
|
|
it("is its own inverse on a non-square (2x3) matrix", () => {
|
|
const arr = [
|
|
["a1", "a2", "a3"],
|
|
["b1", "b2", "b3"],
|
|
];
|
|
const once = transpose(arr);
|
|
// 2x3 -> 3x2
|
|
expect(once.length).toBe(3);
|
|
expect(once[0].length).toBe(2);
|
|
const twice = transpose(once);
|
|
expect(twice).toEqual(arr);
|
|
});
|
|
|
|
it("inverts indices: transpose(arr)[j][i] === arr[i][j]", () => {
|
|
const arr = [
|
|
["a1", "a2", "a3"],
|
|
["b1", "b2", "b3"],
|
|
];
|
|
const t = transpose(arr);
|
|
for (let i = 0; i < arr.length; i++) {
|
|
for (let j = 0; j < arr[0].length; j++) {
|
|
expect(t[j][i]).toBe(arr[i][j]);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("moveRowInArrayOfRows", () => {
|
|
// Helper: the function mutates `rows` in place (it uses splice), so always
|
|
// pass a fresh copy and read the returned array.
|
|
const move = (
|
|
rows: string[],
|
|
origin: number[],
|
|
target: number[],
|
|
dir: -1 | 0 | 1,
|
|
): string[] => moveRowInArrayOfRows([...rows], origin, target, dir);
|
|
|
|
it("moves a single row downward to a later index", () => {
|
|
const result = move(["A", "B", "C", "D"], [0], [2], 0);
|
|
// A starts at 0, target index 2 -> A lands after C.
|
|
expect(result).toEqual(["B", "C", "A", "D"]);
|
|
});
|
|
|
|
it("moves a single row upward to an earlier index", () => {
|
|
const result = move(["A", "B", "C", "D"], [3], [1], 0);
|
|
expect(result).toEqual(["A", "D", "B", "C"]);
|
|
});
|
|
|
|
it("never drops or duplicates rows (set is preserved) for any pair", () => {
|
|
const base = ["A", "B", "C", "D", "E"];
|
|
for (let from = 0; from < base.length; from++) {
|
|
for (let to = 0; to < base.length; to++) {
|
|
if (from === to) continue;
|
|
const result = move(base, [from], [to], 0);
|
|
expect(result.length).toBe(base.length);
|
|
expect([...result].sort()).toEqual([...base].sort());
|
|
}
|
|
}
|
|
});
|
|
|
|
it("moves an even-sized block (2 rows) preserving block order and full set", () => {
|
|
// Move the [B,C] block (origin indexes 1,2) toward target index 3 (D,E region).
|
|
const result = move(["A", "B", "C", "D", "E"], [1, 2], [3], 0);
|
|
expect(result.length).toBe(5);
|
|
expect([...result].sort()).toEqual(["A", "B", "C", "D", "E"]);
|
|
// Block stays contiguous and in original internal order.
|
|
const bi = result.indexOf("B");
|
|
expect(result[bi + 1]).toBe("C");
|
|
});
|
|
|
|
it("moves an odd-sized block (3 rows) without dropping rows", () => {
|
|
const result = move(["A", "B", "C", "D", "E"], [0, 1, 2], [4], 0);
|
|
expect(result.length).toBe(5);
|
|
expect([...result].sort()).toEqual(["A", "B", "C", "D", "E"]);
|
|
// The 3-row block keeps its internal A,B,C order.
|
|
const ai = result.indexOf("A");
|
|
expect(result.slice(ai, ai + 3)).toEqual(["A", "B", "C"]);
|
|
});
|
|
});
|
|
|
|
describe("convert round-trip: TableNode <-> arrayOfRows", () => {
|
|
it("preserves a simple 2x3 grid's text content and dimensions", () => {
|
|
const t = table(
|
|
row(cell("a1"), cell("b1"), cell("c1")),
|
|
row(cell("a2"), cell("b2"), cell("c2")),
|
|
);
|
|
const before = tableTextGrid(t);
|
|
expect(before).toEqual([
|
|
["a1", "b1", "c1"],
|
|
["a2", "b2", "c2"],
|
|
]);
|
|
|
|
const arr = convertTableNodeToArrayOfRows(t);
|
|
const rebuilt = convertArrayOfRowsToTableNode(t, arr);
|
|
|
|
// Structure (text content + shape) survives the round-trip.
|
|
expect(tableTextGrid(rebuilt)).toEqual(before);
|
|
expect(rebuilt.childCount).toBe(t.childCount);
|
|
const mapA = TableMap.get(t);
|
|
const mapB = TableMap.get(rebuilt);
|
|
expect([mapB.width, mapB.height]).toEqual([mapA.width, mapA.height]);
|
|
});
|
|
|
|
it("represents a horizontally merged cell as a null placeholder, and round-trips it", () => {
|
|
// First cell of row 1 spans 2 columns -> the array form has a null where
|
|
// the covered column would be.
|
|
const t = table(
|
|
row(cell("merged", { colspan: 2 }), cell("c1")),
|
|
row(cell("a2"), cell("b2"), cell("c2")),
|
|
);
|
|
|
|
const arr = convertTableNodeToArrayOfRows(t);
|
|
// Row 0: [merged, null, c1] — the null marks the colspan-covered slot.
|
|
expect(arr[0][0]?.textContent).toBe("merged");
|
|
expect(arr[0][1]).toBeNull();
|
|
expect(arr[0][2]?.textContent).toBe("c1");
|
|
|
|
const rebuilt = convertArrayOfRowsToTableNode(t, arr);
|
|
// The merged cell (and its null placeholder) is reconstructed identically.
|
|
expect(tableTextGrid(rebuilt)).toEqual(tableTextGrid(t));
|
|
const map = TableMap.get(rebuilt);
|
|
expect([map.width, map.height]).toEqual([3, 2]);
|
|
});
|
|
});
|