7cb3199d09
Один логический тул жил под двумя именами: внешний MCP snake_case (edit_page_text), in-app camelCase (editPageText) — дублирование доков, путаница при переносе промптов/скиллов, помеха шарингу спек (#294). Решение владельца: единый camelCase везде, включая внешний MCP. После этого mcpName === inAppKey. - tool-specs.ts: mcpName ВЫВЕДЕН из ключа спеки (mcpName == inAppKey) для всех 43 shared-спек — раньше divergent snake, теперь равен ключу (проверено: mcpName читается только структурно — цикл регистрации, генератор <tool_inventory>, TOOL_FAMILY). +5 inline-регистраций (tableGet/updateComment/deleteComment/ docmostTransform; search без изменений). Рантайм: 47 тулов, все camelCase, ноль подчёркиваний. - Контракт-конвенция ИНВЕРТИРОВАНА: shared-tool-specs.contract.spec `mcpName === toSnake(inAppKey)` → `mcpName === inAppKey`; tool-specs.test и tool-inventory.test обновлены. - ROUTING_PROSE/TOOL_FAMILY/INLINE_MCP_INVENTORY (server-instructions.ts) → camelCase (105 замен). ai-chat.prompt/guard уже на in-app camelCase-ключах — без изменений (guard прошёл). comment-signal EXCLUDED_TOOLS схлопнут с дублей snake+camel до camelCase. - Некоторое неочевидное: assertUnambiguousMatch(op: "patch_node"|"delete_node") в prosemirror-markdown/node-ops — op интерполируется в model-facing ошибку; литерал-юнион + call-sites → "patchNode"|"deleteNode". - Все snake-имена в описаниях/error-строках/комментах/тестах/доках → camelCase (whole-token, longest-match-first). CHANGELOG: BREAKING-таблица 46 строк + миграция (allowlists mcp__gitmost-*__get_node→__getNode, промпты/скиллы, .mcp.json, метрики по tool-label); релизится вместе с #411. Внутренние имена методов (PageService.updatePageContent и т.п.) НЕ тронуты — переименованы только ИМЕНА ТУЛОВ. Гейт: mcp node --test 677/677; tsc -p apps/server чисто; jest ai-chat-tools. service + shared-tool-specs.contract + tool-tiers + ai-chat.prompt + comment-signal-inapp → 323. Второй линк breaking-окна (#411→ЭТОТ→#413→#415). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
339 lines
11 KiB
JavaScript
339 lines
11 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import {
|
|
readTable,
|
|
insertTableRow,
|
|
deleteTableRow,
|
|
updateTableCell,
|
|
} from "@docmost/prosemirror-markdown";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Builders. Tables/rows/cells carry NO attrs.id — only the paragraph inside a
|
|
// cell does. A cell holds a single plain-text paragraph.
|
|
// ---------------------------------------------------------------------------
|
|
const textNode = (text) => ({ type: "text", text });
|
|
const para = (id, text) => ({
|
|
type: "paragraph",
|
|
attrs: { id, indent: 0 },
|
|
content: text ? [textNode(text)] : [],
|
|
});
|
|
const cell = (paraId, text, colwidth) => ({
|
|
type: "tableCell",
|
|
attrs: { colspan: 1, rowspan: 1, ...(colwidth ? { colwidth } : {}) },
|
|
content: [para(paraId, text)],
|
|
});
|
|
const row = (...cells) => ({ type: "tableRow", content: cells });
|
|
const doc = (...children) => ({ type: "doc", content: children });
|
|
const snapshot = (v) => JSON.parse(JSON.stringify(v));
|
|
|
|
// Heading at index 0, a 3x3 table at index 1.
|
|
// Header row "A"/"B"/"C" with colwidths [120]/[200]/[150]; two data rows.
|
|
const makeDoc = () =>
|
|
doc(
|
|
{ type: "heading", attrs: { id: "h1", level: 1 }, content: [textNode("Title")] },
|
|
{
|
|
type: "table",
|
|
content: [
|
|
row(
|
|
cell("hpA", "A", [120]),
|
|
cell("hpB", "B", [200]),
|
|
cell("hpC", "C", [150]),
|
|
),
|
|
row(cell("p10", "r1c0"), cell("p11", "r1c1"), cell("p12", "r1c2")),
|
|
row(cell("p20", "r2c0"), cell("p21", "r2c1"), cell("p22", "r2c2")),
|
|
],
|
|
},
|
|
);
|
|
|
|
// Gather every attrs.id present anywhere in a doc.
|
|
const allIds = (node, acc = new Set()) => {
|
|
if (node && typeof node === "object" && !Array.isArray(node)) {
|
|
if (node.attrs && typeof node.attrs.id === "string") acc.add(node.attrs.id);
|
|
if (Array.isArray(node.content)) node.content.forEach((c) => allIds(c, acc));
|
|
}
|
|
return acc;
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// readTable
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("readTable('#1') returns the 3x3 matrix, cell ids, and path", () => {
|
|
const t = readTable(makeDoc(), "#1");
|
|
assert.ok(t);
|
|
assert.equal(t.rows, 3);
|
|
assert.equal(t.cols, 3);
|
|
assert.deepEqual(t.cells, [
|
|
["A", "B", "C"],
|
|
["r1c0", "r1c1", "r1c2"],
|
|
["r2c0", "r2c1", "r2c2"],
|
|
]);
|
|
assert.deepEqual(t.cellIds, [
|
|
["hpA", "hpB", "hpC"],
|
|
["p10", "p11", "p12"],
|
|
["p20", "p21", "p22"],
|
|
]);
|
|
assert.deepEqual(t.path, [1]);
|
|
});
|
|
|
|
test("readTable(<cell paragraph id>) resolves the enclosing table", () => {
|
|
const t = readTable(makeDoc(), "p21"); // a paragraph inside a data cell
|
|
assert.ok(t);
|
|
assert.equal(t.rows, 3);
|
|
assert.equal(t.cols, 3);
|
|
assert.deepEqual(t.path, [1]);
|
|
});
|
|
|
|
test("readTable on a non-table block / unknown ref returns null", () => {
|
|
assert.equal(readTable(makeDoc(), "#0"), null); // heading, not a table
|
|
assert.equal(readTable(makeDoc(), "nope"), null); // no such id
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// insertTableRow
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("insertTableRow appends a 4th row, copies header colwidths, fresh unique ids", () => {
|
|
const input = makeDoc();
|
|
const snap = snapshot(input);
|
|
const existingIds = allIds(input);
|
|
|
|
const { doc: out, inserted } = insertTableRow(input, "#1", ["x", "y", "z"]);
|
|
assert.equal(inserted, true);
|
|
|
|
// Input not mutated.
|
|
assert.deepEqual(input, snap);
|
|
|
|
const tbl = out.content[1];
|
|
assert.equal(tbl.content.length, 4);
|
|
const newRow = tbl.content[3];
|
|
assert.equal(newRow.type, "tableRow");
|
|
assert.equal(newRow.content.length, 3);
|
|
|
|
// Cell texts.
|
|
assert.deepEqual(
|
|
newRow.content.map((c) => c.content[0].content[0]?.text),
|
|
["x", "y", "z"],
|
|
);
|
|
// Colwidths copied from the header row.
|
|
assert.deepEqual(
|
|
newRow.content.map((c) => c.attrs.colwidth),
|
|
[[120], [200], [150]],
|
|
);
|
|
// colspan/rowspan present.
|
|
for (const c of newRow.content) {
|
|
assert.equal(c.attrs.colspan, 1);
|
|
assert.equal(c.attrs.rowspan, 1);
|
|
}
|
|
|
|
// New paragraph ids are unique and not equal to any existing id.
|
|
const newIds = newRow.content.map((c) => c.content[0].attrs.id);
|
|
assert.equal(new Set(newIds).size, 3);
|
|
for (const id of newIds) {
|
|
assert.ok(typeof id === "string" && id.length > 0);
|
|
assert.equal(existingIds.has(id), false);
|
|
}
|
|
});
|
|
|
|
test("insertTableRow at index 0 inserts before the header and pads to 3 cells", () => {
|
|
const { doc: out, inserted } = insertTableRow(makeDoc(), "#1", ["x"], 0);
|
|
assert.equal(inserted, true);
|
|
|
|
const tbl = out.content[1];
|
|
assert.equal(tbl.content.length, 4);
|
|
const newRow = tbl.content[0]; // inserted at the front
|
|
assert.equal(newRow.content.length, 3);
|
|
// First cell "x", remaining two empty.
|
|
assert.deepEqual(
|
|
newRow.content.map((c) => c.content[0].content.length),
|
|
[1, 0, 0],
|
|
);
|
|
assert.equal(newRow.content[0].content[0].content[0].text, "x");
|
|
});
|
|
|
|
test("insertTableRow throws when given more cells than columns", () => {
|
|
assert.throws(
|
|
() => insertTableRow(makeDoc(), "#1", ["a", "b", "c", "d"]),
|
|
/tableInsertRow: got 4 cell\(s\) but the table has 3 column\(s\)/,
|
|
);
|
|
});
|
|
|
|
test("insertTableRow on a missing table returns inserted:false", () => {
|
|
const { inserted } = insertTableRow(makeDoc(), "#0", ["x"]);
|
|
assert.equal(inserted, false);
|
|
});
|
|
|
|
// A header cell uses type "tableHeader" (vs. "tableCell" for data cells).
|
|
const headerCell = (paraId, text, colwidth) => ({
|
|
type: "tableHeader",
|
|
attrs: { colspan: 1, rowspan: 1, ...(colwidth ? { colwidth } : {}) },
|
|
content: [para(paraId, text)],
|
|
});
|
|
|
|
// Table whose first row uses tableHeader cells.
|
|
const makeHeaderDoc = () =>
|
|
doc({
|
|
type: "table",
|
|
content: [
|
|
row(headerCell("hA", "A"), headerCell("hB", "B")),
|
|
row(cell("p10", "r1c0"), cell("p11", "r1c1")),
|
|
],
|
|
});
|
|
|
|
test("insertTableRow at index 0 inherits the header cell type (tableHeader)", () => {
|
|
const { doc: out, inserted } = insertTableRow(makeHeaderDoc(), "#0", ["x", "y"], 0);
|
|
assert.equal(inserted, true);
|
|
|
|
const tbl = out.content[0];
|
|
const newRow = tbl.content[0]; // landed at index 0
|
|
// The new row's cells inherit the header type.
|
|
assert.deepEqual(
|
|
newRow.content.map((c) => c.type),
|
|
["tableHeader", "tableHeader"],
|
|
);
|
|
assert.equal(newRow.content[0].content[0].content[0].text, "x");
|
|
});
|
|
|
|
test("insertTableRow append produces data cells (tableCell), not header cells", () => {
|
|
const { doc: out, inserted } = insertTableRow(makeHeaderDoc(), "#0", ["x", "y"]);
|
|
assert.equal(inserted, true);
|
|
|
|
const tbl = out.content[0];
|
|
const newRow = tbl.content[tbl.content.length - 1]; // appended last
|
|
assert.deepEqual(
|
|
newRow.content.map((c) => c.type),
|
|
["tableCell", "tableCell"],
|
|
);
|
|
});
|
|
|
|
// Ragged table: row 0 has 2 cols, a later row has 3.
|
|
const makeRaggedDoc = () =>
|
|
doc({
|
|
type: "table",
|
|
content: [
|
|
row(cell("a0", "a0"), cell("a1", "a1")),
|
|
row(cell("b0", "b0"), cell("b1", "b1"), cell("b2", "b2")),
|
|
],
|
|
});
|
|
|
|
test("insertTableRow uses the max column count across all rows (ragged table)", () => {
|
|
// colCount is 3 (the widest row), so 3 cells are accepted...
|
|
const { doc: out, inserted } = insertTableRow(makeRaggedDoc(), "#0", ["x", "y", "z"]);
|
|
assert.equal(inserted, true);
|
|
const tbl = out.content[0];
|
|
const newRow = tbl.content[tbl.content.length - 1];
|
|
assert.equal(newRow.content.length, 3);
|
|
assert.deepEqual(
|
|
newRow.content.map((c) => c.content[0].content[0]?.text),
|
|
["x", "y", "z"],
|
|
);
|
|
|
|
// ...but 4 cells exceed the widest row and throw.
|
|
assert.throws(
|
|
() => insertTableRow(makeRaggedDoc(), "#0", ["a", "b", "c", "d"]),
|
|
/tableInsertRow: got 4 cell\(s\) but the table has 3 column\(s\)/,
|
|
);
|
|
});
|
|
|
|
test("insertTableRow into an empty table uses colCount = supplied cells", () => {
|
|
const empty = doc({ type: "table", content: [] });
|
|
const { doc: out, inserted } = insertTableRow(empty, "#0", ["x", "y", "z"]);
|
|
assert.equal(inserted, true);
|
|
const tbl = out.content[0];
|
|
assert.equal(tbl.content.length, 1);
|
|
assert.equal(tbl.content[0].content.length, 3);
|
|
assert.deepEqual(
|
|
tbl.content[0].content.map((c) => c.content[0].content[0]?.text),
|
|
["x", "y", "z"],
|
|
);
|
|
});
|
|
|
|
test("insertTableRow mints 12-char [a-z0-9] ids that are unique and non-colliding", () => {
|
|
const input = makeDoc();
|
|
const existingIds = allIds(input);
|
|
const { doc: out } = insertTableRow(input, "#1", ["x", "y", "z"]);
|
|
|
|
const tbl = out.content[1];
|
|
const newRow = tbl.content[tbl.content.length - 1];
|
|
const newIds = newRow.content.map((c) => c.content[0].attrs.id);
|
|
|
|
// Docmost-style: exactly 12 chars from lowercase a-z0-9.
|
|
for (const id of newIds) {
|
|
assert.match(id, /^[a-z0-9]{12}$/);
|
|
assert.equal(existingIds.has(id), false); // no collision with the doc
|
|
}
|
|
// All distinct within the new row.
|
|
assert.equal(new Set(newIds).size, newIds.length);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// deleteTableRow
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("deleteTableRow removes the 3rd row -> rows:2", () => {
|
|
const { doc: out, deleted } = deleteTableRow(makeDoc(), "#1", 2);
|
|
assert.equal(deleted, true);
|
|
const tbl = out.content[1];
|
|
assert.equal(tbl.content.length, 2);
|
|
// The removed row was the second data row (r2*).
|
|
assert.deepEqual(
|
|
tbl.content.map((r) => r.content[0].content[0].content[0]?.text ?? ""),
|
|
["A", "r1c0"],
|
|
);
|
|
});
|
|
|
|
test("deleteTableRow out-of-range index throws", () => {
|
|
assert.throws(
|
|
() => deleteTableRow(makeDoc(), "#1", 9),
|
|
/tableDeleteRow: row index 9 out of range \(table has 3 row\(s\)\)/,
|
|
);
|
|
});
|
|
|
|
test("deleteTableRow refuses to delete the only row", () => {
|
|
const single = doc({
|
|
type: "table",
|
|
content: [row(cell("only", "x"))],
|
|
});
|
|
assert.throws(
|
|
() => deleteTableRow(single, "#0", 0),
|
|
/refusing to delete the only row of the table/,
|
|
);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// updateTableCell
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("updateTableCell sets cell [1,1] to 'Z' and preserves the paragraph id", () => {
|
|
const input = makeDoc();
|
|
const snap = snapshot(input);
|
|
const { doc: out, updated } = updateTableCell(input, "#1", 1, 1, "Z");
|
|
assert.equal(updated, true);
|
|
|
|
// Input not mutated.
|
|
assert.deepEqual(input, snap);
|
|
|
|
const targetCell = out.content[1].content[1].content[1];
|
|
assert.equal(targetCell.content.length, 1);
|
|
const p = targetCell.content[0];
|
|
assert.equal(p.type, "paragraph");
|
|
assert.equal(p.attrs.id, "p11"); // preserved
|
|
assert.equal(p.content[0].text, "Z");
|
|
|
|
// Cell attrs preserved.
|
|
assert.equal(targetCell.attrs.colspan, 1);
|
|
assert.equal(targetCell.attrs.rowspan, 1);
|
|
});
|
|
|
|
test("updateTableCell out-of-range row/col throws", () => {
|
|
assert.throws(
|
|
() => updateTableCell(makeDoc(), "#1", 9, 0, "x"),
|
|
/tableUpdateCell: cell \[9,0\] out of range/,
|
|
);
|
|
assert.throws(
|
|
() => updateTableCell(makeDoc(), "#1", 0, 9, "x"),
|
|
/tableUpdateCell: cell \[0,9\] out of range/,
|
|
);
|
|
});
|