- Relation registry with medium & direction connection validation (canConnect) - Color schemes (default/dark/blueprint/high-contrast) keyed by medium - Typed property schema (string/int/float/enum/catalogueItem(s)/color/list) with grouping and pure coercion/validation - Catalogues (pipes/pumps/materials) with item attributes - Node type library with SVG icon set + port layouts - Edge style presets with arrow/circle/diamond/bar marker specs - 25 unit tests covering validation, coercion and lookups Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
191 lines
7.0 KiB
TypeScript
191 lines
7.0 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
RelationRegistry,
|
|
canConnect,
|
|
directionsCompatible,
|
|
type PortRef,
|
|
} from "./mediums";
|
|
import {
|
|
coerceValue,
|
|
defaultValue,
|
|
buildDefaults,
|
|
sortByGroup,
|
|
type PropertyDef,
|
|
} from "./properties";
|
|
import { getColorScheme, mediumColor } from "./colorSchemes";
|
|
import { dashArray, markerSpec, edgeStyle } from "./edgeStyles";
|
|
import { cataloguesById, DEFAULT_CATALOGUES } from "./catalogues";
|
|
import { NODE_TYPES, nodeType, nodeTypesByGroup } from "./nodeTypes";
|
|
import { iconDataUri } from "./svgIcons";
|
|
|
|
const rel = new RelationRegistry();
|
|
|
|
function port(p: Partial<PortRef>): PortRef {
|
|
return { nodeId: "n1", portId: "out", medium: "water", direction: "out", ...p };
|
|
}
|
|
|
|
describe("mediums / relations", () => {
|
|
it("connects out → in of same medium", () => {
|
|
const r = canConnect(port({}), port({ nodeId: "n2", portId: "in", direction: "in" }), rel);
|
|
expect(r.ok).toBe(true);
|
|
});
|
|
|
|
it("rejects out → out (bad direction)", () => {
|
|
const r = canConnect(port({}), port({ nodeId: "n2", portId: "o2", direction: "out" }), rel);
|
|
expect(r.ok).toBe(false);
|
|
expect(r.reason).toMatch(/direction/i);
|
|
});
|
|
|
|
it("rejects incompatible mediums", () => {
|
|
const r = canConnect(
|
|
port({ medium: "water" }),
|
|
port({ nodeId: "n2", portId: "in", direction: "in", medium: "power" }),
|
|
rel,
|
|
);
|
|
expect(r.ok).toBe(false);
|
|
expect(r.reason).toMatch(/medium/i);
|
|
});
|
|
|
|
it("allows declared cross-medium compatibility (water ↔ hot-water)", () => {
|
|
const r = canConnect(
|
|
port({ medium: "hot-water" }),
|
|
port({ nodeId: "n2", portId: "in", direction: "in", medium: "water" }),
|
|
rel,
|
|
);
|
|
expect(r.ok).toBe(true);
|
|
});
|
|
|
|
it("rejects self connection on the same node", () => {
|
|
const r = canConnect(port({}), port({ portId: "in", direction: "in" }), rel);
|
|
expect(r.ok).toBe(false);
|
|
expect(r.reason).toMatch(/node to itself/i);
|
|
});
|
|
|
|
it("inout acts as a wildcard direction", () => {
|
|
expect(directionsCompatible("inout", "out")).toBe(true);
|
|
expect(directionsCompatible("in", "out")).toBe(true);
|
|
expect(directionsCompatible("in", "in")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("property coercion", () => {
|
|
const cats = cataloguesById(DEFAULT_CATALOGUES);
|
|
|
|
it("coerces int and clamps to range", () => {
|
|
const def: PropertyDef = { key: "x", label: "x", type: "int", group: "physics", min: 0, max: 10 };
|
|
expect(coerceValue(def, "7.9").value).toBe(7);
|
|
expect(coerceValue(def, "-4").value).toBe(0);
|
|
expect(coerceValue(def, "42").value).toBe(10);
|
|
expect(coerceValue(def, "abc").ok).toBe(false);
|
|
});
|
|
|
|
it("coerces float without truncation", () => {
|
|
const def: PropertyDef = { key: "x", label: "x", type: "float", group: "physics" };
|
|
expect(coerceValue(def, "3.14").value).toBeCloseTo(3.14);
|
|
});
|
|
|
|
it("validates color hex", () => {
|
|
const def: PropertyDef = { key: "c", label: "c", type: "color", group: "presentation" };
|
|
expect(coerceValue(def, "#33aaff").ok).toBe(true);
|
|
expect(coerceValue(def, "#3af").ok).toBe(true);
|
|
expect(coerceValue(def, "red").ok).toBe(false);
|
|
});
|
|
|
|
it("validates enum membership", () => {
|
|
const def: PropertyDef = {
|
|
key: "s", label: "s", type: "enum", group: "physics",
|
|
options: [{ value: "a", label: "A" }, { value: "b", label: "B" }],
|
|
};
|
|
expect(coerceValue(def, "a").ok).toBe(true);
|
|
expect(coerceValue(def, "z").ok).toBe(false);
|
|
});
|
|
|
|
it("validates catalogueItem against its catalogue", () => {
|
|
const def: PropertyDef = { key: "m", label: "m", type: "catalogueItem", group: "physics", catalogue: "pumps" };
|
|
expect(coerceValue(def, "p-small", cats).ok).toBe(true);
|
|
expect(coerceValue(def, "nope", cats).ok).toBe(false);
|
|
expect(coerceValue(def, "", cats).ok).toBe(true); // empty allowed
|
|
});
|
|
|
|
it("dedupes catalogueItems and rejects unknown", () => {
|
|
const def: PropertyDef = { key: "t", label: "t", type: "catalogueItems", group: "physics", catalogue: "materials" };
|
|
expect(coerceValue(def, ["steel", "steel", "pvc"], cats).value).toEqual(["steel", "pvc"]);
|
|
expect(coerceValue(def, ["steel", "unobtanium"], cats).ok).toBe(false);
|
|
});
|
|
|
|
it("coerces numeric list", () => {
|
|
const def: PropertyDef = { key: "l", label: "l", type: "list", group: "physics", itemType: "float" };
|
|
expect(coerceValue(def, ["1.5", "2", 3]).value).toEqual([1.5, 2, 3]);
|
|
expect(coerceValue(def, ["1", "x"]).ok).toBe(false);
|
|
});
|
|
|
|
it("supplies sensible defaults per type", () => {
|
|
expect(defaultValue({ key: "a", label: "a", type: "int", group: "g" })).toBe(0);
|
|
expect(defaultValue({ key: "a", label: "a", type: "list", group: "g" })).toEqual([]);
|
|
expect(defaultValue({ key: "a", label: "a", type: "string", group: "g", default: "hi" })).toBe("hi");
|
|
});
|
|
|
|
it("sorts property defs by group order", () => {
|
|
const defs: PropertyDef[] = [
|
|
{ key: "p", label: "p", type: "float", group: "physics" },
|
|
{ key: "t", label: "t", type: "string", group: "identity" },
|
|
{ key: "c", label: "c", type: "color", group: "presentation" },
|
|
];
|
|
expect(sortByGroup(defs).map((d) => d.group)).toEqual(["identity", "presentation", "physics"]);
|
|
});
|
|
});
|
|
|
|
describe("color schemes", () => {
|
|
it("resolves medium color and falls back to default", () => {
|
|
const s = getColorScheme("default");
|
|
expect(mediumColor(s, "water")).toBe("#2f80ed");
|
|
expect(mediumColor(s, "unknown")).toBe(s.defaultMedium);
|
|
});
|
|
it("falls back to first scheme for unknown id", () => {
|
|
expect(getColorScheme("does-not-exist").id).toBe("default");
|
|
});
|
|
});
|
|
|
|
describe("edge styles", () => {
|
|
it("produces dash arrays only for non-solid lines", () => {
|
|
expect(dashArray("solid", 4)).toBe("");
|
|
expect(dashArray("dashed", 4)).not.toBe("");
|
|
});
|
|
it("returns null marker for none and a spec otherwise", () => {
|
|
expect(markerSpec("none", "#000")).toBeNull();
|
|
expect(markerSpec("arrow", "#000")?.type).toBe("path");
|
|
expect(markerSpec("circle", "#000")?.type).toBe("circle");
|
|
});
|
|
it("falls back to first style for unknown id", () => {
|
|
expect(edgeStyle("nope").id).toBe("pipe");
|
|
});
|
|
});
|
|
|
|
describe("node types", () => {
|
|
it("every node type has an icon and at least one port", () => {
|
|
for (const nt of NODE_TYPES) {
|
|
expect(nt.ports.length).toBeGreaterThan(0);
|
|
expect(iconDataUri(nt.icon)).toMatch(/^data:image\/svg\+xml/);
|
|
}
|
|
});
|
|
it("every node type includes identity title property", () => {
|
|
for (const nt of NODE_TYPES) {
|
|
expect(nt.properties.some((p) => p.key === "title")).toBe(true);
|
|
}
|
|
});
|
|
it("groups node types preserving order", () => {
|
|
const groups = nodeTypesByGroup();
|
|
expect(groups.length).toBeGreaterThan(1);
|
|
expect(groups.flatMap((g) => g.types)).toHaveLength(NODE_TYPES.length);
|
|
});
|
|
it("looks up by id", () => {
|
|
expect(nodeType("pump")?.label).toBe("Pump");
|
|
expect(nodeType("missing")).toBeUndefined();
|
|
});
|
|
it("builds a full default property bag", () => {
|
|
const bag = buildDefaults(nodeType("pump")!.properties);
|
|
expect(bag.title).toBe("");
|
|
expect(bag.power).toBe(7.5);
|
|
});
|
|
});
|