use truce_core::export::PluginExport;
use truce_core::state;
use truce_params::Params;
pub use truce_driver::{
CaptureSpec, DriverResult, InputSource, MeterCapture, MeterReadings, PluginDriver, Script,
SetupContext, TransportSpec,
};
pub mod assertions;
pub use truce_core::editor::for_test_params;
#[macro_export]
macro_rules! driver {
($plugin:ty $(,)?) => {
$crate::PluginDriver::<$plugin>::new().manifest_dir(env!("CARGO_MANIFEST_DIR"))
};
}
pub fn assert_state_round_trip<P: PluginExport>() {
let plugin = P::create();
let blob = state::snapshot_plugin(&plugin);
let mut plugin2 = P::create();
state::restore_plugin(&mut plugin2, &blob).expect("restore_plugin failed");
let param_infos = plugin.params().param_infos();
for pi in ¶m_infos {
let v1 = plugin.params().get_plain(pi.id).unwrap_or_else(|| {
panic!(
"param {} ({}) missing from source plugin after restore_plugin - \
the param id is no longer registered",
pi.id, pi.name
)
});
let v2 = plugin2.params().get_plain(pi.id).unwrap_or_else(|| {
panic!(
"param {} ({}) was lost during state round-trip - \
saved-state blob references an id that the freshly-built plugin \
doesn't expose. Either the param was renamed/renumbered or \
the deserializer is dropping it.",
pi.id, pi.name
)
});
assert!(
(v1 - v2).abs() < 0.0001,
"Param {} ({}) mismatch: {v1} vs {v2}",
pi.id,
pi.name
);
}
}
pub fn assert_has_editor<P: PluginExport>() {
let mut plugin = P::create();
let editor = plugin.editor();
assert!(editor.is_some(), "Plugin::editor() returned None");
let editor = editor.unwrap();
let (w, h) = editor.size();
assert!(w > 0 && h > 0, "Editor size is zero: {w}x{h}");
}
pub fn assert_valid_info<P: PluginExport>() {
let info = P::info();
assert!(!info.name.is_empty(), "Plugin name is empty");
assert!(!info.vendor.is_empty(), "Vendor is empty");
assert!(!info.version.is_empty(), "Version is empty");
assert!(!info.clap_id.is_empty(), "CLAP ID is empty");
assert!(!info.vst3_id.is_empty(), "VST3 ID is empty");
assert!(info.au_type != [0; 4], "AU type is zero");
assert!(info.fourcc != [0; 4], "FourCC is zero");
assert!(info.au_manufacturer != [0; 4], "AU manufacturer is zero");
}
pub fn assert_au_type_codes_ascii<P: PluginExport>() {
let info = P::info();
for (label, code) in [
("au_type", info.au_type),
("fourcc", info.fourcc),
("au_manufacturer", info.au_manufacturer),
] {
for (i, &byte) in code.iter().enumerate() {
assert!(
byte.is_ascii_graphic(),
"{label}[{i}] is not printable ASCII: 0x{byte:02x} (full: {:?})",
std::str::from_utf8(&code).unwrap_or("??")
);
}
}
}
pub fn assert_fourcc_roundtrip<P: PluginExport>() {
let info = P::info();
for (label, code) in [
("au_type", info.au_type),
("fourcc", info.fourcc),
("au_manufacturer", info.au_manufacturer),
] {
let packed = (u32::from(code[0]) << 24)
| (u32::from(code[1]) << 16)
| (u32::from(code[2]) << 8)
| u32::from(code[3]);
#[allow(clippy::cast_possible_truncation)]
let unpacked = [
(packed >> 24) as u8,
(packed >> 16) as u8,
(packed >> 8) as u8,
packed as u8,
];
assert_eq!(code, unpacked, "{label} FourCharCode round-trip failed");
}
}
pub fn assert_bus_config_effect<P: PluginExport>() {
let layouts = P::bus_layouts();
assert!(!layouts.is_empty(), "No bus layouts defined");
let layout = &layouts[0];
let inputs = layout.total_input_channels();
let outputs = layout.total_output_channels();
assert!(
inputs > 0,
"Effect should have input channels, got {inputs}"
);
assert!(
outputs > 0,
"Effect should have output channels, got {outputs}"
);
}
pub fn assert_bus_config_instrument<P: PluginExport>() {
let layouts = P::bus_layouts();
assert!(!layouts.is_empty(), "No bus layouts defined");
let layout = &layouts[0];
let inputs = layout.total_input_channels();
let outputs = layout.total_output_channels();
assert_eq!(
inputs, 0,
"Instrument should have 0 input channels, got {inputs}"
);
assert!(
outputs > 0,
"Instrument should have output channels, got {outputs}"
);
}
pub fn assert_editor_lifecycle<P: PluginExport>() {
let mut plugin = P::create();
let editor1 = plugin.editor();
assert!(editor1.is_some(), "First editor() returned None");
let (w1, h1) = editor1.as_ref().unwrap().size();
assert!(w1 > 0 && h1 > 0, "First editor size is zero: {w1}x{h1}");
drop(editor1);
let editor2 = plugin.editor();
assert!(
editor2.is_some(),
"Second editor() returned None after drop"
);
let (w2, h2) = editor2.as_ref().unwrap().size();
assert_eq!(
(w1, h1),
(w2, h2),
"Editor size changed between creates: ({w1},{h1}) vs ({w2},{h2})"
);
}
pub fn assert_editor_size_consistent<P: PluginExport>() {
let mut plugin = P::create();
let editor = plugin.editor();
assert!(editor.is_some(), "editor() returned None");
let editor = editor.unwrap();
let (w1, h1) = editor.size();
let (w2, h2) = editor.size();
let (w3, h3) = editor.size();
assert_eq!((w1, h1), (w2, h2), "Editor size inconsistent: call 1 vs 2");
assert_eq!((w2, h2), (w3, h3), "Editor size inconsistent: call 2 vs 3");
}
pub fn assert_param_defaults_match<P: PluginExport>() {
let plugin = P::create();
let infos = plugin.params().param_infos();
for pi in &infos {
let current = plugin.params().get_plain(pi.id).unwrap_or_else(|| {
panic!(
"param {} ({}) has a ParamInfo entry but get_plain returned None - \
derive macro inconsistency",
pi.id, pi.name
)
});
assert!(
(current - pi.default_plain).abs() < 0.0001,
"Param {} ({}) default mismatch: declared={}, actual={}",
pi.id,
pi.name,
pi.default_plain,
current
);
}
}
pub fn assert_param_normalized_clamped<P: PluginExport>() {
let plugin = P::create();
let infos = plugin.params().param_infos();
for pi in &infos {
plugin.params().set_normalized(pi.id, 2.0);
let val = plugin.params().get_normalized(pi.id).unwrap_or_else(|| {
panic!(
"param {} ({}) get_normalized returned None despite ParamInfo \
entry - derive macro inconsistency",
pi.id, pi.name
)
});
assert!(
val <= 1.0001,
"Param {} ({}) normalized not clamped above 1.0: set 2.0, got {}",
pi.id,
pi.name,
val
);
plugin.params().set_normalized(pi.id, -1.0);
let val = plugin.params().get_normalized(pi.id).unwrap_or_else(|| {
panic!(
"param {} ({}) get_normalized returned None despite ParamInfo \
entry - derive macro inconsistency",
pi.id, pi.name
)
});
assert!(
val >= -0.0001,
"Param {} ({}) normalized not clamped below 0.0: set -1.0, got {}",
pi.id,
pi.name,
val
);
plugin.params().set_plain(pi.id, pi.default_plain);
}
}
pub fn assert_param_normalized_roundtrip<P: PluginExport>() {
let plugin = P::create();
let infos = plugin.params().param_infos();
for pi in &infos {
let (test_values, tolerance) = if let Some(steps) = pi.range.step_count() {
let steps = steps.get();
let v: Vec<f64> = (0..=steps)
.map(|i| f64::from(i) / f64::from(steps))
.collect();
(v, (0.5 / f64::from(steps)).max(1e-6))
} else {
(vec![0.0, 0.25, 0.5, 0.75, 1.0], 1e-6)
};
for &norm in &test_values {
plugin.params().set_normalized(pi.id, norm);
let got = plugin.params().get_normalized(pi.id).unwrap_or_else(|| {
panic!(
"param {} ({}) get_normalized returned None despite ParamInfo \
entry - derive macro inconsistency",
pi.id, pi.name
)
});
assert!(
(got - norm).abs() <= tolerance,
"Param {} ({}) normalized round-trip: set {norm}, got {got} (tol {tolerance})",
pi.id,
pi.name
);
}
plugin.params().set_plain(pi.id, pi.default_plain);
}
}
pub fn assert_param_count_matches<P: PluginExport>() {
let plugin = P::create();
let count = plugin.params().count();
let infos = plugin.params().param_infos();
assert_eq!(
count,
infos.len(),
"param count() = {count}, but param_infos().len() = {}",
infos.len()
);
}
pub fn assert_no_duplicate_param_ids<P: PluginExport>() {
let plugin = P::create();
let infos = plugin.params().param_infos();
let mut seen = std::collections::HashSet::new();
for pi in &infos {
assert!(
seen.insert(pi.id),
"Duplicate parameter ID {}: {} (already used by another param)",
pi.id,
pi.name
);
}
}
pub fn assert_corrupt_state_no_crash<P: PluginExport>() {
let info = P::info();
let hash = state::hash_plugin_id(info.clap_id);
let garbage: Vec<Vec<u8>> = vec![
vec![0xFF; 64], b"OAST".to_vec(), vec![0; 4096], vec![0xFF, 0xFE, 0xFD, 0xFC, 0xFB], ];
let plugin = P::create();
for blob in &garbage {
let result = state::deserialize_state(blob, hash);
if let Some(d) = result {
plugin.params().restore_values(&d.params);
}
}
let mut snapshot_plugin = P::create();
snapshot_plugin.init();
let blob = state::snapshot_plugin(&snapshot_plugin);
assert!(
state::deserialize_state(&blob, hash).is_some(),
"deserialize_state rejected a blob produced by snapshot_plugin - \
the corruption test would pass trivially under this regression"
);
}
pub fn assert_empty_state_no_crash<P: PluginExport>() {
let info = P::info();
let hash = state::hash_plugin_id(info.clap_id);
let result = state::deserialize_state(&[], hash);
assert!(result.is_none(), "Empty state should return None");
let result = state::deserialize_state(&[0], hash);
assert!(result.is_none(), "Single-byte state should return None");
}
pub use truce_core::screenshot::save_png;
use std::path::PathBuf;
type SetupFn<P> = Box<dyn FnOnce(&mut P)>;
pub struct ScreenshotTest<P: PluginExport> {
ref_path: PathBuf,
manifest_dir: PathBuf,
tolerance: usize,
pixel_threshold: u8,
state_bytes: Option<Vec<u8>>,
param_overrides: Vec<(u32, f64)>,
setup: Option<SetupFn<P>>,
scale: Option<f64>,
}
impl<P: PluginExport> ScreenshotTest<P> {
#[doc(hidden)]
pub fn __new(manifest_dir: &str, ref_path: impl Into<PathBuf>) -> Self {
let manifest_dir = PathBuf::from(manifest_dir);
let raw = ref_path.into();
let ref_path = if raw.is_absolute() {
raw
} else {
manifest_dir.join(raw)
};
Self {
ref_path,
manifest_dir,
tolerance: 0,
pixel_threshold: 0,
state_bytes: None,
param_overrides: Vec::new(),
setup: None,
scale: None,
}
}
#[must_use]
pub fn setup<F: FnOnce(&mut P) + 'static>(mut self, f: F) -> Self {
self.setup = Some(Box::new(f));
self
}
#[must_use]
pub fn set_param(mut self, id: impl Into<u32>, normalized: f64) -> Self {
self.param_overrides.push((id.into(), normalized));
self
}
#[must_use]
pub fn state_file<S: Into<PathBuf>>(mut self, path: S) -> Self {
let raw = path.into();
let resolved = if raw.is_absolute() {
raw
} else {
self.manifest_dir.join(&raw)
};
let bytes = std::fs::read(&resolved)
.unwrap_or_else(|e| panic!("state_file: failed to read {}: {e}", resolved.display()));
self.state_bytes = Some(bytes);
self
}
#[must_use]
pub fn tolerance(mut self, t: usize) -> Self {
self.tolerance = t;
self
}
#[must_use]
pub fn pixel_threshold(mut self, d: u8) -> Self {
self.pixel_threshold = d;
self
}
#[must_use]
pub fn scale(mut self, scale: f64) -> Self {
self.scale = Some(scale);
self
}
pub fn run(self) {
let ref_path = self.ref_path;
let tolerance = self.tolerance;
let pixel_threshold = self.pixel_threshold;
let state_bytes = self.state_bytes;
let param_overrides = self.param_overrides;
let setup = self.setup;
let scale = self
.scale
.unwrap_or(truce_core::screenshot::DEFAULT_SCREENSHOT_SCALE);
let mut plugin = P::create();
plugin.init();
if let Some(bytes) = state_bytes.as_deref()
&& let Err(e) = plugin.load_state(bytes)
{
eprintln!("truce-test: load_state failed: {e}");
}
for (id, value) in ¶m_overrides {
plugin.params().set_normalized(*id, *value);
}
plugin.params().snap_smoothers();
if let Some(f) = setup {
f(&mut plugin);
}
let (pixels, w, h) =
truce_core::screenshot::render_pixels_for_at_scale::<P>(&mut plugin, scale);
compare_against_reference(
&pixels,
w,
h,
&ref_path,
tolerance,
pixel_threshold,
Some(&self.manifest_dir),
);
}
}
#[macro_export]
macro_rules! screenshot {
($plugin:ty, $path:expr $(,)?) => {
$crate::ScreenshotTest::<$plugin>::__new(env!("CARGO_MANIFEST_DIR"), $path)
};
}
fn compare_against_reference(
pixels: &[u8],
width: u32,
height: u32,
ref_path: &std::path::Path,
max_diff_pixels: usize,
pixel_threshold: u8,
manifest_dir_hint: Option<&std::path::Path>,
) {
let render_dir = workspace_target_screenshots_dir(manifest_dir_hint);
let render_path = render_dir.join(ref_path.file_name().map(std::path::Path::new).map_or_else(
|| PathBuf::from("screenshot.png"),
std::path::Path::to_path_buf,
));
if !ref_path.exists() {
std::fs::create_dir_all(&render_dir).ok();
save_png(&render_path, pixels, width, height);
panic!(
"No screenshot baseline at {ref}. Just-rendered PNG saved at {rendered}.\n\
Create the baseline with: cargo truce screenshot --out {ref}\n\
then inspect the rendered PNG and commit it.",
ref = ref_path.display(),
rendered = render_path.display(),
);
}
let (ref_pixels, ref_w, ref_h) = truce_core::screenshot::load_png(ref_path);
if (width, height) != (ref_w, ref_h) {
std::fs::create_dir_all(&render_dir).ok();
save_png(&render_path, pixels, width, height);
panic!(
"GUI size changed: current {width}x{height}, reference {ref_w}x{ref_h}. \
Just-rendered PNG saved at {rendered}.\n\
Regenerate the baseline with: cargo truce screenshot --out {ref}\n\
then inspect the rendered PNG and commit it.",
rendered = render_path.display(),
ref = ref_path.display(),
);
}
let mut diff_count = 0usize;
let mut max_delta_seen: u8 = 0;
for (cur, refp) in pixels.chunks_exact(4).zip(ref_pixels.chunks_exact(4)) {
let delta = cur
.iter()
.zip(refp.iter())
.map(|(c, r)| c.abs_diff(*r))
.max()
.unwrap_or(0);
if delta > pixel_threshold {
diff_count += 1;
}
if delta > max_delta_seen {
max_delta_seen = delta;
}
}
if diff_count > max_diff_pixels {
std::fs::create_dir_all(&render_dir).ok();
save_png(&render_path, pixels, width, height);
panic!(
"GUI screenshot mismatch: {diff_count} pixels differ above threshold {pixel_threshold} \
(max allowed: {max_diff_pixels}; largest channel delta seen: {max_delta_seen}).\n\
Reference: {}\n\
Current: {}\n\
Either fix the regression, or accept the new render with: cp '{}' '{}'",
ref_path.display(),
render_path.display(),
render_path.display(),
ref_path.display(),
);
}
}
fn workspace_target_screenshots_dir(manifest_dir_hint: Option<&std::path::Path>) -> PathBuf {
let start = manifest_dir_hint.map_or_else(
|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
std::path::Path::to_path_buf,
);
let mut dir = start.clone();
let mut topmost_package: Option<PathBuf> = None;
loop {
let toml_path = dir.join("Cargo.toml");
if toml_path.exists()
&& let Ok(s) = std::fs::read_to_string(&toml_path)
&& let Ok(doc) = s.parse::<toml::Table>()
{
if doc.contains_key("workspace") {
return truce_build::target_dir(&dir).join("screenshots");
}
if doc.contains_key("package") {
topmost_package = Some(dir.clone());
}
}
if !dir.pop() {
let anchor = topmost_package.unwrap_or(start);
return truce_build::target_dir(&anchor).join("screenshots");
}
}
}