test(percent_b): cover accessors, kill dead arm, fix flaky middle test

Codecov flagged 17 uncovered lines in
crates/wickra-core/src/indicators/percent_b.rs (file at 81.11%):

  - const accessors period (53-55), multiplier (58-60), value (63-65)
  - Indicator-impl bodies warmup_period (90-92) and name (98-100)
  - the unreachable `_ => panic!("warmup mismatch at {i}")` arm in
    matches_bands_definition (line 141)
  - the inner `assert_relative_eq!(*pv, 0.5, …)` (line 158) inside
    price_at_middle_is_half, gated by `(prices[i] - bv.middle).abs()
    < 1e-9` over a sin-based oscillation that, with period=20 over 60
    samples, never lands within 1e-9 of the rolling SMA — so the
    assertion was silently dead and the test made no checks.

Add accessors_and_metadata covering the five getter bodies, refactor
matches_bands_definition to assert the warmup-shape invariant via
assert_eq!(p.is_some(), b.is_some()) + if let (kills the panic arm),
and replace price_at_middle_is_half with a deterministic construction:
PercentB::new(3, 2.0) on [1.0, 5.0, 3.0] gives SMA=3.0 at index 2
which equals the third price exactly, stddev=√(8/3)≈1.633 keeps the
width strictly positive so the divide path runs, and %b lands on
exactly 0.5 because price sits on the centre line of symmetric bands.

percent_b.rs is now at 90/90 lines, no behavioural change.
This commit is contained in:
kingchenc
2026-05-23 23:25:35 +02:00
parent 050e5b74b8
commit 404fd29c31
+40 -21
View File
@@ -113,6 +113,25 @@ mod tests {
assert!(PercentB::new(20, -1.0).is_err());
}
/// Cover the public const accessors `period`, `multiplier`, `value`
/// and the Indicator-impl `warmup_period` + `name` methods. Existing
/// tests only exercise the numeric output of `update` / `batch` /
/// `reset` / `is_ready`, so the five getter bodies (lines 53-65,
/// 90-92, 98-100) were dead.
#[test]
fn accessors_and_metadata() {
let mut pb = PercentB::new(20, 2.0).unwrap();
assert_eq!(pb.period(), 20);
assert_relative_eq!(pb.multiplier(), 2.0, epsilon = 1e-12);
assert_eq!(pb.value(), None);
assert_eq!(pb.warmup_period(), 20);
assert_eq!(pb.name(), "PercentB");
for i in 1..=20 {
pb.update(f64::from(i));
}
assert!(pb.value().is_some());
}
#[test]
fn constant_series_yields_midpoint() {
// Flat prices: bands collapse, price is exactly mid-band -> 0.5.
@@ -132,33 +151,33 @@ mod tests {
let pb_out = PercentB::new(20, 2.0).unwrap().batch(&prices);
let bands_out = BollingerBands::new(20, 2.0).unwrap().batch(&prices);
for (i, (p, b)) in pb_out.iter().zip(bands_out.iter()).enumerate() {
match (p, b) {
(Some(pv), Some(bv)) => {
let want = (prices[i] - bv.lower) / (bv.upper - bv.lower);
assert_relative_eq!(*pv, want, epsilon = 1e-12);
}
(None, None) => {}
_ => panic!("warmup mismatch at {i}"),
// Same warmup — emission shape must agree at every index.
assert_eq!(p.is_some(), b.is_some(), "warmup mismatch at index {i}");
if let (Some(pv), Some(bv)) = (p, b) {
let want = (prices[i] - bv.lower) / (bv.upper - bv.lower);
assert_relative_eq!(*pv, want, epsilon = 1e-12);
}
}
}
/// Deterministic price-at-middle assertion. With period=3, multiplier=2,
/// inputs `[1.0, 5.0, 3.0]` give SMA = (1+5+3)/3 = 3.0 at index 2, which
/// equals the third price exactly. The stddev is √(8/3) ≈ 1.633, so the
/// bands have non-zero width (the width==0 fallback at line 77 is NOT
/// taken) and the divide path at line 79 runs. Because price sits on
/// the centre line of symmetric bands, %b lands on exactly 0.5.
///
/// The previous oscillation-based variant of this test never landed
/// `prices[i]` within 1e-9 of the rolling SMA, so its inner
/// `assert_relative_eq!` line was never executed.
#[test]
fn price_at_middle_is_half() {
// A symmetric oscillation keeps the SMA centred; when price crosses
// the SMA, %b passes through 0.5. Verified via the bands definition.
let prices: Vec<f64> = (1..=60)
.map(|i| 100.0 + (f64::from(i) * 0.5).sin() * 5.0)
.collect();
let pb_out = PercentB::new(20, 2.0).unwrap().batch(&prices);
let bands_out = BollingerBands::new(20, 2.0).unwrap().batch(&prices);
for (i, (p, b)) in pb_out.iter().zip(bands_out.iter()).enumerate() {
if let (Some(pv), Some(bv)) = (p, b) {
if (prices[i] - bv.middle).abs() < 1e-9 {
assert_relative_eq!(*pv, 0.5, epsilon = 1e-6);
}
}
}
let mut pb = PercentB::new(3, 2.0).unwrap();
let out = pb.batch(&[1.0, 5.0, 3.0]);
assert_eq!(out[0], None);
assert_eq!(out[1], None);
let v = out[2].expect("warmed up at index 2");
assert_relative_eq!(v, 0.5, epsilon = 1e-12);
}
#[test]