From 0f1f7f76a0f3c6064c31adedefb49d1510ece931 Mon Sep 17 00:00:00 2001 From: Mollusk Date: Tue, 2 Jun 2026 05:15:43 -0400 Subject: [PATCH] test(config): unit tests for AppConfig serde + defaults Add #[cfg(test)] coverage for src/config.rs: default round-trip, backward-compat default-fill (old configs missing newer #[serde(default)] fields still load), notifications_enabled default_true wiring, NetworkMode default/ALL/Display/round-trip, and unknown-field tolerance. Derives PartialEq on AppConfig to support equality asserts. Tests-only; no production behavior change. Implemented by Gemini (junior implementer), reviewed and verified by senior (cargo build + clippy --all-targets + cargo test all green). Co-Authored-By: Claude Opus 4.8 --- src/config.rs | 103 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index 6a1f0c5..c91eba5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -40,7 +40,7 @@ fn default_true() -> bool { true } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct AppConfig { pub input_device: String, pub output_device: String, @@ -121,3 +121,104 @@ impl AppConfig { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_round_trip() { + let original = AppConfig::default(); + let serialized = serde_json::to_string(&original).unwrap(); + let deserialized: AppConfig = serde_json::from_str(&serialized).unwrap(); + assert_eq!(original, deserialized); + } + + #[test] + fn test_backward_compat_default_fill() { + let minimal_json = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#; + let deserialized: AppConfig = serde_json::from_str(minimal_json).unwrap(); + + assert_eq!(deserialized.network_mode, NetworkMode::RelayNoDiscovery); + assert!(!deserialized.echo_cancellation_enabled); + assert!(deserialized.notifications_enabled); + assert!(deserialized.custom_sound_self_join.is_none()); + assert!(deserialized.custom_sound_peer_join.is_none()); + assert!(deserialized.custom_sound_peer_leave.is_none()); + assert!(deserialized.custom_sound_reconnect_attempt.is_none()); + assert!(deserialized.custom_sound_reconnected.is_none()); + assert!(deserialized.custom_sound_self_leave.is_none()); + assert!(deserialized.custom_sound_mic_toggle.is_none()); + assert!(deserialized.custom_sound_reconnect_failed.is_none()); + } + + #[test] + fn test_notifications_enabled_specifically() { + let missing_notifications = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#; + let config_missing: AppConfig = serde_json::from_str(missing_notifications).unwrap(); + assert!(config_missing.notifications_enabled); + + let explicit_false = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01,"notifications_enabled":false}"#; + let config_false: AppConfig = serde_json::from_str(explicit_false).unwrap(); + assert!(!config_false.notifications_enabled); + + let explicit_true = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01,"notifications_enabled":true}"#; + let config_true: AppConfig = serde_json::from_str(explicit_true).unwrap(); + assert!(config_true.notifications_enabled); + } + + #[test] + fn test_network_mode() { + // NetworkMode::default() == RelayNoDiscovery + assert_eq!(NetworkMode::default(), NetworkMode::RelayNoDiscovery); + + // ALL.len() == 3 and contains all three variants + assert_eq!(NetworkMode::ALL.len(), 3); + assert!(NetworkMode::ALL.contains(&NetworkMode::RelayNoDiscovery)); + assert!(NetworkMode::ALL.contains(&NetworkMode::N0Full)); + assert!(NetworkMode::ALL.contains(&NetworkMode::DirectOnly)); + + // each variant serde round-trips to itself + for mode in NetworkMode::ALL { + let serialized = serde_json::to_string(&mode).unwrap(); + let deserialized: NetworkMode = serde_json::from_str(&serialized).unwrap(); + assert_eq!(mode, deserialized); + } + + // the three Display strings are non-empty and distinct + let display_0 = NetworkMode::RelayNoDiscovery.to_string(); + let display_1 = NetworkMode::N0Full.to_string(); + let display_2 = NetworkMode::DirectOnly.to_string(); + + assert!(!display_0.is_empty()); + assert!(!display_1.is_empty()); + assert!(!display_2.is_empty()); + + assert_ne!(display_0, display_1); + assert_ne!(display_1, display_2); + assert_ne!(display_0, display_2); + } + + #[test] + fn test_unknown_field_tolerance() { + // Unknown/extra field tolerance: a config JSON containing an extra unrecognized key should still deserialize. + // Assert whatever actually happens and leave a comment; do not add deny_unknown_fields. + let json_with_extra = r#"{ + "input_device": "", + "output_device": "", + "noise_gate_threshold": 0.01, + "unrecognized_field_xyz_123": "some_value" + }"#; + let deserialized_res: Result = serde_json::from_str(json_with_extra); + + // Assert that deserialization succeeds even with unrecognized/unknown fields. + // This confirms that serde does not reject unknown fields (i.e. default behavior). + assert!(deserialized_res.is_ok(), "Config deserialization failed when an unknown field was present"); + + let config = deserialized_res.unwrap(); + assert_eq!(config.input_device, ""); + assert_eq!(config.output_device, ""); + assert_eq!(config.noise_gate_threshold, 0.01); + } +} +