Skip to main content

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 &param_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 &param_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}