Files
gitmost/packages/mcp/test/unit/drawio-cell-ops.test.mjs
T
agent_coder 2a951df096 feat(mcp): drawio стадия 3 — семантические тулы drawioFromGraph/FromMermaid/EditCells (#425)
Сырой XML остаётся escape-hatch, но для 90% случаев модель не видит ни координат,
ни style-строк — весь класс ошибок лейаута и иконок уходит by construction: эти
решения принимает сервер, а не LLM. Стоит на #443, переиспользует конвейер #423 и
shape-index/elkjs/линтер #424.

- drawioFromGraph(pageId, where, graph, direction?, preset?, layout?): граф
  узлов/групп/связей → резолв иконок (shape-index #424; неизвестная → generic по
  kind с подписью, не пустой квадрат), стили из пресета (kind→палитра),
  elkjs-layered с compound-группами, ассемблер XML (линтер-чистый by construction:
  зазоры >=150, прозрачные контейнеры, относительные координаты детей,
  cross-container рёбра parent=1, эскейп меток). Хинты pinned/sameLayerAs/layer и
  layout none/full/incremental — детерминированным post-pass'ом (ELK-констрейнты
  оказались ненадёжны). Пресеты default/dark/colorblind-safe (Okabe-Ito) — данные.
- drawioFromMermaid(pageId, where, mermaid): чистый парсер flowchart (без
  браузера/CLI) → graph → тот же конвейер. Формы/направление/пунктир=async/
  subgraph→группы/цепочки; не-flowchart отвергает внятно.
- drawioEditCells(pageId, node, operations, baseHash): ID-based add/update/delete,
  delete каскадит на детей контейнера и связанные рёбра; baseHash обязателен
  (optimistic lock как drawioUpdate); сентинелы 0/1 от delete защищены.

DoS-границы (LLM-вход): MAX_GRAPH_EDGES=1000/GROUPS=500 в validateGraph (узлы уже
500), mermaid MAX_CHARS=200k/LINES=20k/GROUPS=500, chain 500 — все с быстрым
throw ДО лейаута/ассемблера (ассемблер и маппер вне ELK-таймаута, иначе OOM
воркера). incremental сохраняет неперечисленные существующие ячейки (mergeExisting
Cells) — «добавь узел» не стирает ручную расстановку. sameLayerAs/layer после
снапа раскладываются по перпендикулярной оси с зазором >=150 → 0 quality-warnings;
pinned — точные пользовательские координаты (clamp>=0, могут дать warning, гарантия
«0 by construction» относится к авто-лейауту).

Регистрация: 3 shared-spec на оба хоста (camelCase, execute-in-spec, inlineBoth
Hosts не понадобился), DocmostClientLike/Method += 3, contract, routing-проза
(fromGraph→архитектуры/облака, fromMermaid→стандартные flowchart, raw xml→экзотика).

Тесты: mcp node --test 782/782 (57 новых) — приёмка (15+ узлов/2 вложенные группы/
AWS-иконки→0 lint/0 warnings/иконки резолвятся, hints, incremental без сдвига,
edit_cells update/cascade/stale-baseHash, снапшоты пресетов + colorblind-safe,
mermaid ветвление+subgraph) + регрессии на DoS-границы. tsc чисто; server jest
(contract + ai-chat) 290/290. closes #425.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 23:16:50 +03:00

129 lines
5.0 KiB
JavaScript

// Unit tests for the drawioEditCells operations (issue #425, acceptance #4):
// add / update / delete applied to the parsed model, with a cascade delete that
// removes container children AND every connected edge.
import { test } from "node:test";
import assert from "node:assert/strict";
import { applyCellOps, CellOpsError } from "../../build/lib/drawio-cell-ops.js";
import { parseCells } from "../../build/lib/drawio-xml.js";
const MODEL =
"<mxGraphModel><root><mxCell id=\"0\"/><mxCell id=\"1\" parent=\"0\"/>" +
'<mxCell id="grp" value="G" style="container=1;fillColor=none;" vertex="1" parent="1">' +
'<mxGeometry x="0" y="0" width="300" height="200" as="geometry"/></mxCell>' +
'<mxCell id="c1" value="Child1" style="rounded=1;" vertex="1" parent="grp">' +
'<mxGeometry x="10" y="10" width="80" height="40" as="geometry"/></mxCell>' +
'<mxCell id="c2" value="Child2" style="rounded=1;" vertex="1" parent="grp">' +
'<mxGeometry x="10" y="80" width="80" height="40" as="geometry"/></mxCell>' +
'<mxCell id="out" value="Outside" style="rounded=1;" vertex="1" parent="1">' +
'<mxGeometry x="400" y="10" width="80" height="40" as="geometry"/></mxCell>' +
'<mxCell id="e1" style="" edge="1" parent="1" source="c1" target="out">' +
'<mxGeometry relative="1" as="geometry"/></mxCell>' +
'<mxCell id="e2" style="" edge="1" parent="1" source="out" target="c2">' +
'<mxGeometry relative="1" as="geometry"/></mxCell>' +
"</root></mxGraphModel>";
const ids = (xml) =>
parseCells(xml)
.filter((c) => c.id !== "0" && c.id !== "1")
.map((c) => c.id)
.sort();
test("update changes ONLY the targeted cell", () => {
const out = applyCellOps(MODEL, [
{
op: "update",
cellId: "c1",
xml:
'<mxCell id="c1" value="Renamed" style="rounded=1;" vertex="1" parent="grp">' +
'<mxGeometry x="10" y="10" width="80" height="40" as="geometry"/></mxCell>',
},
]);
const cells = parseCells(out);
assert.equal(cells.find((c) => c.id === "c1").value, "Renamed");
// Every OTHER cell is untouched.
assert.equal(cells.find((c) => c.id === "c2").value, "Child2");
assert.equal(cells.find((c) => c.id === "out").value, "Outside");
assert.deepEqual(ids(out), ids(MODEL));
});
test("delete of a container removes its children AND the connected edges", () => {
const out = applyCellOps(MODEL, [{ op: "delete", cellId: "grp" }]);
// grp + c1 + c2 gone (cascade to children); e1 (c1->out) and e2 (out->c2)
// gone (cascade to connected edges); "out" survives.
assert.deepEqual(ids(out), ["out"]);
});
test("delete of a leaf only cascades to its connected edges, not siblings", () => {
const out = applyCellOps(MODEL, [{ op: "delete", cellId: "c1" }]);
// c1 gone + e1 (c1->out) gone; c2, out, grp, e2 survive.
assert.deepEqual(ids(out), ["c2", "e2", "grp", "out"]);
});
test("add appends a new cell", () => {
const out = applyCellOps(MODEL, [
{
op: "add",
xml:
'<mxCell id="new1" value="N" style="rounded=1;" vertex="1" parent="1">' +
'<mxGeometry x="500" y="10" width="80" height="40" as="geometry"/></mxCell>',
},
]);
assert.ok(parseCells(out).some((c) => c.id === "new1"));
});
test("delete never removes the sentinels", () => {
const out = applyCellOps(MODEL, [{ op: "delete", cellId: "out" }]);
const cells = parseCells(out);
assert.ok(cells.some((c) => c.id === "0"));
assert.ok(cells.some((c) => c.id === "1"));
});
test("errors: unknown update/delete target, duplicate add id, id mismatch", () => {
assert.throws(
() => applyCellOps(MODEL, [{ op: "update", cellId: "ghost", xml: '<mxCell id="ghost"/>' }]),
/does not exist/,
);
assert.throws(
() => applyCellOps(MODEL, [{ op: "delete", cellId: "ghost" }]),
/does not exist/,
);
assert.throws(
() => applyCellOps(MODEL, [{ op: "add", xml: '<mxCell id="c1"/>' }]),
/already exists/,
);
assert.throws(
() =>
applyCellOps(MODEL, [
{ op: "update", cellId: "c1", xml: '<mxCell id="c2"/>' },
]),
/ids are stable/,
);
assert.throws(() => applyCellOps(MODEL, []), CellOpsError);
});
test("an add op with two cells or a missing id is rejected", () => {
assert.throws(
() => applyCellOps(MODEL, [{ op: "add", xml: '<mxCell id="a"/><mxCell id="b"/>' }]),
/exactly one <mxCell>/,
);
assert.throws(
() => applyCellOps(MODEL, [{ op: "add", xml: '<mxCell value="x"/>' }]),
/missing an id/,
);
});
// --- SUGGESTION #5: sentinel cells are protected from delete ------------------
test("delete targeting a sentinel id is rejected (no wipe of the diagram body)", () => {
for (const sid of ["0", "1"]) {
assert.throws(
() => applyCellOps(MODEL, [{ op: "delete", cellId: sid }]),
(e) => e instanceof CellOpsError && /cannot delete sentinel cell/.test(e.message),
);
}
// A normal delete still works and the sentinels remain intact.
const out = applyCellOps(MODEL, [{ op: "delete", cellId: "out" }]);
const remaining = parseCells(out).map((c) => c.id);
assert.ok(remaining.includes("0") && remaining.includes("1"), "sentinels survive");
});