435 lines
17 KiB
Rust
435 lines
17 KiB
Rust
//! Integration tests for JSON serialization/deserialization of systems
|
|
//!
|
|
//! Tests cover:
|
|
//! - Round-trip serialization (system → JSON → system)
|
|
//! - Topology preservation (nodes, edges, component types)
|
|
//! - Constraint and bounded variable preservation
|
|
//! - Thermal coupling preservation
|
|
//! - Version compatibility checks
|
|
//! - Backend validation
|
|
//! - File save/load round-trip
|
|
//! - Human-readable JSON format
|
|
|
|
use entropyk_components::{Compressor, FluidId, Port};
|
|
use entropyk_core::{CircuitId, Enthalpy, Pressure, ThermalConductance};
|
|
use entropyk_solver::{System, ThermalCoupling};
|
|
use serde_json::{json, Value};
|
|
|
|
/// Helper: create a minimal system with a single compressor component.
|
|
fn build_single_compressor_system() -> System {
|
|
let mut system = System::new();
|
|
|
|
let coefficients = entropyk_components::Ahri540Coefficients::new(
|
|
0.85, 2.5, 500.0, 1500.0, -2.5, 1.8, 600.0, 1600.0, -3.0, 2.0,
|
|
);
|
|
|
|
let port_suction = Port::new(
|
|
FluidId::new("R134a"),
|
|
Pressure::from_bar(2.0),
|
|
Enthalpy::from_joules_per_kg(400000.0),
|
|
);
|
|
let port_discharge = Port::new(
|
|
FluidId::new("R134a"),
|
|
Pressure::from_bar(10.0),
|
|
Enthalpy::from_joules_per_kg(450000.0),
|
|
);
|
|
|
|
let disconnected = Compressor::new(
|
|
coefficients,
|
|
port_suction,
|
|
port_discharge,
|
|
2900.0,
|
|
0.0001,
|
|
0.85,
|
|
)
|
|
.expect("Failed to create compressor");
|
|
|
|
let connected = disconnected
|
|
.connect(
|
|
Port::new(
|
|
FluidId::new("R134a"),
|
|
Pressure::from_bar(2.0),
|
|
Enthalpy::from_joules_per_kg(400000.0),
|
|
),
|
|
Port::new(
|
|
FluidId::new("R134a"),
|
|
Pressure::from_bar(10.0),
|
|
Enthalpy::from_joules_per_kg(450000.0),
|
|
),
|
|
)
|
|
.expect("Failed to connect compressor");
|
|
|
|
let node = system.add_component(Box::new(connected));
|
|
system.register_component_name("compressor", node);
|
|
|
|
system
|
|
}
|
|
|
|
/// Helper: create a system with two components and an edge between them,
|
|
/// plus a thermal coupling.
|
|
fn build_two_component_system() -> System {
|
|
let mut system = System::new();
|
|
|
|
let coefficients = entropyk_components::Ahri540Coefficients::new(
|
|
0.85, 2.5, 500.0, 1500.0, -2.5, 1.8, 600.0, 1600.0, -3.0, 2.0,
|
|
);
|
|
|
|
// Create compressor
|
|
let port_s = Port::new(
|
|
FluidId::new("R134a"),
|
|
Pressure::from_bar(2.0),
|
|
Enthalpy::from_joules_per_kg(400000.0),
|
|
);
|
|
let port_d = Port::new(
|
|
FluidId::new("R134a"),
|
|
Pressure::from_bar(10.0),
|
|
Enthalpy::from_joules_per_kg(450000.0),
|
|
);
|
|
let comp = Compressor::new(coefficients, port_s, port_d, 2900.0, 0.0001, 0.85)
|
|
.expect("create compressor")
|
|
.connect(
|
|
Port::new(
|
|
FluidId::new("R134a"),
|
|
Pressure::from_bar(2.0),
|
|
Enthalpy::from_joules_per_kg(400000.0),
|
|
),
|
|
Port::new(
|
|
FluidId::new("R134a"),
|
|
Pressure::from_bar(10.0),
|
|
Enthalpy::from_joules_per_kg(450000.0),
|
|
),
|
|
)
|
|
.expect("connect compressor");
|
|
|
|
let node_comp = system.add_component(Box::new(comp));
|
|
system.register_component_name("compressor", node_comp);
|
|
|
|
// Create a second compressor (acting as condenser proxy)
|
|
let coefficients2 = entropyk_components::Ahri540Coefficients::new(
|
|
0.9, 3.0, 600.0, 1400.0, -1.5, 2.0, 700.0, 1700.0, -2.0, 1.5,
|
|
);
|
|
let port_s2 = Port::new(
|
|
FluidId::new("R134a"),
|
|
Pressure::from_bar(10.0),
|
|
Enthalpy::from_joules_per_kg(450000.0),
|
|
);
|
|
let port_d2 = Port::new(
|
|
FluidId::new("R134a"),
|
|
Pressure::from_bar(8.0),
|
|
Enthalpy::from_joules_per_kg(420000.0),
|
|
);
|
|
let comp2 = Compressor::new(coefficients2, port_s2, port_d2, 2900.0, 0.00012, 0.88)
|
|
.expect("create comp2")
|
|
.connect(
|
|
Port::new(
|
|
FluidId::new("R134a"),
|
|
Pressure::from_bar(10.0),
|
|
Enthalpy::from_joules_per_kg(450000.0),
|
|
),
|
|
Port::new(
|
|
FluidId::new("R134a"),
|
|
Pressure::from_bar(8.0),
|
|
Enthalpy::from_joules_per_kg(420000.0),
|
|
),
|
|
)
|
|
.expect("connect comp2");
|
|
|
|
let node_comp2 = system.add_component(Box::new(comp2));
|
|
system.register_component_name("condenser", node_comp2);
|
|
|
|
// Add edge between them
|
|
system.add_edge(node_comp, node_comp2).expect("add edge");
|
|
|
|
// Add thermal coupling
|
|
let coupling = ThermalCoupling::new(
|
|
CircuitId(0),
|
|
CircuitId(0),
|
|
ThermalConductance::from_watts_per_kelvin(500.0),
|
|
);
|
|
let _ = system.add_thermal_coupling(coupling);
|
|
|
|
system
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Test 1: Topology round-trip
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn test_topology_round_trip() {
|
|
let original = build_two_component_system();
|
|
|
|
let json_str = original.to_json_string().expect("Serialization failed");
|
|
let restored = System::from_json_string(&json_str).expect("Deserialization failed");
|
|
|
|
// Verify topology is identical
|
|
assert_eq!(
|
|
original.node_count(),
|
|
restored.node_count(),
|
|
"Node count mismatch"
|
|
);
|
|
assert_eq!(
|
|
original.edge_count(),
|
|
restored.edge_count(),
|
|
"Edge count mismatch"
|
|
);
|
|
assert_eq!(
|
|
original.thermal_coupling_count(),
|
|
restored.thermal_coupling_count(),
|
|
"Thermal coupling count mismatch"
|
|
);
|
|
|
|
// Verify component names are preserved (order-independent since deserialization sorts keys)
|
|
let mut original_names: Vec<&str> = original.registered_component_names().collect();
|
|
let mut restored_names: Vec<&str> = restored.registered_component_names().collect();
|
|
original_names.sort();
|
|
restored_names.sort();
|
|
assert_eq!(original_names, restored_names, "Component names mismatch");
|
|
|
|
// Verify component types via the JSON snapshot
|
|
let parsed: Value = serde_json::from_str(&json_str).expect("JSON parse");
|
|
let params = parsed.get("parameters").expect("parameters field");
|
|
assert!(params.get("compressor").is_some(), "compressor in params");
|
|
assert!(params.get("condenser").is_some(), "condenser in params");
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Test 3: Constraints preservation
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn test_constraints_preserved_in_round_trip() {
|
|
use entropyk_solver::inverse::{ComponentOutput, Constraint, ConstraintId};
|
|
|
|
let mut system = build_single_compressor_system();
|
|
|
|
// Add a constraint referencing the compressor
|
|
let constraint = Constraint::new(
|
|
ConstraintId::new("superheat_ctrl"),
|
|
ComponentOutput::Superheat {
|
|
component_id: "compressor".to_string(),
|
|
},
|
|
5.0,
|
|
);
|
|
system.add_constraint(constraint).expect("add constraint");
|
|
assert_eq!(system.constraint_count(), 1);
|
|
|
|
// Serialize
|
|
let json_str = system.to_json_string().expect("Serialization failed");
|
|
|
|
// Verify constraints are in the JSON
|
|
let parsed: Value = serde_json::from_str(&json_str).expect("JSON parse");
|
|
let constraints = parsed.get("constraints").expect("constraints field");
|
|
assert!(constraints.is_array());
|
|
assert_eq!(constraints.as_array().unwrap().len(), 1);
|
|
|
|
let c = &constraints.as_array().unwrap()[0];
|
|
assert_eq!(c["id"], "superheat_ctrl");
|
|
assert_eq!(c["component"], "compressor");
|
|
assert_eq!(c["target"], 5.0);
|
|
|
|
// Verify the constraint snapshot round-trips through serde
|
|
let snapshot: entropyk_solver::SystemSnapshot =
|
|
serde_json::from_str(&json_str).expect("snapshot parse");
|
|
assert_eq!(snapshot.constraints.len(), 1);
|
|
assert_eq!(snapshot.constraints[0].id, "superheat_ctrl");
|
|
assert_eq!(snapshot.constraints[0].component, "compressor");
|
|
assert!((snapshot.constraints[0].target - 5.0).abs() < 1e-12);
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Test 4: Thermal couplings preservation
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn test_thermal_couplings_preserved_in_round_trip() {
|
|
let original = build_two_component_system();
|
|
|
|
let json_str = original.to_json_string().expect("Serialization failed");
|
|
|
|
// Verify thermal couplings in JSON
|
|
let parsed: Value = serde_json::from_str(&json_str).expect("JSON parse");
|
|
let couplings = parsed
|
|
.get("topology")
|
|
.and_then(|t| t.get("thermalCouplings"))
|
|
.expect("thermal couplings in topology");
|
|
assert!(couplings.is_array());
|
|
assert_eq!(couplings.as_array().unwrap().len(), 1);
|
|
|
|
let c = &couplings.as_array().unwrap()[0];
|
|
assert_eq!(c["hotCircuit"], 0);
|
|
assert_eq!(c["coldCircuit"], 0);
|
|
|
|
// Verify the snapshot round-trips
|
|
let snapshot: entropyk_solver::SystemSnapshot =
|
|
serde_json::from_str(&json_str).expect("snapshot parse");
|
|
assert_eq!(snapshot.topology.thermal_couplings.len(), 1);
|
|
assert_eq!(snapshot.topology.thermal_couplings[0].hot_circuit, CircuitId(0));
|
|
assert_eq!(snapshot.topology.thermal_couplings[0].cold_circuit, CircuitId(0));
|
|
|
|
// Verify ua value round-trip
|
|
let ua_val = snapshot.topology.thermal_couplings[0].ua.to_watts_per_kelvin();
|
|
assert!((ua_val - 500.0).abs() < 1e-6, "UA value mismatch: {}", ua_val);
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Test 5: File save/load round-trip
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn test_file_save_and_load() {
|
|
let system = build_two_component_system();
|
|
|
|
let temp_dir = std::env::temp_dir();
|
|
let file_path = temp_dir.join("entropyk_test_round_trip.json");
|
|
|
|
// Save
|
|
system.save_json(&file_path).expect("Save failed");
|
|
assert!(file_path.exists());
|
|
|
|
// Load
|
|
let loaded = System::load_json(&file_path).expect("Load failed");
|
|
|
|
// Verify topology matches
|
|
assert_eq!(system.node_count(), loaded.node_count());
|
|
assert_eq!(system.edge_count(), loaded.edge_count());
|
|
assert_eq!(
|
|
system.thermal_coupling_count(),
|
|
loaded.thermal_coupling_count()
|
|
);
|
|
|
|
// Clean up
|
|
std::fs::remove_file(&file_path).ok();
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Test 6: Missing backend error
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn test_missing_backend_returns_error() {
|
|
// AC5: missing backend must produce an explicit error
|
|
let json_with_unknown_backend = json!({
|
|
"version": "1.0",
|
|
"topology": {
|
|
"edges": [],
|
|
"thermalCouplings": []
|
|
},
|
|
"parameters": {},
|
|
"fluidBackend": {
|
|
"name": "NonExistentBackend",
|
|
"version": "99.0.0"
|
|
}
|
|
})
|
|
.to_string();
|
|
|
|
let result = System::from_json_string(&json_with_unknown_backend);
|
|
assert!(result.is_err(), "Should fail with BackendUnavailable for unknown backend");
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Test 7: Version mismatch error
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn test_version_mismatch() {
|
|
let json_with_wrong_version = json!({
|
|
"version": "99.0",
|
|
"topology": {
|
|
"edges": [],
|
|
"thermalCouplings": []
|
|
},
|
|
"parameters": {},
|
|
"fluidBackend": {
|
|
"name": "TestBackend",
|
|
"version": "1.0.0",
|
|
"hash": "abc123"
|
|
}
|
|
})
|
|
.to_string();
|
|
|
|
let result = System::from_json_string(&json_with_wrong_version);
|
|
assert!(result.is_err(), "Should fail with version mismatch");
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Additional: JSON human-readable and deterministic
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn test_simple_system_round_trip() {
|
|
let system = build_single_compressor_system();
|
|
let json_str = system.to_json_string().expect("Serialization failed");
|
|
|
|
let parsed: Value = serde_json::from_str(&json_str).expect("JSON parsing failed");
|
|
assert!(parsed.is_object());
|
|
assert_eq!(parsed["version"], "1.0");
|
|
|
|
// Single isolated component should fail finalize during deserialization
|
|
let result = System::from_json_string(&json_str);
|
|
assert!(result.is_err(), "Isolated node should fail deserialization");
|
|
}
|
|
|
|
#[test]
|
|
fn test_json_is_human_readable() {
|
|
let system = System::new();
|
|
let json_str = system.to_json_string().expect("Serialization failed");
|
|
|
|
assert!(json_str.contains('\n'));
|
|
assert!(json_str.contains(" "));
|
|
|
|
let _: Value = serde_json::from_str(&json_str).expect("Should be valid JSON");
|
|
}
|
|
|
|
#[test]
|
|
fn test_deterministic_serialization() {
|
|
// Note: HashMap-based fields (parameters, ComponentParams) may produce
|
|
// different key ordering across serializations, so we compare parsed
|
|
// JSON values rather than raw strings.
|
|
let system = build_single_compressor_system();
|
|
let json1 = system.to_json_string().expect("Serialization failed");
|
|
let json2 = system.to_json_string().expect("Serialization failed");
|
|
|
|
let val1: Value = serde_json::from_str(&json1).expect("parse json1");
|
|
let val2: Value = serde_json::from_str(&json2).expect("parse json2");
|
|
assert_eq!(val1, val2, "Same system should produce identical JSON (structurally)");
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Test: Bounded variables in snapshot
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn test_bounded_variables_in_snapshot() {
|
|
use entropyk_solver::inverse::{BoundedVariable, BoundedVariableId};
|
|
|
|
let mut system = build_single_compressor_system();
|
|
|
|
let valve =
|
|
BoundedVariable::with_component(BoundedVariableId::new("valve"), "compressor", 0.5, 0.0, 1.0)
|
|
.expect("create bounded var");
|
|
system.add_bounded_variable(valve).expect("add bounded var");
|
|
|
|
let json_str = system.to_json_string().expect("Serialization failed");
|
|
let parsed: Value = serde_json::from_str(&json_str).expect("JSON parse");
|
|
|
|
let bounded = parsed.get("boundedVariables").expect("boundedVariables field");
|
|
assert!(bounded.is_array());
|
|
assert_eq!(bounded.as_array().unwrap().len(), 1);
|
|
|
|
let bv = &bounded.as_array().unwrap()[0];
|
|
assert_eq!(bv["id"], "valve");
|
|
assert_eq!(bv["component"], "compressor");
|
|
assert!((bv["initialValue"].as_f64().unwrap() - 0.5).abs() < 1e-12);
|
|
|
|
// Verify snapshot round-trip
|
|
let snapshot: entropyk_solver::SystemSnapshot =
|
|
serde_json::from_str(&json_str).expect("snapshot parse");
|
|
assert_eq!(snapshot.bounded_variables.len(), 1);
|
|
assert_eq!(snapshot.bounded_variables[0].id, "valve");
|
|
assert_eq!(snapshot.bounded_variables[0].component, "compressor");
|
|
assert!((snapshot.bounded_variables[0].initial_value - 0.5).abs() < 1e-12);
|
|
assert!((snapshot.bounded_variables[0].lower_bound - 0.0).abs() < 1e-12);
|
|
assert!((snapshot.bounded_variables[0].upper_bound - 1.0).abs() < 1e-12);
|
|
}
|