test(family-10): cover cold paths flagged by codecov (#57)

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.
This commit is contained in:
kingchenc
2026-05-25 22:32:00 +02:00
committed by GitHub
parent 7a18a26daf
commit b971e671b4
7 changed files with 70 additions and 12 deletions
@@ -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);
}
}
}
@@ -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);
@@ -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)
@@ -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);
}
}
}
+13
View File
@@ -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();
+14 -3
View File
@@ -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);
}
}
@@ -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());
}
}