truce_test/assertions.rs
1//! Assertion helpers built on top of [`crate::DriverResult`].
2//!
3//! Run a [`crate::PluginDriver`] (typically via the [`crate::driver!`]
4//! macro), then pass the captured result into these helpers for
5//! standard claims:
6//!
7//! - **Whole-run audio shape**: nonzero / silence / no-NaNs / peak
8//! below threshold.
9//! - **Time-windowed audio shape**: silence-after, nonzero-after,
10//! silence-between, nonzero-between (for tail-decay /
11//! gate-between-notes assertions).
12//! - **Meter readings** at end-of-run.
13//! - **Output events** emitted by the plugin.
14//!
15//! ```ignore
16//! use std::time::Duration;
17//! use truce_test::{assertions, driver, InputSource};
18//!
19//! #[test]
20//! fn long_tail_goes_silent() {
21//! let result = driver!(MyReverb)
22//! .duration(Duration::from_secs(3))
23//! .input(InputSource::Constant(0.5))
24//! .run();
25//! assertions::assert_nonzero(&result);
26//! assertions::assert_silence_after(&result, Duration::from_millis(2_500));
27//! }
28//! ```
29
30use std::time::Duration;
31
32use truce_core::cast::sample_count_usize;
33use truce_core::export::PluginExport;
34use truce_driver::{DriverResult, MeterReadings};
35
36const AUDIBLE_THRESHOLD: f32 = 0.001;
37
38fn duration_to_frames<P: PluginExport>(result: &DriverResult<P>, d: Duration) -> usize {
39 let frames = sample_count_usize(d.as_secs_f64() * result.sample_rate);
40 frames.min(result.total_frames)
41}
42
43fn peak_in_range<P: PluginExport>(result: &DriverResult<P>, start: usize, end: usize) -> f32 {
44 if start >= end {
45 return 0.0;
46 }
47 result
48 .output
49 .iter()
50 .flat_map(|ch| {
51 // Bound `start` against the channel too - a channel shorter
52 // than `start` (mismatch between `result.total_frames` and
53 // an individual channel) would otherwise panic on
54 // `ch[start..]`.
55 let s = start.min(ch.len());
56 let e = end.min(ch.len()).max(s);
57 ch[s..e].iter()
58 })
59 .map(|s| s.abs())
60 .fold(0.0f32, f32::max)
61}
62
63// ---------------------------------------------------------------------------
64// Whole-run assertions
65// ---------------------------------------------------------------------------
66
67/// Assert that at least one sample anywhere in the output is above
68/// the audible threshold.
69///
70/// # Panics
71///
72/// Panics if every sample is at or below `AUDIBLE_THRESHOLD` (1e-3).
73//
74// `usize as f64` for sample-count → seconds in the panic message;
75// total_frames is bounded by test duration, well below 2^52.
76#[allow(clippy::cast_precision_loss)]
77pub fn assert_nonzero<P: PluginExport>(result: &DriverResult<P>) {
78 let peak = peak_in_range(result, 0, result.total_frames);
79 assert!(
80 peak > AUDIBLE_THRESHOLD,
81 "Expected non-zero output over the full {:.3} s run, but peak sample was {peak}",
82 result.total_frames as f64 / result.sample_rate
83 );
84}
85
86/// Assert every sample in the output is below the audible threshold.
87///
88/// # Panics
89///
90/// Panics if any sample's absolute value is at or above
91/// `AUDIBLE_THRESHOLD` (1e-3).
92pub fn assert_silence<P: PluginExport>(result: &DriverResult<P>) {
93 let peak = peak_in_range(result, 0, result.total_frames);
94 assert!(
95 peak < AUDIBLE_THRESHOLD,
96 "Expected silence over the full run, but peak sample was {peak}"
97 );
98}
99
100/// Assert no sample is NaN or infinite. If this fails, the DSP went
101/// divergent.
102///
103/// # Panics
104///
105/// Panics on the first non-finite sample, naming the channel,
106/// frame index, and time offset.
107//
108// `usize as f64` for sample-index → milliseconds in the panic
109// message; sample indices are bounded by test duration.
110#[allow(clippy::cast_precision_loss)]
111pub fn assert_no_nans<P: PluginExport>(result: &DriverResult<P>) {
112 let bad = result
113 .output
114 .iter()
115 .enumerate()
116 .flat_map(|(ch, data)| data.iter().enumerate().map(move |(i, &s)| (ch, i, s)))
117 .find(|&(_, _, s)| !s.is_finite());
118 if let Some((ch, i, s)) = bad {
119 panic!(
120 "NaN/Inf at channel {ch} sample {i} (t = {:.3} ms): {s}",
121 (i as f64 / result.sample_rate) * 1000.0
122 );
123 }
124}
125
126/// Assert no sample exceeds `threshold` in absolute value. Typical
127/// use: `assert_peak_below(&result, 1.0)` to catch clipping.
128///
129/// # Panics
130///
131/// Panics if any sample's absolute value exceeds `threshold`.
132pub fn assert_peak_below<P: PluginExport>(result: &DriverResult<P>, threshold: f32) {
133 let peak = peak_in_range(result, 0, result.total_frames);
134 assert!(
135 peak <= threshold,
136 "Peak sample {peak} exceeded threshold {threshold}"
137 );
138}
139
140// ---------------------------------------------------------------------------
141// Time-windowed assertions
142// ---------------------------------------------------------------------------
143
144/// Assert every sample after `t` is below the audible threshold.
145/// Use for reverb / delay tail decay tests.
146///
147/// # Panics
148///
149/// Panics if any sample after `t` has absolute value at or above
150/// `AUDIBLE_THRESHOLD`.
151pub fn assert_silence_after<P: PluginExport>(result: &DriverResult<P>, t: Duration) {
152 let start = duration_to_frames(result, t);
153 let peak = peak_in_range(result, start, result.total_frames);
154 assert!(
155 peak < AUDIBLE_THRESHOLD,
156 "Expected silence after {:.3} ms but peak was {peak} \
157 (tail starts at sample {start}, run ends at sample {})",
158 t.as_secs_f64() * 1000.0,
159 result.total_frames
160 );
161}
162
163/// Assert at least one sample after `t` is above the audible
164/// threshold.
165///
166/// # Panics
167///
168/// Panics if every sample after `t` is at or below
169/// `AUDIBLE_THRESHOLD`.
170pub fn assert_nonzero_after<P: PluginExport>(result: &DriverResult<P>, t: Duration) {
171 let start = duration_to_frames(result, t);
172 let peak = peak_in_range(result, start, result.total_frames);
173 assert!(
174 peak > AUDIBLE_THRESHOLD,
175 "Expected non-zero audio after {:.3} ms but peak was {peak}",
176 t.as_secs_f64() * 1000.0
177 );
178}
179
180/// Assert silence across `[start, end)`. More precise than
181/// `assert_silence_after` when both endpoints matter.
182///
183/// # Panics
184///
185/// Panics if `start >= end`, or if any sample in the half-open
186/// range has absolute value at or above `AUDIBLE_THRESHOLD`.
187pub fn assert_silence_between<P: PluginExport>(
188 result: &DriverResult<P>,
189 start: Duration,
190 end: Duration,
191) {
192 let s = duration_to_frames(result, start);
193 let e = duration_to_frames(result, end);
194 assert!(s < e, "assert_silence_between: start >= end");
195 let peak = peak_in_range(result, s, e);
196 assert!(
197 peak < AUDIBLE_THRESHOLD,
198 "Expected silence in [{:.3} ms, {:.3} ms) but peak was {peak}",
199 start.as_secs_f64() * 1000.0,
200 end.as_secs_f64() * 1000.0
201 );
202}
203
204/// Assert non-zero audio somewhere in `[start, end)`.
205///
206/// # Panics
207///
208/// Panics if `start >= end`, or if every sample in the half-open
209/// range is at or below `AUDIBLE_THRESHOLD`.
210pub fn assert_nonzero_between<P: PluginExport>(
211 result: &DriverResult<P>,
212 start: Duration,
213 end: Duration,
214) {
215 let s = duration_to_frames(result, start);
216 let e = duration_to_frames(result, end);
217 assert!(s < e, "assert_nonzero_between: start >= end");
218 let peak = peak_in_range(result, s, e);
219 assert!(
220 peak > AUDIBLE_THRESHOLD,
221 "Expected non-zero audio in [{:.3} ms, {:.3} ms) but peak was {peak}",
222 start.as_secs_f64() * 1000.0,
223 end.as_secs_f64() * 1000.0
224 );
225}
226
227// ---------------------------------------------------------------------------
228// Meter assertions
229// ---------------------------------------------------------------------------
230
231fn final_meters<P: PluginExport>(result: &DriverResult<P>) -> &[(u32, f32)] {
232 match &result.meters {
233 MeterReadings::Final(v) => v.as_slice(),
234 MeterReadings::PerBlock(blocks) => blocks.last().map_or(&[], std::vec::Vec::as_slice),
235 MeterReadings::None => panic!(
236 "meter assertion called but CaptureSpec::meters was MeterCapture::None - \
237 call .capture_meters(MeterCapture::Final) on the driver"
238 ),
239 }
240}
241
242/// Assert the meter identified by `id` read above `threshold` at
243/// the end of the run.
244///
245/// # Panics
246///
247/// Panics if no meter with `id` is in the result, the meter's
248/// final value is at or below `threshold`, or
249/// `CaptureSpec::meters` was `MeterCapture::None` (call
250/// `.capture_meters(MeterCapture::Final)` on the driver).
251pub fn assert_meter_above<P: PluginExport>(result: &DriverResult<P>, id: u32, threshold: f32) {
252 let meters = final_meters(result);
253 let Some((_, value)) = meters.iter().find(|(mid, _)| *mid == id) else {
254 panic!(
255 "Meter id {id} not found in DriverResult. Available ids: {:?}",
256 meters.iter().map(|(i, _)| i).collect::<Vec<_>>()
257 );
258 };
259 assert!(
260 *value > threshold,
261 "Meter {id} read {value} at end-of-run, expected > {threshold}"
262 );
263}
264
265/// Assert the meter identified by `id` read below `threshold` at
266/// the end of the run.
267///
268/// # Panics
269///
270/// Panics if no meter with `id` is in the result, the meter's
271/// final value is at or above `threshold`, or
272/// `CaptureSpec::meters` was `MeterCapture::None`.
273pub fn assert_meter_below<P: PluginExport>(result: &DriverResult<P>, id: u32, threshold: f32) {
274 let meters = final_meters(result);
275 let Some((_, value)) = meters.iter().find(|(mid, _)| *mid == id) else {
276 panic!(
277 "Meter id {id} not found in DriverResult. Available ids: {:?}",
278 meters.iter().map(|(i, _)| i).collect::<Vec<_>>()
279 );
280 };
281 assert!(
282 *value < threshold,
283 "Meter {id} read {value} at end-of-run, expected < {threshold}"
284 );
285}
286
287// ---------------------------------------------------------------------------
288// Output-event assertions
289// ---------------------------------------------------------------------------
290
291/// Assert exactly `n` output events were emitted by the plugin
292/// across the run. Requires `.capture_output_events(true)` on the
293/// driver.
294///
295/// # Panics
296///
297/// Panics if `result.output_events.len() != n`.
298pub fn assert_output_event_count<P: PluginExport>(result: &DriverResult<P>, n: usize) {
299 assert_eq!(
300 result.output_events.len(),
301 n,
302 "Expected {n} output events, got {} ({:?})",
303 result.output_events.len(),
304 result
305 .output_events
306 .iter()
307 .map(|e| (e.sample_offset, std::mem::discriminant(&e.body)))
308 .collect::<Vec<_>>()
309 );
310}