/
deka
/
buffer
Обзор
Документация
Войти
/
deka
/
buffer
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
dev
tests/converters/board-payload.test.mjs
189 строк
12 KB
Maksim Ratnikov
fix: связи не рисовались — привязанные линии должны лежать в lines[]
05 авг 2026, 17:01
05 авг 2026, 17:01
65acd36
Код
Авторство
О чём код?
import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { parseBoardPayload, sceneToBoardPayload, unwrapPayload } from "../../src/converters/board-payload.js"; const fixture = (name) => readFileSync(fileURLToPath(new URL(`../fixtures/board/${name}`, import.meta.url)), "utf8"); const rawText = fixture("swimlane-2x1.raw.json"); const wrapperText = fixture("swimlane-2x1.wrapper.json"); assert.deepEqual( JSON.parse(JSON.stringify(unwrapPayload(wrapperText))), JSON.parse(JSON.stringify(unwrapPayload(rawText))), "обёртка лаба и сырая строка дают одинаковый документ" ); assert.throws(() => unwrapPayload("{\"nope\":1}"), /tree/, "документ без tree отвергается с внятной ошибкой"); const { scene, warnings } = parseBoardPayload(rawText); const root = scene.nodes.find((node) => node.parent === null); assert.equal(root.kind, "table", "корневой элемент свимлейна — таблица"); assert.equal(root.table.columns.length, 2, "у свимлейна две колонки"); assert.equal(root.table.rows.length, 2, "в фикстуре две дорожки"); assert.equal(root.table.rows[0].cells[0].text.blocks[0].spans[0].text, "Андреев Алексей Валерьевич", "текст ячейки разобран в блоки"); assert.deepEqual(root.table.rows[0].cells[1].text.blocks, [], "пустая ячейка <p></p> даёт ноль блоков"); const nested = scene.nodes.filter((node) => node.parent !== null && node.kind === "table"); assert.ok(nested.length >= 1, "вложенные таблицы найдены"); assert.equal(nested[0].rect.x, -2508, "мировая координата вложенной таблицы = x таблицы (-3062) + смещение колонки (459) + локальный x (95)"); for (const node of scene.nodes) { assert.equal(typeof node.rect.x, "number", `${node.key}: rect в мировых координатах`); assert.ok(!("id" in node), `${node.key}: UUID доски не протекает в сцену`); } const orders = scene.nodes.map((node) => node.paintOrder).sort((a, b) => a - b); assert.deepEqual(orders, orders.map((_, index) => index), "paintOrder — плотная последовательность 0..N-1"); const bound = parseBoardPayload(fixture("figures-bound.raw.json")); assert.equal(bound.scene.edges.length, 1, "связанная стрелка стала ребром"); assert.equal(bound.scene.edges[0].arrow, "end", "наконечник на конце"); assert.ok(bound.scene.edges[0].from.nodeKey && bound.scene.edges[0].to.nodeKey, "оба конца ребра разрешены в узлы сцены"); assert.ok(["top", "right", "bottom", "left"].includes(bound.scene.edges[0].to.side), "portId переведён в сторону"); const absolute = parseBoardPayload(JSON.stringify({ tree: { space: "root", children: [{ element: { type: "line", id: "l1", attrs: { start: { form: null, position: { type: "absolute", x: 0, y: 0 } }, end: { form: "arrow", position: { type: "absolute", x: 10, y: 10 } }, z: 1, space: { type: "root", attrs: {} } }, deleted: false, userId: "", ver: 0 }, space: "root", children: [] }] }, lines: [] })); assert.equal(absolute.scene.edges.length, 0, "абсолютная линия не становится связью"); assert.equal(absolute.warnings[0].code, "absolute-line-dropped", "и попадает в предупреждения"); assert.equal(absolute.warnings[0].severity, "warn"); assert.ok(Array.isArray(warnings), "разбор всегда возвращает массив предупреждений"); assert.deepEqual(warnings, [], "чистая фикстура разбирается без предупреждений"); // Разбор не зависит от порядка: ребёнок ячейки лежит ПЕРЕД своей таблицей и на корневом уровне. const flat = parseBoardPayload(JSON.stringify({ tree: { space: "root", children: [ { element: { type: "sticker", id: "s1", attrs: { x: 10, y: 20, z: 1, w: 50, h: 50, backgroundColor: "#BDD4FF", text: "<p>внутри</p>", textStyle: { fontSize: 24 }, space: { type: "cell", attrs: { id: "t1", cellId: "c1" } } }, deleted: false, userId: "", ver: 0 }, space: "t1", children: [] }, { element: { type: "table", id: "t1", attrs: { x: 1000, y: 2000, z: 0, w: 100, h: 50, space: { type: "root", attrs: {} }, rows: [{ cells: [{ id: "c1", w: 100, h: 50, dataType: "text", data: { text: "" } }] }] } , deleted: false, userId: "", ver: 0 }, space: "root", children: [] } ] }, lines: [] })); const inner = flat.scene.nodes.find((node) => node.kind === "sticker"); assert.ok(inner.parent, "отложенный элемент нашёл свою ячейку после её появления"); assert.deepEqual({ x: inner.rect.x, y: inner.rect.y }, { x: 1010, y: 2020 }, "и получил мировые координаты от родительской ячейки"); assert.deepEqual(flat.warnings, [], "порядок обхода не порождает ложных предупреждений"); // Неразрешимая ссылка не зацикливает разбор. const orphan = parseBoardPayload(JSON.stringify({ tree: { space: "root", children: [{ element: { type: "sticker", id: "s9", attrs: { x: 0, y: 0, z: 0, w: 10, h: 10, space: { type: "cell", attrs: { id: "нет", cellId: "нет" } } }, deleted: false, userId: "", ver: 0 }, space: "нет", children: [] }] }, lines: [] })); assert.equal(orphan.warnings[0].code, "unknown-cell", "сирота попадает в предупреждения, а не в бесконечный цикл"); // Несогласованная геометрия таблицы нормализуется и не проходит молча. const badGeometry = parseBoardPayload(JSON.stringify({ tree: { space: "root", children: [{ element: { type: "table", id: "t1", attrs: { x: 0, y: 0, z: 0, w: 999, h: 999, space: { type: "root", attrs: {} }, rows: [{ cells: [{ id: "c1", w: 100, h: 50, dataType: "text", data: { text: "x" } }] }] }, deleted: false, userId: "", ver: 0 }, space: "root", children: [] }] }, lines: [] })); assert.equal(badGeometry.warnings[0].code, "table-geometry-mismatch"); assert.deepEqual( { w: badGeometry.scene.nodes[0].rect.w, h: badGeometry.scene.nodes[0].rect.h }, { w: 100, h: 50 }, "прямоугольник таблицы приводится к сумме колонок и строк" ); let seq = 0; const stubIds = () => `00000000-0000-4000-8000-${String(++seq).padStart(12, "0")}`; seq = 0; const built = JSON.parse(sceneToBoardPayload(parseBoardPayload(rawText).scene, { idFactory: stubIds })); assert.equal(built.tree.space, "root", "собранный документ начинается с корневого пространства"); assert.equal(built.tree.children.length, 1, "одна корневая таблица"); const builtRoot = built.tree.children[0].element; assert.match(builtRoot.id, /^[0-9a-f-]{36}$/, "элементам выданы UUID"); assert.equal(builtRoot.deleted, false, "служебные поля проставлены"); assert.equal(builtRoot.ver, 0); assert.equal(builtRoot.attrs.space.type, "root"); const rowSum = builtRoot.attrs.rows.reduce((sum, row) => sum + row.cells[0].h, 0); assert.ok(Math.abs(builtRoot.attrs.h - rowSum) < 1e-6, "инвариант доски: высота таблицы равна сумме высот строк"); for (const row of builtRoot.attrs.rows) { const widthSum = row.cells.reduce((sum, cell) => sum + cell.w, 0); assert.ok(Math.abs(builtRoot.attrs.w - widthSum) < 1e-6, "инвариант доски: ширина таблицы равна сумме ширин ячеек в каждой строке"); assert.equal(new Set(row.cells.map((cell) => cell.h)).size, 1, "все ячейки строки одной высоты"); } const builtChild = built.tree.children[0].children.find((entry) => entry.element.type === "table"); assert.equal(builtChild.element.attrs.space.type, "cell", "вложенный элемент лежит в пространстве ячейки"); assert.equal(builtChild.element.attrs.x, 95, "мировые координаты свёрнуты обратно в локальные один раз"); seq = 0; const boundBuilt = JSON.parse(sceneToBoardPayload(bound.scene, { idFactory: stubIds })); assert.equal(boundBuilt.lines.length, 1, "привязанная связь уходит в lines[], а не в дерево"); assert.equal(boundBuilt.tree.children.filter((entry) => entry.element.type === "line").length, 0, "в дереве доска держит только свободные линии absolute → absolute и привязанные оттуда не рисует"); const builtLine = { element: boundBuilt.lines[0] }; assert.ok(builtLine, "связь собрана"); assert.equal(builtLine.element.attrs.start.position.type, "bind", "связь остаётся привязанной"); assert.equal(typeof builtLine.element.attrs.start.position.portId, "number", "сторона свёрнута в portId"); // Связь внутри одной ячейки обязана вернуться в lines[] с пространством этой ячейки, // а не всплыть в корень: доска хранит их именно так. seq = 0; const swimlaneBuilt = JSON.parse(sceneToBoardPayload(parseBoardPayload(rawText).scene, { idFactory: stubIds })); assert.equal(swimlaneBuilt.lines.length, 1, "связь внутри ячейки лежит в lines[]"); assert.equal(swimlaneBuilt.lines[0].attrs.space.type, "cell", "и сохраняет пространство ячейки"); assert.equal(swimlaneBuilt.tree.children.filter((entry) => entry.element.type === "line").length, 0, "и не дублируется в дереве"); // Связь между корневыми карточками — тоже в lines[], просто с пространством root. seq = 0; const rootEdge = JSON.parse(sceneToBoardPayload({ nodes: [ { key: "n1", kind: "sticker", parent: null, rect: { x: 0, y: 0, w: 100, h: 100 }, paintOrder: 0, style: { color: "#BDD4FF", fontSize: 24 }, text: { blocks: [] }, connectable: true }, { key: "n2", kind: "sticker", parent: null, rect: { x: 300, y: 0, w: 100, h: 100 }, paintOrder: 1, style: { color: "#BDD4FF", fontSize: 24 }, text: { blocks: [] }, connectable: true } ], edges: [{ key: "e1", from: { nodeKey: "n1", side: "right" }, to: { nodeKey: "n2", side: "left" }, arrow: "end" }] }, { idFactory: stubIds })); assert.equal(rootEdge.lines.length, 1, "корневая привязанная связь тоже в lines[]"); assert.equal(rootEdge.lines[0].attrs.space.type, "root", "с пространством root"); assert.equal(rootEdge.tree.children.filter((e) => e.element.type === "line").length, 0, "и ничего не остаётся в дереве"); const zValues = []; const collectZ = (entries) => { for (const entry of entries) { zValues.push(entry.element.attrs.z); collectZ(entry.children ?? []); } }; collectZ(swimlaneBuilt.tree.children); zValues.push(...swimlaneBuilt.lines.map((line) => line.attrs.z)); assert.equal(new Set(zValues).size, zValues.length, "все z уникальны — связь не садится на z уже занятый элементом"); console.log("board-payload parse tests passed");