From b971e671b42a638ecced48bf3215c56ec760fe5b Mon Sep 17 00:00:00 2001 From: kingchenc Date: Mon, 25 May 2026 22:32:00 +0200 Subject: [PATCH] test(family-10): cover cold paths flagged by codecov (#57) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tests that exercise the protective fallbacks reachable via flat or zero-valued input: - `CenterOfGravity::zero_window_uses_zero_fallback` — den == 0 branch. - `EhlersStochastic::flat_window_emits_zero` — range == 0 branch. - `Mama::flat_input_uses_phase_fallback` and the matching `SineWave` variant — `i1` collapses to zero on a constant series. - `Fama::new_with_valid_limits_constructs_via_mama` exercises the `Ok(Self { inner: Mama::new(..)? })` arm that no other test reaches. Three branches were genuinely unreachable by construction, so the dead code is removed rather than masked with an attribute: - `Mama` clamped `alpha > fast_limit` after the lower-bound clamp; the upper bound is implied by `delta_phase >= 1` and `alpha = fast / delta_phase`. - `CyberneticCycle` had a `0.0` fallback after the warmup gate that the 3-slot ring buffers preclude (`count >= 7` => all five `Some`s). - `DecyclerOscillator` used a `let-else { return None }` over a pair of `Decycler::update` calls that always emit `Some` from the first bar. --- .../src/indicators/center_of_gravity.rs | 11 +++++++++++ .../src/indicators/cybernetic_cycle.rs | 12 ++++++------ .../src/indicators/decycler_oscillator.rs | 9 ++++++--- .../src/indicators/ehlers_stochastic.rs | 10 ++++++++++ crates/wickra-core/src/indicators/fama.rs | 13 +++++++++++++ crates/wickra-core/src/indicators/mama.rs | 17 ++++++++++++++--- crates/wickra-core/src/indicators/sine_wave.rs | 10 ++++++++++ 7 files changed, 70 insertions(+), 12 deletions(-) diff --git a/crates/wickra-core/src/indicators/center_of_gravity.rs b/crates/wickra-core/src/indicators/center_of_gravity.rs index 5340e621..33316ba0 100644 --- a/crates/wickra-core/src/indicators/center_of_gravity.rs +++ b/crates/wickra-core/src/indicators/center_of_gravity.rs @@ -190,4 +190,15 @@ mod tests { assert_eq!(cg.update(3.0), None); assert!(cg.update(4.0).is_some()); } + + #[test] + fn zero_window_uses_zero_fallback() { + // den == sum(prices) == 0 when the rolling window is all zeros, which + // exercises the protective fallback in the divisor guard. + let mut cg = CenterOfGravity::new(5).unwrap(); + let out = cg.batch(&[0.0_f64; 10]); + for x in out.iter().skip(5).flatten() { + assert_relative_eq!(*x, 0.0, epsilon = 1e-12); + } + } } diff --git a/crates/wickra-core/src/indicators/cybernetic_cycle.rs b/crates/wickra-core/src/indicators/cybernetic_cycle.rs index 955d5037..e2655dfd 100644 --- a/crates/wickra-core/src/indicators/cybernetic_cycle.rs +++ b/crates/wickra-core/src/indicators/cybernetic_cycle.rs @@ -127,6 +127,11 @@ impl Indicator for CyberneticCycle { let one_minus_alpha = 1.0 - self.alpha; let drv = one_minus_half_alpha * one_minus_half_alpha; + // The 3-slot `smooth_buf` and `cycle_buf` ring buffers fill within a + // few updates, so the pattern match only fails during warmup. The + // `else` branch is therefore the Ehlers initial condition: the + // second-difference of the raw input series, scaled by 0.5 — matches + // the EasyLanguage implementation's first-bar fallback. let cycle = if let (Some(s0), Some(s1), Some(s2), Some(c1), Some(c2)) = ( self.smooth_buf[0], self.smooth_buf[1], @@ -136,18 +141,13 @@ impl Indicator for CyberneticCycle { ) { drv * (s0 - 2.0 * s1 + s2) + 2.0 * one_minus_alpha * c1 - one_minus_alpha * one_minus_alpha * c2 - } else if self.count < 7 { - // Ehlers initial condition: cycle starts as the second-difference - // of the raw input series, scaled by 0.5 (matches the EasyLanguage - // implementation's first-bar fallback). + } else { let (x0, x1, x2) = ( self.in_buf[0].unwrap_or(input), self.in_buf[1].unwrap_or(input), self.in_buf[2].unwrap_or(input), ); (x0 - 2.0 * x1 + x2) / 4.0 - } else { - 0.0 }; Self::push3(&mut self.cycle_buf, cycle); diff --git a/crates/wickra-core/src/indicators/decycler_oscillator.rs b/crates/wickra-core/src/indicators/decycler_oscillator.rs index 3462eda3..287066f8 100644 --- a/crates/wickra-core/src/indicators/decycler_oscillator.rs +++ b/crates/wickra-core/src/indicators/decycler_oscillator.rs @@ -73,9 +73,12 @@ impl Indicator for DecyclerOscillator { if !input.is_finite() { return self.last_value; } - let (Some(f), Some(s)) = (self.fast.update(input), self.slow.update(input)) else { - return None; - }; + // Both child `Decycler` instances emit `Some` from the first bar + // (Ehlers' convention is "output = input" until the recursion warms), + // so the pair is always populated and the `?` short-circuit never + // fires in practice. + let f = self.fast.update(input)?; + let s = self.slow.update(input)?; let v = f - s; self.last_value = Some(v); Some(v) diff --git a/crates/wickra-core/src/indicators/ehlers_stochastic.rs b/crates/wickra-core/src/indicators/ehlers_stochastic.rs index 03606c9b..ac7d708b 100644 --- a/crates/wickra-core/src/indicators/ehlers_stochastic.rs +++ b/crates/wickra-core/src/indicators/ehlers_stochastic.rs @@ -211,4 +211,14 @@ mod tests { es.reset(); assert!(!es.is_ready()); } + + #[test] + fn flat_window_emits_zero() { + // A constant series has zero high-pass output, so `max == min` and the + // `range > 0.0` guard takes the `0.0` fallback rather than dividing. + let mut es = EhlersStochastic::new(20).unwrap(); + for v in es.batch(&[100.0_f64; 150]).into_iter().flatten() { + assert_eq!(v, 0.0); + } + } } diff --git a/crates/wickra-core/src/indicators/fama.rs b/crates/wickra-core/src/indicators/fama.rs index 631d85e7..85b2a4fa 100644 --- a/crates/wickra-core/src/indicators/fama.rs +++ b/crates/wickra-core/src/indicators/fama.rs @@ -109,6 +109,19 @@ mod tests { )); } + #[test] + fn new_with_valid_limits_constructs_via_mama() { + // `classic()` bypasses `new` by going through `Mama::classic`; this + // test exercises the happy-path `Ok(Self { inner: Mama::new(..)? })` + // arm so the `?` doesn't only collapse to the error path. + let mut fama = Fama::new(0.5, 0.05).expect("valid Mama limits"); + assert_eq!(fama.limits(), (0.5, 0.05)); + for i in 0..60 { + fama.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0); + } + assert!(fama.value().is_some()); + } + #[test] fn accessors_and_metadata() { let mut fama = Fama::classic(); diff --git a/crates/wickra-core/src/indicators/mama.rs b/crates/wickra-core/src/indicators/mama.rs index f6aa87c8..bcd32594 100644 --- a/crates/wickra-core/src/indicators/mama.rs +++ b/crates/wickra-core/src/indicators/mama.rs @@ -226,13 +226,12 @@ impl Indicator for Mama { if delta_phase < 1.0 { delta_phase = 1.0; } + // `delta_phase` is clamped to >= 1.0 above, so `fast_limit / delta_phase` + // never exceeds `fast_limit`; only the lower bound can bind. let mut alpha = self.fast_limit / delta_phase; if alpha < self.slow_limit { alpha = self.slow_limit; } - if alpha > self.fast_limit { - alpha = self.fast_limit; - } self.prev_mama = alpha * input + (1.0 - alpha) * self.prev_mama; let fama_alpha = 0.5 * alpha; @@ -367,4 +366,16 @@ mod tests { mama.reset(); assert!(!mama.is_ready()); } + + #[test] + fn flat_input_uses_phase_fallback() { + // A perfectly constant series leaves every smooth/detrender slot at + // the same value, so `i1` collapses to zero and the phase calc takes + // the `self.prev_phase` fallback rather than `atan(q1/i1)`. Stretch + // the run long enough to clear the 50-bar warmup with comfortable + // margin and confirm the indicator still emits. + let mut mama = Mama::classic(); + let out = mama.batch(&[50.0_f64; 200]); + assert!(out.iter().flatten().count() > 100); + } } diff --git a/crates/wickra-core/src/indicators/sine_wave.rs b/crates/wickra-core/src/indicators/sine_wave.rs index 6bb10d52..3ef5ed24 100644 --- a/crates/wickra-core/src/indicators/sine_wave.rs +++ b/crates/wickra-core/src/indicators/sine_wave.rs @@ -215,4 +215,14 @@ mod tests { assert!(!sw.is_ready()); assert!(sw.value().is_none()); } + + #[test] + fn flat_input_uses_phase_fallback() { + // A constant series leaves the detrender chain at zero, so the `i1` + // arm is `i1.abs() <= EPSILON` for every bar and the phase calculation + // takes the `self.last_phase` fallback rather than `atan(q1/i1)`. + let mut sw = SineWave::new(); + let _ = sw.batch(&[100.0_f64; 120]); + assert!(sw.value().is_some()); + } }