Ship the Next.js cycle editor with CAD chrome, technical HX symbols, Fixed/Free boundary guidance, and secondary water/air pressure drop support in the solver stack. Co-authored-by: Cursor <cursoragent@cursor.com>
505 lines
18 KiB
TypeScript
505 lines
18 KiB
TypeScript
import { describe, it, expect, beforeEach } from "vitest";
|
|
import {
|
|
useDiagramStore,
|
|
snapToGridValue,
|
|
snapPosition,
|
|
normaliseRotation,
|
|
GRID_SIZE,
|
|
} from "./diagramStore";
|
|
|
|
function reset() {
|
|
useDiagramStore.setState({
|
|
nodes: [],
|
|
edges: [],
|
|
selectedNodeId: null,
|
|
fluid: "R410A",
|
|
fluidBackend: "CoolProp",
|
|
controls: [],
|
|
snapToGrid: true,
|
|
result: null,
|
|
simulating: false,
|
|
simError: null,
|
|
});
|
|
}
|
|
|
|
beforeEach(reset);
|
|
|
|
describe("controls import", () => {
|
|
it("preserves imported co-solved controls for the next simulation run", () => {
|
|
useDiagramStore.getState().loadFromConfig({
|
|
fluid: "R134a",
|
|
circuits: [
|
|
{
|
|
id: 0,
|
|
components: [{ type: "IsentropicCompressor", name: "comp", liquid_injection: true }],
|
|
edges: [],
|
|
},
|
|
],
|
|
controls: [
|
|
{
|
|
type: "SaturatedController",
|
|
id: "dgt_limiter",
|
|
measure: { component: "comp", output: "temperature" },
|
|
actuator: { component: "comp", factor: "injection", initial: 0.15, min: 0, max: 0.3 },
|
|
target: 330,
|
|
gain: -0.5,
|
|
band: 5,
|
|
alpha: 0.002,
|
|
objectives: [
|
|
{
|
|
component: "comp",
|
|
output: "temperature",
|
|
setpoint: 365,
|
|
gain: -0.05,
|
|
combine: "min",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
solver: { strategy: "newton", max_iterations: 300, tolerance: 1e-6 },
|
|
});
|
|
|
|
expect(useDiagramStore.getState().controls).toEqual([
|
|
expect.objectContaining({
|
|
id: "dgt_limiter",
|
|
actuator: expect.objectContaining({ factor: "injection", initial: 0.15 }),
|
|
target: 330,
|
|
}),
|
|
]);
|
|
expect(useDiagramStore.getState().nodes).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
type: "SaturatedController",
|
|
name: "dgt_limiter",
|
|
params: expect.objectContaining({
|
|
measure_component: "comp",
|
|
actuator_factor: "injection",
|
|
alpha: 0.002,
|
|
objectives_json: JSON.stringify([
|
|
{
|
|
component: "comp",
|
|
output: "temperature",
|
|
setpoint: 365,
|
|
gain: -0.05,
|
|
combine: "min",
|
|
},
|
|
]),
|
|
}),
|
|
}),
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("grid helpers", () => {
|
|
it("snaps a value to the nearest grid line", () => {
|
|
expect(snapToGridValue(5, 12)).toBe(0);
|
|
expect(snapToGridValue(7, 12)).toBe(12);
|
|
expect(snapToGridValue(18, 12)).toBe(24);
|
|
});
|
|
|
|
it("snaps an {x, y} position", () => {
|
|
expect(snapPosition({ x: 7, y: 5 }, 12)).toEqual({ x: 12, y: 0 });
|
|
});
|
|
|
|
it("normalises rotation to 0/90/180/270", () => {
|
|
expect(normaliseRotation(0)).toBe(0);
|
|
expect(normaliseRotation(90)).toBe(90);
|
|
expect(normaliseRotation(360)).toBe(0);
|
|
expect(normaliseRotation(-90)).toBe(270);
|
|
expect(normaliseRotation(450)).toBe(90);
|
|
});
|
|
});
|
|
|
|
describe("addComponent", () => {
|
|
it("creates a node with defaults, rotation 0, and selects it", () => {
|
|
useDiagramStore.getState().addComponent("Condenser", { x: 100, y: 100 });
|
|
const { nodes, selectedNodeId } = useDiagramStore.getState();
|
|
expect(nodes).toHaveLength(1);
|
|
expect(nodes[0].data.type).toBe("Condenser");
|
|
expect(nodes[0].data.rotation).toBe(0);
|
|
expect(nodes[0].data.params.ua).toBe(5000);
|
|
expect(selectedNodeId).toBe(nodes[0].id);
|
|
});
|
|
|
|
it("snaps the drop position when snap is enabled", () => {
|
|
useDiagramStore.getState().addComponent("Condenser", { x: 103, y: 97 });
|
|
const n = useDiagramStore.getState().nodes[0];
|
|
expect(n.position.x % GRID_SIZE).toBe(0);
|
|
expect(n.position.y % GRID_SIZE).toBe(0);
|
|
});
|
|
|
|
it("keeps the raw position when snap is disabled", () => {
|
|
useDiagramStore.setState({ snapToGrid: false });
|
|
useDiagramStore.getState().addComponent("Condenser", { x: 103, y: 97 });
|
|
const n = useDiagramStore.getState().nodes[0];
|
|
expect(n.position).toEqual({ x: 103, y: 97 });
|
|
});
|
|
|
|
it("generates unique names per type", () => {
|
|
const s = useDiagramStore.getState();
|
|
s.addComponent("Condenser", { x: 0, y: 0 });
|
|
s.addComponent("Condenser", { x: 0, y: 0 });
|
|
const names = useDiagramStore.getState().nodes.map((n) => n.data.name);
|
|
expect(new Set(names).size).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe("node mutations", () => {
|
|
it("updates params, name, and circuit", () => {
|
|
useDiagramStore.getState().addComponent("Condenser", { x: 0, y: 0 });
|
|
const id = useDiagramStore.getState().nodes[0].id;
|
|
const s = useDiagramStore.getState();
|
|
s.updateNodeParams(id, { ua: 9999 });
|
|
s.updateNodeName(id, "my_cond");
|
|
s.updateNodeCircuit(id, 2);
|
|
const n = useDiagramStore.getState().nodes[0];
|
|
expect(n.data.params.ua).toBe(9999);
|
|
expect(n.data.name).toBe("my_cond");
|
|
expect(n.data.circuit).toBe(2);
|
|
});
|
|
|
|
it("rotates clockwise and counter-clockwise with wrap-around", () => {
|
|
useDiagramStore.getState().addComponent("Compressor", { x: 0, y: 0 });
|
|
const id = useDiagramStore.getState().nodes[0].id;
|
|
const { rotateNode } = useDiagramStore.getState();
|
|
rotateNode(id, 1);
|
|
expect(useDiagramStore.getState().nodes[0].data.rotation).toBe(90);
|
|
rotateNode(id, 1);
|
|
rotateNode(id, 1);
|
|
rotateNode(id, 1);
|
|
expect(useDiagramStore.getState().nodes[0].data.rotation).toBe(0);
|
|
rotateNode(id, -1);
|
|
expect(useDiagramStore.getState().nodes[0].data.rotation).toBe(270);
|
|
});
|
|
|
|
it("flips horizontal and vertical like Modelica", () => {
|
|
useDiagramStore.getState().addComponent("RefrigerantPipe", { x: 0, y: 0 });
|
|
const id = useDiagramStore.getState().nodes[0].id;
|
|
expect(useDiagramStore.getState().nodes[0].data.flipH).toBe(false);
|
|
useDiagramStore.getState().flipNodeH(id);
|
|
expect(useDiagramStore.getState().nodes[0].data.flipH).toBe(true);
|
|
useDiagramStore.getState().flipNodeV(id);
|
|
expect(useDiagramStore.getState().nodes[0].data.flipV).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("connections and removal", () => {
|
|
it("adds an edge on connect", () => {
|
|
const s = useDiagramStore.getState();
|
|
s.addComponent("IsentropicCompressor", { x: 0, y: 0 });
|
|
s.addComponent("Condenser", { x: 200, y: 0 });
|
|
const [a, b] = useDiagramStore.getState().nodes;
|
|
useDiagramStore.getState().onConnect({
|
|
source: a.id,
|
|
target: b.id,
|
|
sourceHandle: "outlet",
|
|
targetHandle: "inlet",
|
|
});
|
|
const edges = useDiagramStore.getState().edges;
|
|
expect(edges).toHaveLength(1);
|
|
expect(edges[0].source).toBe(a.id);
|
|
expect(edges[0].target).toBe(b.id);
|
|
});
|
|
|
|
it("removes a node along with its connected edges and clears selection", () => {
|
|
const s = useDiagramStore.getState();
|
|
s.addComponent("IsentropicCompressor", { x: 0, y: 0 });
|
|
s.addComponent("Condenser", { x: 200, y: 0 });
|
|
const [a, b] = useDiagramStore.getState().nodes;
|
|
useDiagramStore.getState().onConnect({
|
|
source: a.id,
|
|
target: b.id,
|
|
sourceHandle: "outlet",
|
|
targetHandle: "inlet",
|
|
});
|
|
useDiagramStore.getState().setSelected(a.id);
|
|
useDiagramStore.getState().removeNode(a.id);
|
|
const st = useDiagramStore.getState();
|
|
expect(st.nodes).toHaveLength(1);
|
|
expect(st.edges).toHaveLength(0);
|
|
expect(st.selectedNodeId).toBeNull();
|
|
});
|
|
|
|
it("clears selection when the selected node is removed via onNodesChange", () => {
|
|
useDiagramStore.getState().addComponent("Condenser", { x: 0, y: 0 });
|
|
const id = useDiagramStore.getState().nodes[0].id;
|
|
useDiagramStore.getState().setSelected(id);
|
|
useDiagramStore.getState().onNodesChange([{ type: "remove", id }]);
|
|
expect(useDiagramStore.getState().selectedNodeId).toBeNull();
|
|
expect(useDiagramStore.getState().nodes).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("toggleSnapToGrid", () => {
|
|
it("flips the snap flag", () => {
|
|
expect(useDiagramStore.getState().snapToGrid).toBe(true);
|
|
useDiagramStore.getState().toggleSnapToGrid();
|
|
expect(useDiagramStore.getState().snapToGrid).toBe(false);
|
|
useDiagramStore.getState().toggleSnapToGrid();
|
|
expect(useDiagramStore.getState().snapToGrid).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("loadFromConfig and clear", () => {
|
|
const config = {
|
|
fluid: "R290",
|
|
fluid_backend: "Test",
|
|
circuits: [
|
|
{
|
|
id: 0,
|
|
components: [
|
|
{ type: "IsentropicCompressor", name: "comp", isentropic_efficiency: 0.7 },
|
|
{ type: "Condenser", name: "cond", ua: 5000 },
|
|
],
|
|
edges: [{ from: "comp:outlet", to: "cond:inlet" }],
|
|
},
|
|
],
|
|
};
|
|
|
|
it("rebuilds nodes, edges, rotation and fluid from a config", () => {
|
|
useDiagramStore.getState().loadFromConfig(config);
|
|
const st = useDiagramStore.getState();
|
|
expect(st.nodes).toHaveLength(2);
|
|
expect(st.edges).toHaveLength(1);
|
|
expect(st.fluid).toBe("R290");
|
|
expect(st.fluidBackend).toBe("Test");
|
|
for (const n of st.nodes) expect(n.data.rotation).toBe(0);
|
|
// the params should be carried over as primitives
|
|
const comp = st.nodes.find((n) => n.data.name === "comp");
|
|
expect(comp?.data.params.isentropic_efficiency).toBe(0.7);
|
|
});
|
|
|
|
it("ignores configs without circuits", () => {
|
|
useDiagramStore.getState().addComponent("Condenser", { x: 0, y: 0 });
|
|
useDiagramStore.getState().loadFromConfig({ foo: "bar" });
|
|
expect(useDiagramStore.getState().nodes).toHaveLength(1);
|
|
});
|
|
|
|
it("flattens subsystem instances from system model JSON", () => {
|
|
useDiagramStore.getState().loadFromConfig({
|
|
fluid: "R134a",
|
|
fluid_backend: "CoolProp",
|
|
subsystems: {
|
|
Circuit: {
|
|
params: { ua_cond: 700 },
|
|
components: [
|
|
{ type: "IsentropicCompressor", name: "comp", isentropic_efficiency: 0.7 },
|
|
{ type: "Condenser", name: "cond", ua: "$ua_cond" },
|
|
],
|
|
edges: [{ from: "comp:outlet", to: "cond:inlet" }],
|
|
ports: { discharge: "comp:outlet" },
|
|
},
|
|
},
|
|
instances: [
|
|
{ of: "Circuit", name: "A", circuit: 0, params: { ua_cond: 800 } },
|
|
{ of: "Circuit", name: "B", circuit: 1 },
|
|
],
|
|
});
|
|
|
|
const st = useDiagramStore.getState();
|
|
expect(st.nodes).toHaveLength(4);
|
|
expect(st.edges).toHaveLength(2);
|
|
expect(st.nodes.map((n) => n.data.name)).toContain("A.comp");
|
|
expect(st.nodes.map((n) => n.data.name)).toContain("B.cond");
|
|
expect(st.nodes.find((n) => n.data.name === "A.cond")?.data.params.ua).toBe(800);
|
|
expect(st.nodes.find((n) => n.data.name === "B.cond")?.data.params.ua).toBe(700);
|
|
});
|
|
|
|
it("keeps explicit water circuits while flattening refrigerant subsystem instances", () => {
|
|
useDiagramStore.getState().loadFromConfig({
|
|
fluid: "R134a",
|
|
circuits: [
|
|
{
|
|
id: 2,
|
|
components: [
|
|
{ type: "BrineSource", name: "water_in", t_set_c: 30 },
|
|
{ type: "ThermalLoad", name: "water_load", mass_flow_kg_s: 1.0 },
|
|
{ type: "BrineSink", name: "water_out" },
|
|
],
|
|
edges: [
|
|
{ from: "water_in:outlet", to: "water_load:inlet" },
|
|
{ from: "water_load:outlet", to: "water_out:inlet" },
|
|
],
|
|
},
|
|
],
|
|
subsystems: {
|
|
Circuit: {
|
|
components: [
|
|
{ type: "IsentropicCompressor", name: "comp", isentropic_efficiency: 0.7 },
|
|
{ type: "Condenser", name: "cond", ua: 700 },
|
|
],
|
|
edges: [{ from: "comp:outlet", to: "cond:inlet" }],
|
|
},
|
|
},
|
|
instances: [{ of: "Circuit", name: "A", circuit: 0 }],
|
|
});
|
|
|
|
const st = useDiagramStore.getState();
|
|
expect(st.nodes.map((n) => n.data.name)).toEqual(
|
|
expect.arrayContaining(["A.comp", "A.cond", "water_in", "water_load", "water_out"]),
|
|
);
|
|
expect(st.edges).toHaveLength(3);
|
|
expect(st.nodes.find((n) => n.data.name === "water_load")?.data.circuit).toBe(2);
|
|
});
|
|
|
|
it("lays out imported chiller cycles as a horizontal HVAC schematic", () => {
|
|
useDiagramStore.getState().loadFromConfig({
|
|
fluid: "R134a",
|
|
circuits: [
|
|
{
|
|
id: 0,
|
|
components: [
|
|
{ type: "IsentropicCompressor", name: "comp", liquid_injection: true },
|
|
{ type: "Condenser", name: "cond", ua: 5000 },
|
|
{ type: "IsenthalpicExpansionValve", name: "exv", opening: 0.8 },
|
|
{ type: "Evaporator", name: "evap", ua: 6000 },
|
|
{ type: "BrineSource", name: "cond_water_in", t_set_c: 30 },
|
|
{ type: "BrineSink", name: "cond_water_out" },
|
|
{ type: "BrineSource", name: "evap_water_in", t_set_c: 12 },
|
|
{ type: "BrineSink", name: "evap_water_out" },
|
|
],
|
|
edges: [
|
|
{ from: "comp:outlet", to: "cond:inlet" },
|
|
{ from: "cond:outlet", to: "exv:inlet" },
|
|
{ from: "exv:outlet", to: "evap:inlet" },
|
|
{ from: "evap:outlet", to: "comp:inlet" },
|
|
{ from: "cond_water_in:outlet", to: "cond:secondary_inlet" },
|
|
{ from: "cond:secondary_outlet", to: "cond_water_out:inlet" },
|
|
{ from: "evap_water_in:outlet", to: "evap:secondary_inlet" },
|
|
{ from: "evap:secondary_outlet", to: "evap_water_out:inlet" },
|
|
],
|
|
},
|
|
],
|
|
controls: [
|
|
{
|
|
id: "dgt_limiter",
|
|
measure: { component: "comp", output: "temperature" },
|
|
actuator: { component: "comp", factor: "injection", initial: 0.15, min: 0, max: 0.3 },
|
|
target: 330,
|
|
},
|
|
],
|
|
});
|
|
|
|
const nodes = useDiagramStore.getState().nodes;
|
|
const byName = Object.fromEntries(nodes.map((node) => [node.data.name, node]));
|
|
|
|
expect(byName.evap.position.x).toBeLessThan(byName.comp.position.x);
|
|
expect(byName.comp.position.x).toBeLessThan(byName.cond.position.x);
|
|
expect(byName.exv.position.x).toBeGreaterThan(byName.cond.position.x);
|
|
expect(byName.exv.position.y).toBeGreaterThan(byName.cond.position.y);
|
|
expect(byName.dgt_limiter.position.y).toBeLessThan(byName.comp.position.y);
|
|
|
|
expect(byName.evap_water_out.position.y).toBeLessThan(byName.evap.position.y);
|
|
expect(byName.evap_water_in.position.y).toBeGreaterThan(byName.evap.position.y);
|
|
expect(byName.cond_water_out.position.y).toBeLessThan(byName.cond.position.y);
|
|
expect(byName.cond_water_in.position.y).toBeGreaterThan(byName.cond.position.y);
|
|
expect(useDiagramStore.getState().edges).toHaveLength(8);
|
|
});
|
|
|
|
it("uses deterministic fallback positions for partial imported configs", () => {
|
|
useDiagramStore.getState().loadFromConfig({
|
|
fluid: "R134a",
|
|
circuits: [
|
|
{
|
|
id: 0,
|
|
components: [
|
|
{ type: "Pipe", name: "pipe_b", length_m: 2 },
|
|
{ type: "Placeholder", name: "custom_a", n_equations: 1 },
|
|
],
|
|
edges: [{ from: "custom_a:outlet", to: "pipe_b:inlet" }],
|
|
},
|
|
],
|
|
});
|
|
|
|
const first = useDiagramStore.getState().nodes.map((node) => [node.data.name, node.position]);
|
|
useDiagramStore.getState().loadFromConfig({
|
|
fluid: "R134a",
|
|
circuits: [
|
|
{
|
|
id: 0,
|
|
components: [
|
|
{ type: "Pipe", name: "pipe_b", length_m: 2 },
|
|
{ type: "Placeholder", name: "custom_a", n_equations: 1 },
|
|
],
|
|
edges: [{ from: "custom_a:outlet", to: "pipe_b:inlet" }],
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(useDiagramStore.getState().nodes.map((node) => [node.data.name, node.position])).toEqual(first);
|
|
expect(useDiagramStore.getState().edges).toHaveLength(1);
|
|
});
|
|
|
|
it("places secondary boundaries near generic four-port heat exchangers", () => {
|
|
useDiagramStore.getState().loadFromConfig({
|
|
fluid: "R134a",
|
|
circuits: [
|
|
{
|
|
id: 0,
|
|
components: [
|
|
{ type: "HeatExchanger", name: "hx", ua: 3000 },
|
|
{ type: "BrineSource", name: "water_in", t_set_c: 20 },
|
|
{ type: "BrineSink", name: "water_out" },
|
|
{ type: "AirSource", name: "air_in", t_dry_c: 15 },
|
|
{ type: "AirSink", name: "air_out" },
|
|
],
|
|
edges: [
|
|
{ from: "water_in:outlet", to: "hx:hot_inlet" },
|
|
{ from: "hx:hot_outlet", to: "water_out:inlet" },
|
|
{ from: "air_in:outlet", to: "hx:cold_inlet" },
|
|
{ from: "hx:cold_outlet", to: "air_out:inlet" },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
const byName = Object.fromEntries(useDiagramStore.getState().nodes.map((node) => [node.data.name, node]));
|
|
expect(Math.abs(byName.water_in.position.x - byName.hx.position.x)).toBeLessThan(220);
|
|
expect(Math.abs(byName.air_in.position.x - byName.hx.position.x)).toBeLessThan(220);
|
|
expect(byName.water_out.position.y).toBeLessThan(byName.hx.position.y);
|
|
expect(byName.air_out.position.y).toBeLessThan(byName.hx.position.y);
|
|
expect(byName.water_in.position.y).toBeGreaterThan(byName.hx.position.y);
|
|
expect(byName.air_in.position.y).toBeGreaterThan(byName.hx.position.y);
|
|
});
|
|
|
|
it("separates multiple imported refrigeration circuits horizontally", () => {
|
|
useDiagramStore.getState().loadFromConfig({
|
|
fluid: "R134a",
|
|
circuits: [
|
|
{
|
|
id: 0,
|
|
components: [
|
|
{ type: "IsentropicCompressor", name: "comp_a" },
|
|
{ type: "Condenser", name: "cond_a" },
|
|
],
|
|
edges: [{ from: "comp_a:outlet", to: "cond_a:inlet" }],
|
|
},
|
|
{
|
|
id: 1,
|
|
components: [
|
|
{ type: "IsentropicCompressor", name: "comp_b" },
|
|
{ type: "Condenser", name: "cond_b" },
|
|
],
|
|
edges: [{ from: "comp_b:outlet", to: "cond_b:inlet" }],
|
|
},
|
|
],
|
|
});
|
|
|
|
const byName = Object.fromEntries(useDiagramStore.getState().nodes.map((node) => [node.data.name, node]));
|
|
expect(byName.comp_b.position.x - byName.comp_a.position.x).toBeGreaterThan(900);
|
|
expect(byName.cond_b.position.x - byName.cond_a.position.x).toBeGreaterThan(900);
|
|
});
|
|
|
|
it("clear() empties the sheet", () => {
|
|
useDiagramStore.getState().loadFromConfig(config);
|
|
useDiagramStore.getState().clear();
|
|
const st = useDiagramStore.getState();
|
|
expect(st.nodes).toHaveLength(0);
|
|
expect(st.edges).toHaveLength(0);
|
|
expect(st.selectedNodeId).toBeNull();
|
|
});
|
|
});
|