From 34b8631426635c1634364445c66ae7f8871b0c33 Mon Sep 17 00:00:00 2001 From: uqio <276879906+uqio@users.noreply.github.com> Date: Sat, 18 Apr 2026 12:49:25 +1200 Subject: [PATCH 1/2] add more tests --- .github/workflows/ci.yml | 3 +- cobertura.xml | 1 + soundevents/src/lib.rs | 441 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 444 insertions(+), 1 deletion(-) create mode 100644 cobertura.xml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 334ba8b..ff39d9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,8 @@ on: - '**.md' - '**.txt' workflow_dispatch: - schedule: [cron: "0 1 */30 * *"] + schedule: + - cron: "0 1 1 * *" env: CARGO_TERM_COLOR: always diff --git a/cobertura.xml b/cobertura.xml new file mode 100644 index 0000000..1cd1c1e --- /dev/null +++ b/cobertura.xml @@ -0,0 +1 @@ +/Users/user/Develop/findit-studio/soundevents \ No newline at end of file diff --git a/soundevents/src/lib.rs b/soundevents/src/lib.rs index 39baa14..957a32e 100644 --- a/soundevents/src/lib.rs +++ b/soundevents/src/lib.rs @@ -1124,4 +1124,445 @@ mod tests { assert_eq!(indices, vec![1, 3]); } + + #[test] + fn top_k_from_scores_returns_empty_for_zero_k() { + let predictions = + top_k_from_scores(vec![0.0, 1.0].into_iter().enumerate(), 0, sigmoid).unwrap(); + assert!(predictions.is_empty()); + } + + #[test] + fn options_builder_exposes_all_setters_and_getters() { + let defaults = Options::default(); + assert!(defaults.model_path().is_none()); + assert!(matches!( + defaults.optimization_level(), + GraphOptimizationLevel::Disable + )); + + let with_path = Options::new("some/path.onnx"); + assert_eq!( + with_path + .model_path() + .map(|p| p.to_string_lossy().into_owned()), + Some("some/path.onnx".to_string()) + ); + + let mut mutable = Options::default(); + mutable.set_model_path("another/path.onnx"); + assert_eq!( + mutable + .model_path() + .map(|p| p.to_string_lossy().into_owned()), + Some("another/path.onnx".to_string()) + ); + mutable.clear_model_path(); + assert!(mutable.model_path().is_none()); + + let tuned = Options::default() + .with_model_path("tuned/path.onnx") + .with_optimization_level(GraphOptimizationLevel::Level1); + assert_eq!( + tuned.model_path().map(|p| p.to_string_lossy().into_owned()), + Some("tuned/path.onnx".to_string()) + ); + assert!(matches!( + tuned.optimization_level(), + GraphOptimizationLevel::Level1 + )); + + let mut const_style = Options::default(); + const_style.set_optimization_level(GraphOptimizationLevel::Level2); + assert!(matches!( + const_style.optimization_level(), + GraphOptimizationLevel::Level2 + )); + } + + #[test] + fn chunking_options_builder_covers_window_and_aggregation_setters() { + let tuned = ChunkingOptions::default() + .with_window_samples(32_000) + .with_hop_samples(16_000) + .with_aggregation(ChunkAggregation::Max); + assert_eq!(tuned.window_samples(), 32_000); + assert_eq!(tuned.hop_samples(), 16_000); + assert_eq!(tuned.aggregation(), ChunkAggregation::Max); + } + + #[test] + fn event_prediction_exposes_name_and_id() { + let prediction = EventPrediction::from_confidence(0, 0.25).expect("rated event for class 0"); + assert_eq!(prediction.confidence(), 0.25); + assert_eq!(prediction.index(), 0); + let event = RatedSoundEvent::from_index(0).unwrap(); + assert_eq!(prediction.name(), event.name()); + assert_eq!(prediction.id(), event.id()); + assert_eq!(prediction.event().id(), event.id()); + } + + #[test] + fn event_prediction_rejects_unknown_class_index() { + let err = EventPrediction::from_confidence(NUM_CLASSES + 10, 0.5).unwrap_err(); + assert!(matches!( + err, + ClassifierError::MissingRatedEventIndex { index } if index == NUM_CLASSES + 10 + )); + } + + #[test] + fn ranked_score_equality_checks_both_index_and_score() { + let a = RankedScore { + class_index: 3, + score: 0.5, + }; + let b = RankedScore { + class_index: 3, + score: 0.5, + }; + let c = RankedScore { + class_index: 3, + score: 0.6, + }; + let d = RankedScore { + class_index: 4, + score: 0.5, + }; + assert_eq!(a, b); + assert_ne!(a, c); + assert_ne!(a, d); + assert_eq!(a.partial_cmp(&b), Some(Ordering::Equal)); + } + + #[test] + fn classifier_new_without_path_returns_missing_model_path() { + match Classifier::new(Options::default()) { + Err(ClassifierError::MissingModelPath) => {} + _ => panic!("expected MissingModelPath"), + } + } + + #[test] + fn classifier_from_file_rejects_missing_file() { + match Classifier::from_file("definitely/does/not/exist.onnx") { + Err(ClassifierError::Ort(_)) => {} + _ => panic!("expected Ort error"), + } + } + + #[test] + fn classifier_new_with_custom_optimization_surfaces_ort_error() { + match Classifier::new( + Options::new("definitely/does/not/exist.onnx") + .with_optimization_level(GraphOptimizationLevel::Level3), + ) { + Err(ClassifierError::Ort(_)) => {} + _ => panic!("expected Ort error"), + } + } + + #[test] + fn validate_chunking_rejects_zero_window_hop_or_batch() { + let zero_window = ChunkingOptions::default().with_window_samples(0); + assert!(matches!( + validate_chunking(zero_window), + Err(ClassifierError::InvalidChunkingOptions { + window_samples: 0, + .. + }) + )); + + let zero_hop = ChunkingOptions::default().with_hop_samples(0); + assert!(matches!( + validate_chunking(zero_hop), + Err(ClassifierError::InvalidChunkingOptions { hop_samples: 0, .. }) + )); + + let zero_batch = ChunkingOptions::default().with_batch_size(0); + assert!(matches!( + validate_chunking(zero_batch), + Err(ClassifierError::InvalidChunkingOptions { batch_size: 0, .. }) + )); + } + + #[test] + fn validate_output_flags_empty_scores() { + let shape = ort::value::Shape::new([1i64, NUM_CLASSES as i64]); + assert!(matches!( + validate_output(&shape, &[], 1), + Err(ClassifierError::EmptyOutput) + )); + } + + #[test] + fn validate_output_flags_class_count_mismatch_when_divisible() { + // batch=2, 200 scores evenly splits into 100 per batch, but we expect NUM_CLASSES. + let scores = vec![0.0_f32; 200]; + let shape = ort::value::Shape::new([2i64, 100]); + let err = validate_output(&shape, &scores, 2).unwrap_err(); + assert!(matches!( + err, + ClassifierError::UnexpectedClassCount { + expected, + actual: 100, + } if expected == NUM_CLASSES + )); + } + + #[test] + fn validate_output_flags_unexpected_shape_when_not_divisible() { + // 1053 scores with batch_size=2 → not divisible, triggers shape error path. + let scores = vec![0.0_f32; 1053]; + let shape = ort::value::Shape::new([1i64, 1053]); + let err = validate_output(&shape, &scores, 2).unwrap_err(); + assert!(matches!(err, ClassifierError::UnexpectedOutputShape { .. })); + } + + #[test] + fn validate_output_accepts_single_dim_shape_for_batch_one() { + let scores = vec![0.0_f32; NUM_CLASSES]; + let shape = ort::value::Shape::new([NUM_CLASSES as i64]); + assert!(validate_output(&shape, &scores, 1).is_ok()); + } + + #[test] + fn validate_output_rejects_rank_three_shape() { + let scores = vec![0.0_f32; NUM_CLASSES]; + let shape = ort::value::Shape::new([1i64, 1, NUM_CLASSES as i64]); + let err = validate_output(&shape, &scores, 1).unwrap_err(); + assert!(matches!(err, ClassifierError::UnexpectedOutputShape { .. })); + } + + #[cfg(feature = "bundled-tiny")] + fn tiny_classifier() -> Classifier { + Classifier::tiny(Options::default()).expect("load bundled classifier") + } + + #[cfg(feature = "bundled-tiny")] + #[test] + fn classify_all_matches_classify_all_batch() { + let clip = pseudo_audio(SAMPLE_RATE_HZ, 0x1111_2222); + let mut classifier = tiny_classifier(); + + let single = classifier.classify_all(&clip).expect("classify_all"); + assert_eq!(single.len(), NUM_CLASSES); + + let batched = classifier + .classify_all_batch(&[&clip, &clip]) + .expect("classify_all_batch"); + assert_eq!(batched.len(), 2); + for row in &batched { + assert_eq!(row.len(), NUM_CLASSES); + } + for (expected, actual) in single.iter().zip(batched[0].iter()) { + assert_eq!(expected.index(), actual.index()); + assert!((expected.confidence() - actual.confidence()).abs() < 1e-6); + } + } + + #[cfg(feature = "bundled-tiny")] + #[test] + fn classify_and_classify_batch_agree_on_top_k() { + let clip = pseudo_audio(SAMPLE_RATE_HZ, 0x3333_4444); + let mut classifier = tiny_classifier(); + + let single = classifier.classify(&clip, 3).expect("classify"); + assert_eq!(single.len(), 3); + + let batched = classifier + .classify_batch(&[&clip, &clip], 3) + .expect("classify_batch"); + assert_eq!(batched.len(), 2); + for row in &batched { + assert_eq!(row.len(), 3); + } + for (expected, actual) in single.iter().zip(batched[0].iter()) { + assert_eq!(expected.index(), actual.index()); + assert!((expected.confidence() - actual.confidence()).abs() < 1e-6); + } + } + + #[cfg(feature = "bundled-tiny")] + #[test] + fn classify_rejects_empty_input() { + let mut classifier = tiny_classifier(); + assert!(matches!( + classifier.classify(&[], 3), + Err(ClassifierError::EmptyInput) + )); + } + + #[cfg(feature = "bundled-tiny")] + #[test] + fn classify_with_zero_top_k_returns_empty() { + let clip = pseudo_audio(SAMPLE_RATE_HZ, 0x5555_6666); + let mut classifier = tiny_classifier(); + assert!(classifier.classify(&clip, 0).unwrap().is_empty()); + } + + #[cfg(feature = "bundled-tiny")] + #[test] + fn classify_batch_with_zero_top_k_returns_one_empty_vec_per_clip() { + let clip = pseudo_audio(SAMPLE_RATE_HZ, 0x7777_8888); + let mut classifier = tiny_classifier(); + let result = classifier + .classify_batch(&[&clip, &clip, &clip], 0) + .expect("classify_batch with k=0"); + assert_eq!(result.len(), 3); + assert!(result.iter().all(|row| row.is_empty())); + } + + #[cfg(feature = "bundled-tiny")] + #[test] + fn classify_chunked_with_zero_top_k_returns_empty() { + let clip = pseudo_audio(DEFAULT_CHUNK_SAMPLES + 8_000, 0x9999_aaaa); + let mut classifier = tiny_classifier(); + let result = classifier + .classify_chunked(&clip, 0, ChunkingOptions::default()) + .expect("classify_chunked with k=0"); + assert!(result.is_empty()); + } + + #[cfg(feature = "bundled-tiny")] + #[test] + fn classify_chunked_rejects_empty_input() { + let mut classifier = tiny_classifier(); + assert!(matches!( + classifier.classify_chunked(&[], 3, ChunkingOptions::default()), + Err(ClassifierError::EmptyInput) + )); + } + + #[cfg(feature = "bundled-tiny")] + #[test] + fn classify_chunked_top_k_matches_all_chunked_top_indices() { + let clip = pseudo_audio(DEFAULT_CHUNK_SAMPLES * 2, 0xbbbb_cccc); + let opts = ChunkingOptions::default(); + let mut classifier = tiny_classifier(); + + let all = classifier + .classify_all_chunked(&clip, opts) + .expect("classify_all_chunked"); + let top = classifier + .classify_chunked(&clip, 4, opts) + .expect("classify_chunked top 4"); + + assert_eq!(top.len(), 4); + + let mut ranked = all.clone(); + ranked.sort_by(|a, b| { + b.confidence() + .partial_cmp(&a.confidence()) + .unwrap_or(Ordering::Equal) + }); + let expected_indices: Vec<_> = ranked.iter().take(4).map(|p| p.index()).collect(); + let actual_indices: Vec<_> = top.iter().map(|p| p.index()).collect(); + assert_eq!(actual_indices, expected_indices); + } + + #[cfg(feature = "bundled-tiny")] + #[test] + fn chunked_max_aggregation_matches_per_class_max_of_chunks() { + let clip = pseudo_audio(DEFAULT_CHUNK_SAMPLES * 3, 0xdead_beef); + let mean_opts = ChunkingOptions::default(); + let max_opts = mean_opts.with_aggregation(ChunkAggregation::Max); + + let mut classifier = tiny_classifier(); + let mean = classifier + .classify_all_chunked(&clip, mean_opts) + .expect("mean chunked"); + let max = classifier + .classify_all_chunked(&clip, max_opts) + .expect("max chunked"); + + assert_eq!(mean.len(), NUM_CLASSES); + assert_eq!(max.len(), NUM_CLASSES); + + // Per-class max of the chunks must be >= per-class mean. + for (m, x) in mean.iter().zip(max.iter()) { + assert!(x.confidence() >= m.confidence() - 1e-6); + } + } + + #[cfg(feature = "bundled-tiny")] + #[test] + fn classify_chunked_rejects_invalid_chunking_options() { + let clip = pseudo_audio(SAMPLE_RATE_HZ, 0x1122_3344); + let mut classifier = tiny_classifier(); + let bad_opts = ChunkingOptions::default().with_window_samples(0); + assert!(matches!( + classifier.classify_chunked(&clip, 3, bad_opts), + Err(ClassifierError::InvalidChunkingOptions { .. }) + )); + } + + #[cfg(feature = "bundled-tiny")] + #[test] + fn classifier_new_with_path_loads_model_from_disk() { + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/models/tiny.onnx"); + let mut classifier = Classifier::new(Options::new(path)).expect("load via new()"); + let clip = pseudo_audio(SAMPLE_RATE_HZ, 0x0abc_def0); + let scores = classifier + .predict_raw_scores(&clip) + .expect("predict via disk-loaded classifier"); + assert_eq!(scores.len(), NUM_CLASSES); + } + + #[cfg(feature = "bundled-tiny")] + #[test] + fn classifier_from_file_loads_model_from_disk() { + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/models/tiny.onnx"); + let mut classifier = Classifier::from_file(path).expect("load via from_file"); + let clip = pseudo_audio(SAMPLE_RATE_HZ, 0x0fed_cba9); + let scores = classifier + .predict_raw_scores(&clip) + .expect("predict via from_file classifier"); + assert_eq!(scores.len(), NUM_CLASSES); + } + + #[test] + fn rated_sound_event_exposes_all_accessors() { + let event = RatedSoundEvent::from_index(0).expect("class 0 exists"); + + // Exercise the macro-generated accessors so tarpaulin records them. + let _ = event.encode(); + let _ = event.description(); + let _ = event.aliases(); + let _ = event.citation_uri(); + let _ = event.children(); + let _ = event.restrictions(); + + // Display forwards to the event's name. + assert_eq!(format!("{event}"), event.name()); + } + + #[test] + fn rated_sound_event_try_from_code_round_trips() { + let event = RatedSoundEvent::from_index(0).expect("class 0 exists"); + let resolved: &'static RatedSoundEvent = + <&'static RatedSoundEvent>::try_from(event.encode()).expect("valid code resolves"); + assert_eq!(resolved.id(), event.id()); + + let err = <&'static RatedSoundEvent>::try_from(0u64).expect_err("0u64 is not a real code"); + assert_eq!(err.code(), 0); + } + + #[test] + fn restriction_try_from_accepts_known_tokens_and_reports_unknown() { + use soundevents_dataset::{Restriction, UnknownRestriction}; + + assert_eq!( + Restriction::try_from("abstract").expect("valid"), + Restriction::Abstract + ); + assert_eq!( + Restriction::try_from("BLACKLIST").expect("valid"), + Restriction::Blacklist + ); + + let err: UnknownRestriction<'_> = + Restriction::try_from("bogus").expect_err("unknown token surfaced"); + assert_eq!(err.name(), "bogus"); + } } From 1a747d7c221afa9e92ef2f58d1f624cf02aec677 Mon Sep 17 00:00:00 2001 From: uqio <276879906+uqio@users.noreply.github.com> Date: Sat, 18 Apr 2026 12:49:31 +1200 Subject: [PATCH 2/2] add more tests --- cobertura.xml | 1 - 1 file changed, 1 deletion(-) delete mode 100644 cobertura.xml diff --git a/cobertura.xml b/cobertura.xml deleted file mode 100644 index 1cd1c1e..0000000 --- a/cobertura.xml +++ /dev/null @@ -1 +0,0 @@ -/Users/user/Develop/findit-studio/soundevents \ No newline at end of file