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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user