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 { 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); }); });