//! Integration tests for Sequential Substitution (Picard) solver (Story 4.3). //! //! Tests cover: //! - AC #1: Reliable convergence when Newton diverges //! - AC #2: Sequential variable update //! - AC #3: Configurable relaxation factors //! - AC #4: Timeout enforcement //! - AC #5: Divergence detection //! - AC #6: Pre-allocated buffers use approx::assert_relative_eq; use entropyk_solver::{PicardConfig, Solver, SolverError, System}; use std::time::Duration; // ───────────────────────────────────────────────────────────────────────────── // AC #1: Solver Trait and Configuration // ───────────────────────────────────────────────────────────────────────────── #[test] fn test_picard_config_default() { let cfg = PicardConfig::default(); assert_eq!(cfg.max_iterations, 100); assert_relative_eq!(cfg.tolerance, 1e-6); assert_relative_eq!(cfg.relaxation_factor, 0.5); assert!(cfg.timeout.is_none()); assert_relative_eq!(cfg.divergence_threshold, 1e10); assert_eq!(cfg.divergence_patience, 5); } #[test] fn test_picard_config_with_timeout() { let timeout = Duration::from_millis(500); let cfg = PicardConfig::default().with_timeout(timeout); assert_eq!(cfg.timeout, Some(timeout)); } #[test] fn test_picard_config_custom_values() { let cfg = PicardConfig { max_iterations: 200, tolerance: 1e-8, relaxation_factor: 0.3, timeout: Some(Duration::from_millis(1000)), divergence_threshold: 1e8, divergence_patience: 7, ..Default::default() }; assert_eq!(cfg.max_iterations, 200); assert_relative_eq!(cfg.tolerance, 1e-8); assert_relative_eq!(cfg.relaxation_factor, 0.3); assert_eq!(cfg.timeout, Some(Duration::from_millis(1000))); assert_relative_eq!(cfg.divergence_threshold, 1e8); assert_eq!(cfg.divergence_patience, 7); } // ───────────────────────────────────────────────────────────────────────────── // AC #2: Empty System Handling // ───────────────────────────────────────────────────────────────────────────── #[test] fn test_empty_system_returns_invalid() { let mut sys = System::new(); sys.finalize().unwrap(); let mut solver = PicardConfig::default(); let result = solver.solve(&mut sys); assert!(result.is_err()); match result { Err(SolverError::InvalidSystem { message }) => { assert!( message.contains("Empty") || message.contains("no state"), "Expected empty system message, got: {}", message ); } other => panic!("Expected InvalidSystem, got {:?}", other), } } #[test] #[should_panic(expected = "finalize")] fn test_picard_empty_system_without_finalize_panics() { // System panics if solve() is called without finalize() // This is expected behavior - the solver requires a finalized system let mut sys = System::new(); // Don't call finalize let mut solver = PicardConfig::default(); let _ = solver.solve(&mut sys); } // ───────────────────────────────────────────────────────────────────────────── // AC #3: Relaxation Factor Configuration // ───────────────────────────────────────────────────────────────────────────── #[test] fn test_relaxation_factor_default() { let cfg = PicardConfig::default(); assert_relative_eq!(cfg.relaxation_factor, 0.5); } #[test] fn test_relaxation_factor_full_update() { // omega = 1.0: Full update (fastest, may oscillate) let cfg = PicardConfig { relaxation_factor: 1.0, ..Default::default() }; assert_relative_eq!(cfg.relaxation_factor, 1.0); } #[test] fn test_relaxation_factor_heavy_damping() { // omega = 0.1: Heavy damping (slow but very stable) let cfg = PicardConfig { relaxation_factor: 0.1, ..Default::default() }; assert_relative_eq!(cfg.relaxation_factor, 0.1); } #[test] fn test_relaxation_factor_moderate() { // omega = 0.5: Moderate damping (default, good balance) let cfg = PicardConfig { relaxation_factor: 0.5, ..Default::default() }; assert_relative_eq!(cfg.relaxation_factor, 0.5); } // ───────────────────────────────────────────────────────────────────────────── // AC #4: Timeout Enforcement // ───────────────────────────────────────────────────────────────────────────── #[test] fn test_timeout_value_stored() { let timeout = Duration::from_millis(250); let cfg = PicardConfig::default().with_timeout(timeout); assert_eq!(cfg.timeout, Some(timeout)); } #[test] fn test_timeout_preserves_other_fields() { let cfg = PicardConfig { max_iterations: 150, tolerance: 1e-7, relaxation_factor: 0.25, timeout: None, divergence_threshold: 1e9, divergence_patience: 8, ..Default::default() } .with_timeout(Duration::from_millis(300)); assert_eq!(cfg.max_iterations, 150); assert_relative_eq!(cfg.tolerance, 1e-7); assert_relative_eq!(cfg.relaxation_factor, 0.25); assert_eq!(cfg.timeout, Some(Duration::from_millis(300))); assert_relative_eq!(cfg.divergence_threshold, 1e9); assert_eq!(cfg.divergence_patience, 8); } // ───────────────────────────────────────────────────────────────────────────── // AC #5: Divergence Detection Configuration // ───────────────────────────────────────────────────────────────────────────── #[test] fn test_divergence_threshold_default() { let cfg = PicardConfig::default(); assert_relative_eq!(cfg.divergence_threshold, 1e10); } #[test] fn test_divergence_patience_default() { let cfg = PicardConfig::default(); assert_eq!(cfg.divergence_patience, 5); } #[test] fn test_divergence_patience_higher_than_newton() { // Newton uses hardcoded patience of 3 // Picard should be more tolerant (5 by default) let cfg = PicardConfig::default(); assert!( cfg.divergence_patience >= 5, "Picard divergence_patience ({}) should be >= 5 (more tolerant than Newton's 3)", cfg.divergence_patience ); } #[test] fn test_divergence_threshold_custom() { let cfg = PicardConfig { divergence_threshold: 1e6, ..Default::default() }; assert_relative_eq!(cfg.divergence_threshold, 1e6); } #[test] fn test_divergence_patience_custom() { let cfg = PicardConfig { divergence_patience: 10, ..Default::default() }; assert_eq!(cfg.divergence_patience, 10); } // ───────────────────────────────────────────────────────────────────────────── // AC #6: Pre-Allocated Buffers (No Panic) // ───────────────────────────────────────────────────────────────────────────── #[test] fn test_solver_does_not_panic_on_empty_system() { let mut sys = System::new(); sys.finalize().unwrap(); let mut solver = PicardConfig::default(); // Should complete without panic let result = solver.solve(&mut sys); assert!(result.is_err()); } #[test] fn test_solver_does_not_panic_with_small_relaxation() { let mut sys = System::new(); sys.finalize().unwrap(); let mut solver = PicardConfig { relaxation_factor: 0.1, ..Default::default() }; // Should complete without panic let result = solver.solve(&mut sys); assert!(result.is_err()); } #[test] fn test_solver_does_not_panic_with_full_relaxation() { let mut sys = System::new(); sys.finalize().unwrap(); let mut solver = PicardConfig { relaxation_factor: 1.0, ..Default::default() }; // Should complete without panic let result = solver.solve(&mut sys); assert!(result.is_err()); } #[test] fn test_solver_does_not_panic_with_timeout() { let mut sys = System::new(); sys.finalize().unwrap(); let mut solver = PicardConfig { timeout: Some(Duration::from_millis(10)), ..Default::default() }; // Should complete without panic let result = solver.solve(&mut sys); assert!(result.is_err()); } // ───────────────────────────────────────────────────────────────────────────── // Error Types // ───────────────────────────────────────────────────────────────────────────── #[test] fn test_error_display_non_convergence() { let err = SolverError::NonConvergence { iterations: 100, final_residual: 5.67e-4, }; let msg = err.to_string(); assert!(msg.contains("100")); assert!(msg.contains("5.67")); } #[test] fn test_error_display_timeout() { let err = SolverError::Timeout { timeout_ms: 250 }; let msg = err.to_string(); assert!(msg.contains("250")); } #[test] fn test_error_display_divergence() { let err = SolverError::Divergence { reason: "residual increased for 5 consecutive iterations".to_string(), }; let msg = err.to_string(); assert!(msg.contains("residual increased")); } #[test] fn test_error_display_invalid_system() { let err = SolverError::InvalidSystem { message: "State dimension does not match equation count".to_string(), }; let msg = err.to_string(); assert!(msg.contains("State dimension")); } // ───────────────────────────────────────────────────────────────────────────── // ConvergedState // ───────────────────────────────────────────────────────────────────────────── #[test] fn test_converged_state_is_converged() { use entropyk_solver::{ConvergedState, ConvergenceStatus}; let state = ConvergedState::new(vec![1.0, 2.0, 3.0], 25, 1e-7, ConvergenceStatus::Converged, entropyk_solver::SimulationMetadata::new("".to_string())); assert!(state.is_converged()); assert_eq!(state.iterations, 25); assert_eq!(state.state, vec![1.0, 2.0, 3.0]); assert_relative_eq!(state.final_residual, 1e-7); } #[test] fn test_converged_state_timed_out() { use entropyk_solver::{ConvergedState, ConvergenceStatus}; let state = ConvergedState::new( vec![0.5], 75, 1e-2, ConvergenceStatus::TimedOutWithBestState, entropyk_solver::SimulationMetadata::new("".to_string()), ); assert!(!state.is_converged()); assert_eq!(state.status, ConvergenceStatus::TimedOutWithBestState); } // ───────────────────────────────────────────────────────────────────────────── // SolverStrategy Integration // ───────────────────────────────────────────────────────────────────────────── #[test] fn test_solver_strategy_picard_dispatch() { use entropyk_solver::SolverStrategy; let mut strategy = SolverStrategy::SequentialSubstitution(PicardConfig::default()); let mut system = System::new(); system.finalize().unwrap(); let result = strategy.solve(&mut system); assert!(result.is_err()); } #[test] fn test_solver_strategy_picard_with_timeout() { use entropyk_solver::SolverStrategy; let strategy = SolverStrategy::SequentialSubstitution(PicardConfig::default()) .with_timeout(Duration::from_millis(100)); match strategy { SolverStrategy::SequentialSubstitution(cfg) => { assert_eq!(cfg.timeout, Some(Duration::from_millis(100))); } other => panic!("Expected SequentialSubstitution, got {:?}", other), } } // ───────────────────────────────────────────────────────────────────────────── // Dimension Mismatch Handling // ───────────────────────────────────────────────────────────────────────────── #[test] fn test_picard_dimension_mismatch_returns_error() { // Picard requires state dimension == equation count // This is validated in solve() before iteration begins let mut sys = System::new(); sys.finalize().unwrap(); let mut solver = PicardConfig::default(); let result = solver.solve(&mut sys); // Empty system should return InvalidSystem assert!(result.is_err()); match result { Err(SolverError::InvalidSystem { message }) => { assert!( message.contains("Empty") || message.contains("no state"), "Expected empty system message, got: {}", message ); } other => panic!("Expected InvalidSystem, got {:?}", other), } }