test(bollinger_bandwidth): cover accessors, zero-middle branch, kill dead arm

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.
This commit is contained in:
kingchenc
2026-05-23 23:22:54 +02:00
parent 62fe7a81aa
commit 050e5b74b8
@@ -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);
}
}
}