2a951df096
Сырой 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>
129 lines
5.0 KiB
JavaScript
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");
|
|
});
|