// Tests for turning a { nodes, edges } spec into elements, for the layout's crossing count and // size limit, and for the outline the agent reads. // Run `node --test` in the extension folder. import assert from "node:assert/strict"; import test from "node:test"; import { boxesIntersect, elementBounds } from "./geometry.mjs"; import { LayoutError, buildFromSpec, countCrossings, findFreeSpot, layeredLayout, outlinePage } from "./layout.mjs"; import { normalizeElement } from "./model.mjs"; const shape = (id, text, x) => normalizeElement({ id, type: "rect", x, y: 0, text }); const links = (elements) => elements.filter((e) => e.type === "arrow").map((a) => [a.from, a.to]); test("edges can name nodes by id or by label, in any letter case", () => { const { elements, errors } = buildFromSpec({ nodes: [{ id: "api", label: "API" }, { label: "Orders Database" }], edges: [{ from: "api", to: "orders database" }, { from: "ORDERS DATABASE", to: "API" }], }); assert.deepEqual(errors, []); const db = elements.find((e) => e.text === "Orders Database"); assert.deepEqual(links(elements), [["api", db.id], [db.id, "api"]]); }); test("a label that several shapes share has to be referenced by id", () => { const existing = [shape("c1", "Cache", 0), shape("c2", "cache", 300)]; const byLabel = buildFromSpec({ nodes: [{ id: "web", label: "Web" }], edges: [{ from: "web", to: "Cache" }] }, { existing }); assert.equal(byLabel.errors.length, 1); assert.match(byLabel.errors[0], /no node with id or label "Cache"/); const byId = buildFromSpec({ nodes: [{ id: "web", label: "Web" }], edges: [{ from: "web", to: "c2" }] }, { existing }); assert.deepEqual(byId.errors, []); assert.deepEqual(links(byId.elements), [["web", "c2"]]); }); test("a blank reference never matches an unlabeled shape", () => { const existing = [shape("s1", "", 0)]; const { errors } = buildFromSpec({ nodes: [{ id: "n", label: "New" }], edges: [{ from: " ", to: "n" }] }, { existing }); assert.equal(errors.length, 1); }); test("x and y come together, so one without the other is an error and not dropped", () => { const partial = buildFromSpec({ nodes: [{ id: "a", label: "A", x: 100 }, { id: "b", label: "B", y: 50 }], edges: [{ from: "a", to: "b" }], texts: [{ text: "Sign-in\nflow", x: 10 }], }); assert.deepEqual(partial.errors, [ 'Node "a" has only x. Give x and y together, or leave both out for automatic layout.', 'Node "b" has only y. Give x and y together, or leave both out for automatic layout.', 'Text "Sign-in flow" has only x. Give x and y together, or leave both out to put it above the diagram.', ]); const strings = buildFromSpec({ nodes: [{ id: "a", label: "A", x: "100", y: 0 }] }); assert.deepEqual(strings.errors, ['Node "a" needs numbers for x and y.']); const { elements, errors } = buildFromSpec({ nodes: [{ id: "a", label: "A", x: 120, y: 40 }, { id: "b", label: "B" }], edges: [{ from: "a", to: "b" }], texts: [{ text: "Note", x: 5, y: 6 }], }); assert.deepEqual(errors, []); const at = (el) => [el.x, el.y]; assert.deepEqual(at(elements.find((e) => e.id === "a")), [120, 40]); assert.deepEqual(at(elements.find((e) => e.text === "Note")), [5, 6]); }); test("new nodes are placed clear of everything already on the canvas", () => { const existing = [ shape("a", "A", 0), shape("b", "B", 400), normalizeElement({ id: "ab", type: "arrow", from: "a", to: "b", text: "calls" }), normalizeElement({ id: "note", type: "text", x: 200, y: 100, text: "A note under the arrow" }), ]; const { elements, errors } = buildFromSpec({ nodes: [{ id: "c", label: "C" }, { id: "d", label: "D" }], edges: [{ from: "a", to: "c" }, { from: "c", to: "d" }], }, { existing }); assert.deepEqual(errors, []); const byId = new Map([...existing, ...elements].map((e) => [e.id, e])); const taken = existing.map((e) => elementBounds(e, byId)); for (const node of elements.filter((e) => e.type === "rect")) { for (const [i, box] of taken.entries()) { assert.ok(!boxesIntersect(node, box), `${node.id} overlaps ${existing[i].id}`); } } }); test("when every spot near a shape is taken, a new one goes past everything, in line with it", () => { const anchor = { x: 0, y: 0, w: 100, h: 60 }; // Shapes on all four sides of the anchor cover every spot that placement tries. const crowd = [ { x: 120, y: -2000, w: 3000, h: 4000 }, { x: -3020, y: -2000, w: 3000, h: 4000 }, { x: -2000, y: 80, w: 4000, h: 3000 }, { x: -2000, y: -3020, w: 4000, h: 3000 }, ]; for (const dir of ["right", "left", "down", "up"]) { const spot = findFreeSpot(anchor, dir, 100, 60, [anchor, ...crowd]); const box = { ...spot, w: 100, h: 60 }; for (const c of crowd) assert.ok(!boxesIntersect(box, c), `the spot to the ${dir} is on top of a shape`); assert.equal(dir === "right" || dir === "left" ? spot.y : spot.x, 0, `the spot to the ${dir} is not in line with the shape`); } // add_elements places a new connected node the same way. const existing = [ normalizeElement({ id: "a", type: "rect", ...anchor, text: "A" }), ...crowd.map((c, i) => normalizeElement({ id: `c${i}`, type: "rect", ...c })), ]; const { elements, errors } = buildFromSpec({ nodes: [{ id: "b", label: "B" }], edges: [{ from: "a", to: "b" }] }, { existing }); assert.deepEqual(errors, []); const b = elements.find((e) => e.id === "b"); for (const e of existing) assert.ok(!boxesIntersect(b, e), `b overlaps ${e.id}`); }); test("crossings are counted the same as by comparing every pair of edges", () => { // a-d and b-c cross. a-c and b-c only share an end, which is not a crossing. assert.equal(countCrossings(["a", "b"], { a: ["d", "c"], b: ["c"] }, { c: 0, d: 1 }, 2), 1); let seed = 1; const rand = (n) => { seed = (seed * 1103515245 + 12345) % 2147483648; return seed % n; }; const shuffled = (list) => { const out = [...list]; for (let i = out.length - 1; i > 0; i--) { const j = rand(i + 1); [out[i], out[j]] = [out[j], out[i]]; } return out; }; for (let round = 0; round < 300; round++) { // Upper vertices are 0 to upperSize - 1 and lower ones come after, each layer in a shuffled // order. Every upper vertex links to a random set of lower ones, listed in any order. const upperSize = 1 + rand(10); const lowerSize = 1 + rand(10); const upper = shuffled([...Array(upperSize).keys()]); const lower = shuffled(Array.from({ length: lowerSize }, (_, i) => upperSize + i)); const pos = {}; lower.forEach((v, i) => { pos[v] = i; }); const down = {}; for (const u of upper) down[u] = shuffled(lower).slice(0, rand(lowerSize + 1)); const edges = upper.flatMap((u, i) => down[u].map((v) => [i, pos[v]])); let expected = 0; for (let i = 0; i < edges.length; i++) { for (let j = i + 1; j < edges.length; j++) { if ((edges[i][0] - edges[j][0]) * (edges[i][1] - edges[j][1]) < 0) expected++; } } assert.equal(countCrossings(upper, down, pos, lowerSize), expected, JSON.stringify({ upper, down, pos })); } }); test("a long outline comes in parts that stay in budget and list each element once", () => { const long = "word ".repeat(800); const elements = [ ...Array.from({ length: 300 }, (_, i) => shape(`s${i}`, i % 3 ? `Step ${i}` : long, i * 200)), ...Array.from({ length: 299 }, (_, i) => normalizeElement({ id: `a${i}`, type: "arrow", from: `s${i}`, to: `s${i + 1}` })), normalizeElement({ id: "note", type: "text", x: 0, y: -40, text: long }), ]; const doc = { name: "Big", elements }; const seen = []; for (let start = 0; start !== null; ) { const part = outlinePage(doc, { start, budget: 5000 }); assert.ok(part.text.length <= 5000, `a part has ${part.text.length} characters`); assert.ok(part.text.startsWith('Drawing "Big": 300 shapes, 299 arrows, 1 text, 0 pen strokes.\n')); seen.push(...part.shown.map((e) => e.id)); start = part.next; } assert.deepEqual(seen, elements.map((e) => e.id)); const first = outlinePage(doc).text; assert.ok(!first.includes(long.trim())); assert.ok(first.includes(`- s0 (rect) "${long.slice(0, 300)}..." (cut from 4000 characters) at 0,0 size`)); assert.ok(first.includes('- a0: s0 -> s1\n')); const some = outlinePage(doc, { only: new Set(["note", "s5"]) }); assert.deepEqual(some.shown.map((e) => e.id), ["s5", "note"]); assert.equal(some.total, 2); assert.equal(some.next, null); // What a caller sends along counts too, but a part never comes back empty. const heavy = outlinePage(doc, { budget: 100, cost: () => 10000 }); assert.deepEqual(heavy.shown.map((e) => e.id), ["s0"]); assert.equal(heavy.next, 1); }); test("the outline lists every style that is not the default", () => { const doc = { name: "Styles", elements: [ normalizeElement({ id: "a", type: "rect", x: 0, y: 0, text: "A", color: "blue", fill: "solid", dash: true, size: "xl" }), normalizeElement({ id: "b", type: "ellipse", x: 300, y: 0, text: "B" }), normalizeElement({ id: "ab", type: "arrow", from: "a", to: "b", text: "calls", head: "both", route: "elbow", dash: true, color: "red", size: "l" }), normalizeElement({ id: "ba", type: "arrow", from: "b", to: "a" }), normalizeElement({ id: "t1", type: "text", x: 0, y: 200, text: "Note", color: "green", size: "s" }), normalizeElement({ id: "t2", type: "text", x: 0, y: 300, text: "Title", size: "l" }), ], }; const lines = outlinePage(doc).text.split("\n"); for (const line of [ '- a (rect, blue, fill solid, dashed, text size xl) "A" at 0,0 size 160x60', '- b (ellipse) "B" at 300,0 size 160x80', '- ab: a -> b "calls" (head both, elbow, dashed, red, text size l)', "- ba: b -> a", '- t1 (green, text size s): "Note" at 0,200', '- t2: "Title" at 0,300', ]) assert.ok(lines.includes(line), `no line ${line} in:\n${lines.join("\n")}`); }); test("a layout counts a placeholder for each layer an arrow passes, and turns down too many", () => { // The chain puts a, b, c and d on layers 0 to 3, so an arrow back from d to a passes 2 layers, // and one from a to c passes 1. The loose node e has no arrows and needs none. const nodes = ["a", "b", "c", "d", "e"].map((id) => ({ id, w: 100, h: 60 })); const chain = [{ from: "a", to: "b" }, { from: "b", to: "c" }, { from: "c", to: "d" }]; const back = [...chain, { from: "d", to: "a" }]; assert.equal(layeredLayout(nodes, back, { maxPlaceholders: 2 }).size, 5); assert.throws(() => layeredLayout(nodes, back, { maxPlaceholders: 1 }), (err) => { assert.ok(err instanceof LayoutError); assert.match(err.message, /this diagram needs 2, more than the 1 automatic layout handles\./); return true; }); assert.equal(layeredLayout(nodes, [...back, { from: "a", to: "c" }], { maxPlaceholders: 3 }).size, 5); assert.throws(() => layeredLayout(nodes, [...back, { from: "a", to: "c" }], { maxPlaceholders: 2 }), /needs 3, more than the 2/); assert.equal(layeredLayout(nodes, chain, { maxPlaceholders: 0 }).size, 5); });