diagrams-opus-tauri/src/model/model.test.ts
Ilya Ashikhmin e28a0ef074 feat(model): add domain model — mediums, properties, catalogues, node/edge types
- 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>
2026-07-02 23:11:42 +02:00

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