367 lines
11 KiB
JavaScript
367 lines
11 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { compile } from "../src/compile.js";
|
|
import fixture from "../examples/esp32-audio.json" with { type: "json" };
|
|
|
|
test("compile returns svg and topology for valid model", () => {
|
|
const result = compile(fixture);
|
|
assert.equal(result.ok, true);
|
|
assert.ok(result.svg.includes("<svg"));
|
|
assert.ok(result.topology.power_domains.includes("3V3"));
|
|
assert.ok(result.topology.clock_sources.includes("U1"));
|
|
assert.ok(result.layout_metrics);
|
|
assert.equal(result.layout_metrics.overlap_edges, 0);
|
|
assert.equal(result.layout_metrics.crossings, 0);
|
|
assert.equal(typeof result.layout_metrics.total_bends, "number");
|
|
assert.equal(typeof result.layout_metrics.detour_ratio, "number");
|
|
assert.equal(typeof result.layout_metrics.label_tie_fallbacks, "number");
|
|
assert.equal(typeof result.layout_metrics.label_tie_routes, "number");
|
|
assert.ok(Array.isArray(result.bus_groups));
|
|
assert.ok(result.render_mode_used);
|
|
});
|
|
|
|
test("compile fails on invalid model", () => {
|
|
const bad = { symbols: {}, instances: [], nets: [] };
|
|
const result = compile(bad);
|
|
assert.equal(result.ok, false);
|
|
assert.ok(result.errors.length > 0);
|
|
assert.equal(result.layout_metrics.total_bends, 0);
|
|
assert.equal(result.layout_metrics.detour_ratio, 1);
|
|
assert.equal(result.layout_metrics.label_tie_routes, 0);
|
|
});
|
|
|
|
test("compile accepts render mode options", () => {
|
|
const result = compile(fixture, { render_mode: "explicit" });
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.render_mode_used, "explicit");
|
|
});
|
|
|
|
test("compile auto-creates generic symbols for unknown instances", () => {
|
|
const model = {
|
|
meta: { title: "Generic Demo" },
|
|
symbols: {},
|
|
instances: [
|
|
{
|
|
ref: "X1",
|
|
symbol: "mystery_block",
|
|
properties: { value: "Mystery" },
|
|
placement: { x: null, y: null, rotation: 0, locked: false }
|
|
},
|
|
{
|
|
ref: "X2",
|
|
symbol: "mystery_sensor",
|
|
properties: { value: "Sensor" },
|
|
placement: { x: null, y: null, rotation: 0, locked: false }
|
|
}
|
|
],
|
|
nets: [
|
|
{
|
|
name: "SIG_A",
|
|
class: "signal",
|
|
nodes: [
|
|
{ ref: "X1", pin: "IN" },
|
|
{ ref: "X2", pin: "OUT" }
|
|
]
|
|
},
|
|
{
|
|
name: "GND",
|
|
class: "ground",
|
|
nodes: [
|
|
{ ref: "X1", pin: "GND" },
|
|
{ ref: "X2", pin: "GND" }
|
|
]
|
|
}
|
|
],
|
|
constraints: {},
|
|
annotations: []
|
|
};
|
|
|
|
const result = compile(model);
|
|
assert.equal(result.ok, true);
|
|
assert.ok(result.svg.includes("<svg"));
|
|
assert.ok(result.warnings.some((w) => w.code === "auto_generic_symbol_created"));
|
|
});
|
|
|
|
test("compile auto-creates passive templates for common refs", () => {
|
|
const model = {
|
|
meta: { title: "Passive Template Demo" },
|
|
symbols: {},
|
|
instances: [
|
|
{
|
|
ref: "R1",
|
|
symbol: "resistor_generic",
|
|
properties: { value: "10k resistor" },
|
|
placement: { x: null, y: null, rotation: 0, locked: false }
|
|
},
|
|
{
|
|
ref: "U1",
|
|
symbol: "mystery_logic",
|
|
properties: { value: "Logic" },
|
|
placement: { x: null, y: null, rotation: 0, locked: false }
|
|
}
|
|
],
|
|
nets: [
|
|
{
|
|
name: "SIG",
|
|
class: "signal",
|
|
nodes: [
|
|
{ ref: "R1", pin: "1" },
|
|
{ ref: "U1", pin: "IN" }
|
|
]
|
|
},
|
|
{
|
|
name: "GND",
|
|
class: "ground",
|
|
nodes: [
|
|
{ ref: "R1", pin: "2" },
|
|
{ ref: "U1", pin: "GND" }
|
|
]
|
|
}
|
|
],
|
|
constraints: {},
|
|
annotations: []
|
|
};
|
|
|
|
const result = compile(model);
|
|
assert.equal(result.ok, true);
|
|
assert.ok(result.warnings.some((w) => w.code === "auto_template_symbol_created"));
|
|
});
|
|
|
|
test("compile accepts minimal shorthand symbols and hydrates fields", () => {
|
|
const model = {
|
|
meta: { title: "Shorthand Symbols" },
|
|
symbols: {
|
|
resistor_short: {
|
|
template_name: "resistor"
|
|
},
|
|
generic_short: {
|
|
category: "generic"
|
|
}
|
|
},
|
|
instances: [
|
|
{
|
|
ref: "R1",
|
|
symbol: "resistor_short",
|
|
properties: { value: "1k" },
|
|
placement: { x: null, y: null, rotation: 0, locked: false }
|
|
},
|
|
{
|
|
ref: "X1",
|
|
symbol: "generic_short",
|
|
properties: { value: "Mystery" },
|
|
placement: { x: null, y: null, rotation: 0, locked: false }
|
|
}
|
|
],
|
|
nets: [
|
|
{
|
|
name: "SIG",
|
|
class: "signal",
|
|
nodes: [
|
|
{ ref: "R1", pin: "1" },
|
|
{ ref: "X1", pin: "IO" }
|
|
]
|
|
},
|
|
{
|
|
name: "GND",
|
|
class: "ground",
|
|
nodes: [
|
|
{ ref: "R1", pin: "2" },
|
|
{ ref: "X1", pin: "GND" }
|
|
]
|
|
}
|
|
],
|
|
constraints: {},
|
|
annotations: []
|
|
};
|
|
|
|
const result = compile(model);
|
|
assert.equal(result.ok, true);
|
|
assert.ok(result.svg.includes("<svg"));
|
|
assert.ok(result.warnings.some((w) => w.code === "auto_template_symbol_hydrated"));
|
|
assert.ok(result.warnings.some((w) => w.code === "auto_generic_symbol_hydrated"));
|
|
});
|
|
|
|
test("compile supports instance.part without explicit symbols", () => {
|
|
const model = {
|
|
meta: { title: "Part Shortcut" },
|
|
symbols: {},
|
|
instances: [
|
|
{
|
|
ref: "R1",
|
|
part: "resistor",
|
|
properties: { value: "10k" },
|
|
placement: { x: null, y: null, rotation: 0, locked: false }
|
|
},
|
|
{
|
|
ref: "C1",
|
|
part: "capacitor",
|
|
properties: { value: "100nF" },
|
|
placement: { x: null, y: null, rotation: 0, locked: false }
|
|
}
|
|
],
|
|
nets: [
|
|
{
|
|
name: "SIG",
|
|
class: "signal",
|
|
nodes: [
|
|
{ ref: "R1", pin: "1" },
|
|
{ ref: "C1", pin: "1" }
|
|
]
|
|
},
|
|
{
|
|
name: "GND",
|
|
class: "ground",
|
|
nodes: [
|
|
{ ref: "R1", pin: "2" },
|
|
{ ref: "C1", pin: "2" }
|
|
]
|
|
}
|
|
],
|
|
constraints: {},
|
|
annotations: []
|
|
};
|
|
|
|
const result = compile(model);
|
|
assert.equal(result.ok, true);
|
|
assert.ok(result.svg.includes("<svg"));
|
|
assert.equal(result.errors.length, 0);
|
|
});
|
|
|
|
test("grouped auto-layout avoids tall single-column collapse", () => {
|
|
const model = {
|
|
meta: { title: "Group packing" },
|
|
symbols: {
|
|
q: {
|
|
symbol_id: "q",
|
|
category: "analog",
|
|
body: { width: 90, height: 70 },
|
|
pins: [
|
|
{ name: "B", number: "1", side: "left", offset: 35, type: "analog" },
|
|
{ name: "C", number: "2", side: "top", offset: 45, type: "analog" },
|
|
{ name: "E", number: "3", side: "bottom", offset: 45, type: "analog" }
|
|
]
|
|
},
|
|
adc: {
|
|
symbol_id: "adc",
|
|
category: "generic",
|
|
body: { width: 120, height: 60 },
|
|
pins: [
|
|
{ name: "IN", number: "1", side: "left", offset: 30, type: "analog" },
|
|
{ name: "3V3", number: "2", side: "top", offset: 30, type: "power_in" },
|
|
{ name: "GND", number: "3", side: "bottom", offset: 30, type: "ground" }
|
|
]
|
|
}
|
|
},
|
|
instances: [
|
|
{ ref: "Q1", symbol: "q", properties: {}, placement: { x: null, y: null, rotation: 0, locked: false } },
|
|
{ ref: "R1", part: "resistor", properties: {}, placement: { x: null, y: null, rotation: 0, locked: false } },
|
|
{ ref: "R2", part: "resistor", properties: {}, placement: { x: null, y: null, rotation: 0, locked: false } },
|
|
{ ref: "C1", part: "capacitor", properties: {}, placement: { x: null, y: null, rotation: 0, locked: false } },
|
|
{ ref: "U1", symbol: "adc", properties: {}, placement: { x: null, y: null, rotation: 0, locked: false } },
|
|
{ ref: "R3", part: "resistor", properties: {}, placement: { x: null, y: null, rotation: 0, locked: false } },
|
|
{ ref: "R4", part: "resistor", properties: {}, placement: { x: null, y: null, rotation: 0, locked: false } },
|
|
{ ref: "C2", part: "capacitor", properties: {}, placement: { x: null, y: null, rotation: 0, locked: false } }
|
|
],
|
|
nets: [
|
|
{ name: "N1", class: "analog", nodes: [{ ref: "Q1", pin: "C" }, { ref: "U1", pin: "IN" }] },
|
|
{ name: "N2", class: "ground", nodes: [{ ref: "Q1", pin: "E" }, { ref: "U1", pin: "GND" }] },
|
|
{ name: "N3", class: "power", nodes: [{ ref: "R1", pin: "1" }, { ref: "U1", pin: "3V3" }] }
|
|
],
|
|
constraints: {
|
|
groups: [
|
|
{ name: "front", members: ["Q1", "R1", "R2", "C1"], layout: "cluster" },
|
|
{ name: "adc", members: ["U1", "R3", "R4", "C2"], layout: "cluster" }
|
|
]
|
|
},
|
|
annotations: []
|
|
};
|
|
|
|
const result = compile(model);
|
|
assert.equal(result.ok, true);
|
|
const xs = result.layout.placed.map((p) => p.x);
|
|
const ys = result.layout.placed.map((p) => p.y);
|
|
const widthSpread = Math.max(...xs) - Math.min(...xs);
|
|
const heightSpread = Math.max(...ys) - Math.min(...ys);
|
|
|
|
assert.ok(widthSpread > 500);
|
|
assert.ok(heightSpread < 900);
|
|
});
|
|
|
|
test("multi-node signal nets render explicit junction dots", () => {
|
|
const model = {
|
|
meta: { title: "Junction coverage" },
|
|
symbols: {},
|
|
instances: [
|
|
{ ref: "R1", part: "resistor", properties: {}, placement: { x: null, y: null, rotation: 0, locked: false } },
|
|
{ ref: "R2", part: "resistor", properties: {}, placement: { x: null, y: null, rotation: 0, locked: false } },
|
|
{ ref: "R3", part: "resistor", properties: {}, placement: { x: null, y: null, rotation: 0, locked: false } }
|
|
],
|
|
nets: [
|
|
{
|
|
name: "SIG",
|
|
class: "signal",
|
|
nodes: [
|
|
{ ref: "R1", pin: "1" },
|
|
{ ref: "R2", pin: "1" },
|
|
{ ref: "R3", pin: "1" }
|
|
]
|
|
},
|
|
{
|
|
name: "GND",
|
|
class: "ground",
|
|
nodes: [
|
|
{ ref: "R1", pin: "2" },
|
|
{ ref: "R2", pin: "2" },
|
|
{ ref: "R3", pin: "2" }
|
|
]
|
|
}
|
|
],
|
|
constraints: {},
|
|
annotations: []
|
|
};
|
|
|
|
const result = compile(model, { render_mode: "explicit" });
|
|
assert.equal(result.ok, true);
|
|
assert.ok(result.svg.includes('data-net-junction="SIG"'));
|
|
});
|
|
|
|
test("auto-rotation chooses non-zero orientation when it improves pin alignment", () => {
|
|
const model = {
|
|
meta: { title: "Rotation heuristic" },
|
|
symbols: {
|
|
n1: {
|
|
symbol_id: "n1",
|
|
category: "generic",
|
|
body: { width: 120, height: 80 },
|
|
pins: [
|
|
{ name: "L", number: "1", side: "left", offset: 40, type: "passive" },
|
|
{ name: "R", number: "2", side: "right", offset: 40, type: "passive" }
|
|
]
|
|
},
|
|
n2: {
|
|
symbol_id: "n2",
|
|
category: "generic",
|
|
body: { width: 120, height: 80 },
|
|
pins: [
|
|
{ name: "T", number: "1", side: "top", offset: 60, type: "passive" },
|
|
{ name: "B", number: "2", side: "bottom", offset: 60, type: "passive" }
|
|
]
|
|
}
|
|
},
|
|
instances: [
|
|
{ ref: "A1", symbol: "n1", properties: {}, placement: { x: null, y: null, rotation: 0, locked: false } },
|
|
{ ref: "A2", symbol: "n2", properties: {}, placement: { x: null, y: null, rotation: 0, locked: false } }
|
|
],
|
|
nets: [
|
|
{ name: "N1", class: "signal", nodes: [{ ref: "A1", pin: "R" }, { ref: "A2", pin: "T" }] },
|
|
{ name: "N2", class: "ground", nodes: [{ ref: "A1", pin: "L" }, { ref: "A2", pin: "B" }] }
|
|
],
|
|
constraints: {},
|
|
annotations: []
|
|
};
|
|
|
|
const result = compile(model, { render_mode: "explicit" });
|
|
assert.equal(result.ok, true);
|
|
assert.ok(result.layout.placed.some((p) => (p.rotation ?? 0) % 360 !== 0));
|
|
});
|