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