From 050e5b74b816aa75ebf0d9afc6a1a9a9d6303dbe Mon Sep 17 00:00:00 2001 From: kingchenc Date: Sat, 23 May 2026 23:22:54 +0200 Subject: [PATCH] test(bollinger_bandwidth): cover accessors, zero-middle branch, kill dead arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codecov flagged 17 uncovered lines in crates/wickra-core/src/indicators/bollinger_bandwidth.rs (file at 79.51%): - the const accessors period (54-56), multiplier (59-61), value (64-66) - the zero-middle defensive fallback 0.0 (line 77) inside update - the Indicator-impl bodies warmup_period (90-92) and name (98-100) - the unreachable `_ => panic!("warmup mismatch")` arm (line 140) in the existing matches_bands_definition test None of the existing tests inspected the metadata surface — every test fed numeric updates and asserted on bandwidth values, leaving the five getter bodies dead. The zero-middle path was unreachable because all existing tests used positive price levels ≈100, so the rolling SMA was always strictly positive and the divide-by-zero guard never fired. The panic arm in matches_bands_definition was an invariant guard that by design cannot fire when the two streams share a warmup period; that invariant is now asserted directly with assert_eq!(w.is_some(), b.is_some()), and the catch-all arm is gone. Add two new tests and refactor one existing: - accessors_and_metadata asserts period == 20, multiplier == 2.0, value() == None before warmup, warmup_period == 20, name == "BollingerBandwidth", then drives 20 updates so value() also exercises the Some branch. - zero_middle_band_yields_zero_bandwidth feeds [-2, -1, 0, 1, 2] so the 5-bar SMA lands on exactly 0.0 at the fifth input, and asserts the emitted bandwidth is exactly 0.0 (rather than inf/nan from the would-be divide-by-zero). - matches_bands_definition now uses an explicit assert_eq! on is_some() agreement plus an if let for the numeric compare, removing the unreachable panic arm without weakening the invariant check. bollinger_bandwidth.rs is now at 83/83 lines, no behavioural change. --- .../src/indicators/bollinger_bandwidth.rs | 50 ++++++++++++++++--- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/crates/wickra-core/src/indicators/bollinger_bandwidth.rs b/crates/wickra-core/src/indicators/bollinger_bandwidth.rs index 934b86db..d8a6a42b 100644 --- a/crates/wickra-core/src/indicators/bollinger_bandwidth.rs +++ b/crates/wickra-core/src/indicators/bollinger_bandwidth.rs @@ -113,6 +113,27 @@ mod tests { assert!(BollingerBandwidth::new(20, -1.0).is_err()); } + /// Cover the public const accessors `period`, `multiplier`, `value` and + /// the Indicator-impl `warmup_period` + `name` methods. None of the + /// pre-existing tests inspected the metadata surface — they only fed + /// numeric updates and asserted on the bandwidth values, leaving the + /// five getter bodies (lines 54-66, 90-92, 98-100) untouched. + #[test] + fn accessors_and_metadata() { + let mut bbw = BollingerBandwidth::new(20, 2.0).unwrap(); + assert_eq!(bbw.period(), 20); + assert_relative_eq!(bbw.multiplier(), 2.0, epsilon = 1e-12); + // value() before warmup must be the literal None branch of self.last. + assert_eq!(bbw.value(), None); + assert_eq!(bbw.warmup_period(), 20); + assert_eq!(bbw.name(), "BollingerBandwidth"); + // Drive past warmup so value() exercises the Some branch as well. + for i in 1..=20 { + bbw.update(f64::from(i)); + } + assert!(bbw.value().is_some()); + } + #[test] fn constant_series_yields_zero() { // Flat prices: the bands collapse onto the middle, so width is 0. @@ -123,6 +144,23 @@ mod tests { } } + /// Cover the defensive `o.middle == 0.0` branch in `update` (line 77). + /// All other tests use price levels ≈100, so the rolling SMA is always + /// strictly positive and the zero-middle fallback is unreachable. Feed + /// a symmetric series whose 5-bar mean is exactly 0 to force the branch + /// and assert the indicator yields exactly 0.0 (rather than inf/nan). + #[test] + fn zero_middle_band_yields_zero_bandwidth() { + let mut bbw = BollingerBandwidth::new(5, 2.0).unwrap(); + // sum(-2, -1, 0, 1, 2) = 0 exactly in IEEE-754, so the SMA middle + // lands on exactly 0.0 at the fifth input. Stddev > 0, so absent + // the guard the next line would divide by zero. + let out = bbw.batch(&[-2.0, -1.0, 0.0, 1.0, 2.0]); + assert_eq!(out[..4], [None, None, None, None]); + let v = out[4].expect("warmed up"); + assert_eq!(v, 0.0, "zero-middle fallback must emit exactly 0.0"); + } + #[test] fn matches_bands_definition() { // Bandwidth must equal (upper - lower) / middle from BollingerBands. @@ -131,13 +169,11 @@ mod tests { .collect(); let bbw_out = BollingerBandwidth::new(20, 2.0).unwrap().batch(&prices); let bands_out = BollingerBands::new(20, 2.0).unwrap().batch(&prices); - for (w, b) in bbw_out.iter().zip(bands_out.iter()) { - match (w, b) { - (Some(wv), Some(bv)) => { - assert_relative_eq!(*wv, (bv.upper - bv.lower) / bv.middle, epsilon = 1e-12); - } - (None, None) => {} - _ => panic!("warmup mismatch"), + for (i, (w, b)) in bbw_out.iter().zip(bands_out.iter()).enumerate() { + // Same warmup period on both — emission shape must agree at every index. + assert_eq!(w.is_some(), b.is_some(), "warmup mismatch at index {i}"); + if let (Some(wv), Some(bv)) = (w, b) { + assert_relative_eq!(*wv, (bv.upper - bv.lower) / bv.middle, epsilon = 1e-12); } } }