truce_test/lib.rs
1//! Test utilities for truce plugins.
2//!
3//! Two layers:
4//!
5//! - **Audio runs** - built on top of [`truce_driver::PluginDriver`].
6//! Re-exported here so plugin tests have one crate to depend on.
7//! Use the [`driver!`] macro for ergonomic builder construction
8//! (it wires `manifest_dir` from the calling crate's
9//! `CARGO_MANIFEST_DIR`, so `state_file` paths resolve correctly).
10//! Assertions live in [`assertions`].
11//! - **Static plugin checks** - `assert_state_round_trip`,
12//! `assert_has_editor`, AU `FourCC`, bus config, param defaults, GUI
13//! lifecycle, etc. These don't render audio, just instantiate the
14//! plugin and inspect.
15//!
16//! # Usage
17//!
18//! Add to your plugin crate's `[dev-dependencies]`:
19//! ```toml
20//! [dev-dependencies]
21//! truce-test = { workspace = true }
22//! ```
23//!
24//! ```ignore
25//! use truce_test::{assertions, driver, InputSource};
26//! use std::time::Duration;
27//!
28//! #[test]
29//! fn passthrough() {
30//! let result = driver!(MyPlugin)
31//! .duration(Duration::from_millis(100))
32//! .input(InputSource::Constant(0.5))
33//! .run();
34//! assertions::assert_nonzero(&result);
35//! assertions::assert_no_nans(&result);
36//! assertions::assert_peak_below(&result, 1.0);
37//! }
38//! ```
39
40use truce_core::export::PluginExport;
41use truce_core::state;
42use truce_params::Params;
43
44// ---------------------------------------------------------------------------
45// Driver re-exports + ergonomic macro
46// ---------------------------------------------------------------------------
47
48pub use truce_driver::{
49 CaptureSpec, DriverResult, InputSource, MeterCapture, MeterReadings, PluginDriver, Script,
50 SetupContext, TransportSpec,
51};
52
53pub mod assertions;
54
55/// Re-export of [`truce_core::editor::for_test_params`]
56/// for plugin authors who want to drive snapshot tests directly
57/// without the `assert_screenshot!` macro.
58pub use truce_core::editor::for_test_params;
59
60/// Construct a [`PluginDriver`] for the given plugin type, with
61/// `manifest_dir` wired to the calling crate's `CARGO_MANIFEST_DIR`.
62/// That lets `.state_file("test_states/foo.pluginstate")` resolve
63/// against the crate's own directory regardless of where `cargo
64/// test` was launched.
65///
66/// ```ignore
67/// truce_test::driver!(MyPlugin)
68/// .duration(Duration::from_millis(100))
69/// .state_file("test_states/preset.pluginstate")
70/// .run();
71/// ```
72#[macro_export]
73macro_rules! driver {
74 ($plugin:ty $(,)?) => {
75 $crate::PluginDriver::<$plugin>::new().manifest_dir(env!("CARGO_MANIFEST_DIR"))
76 };
77}
78
79// ---------------------------------------------------------------------------
80// Static plugin checks (no audio render)
81// ---------------------------------------------------------------------------
82
83/// Assert state save/load round-trips correctly.
84///
85/// Saves state, creates a new instance, loads state, and verifies
86/// all parameter values match.
87///
88/// # Panics
89///
90/// Panics if `restore_plugin` fails, any parameter id is missing
91/// after restore (renamed / renumbered between save and load), or
92/// any restored value differs from the source by more than `1e-4`.
93pub fn assert_state_round_trip<P: PluginExport>() {
94 let plugin = P::create();
95 let blob = state::snapshot_plugin(&plugin);
96
97 let mut plugin2 = P::create();
98 state::restore_plugin(&mut plugin2, &blob).expect("restore_plugin failed");
99
100 let param_infos = plugin.params().param_infos();
101 for pi in ¶m_infos {
102 // `get_plain` returns `None` if the param id was dropped during
103 // round-trip - for example, a plugin update that renumbered
104 // params. We surface that as the assertion failure rather than
105 // an `.unwrap()` panic that would point at the wrong line.
106 let v1 = plugin.params().get_plain(pi.id).unwrap_or_else(|| {
107 panic!(
108 "param {} ({}) missing from source plugin after restore_plugin - \
109 the param id is no longer registered",
110 pi.id, pi.name
111 )
112 });
113 let v2 = plugin2.params().get_plain(pi.id).unwrap_or_else(|| {
114 panic!(
115 "param {} ({}) was lost during state round-trip - \
116 saved-state blob references an id that the freshly-built plugin \
117 doesn't expose. Either the param was renamed/renumbered or \
118 the deserializer is dropping it.",
119 pi.id, pi.name
120 )
121 });
122 assert!(
123 (v1 - v2).abs() < 0.0001,
124 "Param {} ({}) mismatch: {v1} vs {v2}",
125 pi.id,
126 pi.name
127 );
128 }
129}
130
131/// Assert the plugin has a working editor with valid dimensions.
132///
133/// # Panics
134///
135/// Panics if `Plugin::editor()` returns `None` or the editor's
136/// reported size has a zero dimension.
137pub fn assert_has_editor<P: PluginExport>() {
138 let mut plugin = P::create();
139 let editor = plugin.editor();
140 assert!(editor.is_some(), "Plugin::editor() returned None");
141 let editor = editor.unwrap();
142 let (w, h) = editor.size();
143 assert!(w > 0 && h > 0, "Editor size is zero: {w}x{h}");
144}
145
146/// Assert `plugin_info`!() returns valid metadata.
147///
148/// # Panics
149///
150/// Panics if any string field is empty or any `FourCC` code is all
151/// zeros.
152pub fn assert_valid_info<P: PluginExport>() {
153 let info = P::info();
154 assert!(!info.name.is_empty(), "Plugin name is empty");
155 assert!(!info.vendor.is_empty(), "Vendor is empty");
156 assert!(!info.version.is_empty(), "Version is empty");
157 assert!(!info.clap_id.is_empty(), "CLAP ID is empty");
158 assert!(!info.vst3_id.is_empty(), "VST3 ID is empty");
159 assert!(info.au_type != [0; 4], "AU type is zero");
160 assert!(info.fourcc != [0; 4], "FourCC is zero");
161 assert!(info.au_manufacturer != [0; 4], "AU manufacturer is zero");
162}
163
164// ---------------------------------------------------------------------------
165// AU metadata tests
166// ---------------------------------------------------------------------------
167
168/// Assert AU type codes are valid 4-char ASCII.
169///
170/// Catches the `FourCharCode` endianness bug (big-endian on ARM64).
171///
172/// # Panics
173///
174/// Panics if any byte of `au_type`, `fourcc`, or `au_manufacturer`
175/// isn't a printable ASCII glyph.
176pub fn assert_au_type_codes_ascii<P: PluginExport>() {
177 let info = P::info();
178 for (label, code) in [
179 ("au_type", info.au_type),
180 ("fourcc", info.fourcc),
181 ("au_manufacturer", info.au_manufacturer),
182 ] {
183 for (i, &byte) in code.iter().enumerate() {
184 assert!(
185 byte.is_ascii_graphic(),
186 "{label}[{i}] is not printable ASCII: 0x{byte:02x} (full: {:?})",
187 std::str::from_utf8(&code).unwrap_or("??")
188 );
189 }
190 }
191}
192
193/// Assert AU `FourCharCode` round-trips through big-endian u32.
194///
195/// This is the encoding used by `AudioComponentDescription` on macOS.
196///
197/// # Panics
198///
199/// Panics if the big-endian pack/unpack of any `FourCharCode`
200/// doesn't reproduce the original byte sequence.
201pub fn assert_fourcc_roundtrip<P: PluginExport>() {
202 let info = P::info();
203 for (label, code) in [
204 ("au_type", info.au_type),
205 ("fourcc", info.fourcc),
206 ("au_manufacturer", info.au_manufacturer),
207 ] {
208 let packed = (u32::from(code[0]) << 24)
209 | (u32::from(code[1]) << 16)
210 | (u32::from(code[2]) << 8)
211 | u32::from(code[3]);
212 // Bit-extraction: each byte is a deliberate truncation of the
213 // packed `u32` into one of its four bytes.
214 #[allow(clippy::cast_possible_truncation)]
215 let unpacked = [
216 (packed >> 24) as u8,
217 (packed >> 16) as u8,
218 (packed >> 8) as u8,
219 packed as u8,
220 ];
221 assert_eq!(code, unpacked, "{label} FourCharCode round-trip failed");
222 }
223}
224
225/// Assert bus config is correct for an effect (has inputs and outputs).
226///
227/// # Panics
228///
229/// Panics if no bus layouts are defined, or the first layout
230/// reports zero input or output channels.
231pub fn assert_bus_config_effect<P: PluginExport>() {
232 let layouts = P::bus_layouts();
233 assert!(!layouts.is_empty(), "No bus layouts defined");
234 let layout = &layouts[0];
235 let inputs = layout.total_input_channels();
236 let outputs = layout.total_output_channels();
237 assert!(
238 inputs > 0,
239 "Effect should have input channels, got {inputs}"
240 );
241 assert!(
242 outputs > 0,
243 "Effect should have output channels, got {outputs}"
244 );
245}
246
247/// Assert bus config is correct for an instrument (no inputs, has outputs).
248///
249/// Catches the `GarageBand` `SupportedNumChannels` bug - instruments must
250/// report 0 input channels for AU hosts to show them.
251///
252/// # Panics
253///
254/// Panics if no bus layouts are defined, the first layout reports
255/// any input channels, or it reports zero output channels.
256pub fn assert_bus_config_instrument<P: PluginExport>() {
257 let layouts = P::bus_layouts();
258 assert!(!layouts.is_empty(), "No bus layouts defined");
259 let layout = &layouts[0];
260 let inputs = layout.total_input_channels();
261 let outputs = layout.total_output_channels();
262 assert_eq!(
263 inputs, 0,
264 "Instrument should have 0 input channels, got {inputs}"
265 );
266 assert!(
267 outputs > 0,
268 "Instrument should have output channels, got {outputs}"
269 );
270}
271
272// ---------------------------------------------------------------------------
273// GUI lifecycle tests
274// ---------------------------------------------------------------------------
275
276/// Assert editor can be created multiple times without issues.
277///
278/// Catches lifecycle bugs where create/drop leaves state dirty.
279///
280/// # Panics
281///
282/// Panics if `editor()` returns `None` on first or second creation,
283/// the first editor reports a zero dimension, or the size differs
284/// between consecutive `editor()` calls.
285pub fn assert_editor_lifecycle<P: PluginExport>() {
286 let mut plugin = P::create();
287
288 // First creation
289 let editor1 = plugin.editor();
290 assert!(editor1.is_some(), "First editor() returned None");
291 let (w1, h1) = editor1.as_ref().unwrap().size();
292 assert!(w1 > 0 && h1 > 0, "First editor size is zero: {w1}x{h1}");
293 drop(editor1);
294
295 // Second creation after drop
296 let editor2 = plugin.editor();
297 assert!(
298 editor2.is_some(),
299 "Second editor() returned None after drop"
300 );
301 let (w2, h2) = editor2.as_ref().unwrap().size();
302 assert_eq!(
303 (w1, h1),
304 (w2, h2),
305 "Editor size changed between creates: ({w1},{h1}) vs ({w2},{h2})"
306 );
307}
308
309/// Assert editor size is consistent across multiple calls.
310///
311/// # Panics
312///
313/// Panics if `editor()` returns `None` or the reported size differs
314/// across three back-to-back `size()` calls.
315pub fn assert_editor_size_consistent<P: PluginExport>() {
316 let mut plugin = P::create();
317 let editor = plugin.editor();
318 assert!(editor.is_some(), "editor() returned None");
319 let editor = editor.unwrap();
320 let (w1, h1) = editor.size();
321 let (w2, h2) = editor.size();
322 let (w3, h3) = editor.size();
323 assert_eq!((w1, h1), (w2, h2), "Editor size inconsistent: call 1 vs 2");
324 assert_eq!((w2, h2), (w3, h3), "Editor size inconsistent: call 2 vs 3");
325}
326
327// ---------------------------------------------------------------------------
328// Parameter tests
329// ---------------------------------------------------------------------------
330
331/// Assert all parameter default values match their declared defaults.
332///
333/// # Panics
334///
335/// Panics if `get_plain` returns `None` for an id that has a
336/// `ParamInfo` entry (derive-macro inconsistency), or if the current
337/// plain value differs from `default_plain` by more than `1e-4`.
338pub fn assert_param_defaults_match<P: PluginExport>() {
339 let plugin = P::create();
340 let infos = plugin.params().param_infos();
341 for pi in &infos {
342 let current = plugin.params().get_plain(pi.id).unwrap_or_else(|| {
343 panic!(
344 "param {} ({}) has a ParamInfo entry but get_plain returned None - \
345 derive macro inconsistency",
346 pi.id, pi.name
347 )
348 });
349 assert!(
350 (current - pi.default_plain).abs() < 0.0001,
351 "Param {} ({}) default mismatch: declared={}, actual={}",
352 pi.id,
353 pi.name,
354 pi.default_plain,
355 current
356 );
357 }
358}
359
360/// Assert normalized param values are clamped to [0, 1].
361///
362/// `set_plain` stores raw atomics (no clamping) but normalized
363/// values should always round-trip within [0, 1].
364///
365/// # Panics
366///
367/// Panics if `get_normalized` returns `None` for an id that has a
368/// `ParamInfo` entry, or if the read-back value escapes
369/// `[-1e-4, 1+1e-4]` after writing 2.0 / -1.0.
370pub fn assert_param_normalized_clamped<P: PluginExport>() {
371 let plugin = P::create();
372 let infos = plugin.params().param_infos();
373 for pi in &infos {
374 // Set above 1.0
375 plugin.params().set_normalized(pi.id, 2.0);
376 let val = plugin.params().get_normalized(pi.id).unwrap_or_else(|| {
377 panic!(
378 "param {} ({}) get_normalized returned None despite ParamInfo \
379 entry - derive macro inconsistency",
380 pi.id, pi.name
381 )
382 });
383 assert!(
384 val <= 1.0001,
385 "Param {} ({}) normalized not clamped above 1.0: set 2.0, got {}",
386 pi.id,
387 pi.name,
388 val
389 );
390
391 // Set below 0.0
392 plugin.params().set_normalized(pi.id, -1.0);
393 let val = plugin.params().get_normalized(pi.id).unwrap_or_else(|| {
394 panic!(
395 "param {} ({}) get_normalized returned None despite ParamInfo \
396 entry - derive macro inconsistency",
397 pi.id, pi.name
398 )
399 });
400 assert!(
401 val >= -0.0001,
402 "Param {} ({}) normalized not clamped below 0.0: set -1.0, got {}",
403 pi.id,
404 pi.name,
405 val
406 );
407
408 // Restore default
409 plugin.params().set_plain(pi.id, pi.default_plain);
410 }
411}
412
413/// Assert `set_normalized` → `get_normalized` round-trips for all params.
414///
415/// For discrete/bool/enum params, only tests boundary values (0.0, 1.0)
416/// since intermediate values snap to the nearest discrete step.
417///
418/// # Panics
419///
420/// Panics if `get_normalized` returns `None` for an id with a
421/// `ParamInfo` entry, or if the round-trip error exceeds the
422/// per-param tolerance (half a step for discrete params, `1e-6` for
423/// continuous).
424pub fn assert_param_normalized_roundtrip<P: PluginExport>() {
425 let plugin = P::create();
426 let infos = plugin.params().param_infos();
427 for pi in &infos {
428 let (test_values, tolerance) = if let Some(steps) = pi.range.step_count() {
429 // Discrete param: test exact step positions. Tolerance
430 // sized for one-step quantization (half a step).
431 let steps = steps.get();
432 let v: Vec<f64> = (0..=steps)
433 .map(|i| f64::from(i) / f64::from(steps))
434 .collect();
435 (v, (0.5 / f64::from(steps)).max(1e-6))
436 } else {
437 // Continuous param: tighter tolerance - round-trip should
438 // be exact modulo `clamp(0, 1)` and float rounding.
439 (vec![0.0, 0.25, 0.5, 0.75, 1.0], 1e-6)
440 };
441 for &norm in &test_values {
442 plugin.params().set_normalized(pi.id, norm);
443 let got = plugin.params().get_normalized(pi.id).unwrap_or_else(|| {
444 panic!(
445 "param {} ({}) get_normalized returned None despite ParamInfo \
446 entry - derive macro inconsistency",
447 pi.id, pi.name
448 )
449 });
450 assert!(
451 (got - norm).abs() <= tolerance,
452 "Param {} ({}) normalized round-trip: set {norm}, got {got} (tol {tolerance})",
453 pi.id,
454 pi.name
455 );
456 }
457 // Restore default
458 plugin.params().set_plain(pi.id, pi.default_plain);
459 }
460}
461
462/// Assert param count matches `param_infos` length.
463///
464/// # Panics
465///
466/// Panics if `count()` disagrees with `param_infos().len()`.
467pub fn assert_param_count_matches<P: PluginExport>() {
468 let plugin = P::create();
469 let count = plugin.params().count();
470 let infos = plugin.params().param_infos();
471 assert_eq!(
472 count,
473 infos.len(),
474 "param count() = {count}, but param_infos().len() = {}",
475 infos.len()
476 );
477}
478
479/// Assert all parameter IDs are unique.
480///
481/// # Panics
482///
483/// Panics on the first duplicate `id` encountered while iterating
484/// `param_infos`.
485pub fn assert_no_duplicate_param_ids<P: PluginExport>() {
486 let plugin = P::create();
487 let infos = plugin.params().param_infos();
488 let mut seen = std::collections::HashSet::new();
489 for pi in &infos {
490 assert!(
491 seen.insert(pi.id),
492 "Duplicate parameter ID {}: {} (already used by another param)",
493 pi.id,
494 pi.name
495 );
496 }
497}
498
499// ---------------------------------------------------------------------------
500// State resilience tests
501// ---------------------------------------------------------------------------
502
503/// Assert corrupt state data doesn't crash.
504///
505/// Each blob in the corpus must either deserialize cleanly OR return
506/// `None` - and `restore_values` on a successful parse must not panic.
507/// The previous form passed trivially when `deserialize_state` returned
508/// `None` for everything (which would happen if the implementation
509/// regressed to "always reject"), so we now also exercise at least one
510/// valid blob to prove the code path under test is reachable.
511///
512/// # Panics
513///
514/// Panics if `deserialize_state` rejects a blob produced by
515/// `snapshot_plugin` (sanity check - without this the test passes
516/// trivially when `deserialize_state` is hard-broken), or if any of
517/// the corruption probes (`deserialize_state` / `restore_values`)
518/// itself panics.
519pub fn assert_corrupt_state_no_crash<P: PluginExport>() {
520 let info = P::info();
521 let hash = state::hash_plugin_id(info.clap_id);
522
523 let garbage: Vec<Vec<u8>> = vec![
524 vec![0xFF; 64], // random bytes
525 b"OAST".to_vec(), // valid magic, truncated
526 vec![0; 4096], // all zeros
527 vec![0xFF, 0xFE, 0xFD, 0xFC, 0xFB], // short garbage
528 ];
529
530 let plugin = P::create();
531 for blob in &garbage {
532 let result = state::deserialize_state(blob, hash);
533 // Should return None (not panic)
534 if let Some(d) = result {
535 // Even if it parses, loading shouldn't crash
536 plugin.params().restore_values(&d.params);
537 }
538 }
539
540 // Sanity check: a freshly-snapshotted state for *this* plugin must
541 // round-trip. Without this, the loop above would silently pass
542 // even if `deserialize_state` was hard-broken (always-`None`).
543 let mut snapshot_plugin = P::create();
544 snapshot_plugin.init();
545 let blob = state::snapshot_plugin(&snapshot_plugin);
546 assert!(
547 state::deserialize_state(&blob, hash).is_some(),
548 "deserialize_state rejected a blob produced by snapshot_plugin - \
549 the corruption test would pass trivially under this regression"
550 );
551}
552
553/// Assert empty state data doesn't crash.
554///
555/// # Panics
556///
557/// Panics if `deserialize_state` returns `Some` for a zero-byte or
558/// single-byte input (both must be rejected).
559pub fn assert_empty_state_no_crash<P: PluginExport>() {
560 let info = P::info();
561 let hash = state::hash_plugin_id(info.clap_id);
562
563 let result = state::deserialize_state(&[], hash);
564 assert!(result.is_none(), "Empty state should return None");
565
566 let result = state::deserialize_state(&[0], hash);
567 assert!(result.is_none(), "Single-byte state should return None");
568}
569
570// ---------------------------------------------------------------------------
571// GUI screenshot tests
572// ---------------------------------------------------------------------------
573
574// Render + save are in `truce-core` so non-test contexts (like
575// `cargo truce` tooling) can invoke them without pulling in dev-deps.
576pub use truce_core::screenshot::save_png;
577
578// ---------------------------------------------------------------------------
579// ScreenshotTest builder
580// ---------------------------------------------------------------------------
581
582use std::path::PathBuf;
583
584/// Boxed closure handed to [`ScreenshotTest::setup`]. Aliased so the
585/// `setup` field type stays readable instead of tripping clippy's
586/// `type_complexity` lint.
587type SetupFn<P> = Box<dyn FnOnce(&mut P)>;
588
589/// Builder for a screenshot regression test.
590///
591/// Construct via the [`screenshot!`] macro:
592/// `screenshot!(Plugin, "screenshots/main.png")`. The path is the
593/// committed reference PNG location - relative to the calling
594/// crate's `Cargo.toml` directory, or absolute. There's no implicit
595/// directory and no auto-derived filename; every test names its
596/// own reference.
597///
598/// Lifecycle: `P::create()` -> `init()` -> optional `state_file` load
599/// -> optional `set_param` shortcuts -> optional `setup` closure ->
600/// render. Matches [`PluginDriver`]'s ordering so the same builder
601/// vocabulary works for both audio and GUI tests.
602///
603/// # Examples
604///
605/// ```ignore
606/// #[test]
607/// fn screenshot() {
608/// truce_test::screenshot!(Plugin, "screenshots/default.png").run();
609/// }
610///
611/// // State-dependent: tweak params before rendering.
612/// #[test]
613/// fn screenshot_max_gain() {
614/// truce_test::screenshot!(Plugin, "screenshots/max_gain.png")
615/// .set_param(MyParamId::Gain, 1.0)
616/// .run();
617/// }
618///
619/// // Pre-saved state from the standalone host's Cmd+S.
620/// #[test]
621/// fn screenshot_evening() {
622/// truce_test::screenshot!(Plugin, "screenshots/evening.png")
623/// .state_file("test_states/evening.pluginstate")
624/// .run();
625/// }
626/// ```
627pub struct ScreenshotTest<P: PluginExport> {
628 /// Reference PNG path, resolved at `new`-time. Absolute, or
629 /// joined to `CARGO_MANIFEST_DIR` if the caller passed a
630 /// relative path.
631 ref_path: PathBuf,
632 /// Manifest dir of the calling crate. Used to resolve the
633 /// `state_file` path; not used after `ref_path` is built.
634 manifest_dir: PathBuf,
635 /// Max allowed differing-pixel count. `0` = strict.
636 tolerance: usize,
637 /// Per-pixel "different enough to count" threshold: a pixel only
638 /// adds to `tolerance` if any RGBA channel differs from the
639 /// reference by more than this. `0` = strict (any byte
640 /// difference counts).
641 pixel_threshold: u8,
642 /// `.pluginstate` bytes loaded after init, before `set_param`
643 /// shortcuts and `setup` closure.
644 state_bytes: Option<Vec<u8>>,
645 /// `.set_param(id, v)` shortcuts - applied after state load,
646 /// before the `setup` closure.
647 param_overrides: Vec<(u32, f64)>,
648 /// Optional plugin mutation between `P::create()` and render.
649 setup: Option<SetupFn<P>>,
650 /// Render scale override. `None` uses
651 /// [`truce_core::screenshot::DEFAULT_SCREENSHOT_SCALE`] so a
652 /// test PNG baked on one host renders at identical dimensions on
653 /// another.
654 scale: Option<f64>,
655}
656
657impl<P: PluginExport> ScreenshotTest<P> {
658 /// Internal constructor used by [`screenshot!`]. Plugin authors
659 /// should not call this directly - the macro fills
660 /// `manifest_dir` from the calling crate's compile-time
661 /// `CARGO_MANIFEST_DIR`.
662 #[doc(hidden)]
663 pub fn __new(manifest_dir: &str, ref_path: impl Into<PathBuf>) -> Self {
664 let manifest_dir = PathBuf::from(manifest_dir);
665 let raw = ref_path.into();
666 let ref_path = if raw.is_absolute() {
667 raw
668 } else {
669 manifest_dir.join(raw)
670 };
671 Self {
672 ref_path,
673 manifest_dir,
674 tolerance: 0,
675 pixel_threshold: 0,
676 state_bytes: None,
677 param_overrides: Vec::new(),
678 setup: None,
679 scale: None,
680 }
681 }
682
683 /// Mutate the plugin between `P::create()` / `init()` and the
684 /// render. Use this to set custom (non-param) state, drive a
685 /// `process()` block to populate meters, etc.
686 ///
687 /// Composes with [`Self::state_file`] (state loads first) and
688 /// [`Self::set_param`] (shortcuts apply first); the closure runs
689 /// last.
690 #[must_use]
691 pub fn setup<F: FnOnce(&mut P) + 'static>(mut self, f: F) -> Self {
692 self.setup = Some(Box::new(f));
693 self
694 }
695
696 /// Set a parameter to a normalized [0, 1] value before the
697 /// render. Equivalent to a `setup(|p| p.params().set_normalized(id, v))`
698 /// closure but written as one builder call. Multiple `.set_param`
699 /// calls compose; they apply after `.state_file` (if any) and
700 /// before `.setup`.
701 #[must_use]
702 pub fn set_param(mut self, id: impl Into<u32>, normalized: f64) -> Self {
703 self.param_overrides.push((id.into(), normalized));
704 self
705 }
706
707 /// Read a `.pluginstate` file (the standalone host's `Cmd+S`
708 /// save format) and apply it via `plugin.load_state(&bytes)`
709 /// after init and before any `set_param` overrides / `setup`
710 /// closure. Path is resolved relative to the crate's manifest
711 /// dir, or used as-is if absolute.
712 ///
713 /// # Panics
714 ///
715 /// Panics if the file cannot be read (missing path, permission
716 /// error, etc.) - the test failure points at the resolved path so
717 /// it's easy to fix the call site.
718 #[must_use]
719 pub fn state_file<S: Into<PathBuf>>(mut self, path: S) -> Self {
720 let raw = path.into();
721 let resolved = if raw.is_absolute() {
722 raw
723 } else {
724 self.manifest_dir.join(&raw)
725 };
726 let bytes = std::fs::read(&resolved)
727 .unwrap_or_else(|e| panic!("state_file: failed to read {}: {e}", resolved.display()));
728 self.state_bytes = Some(bytes);
729 self
730 }
731
732 /// Max allowed differing-pixel count. `0` is strict equality;
733 /// bump for cross-machine antialiasing tolerance.
734 ///
735 /// Composes with [`Self::pixel_threshold`]: a pixel only counts
736 /// toward this budget if its max channel delta exceeds the
737 /// threshold, so sub-perceptual AA wobble doesn't have to inflate
738 /// `tolerance` to numbers that would also hide real regressions.
739 #[must_use]
740 pub fn tolerance(mut self, t: usize) -> Self {
741 self.tolerance = t;
742 self
743 }
744
745 /// Per-pixel "different enough to count" threshold. A pixel
746 /// only adds to the [`Self::tolerance`] budget if at least one
747 /// of its R/G/B/A channels differs from the reference by more
748 /// than this. `0` = strict (any byte difference counts).
749 ///
750 /// Practical values: `1`–`3` ignore tiny rasterizer / filter
751 /// drift between machines without masking real visual changes;
752 /// `8`+ starts to hide things a human would notice.
753 #[must_use]
754 pub fn pixel_threshold(mut self, d: u8) -> Self {
755 self.pixel_threshold = d;
756 self
757 }
758
759 /// Override the render scale used for the screenshot. Without
760 /// this, [`truce_core::screenshot::DEFAULT_SCREENSHOT_SCALE`] is
761 /// used so the reference PNG renders at the same physical
762 /// dimensions on every host. Set this when you specifically want
763 /// to bake a 1× / 3× / fractional reference; the same value must
764 /// be passed to `cargo truce screenshot --scale` when (re)generating
765 /// the baseline.
766 #[must_use]
767 pub fn scale(mut self, scale: f64) -> Self {
768 self.scale = Some(scale);
769 self
770 }
771
772 /// Build the plugin (with `state_file`/`set_param`/`setup`
773 /// applied if present, in that order), render, and compare
774 /// against the reference at the supplied path:
775 ///
776 /// - No reference → panic, pointing at
777 /// `cargo truce screenshot --out <ref_path>` to create one.
778 /// - Match within tolerance → pass silently.
779 /// - Mismatch → panic with both PNG paths and the `cp` command
780 /// to accept the new render as the baseline.
781 pub fn run(self) {
782 let ref_path = self.ref_path;
783 let tolerance = self.tolerance;
784 let pixel_threshold = self.pixel_threshold;
785 let state_bytes = self.state_bytes;
786 let param_overrides = self.param_overrides;
787 let setup = self.setup;
788 let scale = self
789 .scale
790 .unwrap_or(truce_core::screenshot::DEFAULT_SCREENSHOT_SCALE);
791
792 let mut plugin = P::create();
793 plugin.init();
794 if let Some(bytes) = state_bytes.as_deref()
795 && let Err(e) = plugin.load_state(bytes)
796 {
797 eprintln!("truce-test: load_state failed: {e}");
798 }
799 for (id, value) in ¶m_overrides {
800 plugin.params().set_normalized(*id, *value);
801 }
802 plugin.params().snap_smoothers();
803 if let Some(f) = setup {
804 f(&mut plugin);
805 }
806 let (pixels, w, h) =
807 truce_core::screenshot::render_pixels_for_at_scale::<P>(&mut plugin, scale);
808 compare_against_reference(
809 &pixels,
810 w,
811 h,
812 &ref_path,
813 tolerance,
814 pixel_threshold,
815 Some(&self.manifest_dir),
816 );
817 }
818}
819
820/// Construct a [`ScreenshotTest`] for the given plugin type, with
821/// the reference-PNG path required as the second argument. The
822/// path is anchored to the calling crate's `CARGO_MANIFEST_DIR`
823/// when relative, or used as-is when absolute.
824///
825/// ```ignore
826/// #[test]
827/// fn screenshot() {
828/// truce_test::screenshot!(Plugin, "screenshots/default.png").run();
829/// }
830/// ```
831#[macro_export]
832macro_rules! screenshot {
833 ($plugin:ty, $path:expr $(,)?) => {
834 $crate::ScreenshotTest::<$plugin>::__new(env!("CARGO_MANIFEST_DIR"), $path)
835 };
836}
837
838/// Compare RGBA pixels against the reference PNG at `ref_path`.
839/// Render gets saved to `<workspace>/target/screenshots/<basename>`
840/// regardless of where the reference lives, so a failed comparison
841/// always has a sibling artifact to inspect.
842///
843/// `manifest_dir_hint`, when given, is the calling crate's
844/// `CARGO_MANIFEST_DIR` (captured at compile time by the
845/// `screenshot!` macro). Walking up from there to the workspace root
846/// is more reliable than walking up from CWD - the latter is
847/// mis-anchored when tests run from a different directory or when
848/// CWD is inside `target/`.
849fn compare_against_reference(
850 pixels: &[u8],
851 width: u32,
852 height: u32,
853 ref_path: &std::path::Path,
854 max_diff_pixels: usize,
855 pixel_threshold: u8,
856 manifest_dir_hint: Option<&std::path::Path>,
857) {
858 let render_dir = workspace_target_screenshots_dir(manifest_dir_hint);
859 let render_path = render_dir.join(ref_path.file_name().map(std::path::Path::new).map_or_else(
860 || PathBuf::from("screenshot.png"),
861 std::path::Path::to_path_buf,
862 ));
863
864 if !ref_path.exists() {
865 // No baseline - save the current render so the user can
866 // inspect it before committing.
867 std::fs::create_dir_all(&render_dir).ok();
868 save_png(&render_path, pixels, width, height);
869 panic!(
870 "No screenshot baseline at {ref}. Just-rendered PNG saved at {rendered}.\n\
871 Create the baseline with: cargo truce screenshot --out {ref}\n\
872 then inspect the rendered PNG and commit it.",
873 ref = ref_path.display(),
874 rendered = render_path.display(),
875 );
876 }
877
878 let (ref_pixels, ref_w, ref_h) = truce_core::screenshot::load_png(ref_path);
879 if (width, height) != (ref_w, ref_h) {
880 std::fs::create_dir_all(&render_dir).ok();
881 save_png(&render_path, pixels, width, height);
882 panic!(
883 "GUI size changed: current {width}x{height}, reference {ref_w}x{ref_h}. \
884 Just-rendered PNG saved at {rendered}.\n\
885 Regenerate the baseline with: cargo truce screenshot --out {ref}\n\
886 then inspect the rendered PNG and commit it.",
887 rendered = render_path.display(),
888 ref = ref_path.display(),
889 );
890 }
891
892 // Walk pixel-by-pixel (4 bytes each), counting only pixels whose
893 // max RGBA channel delta exceeds `pixel_threshold`. Threshold = 0
894 // recovers strict byte-equality at pixel granularity.
895 let mut diff_count = 0usize;
896 let mut max_delta_seen: u8 = 0;
897 for (cur, refp) in pixels.chunks_exact(4).zip(ref_pixels.chunks_exact(4)) {
898 let delta = cur
899 .iter()
900 .zip(refp.iter())
901 .map(|(c, r)| c.abs_diff(*r))
902 .max()
903 .unwrap_or(0);
904 if delta > pixel_threshold {
905 diff_count += 1;
906 }
907 if delta > max_delta_seen {
908 max_delta_seen = delta;
909 }
910 }
911
912 if diff_count > max_diff_pixels {
913 // Save the failing render only on failure - successful tests
914 // no longer eat I/O writing artifacts they don't need.
915 std::fs::create_dir_all(&render_dir).ok();
916 save_png(&render_path, pixels, width, height);
917 panic!(
918 "GUI screenshot mismatch: {diff_count} pixels differ above threshold {pixel_threshold} \
919 (max allowed: {max_diff_pixels}; largest channel delta seen: {max_delta_seen}).\n\
920 Reference: {}\n\
921 Current: {}\n\
922 Either fix the regression, or accept the new render with: cp '{}' '{}'",
923 ref_path.display(),
924 render_path.display(),
925 render_path.display(),
926 ref_path.display(),
927 );
928 }
929}
930
931/// `<cargo-target-dir>/screenshots/`. Walks up from CWD looking for
932/// the topmost `Cargo.toml` (preferring one with `[workspace]`) to
933/// anchor the resolution, then routes through `truce_build::target_dir`
934/// so `CARGO_TARGET_DIR` and `<root>/.cargo/config.toml`'s
935/// `[build].target-dir` both override the literal `target/`. Used
936/// only for the failing-render artifact path - committed reference
937/// paths come from the builder's manifest-dir-anchored resolution.
938fn workspace_target_screenshots_dir(manifest_dir_hint: Option<&std::path::Path>) -> PathBuf {
939 // Prefer the calling crate's `CARGO_MANIFEST_DIR` (captured at
940 // compile time and threaded through the `screenshot!` macro). It's
941 // a stable anchor regardless of where `cargo test` runs from. Fall
942 // back to CWD only when no hint is available - old code paths or
943 // direct calls into this function.
944 let start = manifest_dir_hint.map_or_else(
945 || std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
946 std::path::Path::to_path_buf,
947 );
948 let mut dir = start.clone();
949 let mut topmost_package: Option<PathBuf> = None;
950 loop {
951 let toml_path = dir.join("Cargo.toml");
952 if toml_path.exists()
953 && let Ok(s) = std::fs::read_to_string(&toml_path)
954 && let Ok(doc) = s.parse::<toml::Table>()
955 {
956 // Workspace `Cargo.toml` is the strongest anchor we'll
957 // see - short-circuit and take its enclosing dir as
958 // the target-dir root.
959 if doc.contains_key("workspace") {
960 return truce_build::target_dir(&dir).join("screenshots");
961 }
962 // Otherwise we may be under a single-crate or workspace
963 // member. Remember the topmost package and keep walking
964 // - if we never find a workspace, the topmost package
965 // is the right anchor.
966 if doc.contains_key("package") {
967 topmost_package = Some(dir.clone());
968 }
969 }
970 if !dir.pop() {
971 let anchor = topmost_package.unwrap_or(start);
972 return truce_build::target_dir(&anchor).join("screenshots");
973 }
974 }
975}