/
deka
/
buffer
Обзор
Документация
Войти
/
deka
/
buffer
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
dev
tests/converters/board-layout.test.mjs
447 строк
28 KB
Maksim Ratnikov
fix: доска отвергала вставку из-за дробной координаты
11 авг 2026, 10:55
11 авг 2026, 10:55
51ca8ed
Код
Авторство
О чём код?
import assert from "node:assert/strict"; import { boardLayoutToScene, validateBoardLayout, heuristicMeasure, ELEMENT_COLORS, STICKER_COLORS, TEXT_COLORS, FIGURE_NATURAL, FLOWCHART_TYPES, CARD_SIZES, TABLE_ROW_MIN_HEIGHT, TABLE_COLUMN_MIN_WIDTH, FIGURE_TYPES } from "../../src/converters/board-layout.js"; import { sceneToBoardPayload, parseBoardPayload } from "../../src/converters/board-payload.js"; const card = (id, extra = {}) => ({ type: "card", id, variant: "sticker", title: id, ...extra }); // --- валидация --------------------------------------------------------------- const codesOf = (layout) => validateBoardLayout(layout).errors.map((error) => error.code); assert.deepEqual( validateBoardLayout({ $schema: "board-layout-v1", content: { type: "grid", columns: 2, items: [card("a")] } }).errors, [], "корректный layout не даёт ошибок" ); assert.ok(codesOf({ content: { type: "grid", columns: 0, items: [] } }).includes("bad-grid-columns"), "grid.columns вне 1..6 — ошибка"); assert.ok(codesOf({ content: { type: "grid", columns: 9, items: [] } }).includes("bad-grid-columns"), "и сверху тоже"); assert.ok(codesOf({ content: { type: "swimlanes", id: "s", lanes: [ { id: "l", title: "Кластер", fontSize: 201 } ] } }).includes("bad-lane-font-size"), "кегль дорожки ограничен сверху"); assert.ok(codesOf({ content: { type: "swimlanes", id: "s", lanes: [ { id: "l", title: "Кластер", fontSize: 7 } ] } }).includes("bad-lane-font-size"), "кегль дорожки ограничен снизу"); assert.ok(codesOf({ content: { type: "row", items: [card("a"), card("a")] } }).includes("duplicate-id"), "повторный id — ошибка"); assert.ok(codesOf({ content: { type: "row", items: [{ type: "card", variant: "sticker", title: "без id" }] } }) .includes("card-without-id"), "карточка без id — ошибка: на неё нельзя сослаться"); assert.ok(codesOf({ content: { type: "row", items: [card("a", { color: "мокрый асфальт" })] } }) .includes("unknown-color"), "цвет вне палитры — ошибка"); assert.ok(codesOf({ content: { type: "row", items: [card("a", { text: "<b>нельзя</b>" })] } }) .includes("html-in-text"), "HTML в тексте — ошибка, текст только markdown"); assert.ok(codesOf({ content: { type: "row", items: [card("a")] }, edges: [{ id: "e", from: { id: "a" }, to: { id: "нет" } }] }) .includes("dangling-edge"), "связь в никуда — ошибка, а не тихое удаление"); assert.ok(codesOf({ content: { type: "swimlanes", id: "s", lanes: [{ id: "lane", title: "Дорожка", content: { type: "row", items: [card("a")] } }] }, edges: [{ id: "e", from: { id: "a" }, to: { id: "lane" } }] }).includes("edge-to-container"), "связь к дорожке — ошибка: у контейнера нет элемента для привязки"); assert.ok(codesOf({ content: { type: "row", items: [{ type: "мемуары" }] } }).includes("unknown-type"), "неизвестный тип узла — ошибка"); // Таблица ограничена 30×30: гигантскую сетку невозможно читать, а LLM без предела // охотно сваливает весь массив данных в один узел. Считаются объявленные строки и // колонки — строка заголовков рождается у рендерера и в лимит не входит. const tableOf = (rowCount, columnCount) => ({ content: { type: "table", id: "t", columns: Array.from({ length: columnCount }, (_, index) => ({ id: `c${index}`, title: `К${index}` })), rows: Array.from({ length: rowCount }, (_, index) => ({ id: `r${index}`, cells: Object.fromEntries(Array.from({ length: columnCount }, (_, ci) => [`c${ci}`, `v${index}-${ci}`])) })) } }); const errorsOf = (layout) => validateBoardLayout(layout).errors; assert.deepEqual(errorsOf(tableOf(30, 30)), [], "таблица ровно 30×30 законна"); const tooManyRows = errorsOf(tableOf(31, 2)).find((error) => error.code === "too-many-rows"); assert.ok(tooManyRows, "31 строка — ошибка too-many-rows"); assert.equal(tooManyRows.path, "content.rows", "путь ведёт к нарушившему полю, а не только к узлу"); assert.match(tooManyRows.detail, /31/, "detail называет фактический размер"); assert.match(tooManyRows.detail, /30/, "detail называет предел"); const tooManyColumns = errorsOf(tableOf(2, 31)).find((error) => error.code === "too-many-columns"); assert.ok(tooManyColumns, "31 колонка — ошибка too-many-columns"); assert.equal(tooManyColumns.path, "content.columns", "путь ведёт к колонкам"); assert.match(tooManyColumns.detail, /31/, "detail называет фактический размер"); // Вложенная таблица — такой же узел table, обход walk() обязан дойти и до неё. const nestedTooBig = errorsOf({ content: { type: "table", id: "outer", columns: [{ id: "c", title: "Раздел" }], rows: [{ id: "r", content: { c: tableOf(31, 2).content } }] } }).find((error) => error.code === "too-many-rows"); assert.ok(nestedTooBig, "вложенная в ячейку таблица подчиняется тому же лимиту"); assert.equal(nestedTooBig.path, "content.rows[0].content.c.rows", "путь ведёт внутрь вложенной таблицы"); // Обход негодной таблицы брошен на полпути, поэтому её карточки не попали в ids. // Жаловаться на висячие связи в таком прогоне нельзя: человек пойдёт чинить не // тот дефект. Ошибка должна остаться ровно одна — про размер. const oversizedWithEdge = errorsOf({ content: { type: "column", items: [{ type: "table", id: "big", columns: [{ id: "c", title: "Раздел" }], rows: [ ...Array.from({ length: 30 }, (_, index) => ({ id: `r${index}`, cells: { c: `v${index}` } })), { id: "r30", content: { c: card("inside") } } ] }] }, edges: [{ from: { id: "inside" }, to: { id: "inside" } }] }); assert.deepEqual( oversizedWithEdge.map((error) => error.code), ["too-many-rows"], "слишком большая таблица не порождает ложных dangling-edge к своим же карточкам" ); // Граница считается по данным, а не по тому, что видно на доске: у законных // тридцати строк с заголовками физических строк тридцать одна. const borderline = boardLayoutToScene(tableOf(30, 2)).scene.nodes[0]; assert.equal(borderline.table.rows.length, 31, "30 строк данных плюс шапка — 31 строка сцены"); assert.ok(codesOf({ content: { type: "table", id: "t", columns: [], rows: [] } }).includes("table-without-columns"), "таблица без колонок — ошибка"); assert.ok(codesOf({ content: { type: "table", id: "t", columns: [{ id: "c", title: "К" }], rows: [{ id: "r", cells: { нет: "x" } }] } }) .includes("unknown-column"), "ячейка ссылается на несуществующую колонку — ошибка"); // Локальность имён колонок: одинаковый columnId в двух таблицах — норма. assert.deepEqual( validateBoardLayout({ content: { type: "row", items: [ { type: "table", id: "t1", columns: [{ id: "owner", title: "Владелец" }], rows: [{ id: "r1", cells: { owner: "А" } }] }, { type: "table", id: "t2", columns: [{ id: "owner", title: "Владелец" }], rows: [{ id: "r2", cells: { owner: "Б" } }] } ] } }).errors, [], "id колонок живут в пространстве своей таблицы" ); assert.throws(() => boardLayoutToScene({ content: { type: "row", items: [card("a"), card("a")] } }), /duplicate-id/, "сборка сцены падает на невалидном layout, а не собирает мусор"); // --- измерение --------------------------------------------------------------- const short = { blocks: [{ type: "p", spans: [{ text: "коротко" }] }] }; const long = { blocks: [{ type: "p", spans: [{ text: "очень длинный текст ".repeat(20) }] }] }; assert.ok(heuristicMeasure(long, 360, 24) > heuristicMeasure(short, 360, 24), "длинный текст требует большей высоты"); assert.ok(heuristicMeasure(short, 200, 24) >= heuristicMeasure(short, 600, 24), "узкая колонка требует не меньшей высоты, чем широкая"); assert.ok(heuristicMeasure({ blocks: [] }, 360, 24) > 0, "пустой текст всё равно даёт минимальную высоту"); // --- раскладка --------------------------------------------------------------- const layout = { $schema: "board-layout-v1", content: { type: "swimlanes", id: "map", lanes: [ { id: "team-x", title: "Команда X", color: "green", content: { type: "table", id: "t-x", columns: [{ id: "sys", title: "Система" }, { id: "owner", title: "Владелец" }], rows: [ { id: "r1", cells: { sys: "SberBilling", owner: "Семенюк А. А." } }, { id: "r2", color: "yellow", cells: { sys: "Отчётность внутреннего учёта и XBRL", owner: "Семенюк А. А." } } ] } }, { id: "team-y", title: "Команда Y", color: "blue", content: { type: "grid", columns: 2, items: [ card("c1", { text: "- раз\n- два" }), { type: "card", id: "c2", variant: "figure", figure: "database", title: "Хранилище" } ] } } ] }, edges: [{ id: "e1", from: { id: "c1", side: "bottom" }, to: { id: "c2", side: "top" } }] }; const { scene, warnings } = boardLayoutToScene(layout); assert.deepEqual(warnings, [], "чистый layout не даёт предупреждений"); 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].color, ELEMENT_COLORS.green, "цвет дорожки — из палитры по имени"); assert.equal(root.table.rows[0].cells[0].text.blocks[0].spans[0].text, "Команда X", "заголовок дорожки на месте"); const largeLane = boardLayoutToScene({ content: { type: "swimlanes", id: "large", lanes: [ { id: "cluster", title: "Название кластера", fontSize: 100, content: card("inside") } ] } }).scene; assert.equal(largeLane.nodes[0].table.rows[0].cells[0].fontSize, 100, "индивидуальный кегль заголовка дорожки попадает в сцену"); const largeLaneNative = JSON.parse(sceneToBoardPayload(largeLane)); assert.equal(largeLaneNative.tree.children[0].element.attrs.rows[0].cells[0].data.textStyle.fontSize, 100, "индивидуальный кегль заголовка дорожки попадает в нативный payload"); const nested = scene.nodes.filter((node) => node.parent !== null); assert.equal(nested.length, 3, "вложены таблица, стикер и фигура"); for (const node of nested) { assert.ok(node.parent.cellKey, `${node.key}: вложенный элемент знает свою ячейку`); assert.ok( node.rect.x >= root.rect.x && node.rect.y >= root.rect.y && node.rect.x + node.rect.w <= root.rect.x + root.rect.w && node.rect.y + node.rect.h <= root.rect.y + root.rect.h, `${node.key}: вложенный элемент не вылезает за границы свимлейна` ); } const figure = scene.nodes.find((node) => node.kind === "figure"); assert.equal(figure.style.figureType, "database", "figureType проходит насквозь"); const naturalRatio = FIGURE_NATURAL.database.w / FIGURE_NATURAL.database.h; assert.ok(Math.abs(figure.rect.w / figure.rect.h - naturalRatio) < 0.01, "фигура держит своё отношение сторон, снятое с доски"); assert.ok(figure.rect.h >= FIGURE_NATURAL.database.h, "подпись может увеличить фигуру, но не уменьшить"); const orders = scene.nodes.map((node) => node.paintOrder).sort((a, b) => a - b); assert.deepEqual(orders, orders.map((_, index) => index), "paintOrder — плотная последовательность"); assert.equal(scene.edges.length, 1, "связь перенесена в сцену"); assert.equal(scene.edges[0].from.side, "bottom"); assert.equal(scene.edges[0].to.side, "top"); // Ряд не накладывается сам на себя. const rowScene = boardLayoutToScene({ content: { type: "row", items: [card("a"), card("b"), card("c")] } }).scene; const rects = rowScene.nodes.map((node) => node.rect).sort((a, b) => a.x - b.x); for (let i = 1; i < rects.length; i += 1) { assert.ok(rects[i].x >= rects[i - 1].x + rects[i - 1].w, "карточки в ряду не накладываются"); } // Длинный текст растит карточку по высоте, а не обрезается. const tall = boardLayoutToScene({ content: { type: "row", items: [ card("a", { text: "очень длинный текст ".repeat(30) }) ] } }).scene.nodes[0]; assert.ok(tall.rect.h > CARD_SIZES.sticker.m.h, "карточка с длинным текстом выше базовой"); // Широкое содержимое расширяет родительскую ячейку, а не вылезает из неё. const wide = boardLayoutToScene({ content: { type: "swimlanes", id: "s", lanes: [ { id: "lane", title: "Дорожка", content: { type: "row", items: [card("a"), card("b"), card("c"), card("d")] } } ] } }).scene; const wideRoot = wide.nodes.find((node) => node.parent === null); for (const node of wide.nodes.filter((n) => n.parent !== null)) { assert.ok(node.rect.x + node.rect.w <= wideRoot.rect.x + wideRoot.rect.w, "четыре карточки в дорожке расширили колонку, а не вылезли за таблицу"); } // Детерминизм: один и тот же layout даёт одну и ту же сцену. assert.deepEqual(boardLayoutToScene(layout).scene, boardLayoutToScene(layout).scene, "раскладка детерминирована"); // Доска держит собственный минимум высоты строки: отдать меньше нельзя, иначе она // поднимет строки до своего пола, а table.h останется нашим — рамка окажется // короче суммы строк, и таблица отрисуется разъехавшейся. const manyRows = boardLayoutToScene({ content: { type: "table", id: "t", columns: [{ id: "sys", title: "Система" }, { id: "owner", title: "Владелец" }], rows: Array.from({ length: 12 }, (_, index) => ({ id: `r${index}`, cells: { sys: `Система ${index + 1}`, owner: "Иванов И. И." } })) } }).scene; const manyRowsTable = manyRows.nodes[0]; for (const row of manyRowsTable.table.rows) { assert.ok(row.h >= TABLE_ROW_MIN_HEIGHT, `высота строки ${row.key} не опускается ниже минимума доски (${TABLE_ROW_MIN_HEIGHT})`); } for (const column of manyRowsTable.table.columns) { assert.ok(column.w >= TABLE_COLUMN_MIN_WIDTH, `ширина колонки ${column.key} не уже минимума доски (${TABLE_COLUMN_MIN_WIDTH})`); } assert.equal( manyRowsTable.rect.h, manyRowsTable.table.rows.reduce((sum, row) => sum + row.h, 0), "много строк — таблица растёт ровно на их сумму" ); assert.equal(manyRowsTable.table.rows.length, 13, "заголовок плюс двенадцать строк"); // Свимлейн наследует тот же пол. const laneRows = boardLayoutToScene({ content: { type: "swimlanes", id: "s", lanes: [ { id: "lane-1", title: "Пусто" }, { id: "lane-2", title: "С содержимым", content: { type: "row", items: [card("x")] } } ] } }).scene.nodes[0]; for (const row of laneRows.table.rows) { assert.ok(row.h >= TABLE_ROW_MIN_HEIGHT, "строка дорожки тоже не ниже минимума доски"); } // Заголовок таблицы виден на доске крупным планом: если движок его потеряет, // доска подставит своё «Таблица N», и на полотне из 60 таблиц ничего не найти. const titled = boardLayoutToScene({ content: { type: "table", id: "t", title: "Ландшафт SDD — Команда заказов", columns: [{ id: "f", title: "Поле" }, { id: "v", title: "Значение" }], rows: [{ id: "r", cells: { f: "АС / система", v: "" } }] } }).scene; assert.equal(titled.nodes[0].title.blocks[0].spans[0].text, "Ландшафт SDD — Команда заказов", "заголовок таблицы доезжает до сцены"); const titledNative = JSON.parse(sceneToBoardPayload(titled)); assert.equal(titledNative.tree.children[0].element.attrs.title, "<p>Ландшафт SDD — Команда заказов</p>", "и попадает в нативный attrs.title"); assert.deepEqual(parseBoardPayload(JSON.stringify(titledNative)).scene.nodes[0].title, titled.nodes[0].title, "и переживает обратный разбор"); const swimTitled = boardLayoutToScene({ content: { type: "swimlanes", id: "s", title: "Кластеры и команды", lanes: [{ id: "l", title: "Дорожка" }] } }).scene; assert.equal(swimTitled.nodes[0].title.blocks[0].spans[0].text, "Кластеры и команды", "у свимлейна тоже есть свой заголовок"); // Реестры — данные, снятые с доски, а не догадки. Если они разъедутся с тем, // что доска принимает, промпт начнёт обещать модели несуществующие фигуры. assert.equal(FIGURE_TYPES.length, 9, "базовых фигур девять — отдельный набор доски"); assert.equal(FLOWCHART_TYPES.length, 30, "блок-схем тридцать — это другой набор, не смешивать"); assert.equal(new Set([...FIGURE_TYPES, ...FLOWCHART_TYPES]).size, 39, "наборы не пересекаются"); assert.ok(FLOWCHART_TYPES.includes("cylinderHalf") && FLOWCHART_TYPES.includes("threeRhombs"), "редкие символы схем на месте"); assert.ok(!FIGURE_TYPES.includes("rect"), "выдуманного rect нет: у доски базовый прямоугольник — rectangle"); // Палитры у разных объектов разные — одно имя может значить разный цвет. assert.notEqual(ELEMENT_COLORS.red, STICKER_COLORS.red, "красный у фигуры и у стикера — это разные цвета, палитры не общие"); assert.equal(ELEMENT_COLORS.none, "#FFFFFF00", "прозрачная граница фигуры"); assert.equal(STICKER_COLORS.none, undefined, "у стикера нет границы, а значит и none ему не нужен"); // Фигура не растягивается: пропорции и относительный масштаб — часть смысла. for (const size of ["s", "m", "l"]) { const figScene = boardLayoutToScene({ content: { type: "row", items: [ { type: "card", id: "d", variant: "figure", figure: "diamond", size }, { type: "card", id: "t", variant: "figure", figure: "terminator", size } ] } }).scene; const [diamond, terminator] = figScene.nodes; assert.ok(Math.abs(diamond.rect.w / diamond.rect.h - 1) < 0.01, `${size}: ромб остаётся квадратным`); const natural = FIGURE_NATURAL.terminator.w / FIGURE_NATURAL.terminator.h; assert.ok(Math.abs(terminator.rect.w / terminator.rect.h - natural) < 0.01, `${size}: терминатор держит своё отношение сторон ${natural.toFixed(2)}`); assert.ok(terminator.rect.w > diamond.rect.w, `${size}: терминатор шире ромба — относительный масштаб набора сохранён`); } // Фигура не растёт под длинную подпись: доска сама уменьшает кегль, а ряд из // фигур разного размера перестаёт читаться как схема. const longFig = boardLayoutToScene({ content: { type: "row", items: [ { type: "card", id: "d", variant: "figure", figure: "diamond", text: "очень длинная подпись ".repeat(6) } ] } }).scene.nodes[0]; assert.deepEqual({ w: longFig.rect.w, h: longFig.rect.h }, { w: FIGURE_NATURAL.diamond.w, h: FIGURE_NATURAL.diamond.h }, "ромб с длинной подписью остаётся своего размера"); const longFigNative = JSON.parse(sceneToBoardPayload({ nodes: [longFig], edges: [] })); assert.equal(longFigNative.tree.children[0].element.attrs.textStyle.fontSizeAuto, true, "вместо роста фигуры доска подгоняет кегль"); // Ряд выравнивает элементы по центру: иначе схема выглядит россыпью. const rowCentered = boardLayoutToScene({ content: { type: "row", items: [ { type: "card", id: "a", variant: "figure", figure: "terminator", title: "Старт" }, { type: "card", id: "b", variant: "figure", figure: "manualInput", title: "Разбор" }, { type: "card", id: "c", variant: "figure", figure: "database", title: "Журнал" } ] } }).scene; const axes = new Set(rowCentered.nodes.map((n) => Math.round(n.rect.y + n.rect.h / 2))); assert.equal(axes.size, 1, "фигуры разной высоты стоят на одной оси, а не по верхнему краю"); // Граница фигуры — из той же палитры, что и заливка. const stroked = boardLayoutToScene({ content: { type: "row", items: [ { type: "card", id: "f", variant: "figure", figure: "database", color: "green", stroke: "teal" } ] } }).scene.nodes[0]; assert.equal(stroked.style.color, ELEMENT_COLORS.green, "заливка из палитры элементов"); assert.equal(stroked.style.strokeColor, ELEMENT_COLORS.teal, "и граница оттуда же"); assert.ok(codesOf({ content: { type: "row", items: [ { type: "card", id: "s", variant: "sticker", stroke: "teal" }] } }).includes("stroke-on-non-figure"), "у стикера границы нет — попытка её задать это ошибка"); assert.ok(codesOf({ content: { type: "row", items: [ { type: "card", id: "f", variant: "figure", figure: "нетакой" }] } }).includes("unknown-figure"), "несуществующая фигура — ошибка, а не предупреждение"); assert.equal(TEXT_COLORS.transparent, "#FFFFFF00", "прозрачный фон — для подписей без плашки"); // textArea: текст на полотне без плашки. const textScene = boardLayoutToScene({ content: { type: "row", items: [ { type: "card", id: "cap", variant: "text", size: "l", title: "Раздел без плашки" } ] } }).scene; assert.equal(textScene.nodes[0].kind, "textArea", "variant text даёт нативный textArea"); assert.equal(textScene.nodes[0].style.color, TEXT_COLORS.transparent, "и прозрачный фон по умолчанию"); const textNative = JSON.parse(sceneToBoardPayload(textScene)); assert.equal(textNative.tree.children[0].element.attrs.textStyle.fontSizeAuto, true, "у textArea кегль подгоняется под рамку, иначе подпись обрежется"); // Тип линии доезжает до нативного payload. const lineScene = boardLayoutToScene({ content: { type: "row", items: [ { type: "card", id: "a", variant: "sticker", title: "A" }, { type: "card", id: "b", variant: "sticker", title: "B" } ] }, edges: [{ id: "e", from: { id: "a" }, to: { id: "b" }, line: "straight" }] }).scene; assert.equal(JSON.parse(sceneToBoardPayload(lineScene)).lines.length + JSON.parse(sceneToBoardPayload(lineScene)).tree.children.filter((c) => c.element.type === "line").length, 1, "связь собрана"); assert.ok(codesOf({ content: { type: "row", items: [card("a"), card("b")] }, edges: [{ id: "e", from: { id: "a" }, to: { id: "b" }, line: "зигзагом" }] }).includes("bad-line-type"), "неизвестный тип линии — ошибка валидации"); assert.ok(codesOf({ content: { type: "row", items: [{ type: "card", id: "a", variant: "мемуары" }] } }) .includes("bad-variant"), "неизвестный вариант карточки — ошибка"); // Доска принимает координаты только целыми: её бэкенд отвечает «cannot unmarshal // number 191.5 into Go struct field Sticker.attrs.Coordinates.y of type int». // Центрирование поперёк потока делит остаток пополам, и на нечётном остатке // половинка доезжала до payload — вся вставка отвергалась целиком. const centeredOddLeftover = boardLayoutToScene({ content: { type: "row", items: [ { type: "card", id: "s", variant: "sticker", title: "Заметка" }, { type: "table", id: "t", title: "Т", columns: [{ id: "c", title: "К" }], rows: [{ id: "r", cells: { c: "текст подлиннее, чтобы строка выросла" } }] } ] } }).scene; for (const node of centeredOddLeftover.nodes) { for (const axis of ["x", "y", "w", "h"]) { assert.ok(Number.isInteger(node.rect[axis]), `${node.key}.rect.${axis} = ${node.rect[axis]} — доска не принимает дробные координаты`); } } const centeredNative = JSON.parse(sceneToBoardPayload(centeredOddLeftover)); const fractional = []; const scanIntegers = (value, path) => { if (typeof value === "number") { if (path.endsWith(".x") || path.endsWith(".y")) { if (!Number.isInteger(value)) fractional.push(`${path} = ${value}`); } return; } if (Array.isArray(value)) { value.forEach((item, index) => scanIntegers(item, `${path}[${index}]`)); return; } if (value && typeof value === "object") { for (const [key, item] of Object.entries(value)) scanIntegers(item, `${path}.${key}`); } }; scanIntegers(centeredNative, "payload"); assert.deepEqual(fractional, [], "в нативном payload нет дробных координат"); // --- стык с кодеком ---------------------------------------------------------- const payload = JSON.parse(sceneToBoardPayload(scene)); const nativeRoot = payload.tree.children[0].element.attrs; const sumH = nativeRoot.rows.reduce((sum, row) => sum + row.cells[0].h, 0); assert.ok(Math.abs(nativeRoot.h - sumH) < 1e-6, "инвариант доски: высота таблицы равна сумме высот строк"); for (const row of nativeRoot.rows) { const sumW = row.cells.reduce((sum, cell) => sum + cell.w, 0); assert.ok(Math.abs(nativeRoot.w - sumW) < 1e-6, "инвариант доски: ширина совпадает во всех строках"); } assert.equal(payload.lines.length, 1, "связь между карточками одной ячейки уходит в lines[]"); const reparsed = parseBoardPayload(JSON.stringify(payload)); assert.deepEqual(reparsed.warnings, [], "собранный нами payload разбирается обратно без предупреждений"); assert.equal(reparsed.scene.nodes.length, scene.nodes.length, "и даёт столько же элементов"); assert.equal(reparsed.scene.edges.length, scene.edges.length, "и столько же связей"); console.log("board-layout tests passed");