From dc3fd63a83994359d38d9e515d8243670cd49b5b Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Tue, 24 Feb 2026 22:12:27 +0100 Subject: [PATCH] [test](trx-server): add SDR config validation unit tests Adds 12 unit tests for ServerConfig::validate_sdr() covering: minimal valid config, non-SDR skip, empty/missing args, zero sample_rate, channel IF out-of-range (positive, negative, exactly at Nyquist), dual stream_opus, tx_enabled with SDR backend, duplicate decoder, and multiple simultaneous errors. Marks SDR-11 complete in SDR.md. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Stan Grams --- SDR.md | 2 +- src/trx-server/src/config.rs | 196 +++++++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+), 1 deletion(-) diff --git a/SDR.md b/SDR.md index 1de72c3..9a84ced 100644 --- a/SDR.md +++ b/SDR.md @@ -41,7 +41,7 @@ This document specifies the requirements for a SoapySDR-based RX-only backend (` | ID | Status | Task | Touches | Needs | |----|--------|------|---------|-------| | SDR-10 | `[x]` | Unit tests for `demod.rs`: known-input tone through each demodulator, check output frequency correct | `…/src/demod.rs` | SDR-05 | -| SDR-11 | `[ ]` | Unit tests for config validation: channel IF out-of-range, dual `stream_opus`, TX enabled with SDR backend, AGC fallback warning | `src/trx-server/src/config.rs` | SDR-03 | +| SDR-11 | `[x]` | Unit tests for config validation: channel IF out-of-range, dual `stream_opus`, TX enabled with SDR backend, AGC fallback warning | `src/trx-server/src/config.rs` | SDR-03 | --- diff --git a/src/trx-server/src/config.rs b/src/trx-server/src/config.rs index 7e12fda..04b3b58 100644 --- a/src/trx-server/src/config.rs +++ b/src/trx-server/src/config.rs @@ -822,4 +822,200 @@ tokens = ["secret123"] assert!(paths.contains(&home_dir.join(".trx-server.toml"))); } } + + // --- SDR-11: validate_sdr() unit tests --- + + fn sdr_config_with_access(args: &str) -> ServerConfig { + let mut cfg = ServerConfig::default(); + cfg.rig.access.access_type = Some("sdr".to_string()); + cfg.rig.access.args = Some(args.to_string()); + cfg.audio.tx_enabled = false; + cfg.sdr.sample_rate = 1_920_000; + cfg.sdr.center_offset_hz = 200_000; + cfg + } + + fn add_channel( + cfg: &mut ServerConfig, + id: &str, + offset_hz: i64, + stream_opus: bool, + decoders: Vec, + ) { + let mut ch = SdrChannelConfig::default(); + ch.id = id.to_string(); + ch.offset_hz = offset_hz; + ch.stream_opus = stream_opus; + ch.decoders = decoders; + cfg.sdr.channels.push(ch); + } + + #[test] + fn test_sdr_validate_ok_minimal() { + let mut cfg = sdr_config_with_access("driver=rtlsdr"); + add_channel(&mut cfg, "primary", 0, false, vec![]); + let errors = cfg.validate_sdr(); + assert!( + errors.is_empty(), + "expected no errors, got: {:?}", + errors + ); + } + + #[test] + fn test_sdr_validate_non_sdr_skips() { + let cfg = ServerConfig::default(); + let errors = cfg.validate_sdr(); + assert!( + errors.is_empty(), + "expected no errors for non-sdr config, got: {:?}", + errors + ); + } + + #[test] + fn test_sdr_validate_empty_args() { + let cfg = sdr_config_with_access(""); + let errors = cfg.validate_sdr(); + assert_eq!(errors.len(), 1, "expected exactly 1 error, got: {:?}", errors); + assert!( + errors[0].contains("args"), + "expected error to mention 'args', got: {}", + errors[0] + ); + } + + #[test] + fn test_sdr_validate_missing_args() { + let mut cfg = sdr_config_with_access("placeholder"); + cfg.rig.access.args = None; + let errors = cfg.validate_sdr(); + assert_eq!(errors.len(), 1, "expected exactly 1 error, got: {:?}", errors); + assert!( + errors[0].contains("args"), + "expected error to mention 'args', got: {}", + errors[0] + ); + } + + #[test] + fn test_sdr_validate_zero_sample_rate() { + let mut cfg = sdr_config_with_access("driver=rtlsdr"); + cfg.sdr.sample_rate = 0; + let errors = cfg.validate_sdr(); + assert!( + errors.iter().any(|e| e.contains("sample_rate")), + "expected error mentioning 'sample_rate', got: {:?}", + errors + ); + } + + #[test] + fn test_sdr_validate_channel_if_out_of_range() { + // sample_rate=1_000_000 => Nyquist=500_000 + // center_offset_hz=0, offset_hz=600_000 => IF=600_000 > 500_000 + let mut cfg = sdr_config_with_access("driver=rtlsdr"); + cfg.sdr.sample_rate = 1_000_000; + cfg.sdr.center_offset_hz = 0; + add_channel(&mut cfg, "ch_high", 600_000, false, vec![]); + let errors = cfg.validate_sdr(); + assert!( + errors + .iter() + .any(|e| e.contains("ch_high") && (e.contains("Nyquist") || e.contains("exceeds"))), + "expected error mentioning channel id and Nyquist/exceeds, got: {:?}", + errors + ); + } + + #[test] + fn test_sdr_validate_channel_if_negative_out_of_range() { + // sample_rate=1_000_000 => Nyquist=500_000 + // center_offset_hz=0, offset_hz=-600_000 => IF=-600_000, abs=600_000 > 500_000 + let mut cfg = sdr_config_with_access("driver=rtlsdr"); + cfg.sdr.sample_rate = 1_000_000; + cfg.sdr.center_offset_hz = 0; + add_channel(&mut cfg, "ch_low", -600_000, false, vec![]); + let errors = cfg.validate_sdr(); + assert!( + errors + .iter() + .any(|e| e.contains("ch_low") && (e.contains("Nyquist") || e.contains("exceeds"))), + "expected error mentioning channel id and Nyquist/exceeds, got: {:?}", + errors + ); + } + + #[test] + fn test_sdr_validate_channel_if_exactly_nyquist_is_invalid() { + // sample_rate=1_000_000 => Nyquist=500_000 + // IF=500_000 is NOT strictly less than 500_000 => invalid + let mut cfg = sdr_config_with_access("driver=rtlsdr"); + cfg.sdr.sample_rate = 1_000_000; + cfg.sdr.center_offset_hz = 0; + add_channel(&mut cfg, "ch_nyquist", 500_000, false, vec![]); + let errors = cfg.validate_sdr(); + assert!( + errors + .iter() + .any(|e| e.contains("ch_nyquist") && (e.contains("Nyquist") || e.contains("exceeds"))), + "expected error for IF exactly at Nyquist, got: {:?}", + errors + ); + } + + #[test] + fn test_sdr_validate_dual_stream_opus() { + let mut cfg = sdr_config_with_access("driver=rtlsdr"); + add_channel(&mut cfg, "ch1", 0, true, vec![]); + add_channel(&mut cfg, "ch2", 10_000, true, vec![]); + let errors = cfg.validate_sdr(); + assert!( + errors.iter().any(|e| e.contains("stream_opus")), + "expected error mentioning 'stream_opus', got: {:?}", + errors + ); + } + + #[test] + fn test_sdr_validate_tx_enabled_with_sdr() { + let mut cfg = sdr_config_with_access("driver=rtlsdr"); + cfg.audio.tx_enabled = true; + let errors = cfg.validate_sdr(); + assert!( + errors.iter().any(|e| e.contains("tx_enabled")), + "expected error mentioning 'tx_enabled', got: {:?}", + errors + ); + } + + #[test] + fn test_sdr_validate_duplicate_decoder() { + let mut cfg = sdr_config_with_access("driver=rtlsdr"); + add_channel(&mut cfg, "ch1", 0, false, vec!["ft8".to_string()]); + add_channel(&mut cfg, "ch2", 10_000, false, vec!["ft8".to_string()]); + let errors = cfg.validate_sdr(); + assert!( + errors + .iter() + .any(|e| e.contains("ft8") || e.contains("decoder")), + "expected error mentioning 'ft8' or 'decoder', got: {:?}", + errors + ); + } + + #[test] + fn test_sdr_validate_multiple_errors() { + let mut cfg = sdr_config_with_access("placeholder"); + cfg.rig.access.args = None; + cfg.sdr.sample_rate = 0; + cfg.audio.tx_enabled = true; + let errors = cfg.validate_sdr(); + assert_eq!( + errors.len(), + 3, + "expected exactly 3 errors, got: {:?}", + errors + ); + } }