use super::{
Detection, MAD_CONSISTENCY, OutlierError, iqr_detect, modified_zscore_detect, zscore_detect,
};
fn score_at(det: &Detection, i: usize) -> f64 {
det.scores().get(i).copied().unwrap_or(f64::NAN)
}
#[test]
fn zscore_matches_scipy() -> Result<(), OutlierError> {
let det = zscore_detect(&[1.0, 2.0, 3.0, 4.0, 100.0], 2.0)?;
let z = score_at(&det, 4);
assert!((z - 1.999_342_861_818_962_6).abs() < 1e-12, "z was {z}");
Ok(())
}
#[test]
fn zscore_flags_extreme_point() -> Result<(), OutlierError> {
let det = zscore_detect(&[1.0, 2.0, 3.0, 4.0, 100.0], 1.5)?;
assert_eq!(
det.mask(),
&[false, false, false, false, true],
"mask {:?}",
det.mask()
);
assert_eq!(det.outlier_count(), 1, "count {}", det.outlier_count());
Ok(())
}
#[test]
fn zscore_rejects_constant_sample() {
assert_eq!(
zscore_detect(&[5.0, 5.0, 5.0], 3.0),
Err(OutlierError::ZeroSpread)
);
}
#[test]
fn iqr_matches_numpy_fences() -> Result<(), OutlierError> {
let det = iqr_detect(&[1.0, 2.0, 3.0, 4.0, 100.0], 1.5)?;
assert_eq!(
det.mask(),
&[false, false, false, false, true],
"mask {:?}",
det.mask()
);
let s = score_at(&det, 4);
assert!((s - 93.0).abs() < 1e-12, "score was {s}");
Ok(())
}
#[test]
fn iqr_inside_points_score_zero() -> Result<(), OutlierError> {
let det = iqr_detect(&[1.0, 2.0, 3.0, 4.0, 100.0], 1.5)?;
for i in 0..4 {
let s = score_at(&det, i);
assert!(s.abs() < 1e-12, "index {i} score {s}");
}
Ok(())
}
#[test]
fn iqr_flags_low_outlier_negative() -> Result<(), OutlierError> {
let det = iqr_detect(&[-100.0, 10.0, 11.0, 12.0, 13.0], 1.5)?;
assert!(det.mask().first().copied().unwrap_or(false), "low flagged");
let s = score_at(&det, 0);
assert!(s < 0.0, "low score should be negative, was {s}");
Ok(())
}
#[test]
fn modified_zscore_uses_mad() -> Result<(), OutlierError> {
let det = modified_zscore_detect(&[1.0, 2.0, 3.0, 4.0, 100.0], 3.5)?;
let m = score_at(&det, 4);
let want = MAD_CONSISTENCY * 97.0;
assert!((m - want).abs() < 1e-12, "modified-z was {m}, want {want}");
assert!(
det.mask().last().copied().unwrap_or(false),
"extreme flagged"
);
Ok(())
}
#[test]
fn modified_zscore_rejects_zero_mad() {
assert_eq!(
modified_zscore_detect(&[1.0, 1.0, 1.0, 1.0, 9.0], 3.5),
Err(OutlierError::ZeroSpread)
);
}
#[test]
fn empty_sample_is_rejected() {
assert_eq!(zscore_detect(&[], 3.0), Err(OutlierError::EmptyInput));
assert_eq!(iqr_detect(&[], 1.5), Err(OutlierError::EmptyInput));
assert_eq!(
modified_zscore_detect(&[], 3.5),
Err(OutlierError::EmptyInput)
);
}
#[test]
fn non_finite_is_rejected() {
let bad = [1.0, f64::NAN, 3.0];
assert_eq!(zscore_detect(&bad, 3.0), Err(OutlierError::NonFinite));
assert_eq!(iqr_detect(&bad, 1.5), Err(OutlierError::NonFinite));
let inf = [1.0, f64::INFINITY, 3.0];
assert_eq!(
modified_zscore_detect(&inf, 3.5),
Err(OutlierError::NonFinite)
);
}
#[test]
fn invalid_threshold_is_rejected() {
let data = [1.0, 2.0, 3.0, 4.0, 100.0];
assert_eq!(
zscore_detect(&data, 0.0),
Err(OutlierError::InvalidThreshold)
);
assert_eq!(iqr_detect(&data, -1.5), Err(OutlierError::InvalidThreshold));
assert_eq!(
modified_zscore_detect(&data, f64::NAN),
Err(OutlierError::InvalidThreshold)
);
}
#[test]
fn scores_and_mask_align_to_input() -> Result<(), OutlierError> {
let data = [3.0, 1.0, 4.0, 1.0, 5.0, 9.0, 2.0, 100.0];
let det = zscore_detect(&data, 3.0)?;
assert_eq!(det.scores().len(), data.len(), "scores length");
assert_eq!(det.mask().len(), data.len(), "mask length");
Ok(())
}