`mesh-lab diagram` renders a scenario as draw.io, from either source, through one layout — so a difference between what was asked for and what exists is a difference you can see. The shape says what a resource is and is fixed per kind. The badges say what is true about that particular one and come entirely from metadata: translation, forwardability, mapping expiry, refuses-inbound, container-or-VM, running. The interesting properties of a network are exactly the ones with no visual consequence — a translated address looks identical to an untranslated one. For the live picture to be a record rather than a restatement, raise now writes down what it applied: a segment's kind, ranges and MTU on the link; a gateway's translation, forwardability and expiry on the gateway; inbound: deny on the machine. Every behavioural tag is written AFTER the thing works, never at creation — a failed raise leaves wreckage standing on purpose, and a picture of that wreckage must not badge translation the router never got. The pairing earned itself immediately: drawn side by side, every virtual machine held no addresses. A container's interface carries the device's name and a VM names its own, so joining them by name silently dropped one whole class of machine. Fixed by joining on MAC. Also brings tests under the typecheck gate, which caught integration timeouts being passed as a 4th argument and therefore ignored entirely.
150 lines
6.6 KiB
TypeScript
150 lines
6.6 KiB
TypeScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { loadScenario } from "../src/declaration/parse.ts";
|
|
import { diagramFromDeclaration } from "../src/diagram/from-declaration.ts";
|
|
import { toDrawio } from "../src/diagram/drawio.ts";
|
|
|
|
/**
|
|
* A generated diagram that will not open is worse than no diagram — it looks like a
|
|
* deliverable and is not one. The first version was unparseable because HTML labels were
|
|
* concatenated raw into an XML attribute, so these assert the file itself.
|
|
*/
|
|
|
|
function parseCells(xml: string): { id: string; vertex: boolean; edge: boolean; source?: string; target?: string }[] {
|
|
const cells: ReturnType<typeof parseCells> = [];
|
|
// <object> wrappers carry the id for any cell with a tooltip; the mxCell inside has none.
|
|
for (const match of xml.matchAll(/<object ([^>]*?)>/g)) {
|
|
const id = /id="([^"]*)"/.exec(match[1] ?? "")?.[1];
|
|
if (id) cells.push({ id, vertex: true, edge: false });
|
|
}
|
|
for (const match of xml.matchAll(/<mxCell ([^>]*?)(?:\/>|>)/g)) {
|
|
const attrs = match[1] ?? "";
|
|
const get = (name: string) => new RegExp(`${name}="([^"]*)"`).exec(attrs)?.[1];
|
|
const id = get("id");
|
|
if (!id) continue;
|
|
const entry: (typeof cells)[number] = {
|
|
id,
|
|
vertex: get("vertex") === "1",
|
|
edge: get("edge") === "1",
|
|
};
|
|
const source = get("source");
|
|
const target = get("target");
|
|
if (source) entry.source = source;
|
|
if (target) entry.target = target;
|
|
cells.push(entry);
|
|
}
|
|
return cells;
|
|
}
|
|
|
|
const scenario = loadScenario("scenarios/the-ordinary-shape.yml");
|
|
const xml = toDrawio(diagramFromDeclaration(scenario));
|
|
|
|
test("the file is well-formed XML — raw markup in an attribute is not", () => {
|
|
// No unescaped angle bracket may appear inside a value="..." attribute.
|
|
for (const match of xml.matchAll(/(?:value|label|tooltip)="([^"]*)"/g)) {
|
|
assert.doesNotMatch(match[1] ?? "", /[<>]/, "a label carries raw markup into an attribute");
|
|
}
|
|
assert.match(xml, /^<mxfile /);
|
|
assert.match(xml, /<\/mxfile>\s*$/);
|
|
});
|
|
|
|
test("every edge connects two cells that exist", () => {
|
|
const cells = parseCells(xml);
|
|
const ids = new Set(cells.map((c) => c.id));
|
|
for (const edge of cells.filter((c) => c.edge)) {
|
|
assert.ok(ids.has(edge.source ?? ""), `edge ${edge.id} has no source`);
|
|
assert.ok(ids.has(edge.target ?? ""), `edge ${edge.id} has no target`);
|
|
}
|
|
});
|
|
|
|
test("the diagram draws the implicit routers, not only what is written down", () => {
|
|
// A scenario never names its gateways. A picture that showed only declared machines
|
|
// would omit every node carrying NAT, forwarding and expiry.
|
|
const diagram = diagramFromDeclaration(scenario);
|
|
assert.ok(diagram.machines.some((m) => m.kind === "router"), "no router drawn");
|
|
assert.ok(diagram.machines.some((m) => m.kind === "transit"), "no transit drawn");
|
|
});
|
|
|
|
test("a router's badges say what the declaration decided", () => {
|
|
const diagram = diagramFromDeclaration(scenario);
|
|
const unforwardable = diagram.machines.find((m) => m.notes.some((n) => n.includes("NOT forwardable")));
|
|
assert.ok(unforwardable, "the unforwardable gateway is not marked as such");
|
|
assert.ok(
|
|
diagram.machines.some((m) => m.notes.some((n) => n.includes("mappings expire"))),
|
|
"a declared mapping expiry is not shown",
|
|
);
|
|
});
|
|
|
|
test("a metadata fact becomes a badge, and absence of the fact does not", () => {
|
|
// The point of the badges: the properties worth seeing are the ones with no visual
|
|
// consequence. A translated address looks exactly like an untranslated one.
|
|
for (const tip of ["translates v4", "no port forwarding", "mappings expire"]) {
|
|
assert.ok(xml.includes(tip), `no badge explains '${tip}'`);
|
|
}
|
|
// A gateway that does not translate gets no mark, rather than a struck-through one.
|
|
assert.doesNotMatch(xml, /label="N"[^>]*tooltip="[^"]*no NAT/);
|
|
});
|
|
|
|
test("every badge says in words what its letter means", () => {
|
|
// A one-letter code with no tooltip is a private language. Each badge is wrapped in an
|
|
// <object>, which is the only place draw.io reads a tooltip from.
|
|
const badges = [...xml.matchAll(/<object [^>]*label="([A-Z▶■]{1,2})"[^>]*tooltip="([^"]*)"/g)];
|
|
assert.ok(badges.length > 0, "no badges rendered at all");
|
|
for (const [, code, tip] of badges) {
|
|
assert.ok((tip ?? "").length > 10, `badge ${code} has no explanation`);
|
|
}
|
|
});
|
|
|
|
test("no two cells share an id — a machine may be named after a gateway", () => {
|
|
const ids = parseCells(xml).map((c) => c.id);
|
|
assert.equal(new Set(ids).size, ids.length, "duplicate cell id");
|
|
});
|
|
|
|
test("segments are ordered public first, then by depth behind them", () => {
|
|
const diagram = diagramFromDeclaration(scenario);
|
|
const publicDepths = diagram.segments.filter((s) => s.kind === "public").map((s) => s.depth);
|
|
assert.deepEqual([...new Set(publicDepths)], [0], "a public segment should be at depth 0");
|
|
const home = diagram.segments.find((s) => s.name === "home");
|
|
assert.equal(home?.depth, 1, "a segment behind one gateway is at depth 1");
|
|
});
|
|
|
|
test("a declared address appears on the machine that holds it", () => {
|
|
assert.match(xml, /192\.168\.1\.135/);
|
|
assert.match(xml, /198\.51\.100\.7/);
|
|
});
|
|
|
|
test("every shape names a stencil that exists", () => {
|
|
// A style naming a stencil draw.io does not have renders as an empty box — no error, no
|
|
// warning, just a missing picture. Checked against the names in draw.io's own
|
|
// stencils/networks.xml, which is where mxgraph.networks.* is defined.
|
|
const KNOWN = new Set([
|
|
"mxgraph.networks.server",
|
|
"mxgraph.networks.router",
|
|
"mxgraph.networks.cloud",
|
|
"mxgraph.networks.firewall",
|
|
"mxgraph.networks.switch",
|
|
"mxgraph.networks.pc",
|
|
"mxgraph.networks.laptop",
|
|
"mxgraph.networks.storage",
|
|
"mxgraph.networks.modem",
|
|
"mxgraph.networks.mainframe",
|
|
]);
|
|
const used = new Set([...xml.matchAll(/shape=([a-z0-9_.]+)/g)].map((m) => m[1] as string));
|
|
assert.ok(used.size > 0, "no stencil shapes used at all");
|
|
for (const shape of used) {
|
|
assert.ok(KNOWN.has(shape), `'${shape}' is not a stencil draw.io ships`);
|
|
}
|
|
});
|
|
|
|
test("a resource's shape is fixed by kind, and never varies with its metadata", () => {
|
|
// The split the whole design rests on: shape says what a thing is, badges say what is
|
|
// true about it. A gateway that stops translating must still look like a gateway.
|
|
const routers = [...xml.matchAll(/shape=mxgraph\.networks\.router/g)].length;
|
|
const diagram = diagramFromDeclaration(scenario);
|
|
assert.equal(routers, diagram.machines.filter((m) => m.kind === "router").length);
|
|
assert.equal(
|
|
[...xml.matchAll(/shape=mxgraph\.networks\.server/g)].length,
|
|
diagram.machines.filter((m) => m.kind === "machine").length,
|
|
);
|
|
});
|