use std::collections::HashSet;
use std::error::Error;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::Arc;
use tracing::info;
use crate::audio::format::SampleFormat;
use crate::calibrate;
use crate::config;
use crate::lighting::parser::parse_light_shows;
use crate::lighting::validation::validate_groups;
use crate::playlist::Playlist;
use crate::songs;
use crate::verify;
const DEFAULT_THREAD_PRIORITY: u8 = 70;
fn resolve_thread_priority(env_value: Option<&str>) -> u8 {
env_value
.and_then(|v| {
let n = v.parse::<u8>().ok()?;
(n < 100).then_some(n)
})
.unwrap_or(DEFAULT_THREAD_PRIORITY)
}
fn apply_thread_priority() {
use thread_priority::{set_current_thread_priority, ThreadPriority, ThreadPriorityValue};
let value = resolve_thread_priority(std::env::var("MTRACK_THREAD_PRIORITY").ok().as_deref());
let priority = ThreadPriorityValue::try_from(value).unwrap();
match set_current_thread_priority(ThreadPriority::Crossplatform(priority)) {
Ok(()) => info!("Set thread priority for audio"),
Err(e) => tracing::warn!(
error = %e,
"Could not set thread priority (e.g. run as root or with CAP_SYS_NICE on Linux)"
),
}
}
pub fn songs(path: &str, init: bool) -> Result<(), Box<dyn Error>> {
if init {
info!("Initializing songs");
songs::initialize_songs(Path::new(path))?;
} else {
info!("Not initializing songs");
}
let songs = songs::get_all_songs(Path::new(path))?;
if songs.is_empty() {
println!("No songs found in {}.", path);
return Ok(());
}
let mut all_tracks: HashSet<String> = HashSet::new();
println!("Songs (count: {}):", songs.len());
for song in songs.sorted_list() {
for track in song.tracks().iter() {
all_tracks.insert(track.name().to_string());
}
println!("- {}", song);
}
let mut tracks: Vec<String> = all_tracks.into_iter().collect();
tracks.sort();
println!("\nTracks (count: {}):", tracks.len());
for track in tracks.iter() {
println!("- {}", track)
}
Ok(())
}
pub fn playlist(repository_path: &str, playlist_path: &str) -> Result<(), Box<dyn Error>> {
let songs = songs::get_all_songs(Path::new(repository_path))?;
let playlist = Playlist::new(
"playlist",
&config::Playlist::deserialize(Path::new(playlist_path))?,
songs,
)?;
println!("{}", playlist);
Ok(())
}
pub async fn start(
path: &str,
playlist_path: Option<String>,
web_config: crate::webui::server::WebConfig,
tui_mode: bool,
) -> Result<(), Box<dyn Error>> {
apply_thread_priority();
let input = Path::new(path);
let player_path =
&if input.is_file() || input.extension().is_some_and(|e| e == "yaml" || e == "yml") {
input.to_path_buf()
} else {
input.join("mtrack.yaml")
};
let player_config = match config::Player::deserialize(player_path) {
Ok(cfg) => cfg,
Err(e) => {
if player_path.exists() {
return Err(e.into());
}
info!(
"Config file not found at {:?}, creating with defaults",
player_path
);
let mut default_config = config::Player::default();
default_config.set_songs(".");
if let Some(parent) = player_path.parent() {
if !parent.exists() {
std::fs::create_dir_all(parent)?;
}
}
let yaml = crate::util::to_yaml_string(&default_config)?;
std::fs::write(player_path, &yaml)?;
default_config
}
};
let config_store = Arc::new(config::ConfigStore::new(
player_config.clone(),
player_path.to_path_buf(),
));
let songs_path = player_config.songs(player_path);
if !songs_path.exists() {
info!("Creating songs directory at {:?}", songs_path);
std::fs::create_dir_all(&songs_path)?;
}
let songs = songs::get_all_songs(&songs_path)?;
let playlists_dir = player_config
.playlists_dir(player_path)
.or_else(|| player_path.parent().map(|p| p.join("playlists")));
let mut legacy_playlist_path = playlist_path
.as_ref()
.map(PathBuf::from)
.or(player_config.playlist());
if let Some(ref mut pp) = legacy_playlist_path {
if !pp.is_absolute() {
*pp = if let Some(parent) = player_path.parent() {
parent.join(&pp)
} else {
return Err(format!(
"Unable to determine playlist path (config base path has no parent): {}. \
Please specify the playlist path using an absolute path.",
pp.display()
)
.into());
};
}
}
let playlists = crate::player::load_playlists(
playlists_dir.as_deref(),
legacy_playlist_path.as_deref(),
songs.clone(),
)?;
let active_playlist = player_config.active_playlist().to_string();
let legacy_playlist_path = legacy_playlist_path.unwrap_or_else(|| {
player_path
.parent()
.unwrap_or(Path::new("."))
.join("playlist.yaml")
});
let player = crate::player::Player::new(
playlists,
active_playlist,
&player_config,
player_path.parent(),
)?;
player.set_config_store(config_store);
let (state_tx, state_rx) =
tokio::sync::watch::channel(std::sync::Arc::new(crate::state::StateSnapshot::default()));
player.set_state_tx(state_tx);
let _webui_handle = {
let (broadcast_tx, _) = tokio::sync::broadcast::channel::<String>(128);
player.set_broadcast_tx(broadcast_tx.clone());
let profiles_dir = player_config.profiles_dir_resolved(player_path);
let webui_state =
crate::webui::server::WebUiState::new(crate::webui::server::WebUiStateParams {
player: player.clone(),
state_rx: state_rx.clone(),
broadcast_tx,
config_path: player_path.to_path_buf(),
songs_path: player_config.songs(player_path),
playlists_dir: playlists_dir.clone(),
legacy_playlist_path: Some(legacy_playlist_path.clone()),
profiles_dir,
waveform_cache: crate::webui::state::new_waveform_cache(),
});
match crate::webui::server::start(webui_state, web_config.address.clone(), web_config.port)
.await
{
Ok(handle) => Some(handle),
Err(e) => {
tracing::warn!("Failed to start web UI: {}", e);
None
}
}
};
if tui_mode {
crate::tui::run(player.clone(), state_rx).await?;
} else {
info!("mtrack is running. Press Ctrl+C to stop.");
std::future::pending::<()>().await;
}
player.shutdown_controllers();
Ok(())
}
pub fn calibrate_triggers(
device: &str,
sample_rate: Option<u32>,
duration: f32,
sample_format: Option<String>,
bits_per_sample: Option<u16>,
) -> Result<(), Box<dyn Error>> {
if duration <= 0.0 || !duration.is_finite() {
return Err("--duration must be a positive finite number".into());
}
let fmt = sample_format
.as_deref()
.map(SampleFormat::from_str)
.transpose()?;
calibrate::run(calibrate::CalibrationConfig {
device_name: device.to_string(),
sample_rate,
noise_floor_duration_secs: duration,
sample_format: fmt,
bits_per_sample,
})
}
pub fn verify_light_show(show_path: &str, config_path: Option<&str>) -> Result<(), Box<dyn Error>> {
let path = Path::new(show_path);
if !path.exists() {
return Err(format!("Light show file not found: {}", show_path).into());
}
let content = std::fs::read_to_string(path)?;
let shows = match parse_light_shows(&content) {
Ok(shows) => shows,
Err(e) => {
eprintln!("❌ Syntax error in light show:");
eprintln!("{}", e);
return Err(e);
}
};
if shows.is_empty() {
eprintln!("⚠️ Warning: No shows found in file");
return Ok(());
}
println!("✅ Light show syntax is valid");
println!(" Found {} show(s):", shows.len());
for (name, show) in &shows {
println!(" - \"{}\" ({} cues)", name, show.cues.len());
}
let (lighting_config, valid_groups_count, valid_fixtures_count) = if let Some(config_path) =
config_path
{
let config_file = Path::new(config_path);
if !config_file.exists() {
eprintln!("⚠️ Warning: Config file not found: {}", config_path);
(None, 0, 0)
} else {
match config::Player::deserialize(config_file) {
Ok(player_config) => {
if let Some(dmx) = player_config.dmx() {
if let Some(lighting) = dmx.lighting() {
let groups_count = lighting.groups().len();
let fixtures_count = lighting.fixtures().len();
(Some(lighting.clone()), groups_count, fixtures_count)
} else {
eprintln!("⚠️ Warning: No lighting configuration found in DMX config");
(None, 0, 0)
}
} else {
eprintln!("⚠️ Warning: No DMX configuration found in config file");
(None, 0, 0)
}
}
Err(e) => {
eprintln!("⚠️ Warning: Failed to parse config file: {}", e);
(None, 0, 0)
}
}
}
} else {
(None, 0, 0)
};
let validation_result = validate_groups(&shows, lighting_config.as_ref());
if validation_result.groups.is_empty() {
println!("⚠️ Warning: No fixture groups found in show");
} else {
println!("\n Groups used in show:");
let mut sorted_groups: Vec<String> = validation_result.groups.iter().cloned().collect();
sorted_groups.sort();
for group in &sorted_groups {
println!(" - {}", group);
}
}
if lighting_config.is_some() {
if !validation_result.is_valid() {
eprintln!("\n❌ Invalid groups/fixtures referenced in show:");
for group in &validation_result.invalid_groups {
eprintln!(" - {} (not found in config)", group);
}
return Err(format!(
"Show references {} invalid group(s)",
validation_result.invalid_groups.len()
)
.into());
} else {
println!("\n✅ All groups/fixtures are valid in config");
println!(
" Validated against {} group(s) and {} fixture(s) in config",
valid_groups_count, valid_fixtures_count
);
}
}
Ok(())
}
pub fn verify(
config: &str,
check: Option<Vec<String>>,
hostname: Option<String>,
) -> Result<(), Box<dyn Error>> {
let config_path = Path::new(config);
let player_config = config::Player::deserialize(config_path)?;
let songs_path = player_config.songs(config_path);
let songs = songs::get_all_songs(&songs_path)?;
if songs.is_empty() {
println!("No songs found in {}.", songs_path.display());
return Ok(());
}
let run_all = check.is_none();
let checks: Vec<String> = check.unwrap_or_default();
let mut report = verify::VerificationReport::default();
if run_all || checks.iter().any(|c| c == "track-mappings") {
let all_profiles = player_config.all_profiles();
if all_profiles.len() > 1 {
let profiles_to_check: Vec<&config::Profile> = match &hostname {
Some(h) => {
let filtered = player_config.profiles(h);
if filtered.is_empty() {
eprintln!("Warning: no profiles match hostname '{}'", h);
}
filtered
}
None => all_profiles.iter().collect(),
};
for (i, profile) in profiles_to_check.iter().enumerate() {
let audio_config = match profile.audio_config() {
Some(ac) => ac,
None => {
println!("Skipping profile {} (no audio configured)", i);
continue;
}
};
let label = match profile.hostname() {
Some(h) => format!(
"profile {} (hostname: {}, device: {})",
i,
h,
audio_config.audio().device()
),
None => format!(
"profile {} (any host, device: {})",
i,
audio_config.audio().device()
),
};
println!("Checking track mappings for {}...", label);
let track_report =
verify::check_all_track_mappings(&songs, &audio_config.track_mappings_hash());
report.merge(track_report);
}
} else {
let track_report =
verify::check_all_track_mappings(&songs, &player_config.track_mappings());
report.merge(track_report);
}
}
verify::print_report(&report, &songs);
if report.has_errors() {
std::process::exit(1);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
mod resolve_thread_priority_tests {
use super::*;
#[test]
fn none_returns_default() {
assert_eq!(resolve_thread_priority(None), DEFAULT_THREAD_PRIORITY);
}
#[test]
fn valid_value() {
assert_eq!(resolve_thread_priority(Some("50")), 50);
}
#[test]
fn zero_is_valid() {
assert_eq!(resolve_thread_priority(Some("0")), 0);
}
#[test]
fn ninety_nine_is_valid() {
assert_eq!(resolve_thread_priority(Some("99")), 99);
}
#[test]
fn one_hundred_is_out_of_range() {
assert_eq!(
resolve_thread_priority(Some("100")),
DEFAULT_THREAD_PRIORITY
);
}
#[test]
fn large_value_out_of_range() {
assert_eq!(
resolve_thread_priority(Some("255")),
DEFAULT_THREAD_PRIORITY
);
}
#[test]
fn negative_value_unparseable() {
assert_eq!(resolve_thread_priority(Some("-1")), DEFAULT_THREAD_PRIORITY);
}
#[test]
fn non_numeric_returns_default() {
assert_eq!(
resolve_thread_priority(Some("high")),
DEFAULT_THREAD_PRIORITY
);
}
#[test]
fn empty_string_returns_default() {
assert_eq!(resolve_thread_priority(Some("")), DEFAULT_THREAD_PRIORITY);
}
#[test]
fn boundary_value_one() {
assert_eq!(resolve_thread_priority(Some("1")), 1);
}
}
mod songs_tests {
use super::*;
#[test]
fn empty_directory_reports_no_songs() {
let tmp = tempfile::tempdir().unwrap();
assert!(songs(tmp.path().to_str().unwrap(), false).is_ok());
}
#[test]
fn lists_songs_with_tracks() {
let tmp = tempfile::tempdir().unwrap();
let song_dir = tmp.path().join("My Song");
std::fs::create_dir(&song_dir).unwrap();
crate::testutil::write_wav(
song_dir.join("guitar.wav"),
vec![vec![1_i32, 2, 3, 4, 5]],
44100,
)
.unwrap();
crate::testutil::write_wav(
song_dir.join("bass.wav"),
vec![vec![1_i32, 2, 3, 4, 5]],
44100,
)
.unwrap();
assert!(songs(tmp.path().to_str().unwrap(), false).is_ok());
}
#[test]
fn with_init_creates_yaml() {
let tmp = tempfile::tempdir().unwrap();
let song_dir = tmp.path().join("Init Song");
std::fs::create_dir(&song_dir).unwrap();
crate::testutil::write_wav(
song_dir.join("track.wav"),
vec![vec![1_i32, 2, 3, 4, 5]],
44100,
)
.unwrap();
assert!(songs(tmp.path().to_str().unwrap(), true).is_ok());
assert!(song_dir.join("song.yaml").exists());
}
#[test]
fn nonexistent_path_returns_error() {
assert!(songs("/nonexistent/path/to/songs", false).is_err());
}
#[test]
fn multiple_songs_with_overlapping_tracks() {
let tmp = tempfile::tempdir().unwrap();
let song1_dir = tmp.path().join("Song One");
std::fs::create_dir(&song1_dir).unwrap();
crate::testutil::write_wav(
song1_dir.join("guitar.wav"),
vec![vec![1_i32, 2, 3, 4, 5]],
44100,
)
.unwrap();
crate::testutil::write_wav(
song1_dir.join("bass.wav"),
vec![vec![1_i32, 2, 3, 4, 5]],
44100,
)
.unwrap();
let song2_dir = tmp.path().join("Song Two");
std::fs::create_dir(&song2_dir).unwrap();
crate::testutil::write_wav(
song2_dir.join("guitar.wav"),
vec![vec![1_i32, 2, 3, 4, 5]],
44100,
)
.unwrap();
crate::testutil::write_wav(
song2_dir.join("drums.wav"),
vec![vec![1_i32, 2, 3, 4, 5]],
44100,
)
.unwrap();
assert!(songs(tmp.path().to_str().unwrap(), false).is_ok());
}
#[test]
fn init_with_multiple_songs() {
let tmp = tempfile::tempdir().unwrap();
let song1_dir = tmp.path().join("Song A");
std::fs::create_dir(&song1_dir).unwrap();
crate::testutil::write_wav(
song1_dir.join("track1.wav"),
vec![vec![1_i32, 2, 3, 4, 5]],
44100,
)
.unwrap();
let song2_dir = tmp.path().join("Song B");
std::fs::create_dir(&song2_dir).unwrap();
crate::testutil::write_wav(
song2_dir.join("track2.wav"),
vec![vec![1_i32, 2, 3, 4, 5]],
44100,
)
.unwrap();
assert!(songs(tmp.path().to_str().unwrap(), true).is_ok());
assert!(song1_dir.join("song.yaml").exists());
assert!(song2_dir.join("song.yaml").exists());
}
}
mod playlist_tests {
use super::*;
#[test]
fn valid_playlist() {
let tmp = tempfile::tempdir().unwrap();
let song_dir = tmp.path().join("Cool Song");
std::fs::create_dir(&song_dir).unwrap();
crate::testutil::write_wav(
song_dir.join("track.wav"),
vec![vec![1_i32, 2, 3, 4, 5]],
44100,
)
.unwrap();
crate::songs::initialize_songs(tmp.path()).unwrap();
let playlist_path = tmp.path().join("playlist.yaml");
std::fs::write(&playlist_path, "songs:\n- Cool Song\n").unwrap();
assert!(playlist(
tmp.path().to_str().unwrap(),
playlist_path.to_str().unwrap()
)
.is_ok());
}
#[test]
fn invalid_playlist_path() {
let tmp = tempfile::tempdir().unwrap();
assert!(playlist(tmp.path().to_str().unwrap(), "/nonexistent/playlist.yaml").is_err());
}
}
mod calibrate_triggers_tests {
use super::*;
#[test]
fn negative_duration_returns_error() {
let result = calibrate_triggers("device", None, -1.0, None, None);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("positive finite"));
}
#[test]
fn zero_duration_returns_error() {
let result = calibrate_triggers("device", None, 0.0, None, None);
assert!(result.is_err());
}
#[test]
fn infinite_duration_returns_error() {
let result = calibrate_triggers("device", None, f32::INFINITY, None, None);
assert!(result.is_err());
}
#[test]
fn nan_duration_returns_error() {
let result = calibrate_triggers("device", None, f32::NAN, None, None);
assert!(result.is_err());
}
#[test]
fn invalid_sample_format_returns_error() {
let result = calibrate_triggers("device", None, 3.0, Some("invalid".to_string()), None);
assert!(result.is_err());
}
#[test]
fn valid_int_sample_format_passes_validation() {
let result = calibrate_triggers(
"nonexistent-device",
None,
3.0,
Some("int".to_string()),
None,
);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(!err_msg.contains("Unsupported sample format"));
}
#[test]
fn valid_float_sample_format_passes_validation() {
let result = calibrate_triggers(
"nonexistent-device",
None,
3.0,
Some("float".to_string()),
None,
);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(!err_msg.contains("Unsupported sample format"));
}
#[test]
fn neg_infinity_duration_returns_error() {
let result = calibrate_triggers("device", None, f32::NEG_INFINITY, None, None);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("positive finite"));
}
#[test]
fn with_sample_rate_and_bits_per_sample() {
let result = calibrate_triggers(
"nonexistent-device",
Some(48000),
3.0,
Some("int".to_string()),
Some(16),
);
assert!(result.is_err());
}
}
mod verify_light_show_tests {
use super::*;
#[test]
fn nonexistent_file_returns_error() {
let result = verify_light_show("/nonexistent/show.light", None);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
}
#[test]
fn valid_show_file() {
let tmp = tempfile::tempdir().unwrap();
let show_path = tmp.path().join("show.light");
std::fs::write(
&show_path,
r#"show "test" {
@00:00.000
front_wash: static color: "blue", duration: 5s, dimmer: 100%
}"#,
)
.unwrap();
assert!(verify_light_show(show_path.to_str().unwrap(), None).is_ok());
}
#[test]
fn invalid_syntax_returns_error() {
let tmp = tempfile::tempdir().unwrap();
let show_path = tmp.path().join("bad.light");
std::fs::write(&show_path, "this is not valid light show syntax {{{").unwrap();
assert!(verify_light_show(show_path.to_str().unwrap(), None).is_err());
}
#[test]
fn empty_show_file() {
let tmp = tempfile::tempdir().unwrap();
let show_path = tmp.path().join("empty.light");
std::fs::write(&show_path, "").unwrap();
assert!(verify_light_show(show_path.to_str().unwrap(), None).is_ok());
}
#[test]
fn with_valid_config() {
let show_dir = tempfile::tempdir().unwrap();
let show_path = show_dir.path().join("show.light");
std::fs::write(
&show_path,
r#"show "test" {
@00:00.000
front_wash: static color: "blue", duration: 5s, dimmer: 100%
}"#,
)
.unwrap();
let config_path = PathBuf::from("examples/mtrack.yaml");
if config_path.exists() {
let result = verify_light_show(
show_path.to_str().unwrap(),
Some(config_path.to_str().unwrap()),
);
let _ = result;
}
}
#[test]
fn with_nonexistent_config() {
let show_dir = tempfile::tempdir().unwrap();
let show_path = show_dir.path().join("show.light");
std::fs::write(
&show_path,
r#"show "test" {
@00:00.000
front_wash: static color: "blue", duration: 5s, dimmer: 100%
}"#,
)
.unwrap();
assert!(verify_light_show(
show_path.to_str().unwrap(),
Some("/nonexistent/mtrack.yaml")
)
.is_ok());
}
#[test]
fn with_config_missing_dmx() {
let tmp = tempfile::tempdir().unwrap();
let show_path = tmp.path().join("show.light");
std::fs::write(
&show_path,
r#"show "test" {
@00:00.000
front_wash: static color: "blue", duration: 5s, dimmer: 100%
}"#,
)
.unwrap();
let config_path = tmp.path().join("mtrack.yaml");
std::fs::write(&config_path, "songs: songs\naudio:\n device: mock\n").unwrap();
assert!(verify_light_show(
show_path.to_str().unwrap(),
Some(config_path.to_str().unwrap())
)
.is_ok());
}
#[test]
fn multiple_shows_in_file() {
let tmp = tempfile::tempdir().unwrap();
let show_path = tmp.path().join("multi.light");
std::fs::write(
&show_path,
r#"show "show1" {
@00:00.000
front_wash: static color: "blue", duration: 5s, dimmer: 100%
}
show "show2" {
@00:00.000
rear_wash: static color: "red", duration: 5s, dimmer: 50%
}"#,
)
.unwrap();
assert!(verify_light_show(show_path.to_str().unwrap(), None).is_ok());
}
#[test]
fn with_config_dmx_but_no_lighting_section() {
let tmp = tempfile::tempdir().unwrap();
let show_path = tmp.path().join("show.light");
std::fs::write(
&show_path,
r#"show "test" {
@00:00.000
front_wash: static color: "blue", duration: 5s, dimmer: 100%
}"#,
)
.unwrap();
let config_path = tmp.path().join("mtrack.yaml");
std::fs::write(
&config_path,
"songs: songs\naudio:\n device: mock\ndmx:\n universes:\n - universe: 1\n name: light-show\n",
)
.unwrap();
assert!(verify_light_show(
show_path.to_str().unwrap(),
Some(config_path.to_str().unwrap())
)
.is_ok());
}
#[test]
fn with_config_having_lighting_and_valid_groups() {
let tmp = tempfile::tempdir().unwrap();
let show_path = tmp.path().join("show.light");
std::fs::write(
&show_path,
r#"show "test" {
@00:00.000
front_wash: static color: "blue", duration: 5s, dimmer: 100%
}"#,
)
.unwrap();
let config_path = tmp.path().join("mtrack.yaml");
std::fs::write(
&config_path,
r#"songs: songs
audio:
device: mock
dmx:
universes:
- universe: 1
name: light-show
lighting:
fixtures:
front_wash: "Front Wash @ 1:1"
groups:
front_wash:
name: front_wash
constraints:
- AllOf: ["wash"]
- AllowEmpty: true
"#,
)
.unwrap();
let result = verify_light_show(
show_path.to_str().unwrap(),
Some(config_path.to_str().unwrap()),
);
let _ = result;
}
#[test]
fn with_invalid_config_file_syntax() {
let tmp = tempfile::tempdir().unwrap();
let show_path = tmp.path().join("show.light");
std::fs::write(
&show_path,
r#"show "test" {
@00:00.000
front_wash: static color: "blue", duration: 5s, dimmer: 100%
}"#,
)
.unwrap();
let config_path = tmp.path().join("mtrack.yaml");
std::fs::write(&config_path, "{{invalid yaml!!").unwrap();
assert!(verify_light_show(
show_path.to_str().unwrap(),
Some(config_path.to_str().unwrap()),
)
.is_ok());
}
#[test]
fn show_with_no_groups() {
let tmp = tempfile::tempdir().unwrap();
let show_path = tmp.path().join("show.light");
std::fs::write(
&show_path,
r#"show "empty_show" {
@00:00.000
}"#,
)
.unwrap();
assert!(verify_light_show(show_path.to_str().unwrap(), None).is_ok());
}
}
mod verify_tests {
use super::*;
#[test]
fn empty_songs_dir() {
let tmp = tempfile::tempdir().unwrap();
let songs_dir = tmp.path().join("songs");
std::fs::create_dir(&songs_dir).unwrap();
let config_path = tmp.path().join("mtrack.yaml");
std::fs::write(&config_path, format!("songs: {}\n", songs_dir.display())).unwrap();
assert!(verify(config_path.to_str().unwrap(), None, None).is_ok());
}
#[test]
fn invalid_config_path() {
assert!(verify("/nonexistent/mtrack.yaml", None, None).is_err());
}
fn create_songs_dir(base: &Path, song_name: &str, track_names: &[&str]) -> PathBuf {
let songs_dir = base.join("songs");
std::fs::create_dir_all(&songs_dir).unwrap();
let song_dir = songs_dir.join(song_name);
std::fs::create_dir_all(&song_dir).unwrap();
for track in track_names {
crate::testutil::write_wav(
song_dir.join(format!("{}.wav", track)),
vec![vec![1_i32, 2, 3, 4, 5]],
44100,
)
.unwrap();
}
crate::songs::initialize_songs(&songs_dir).unwrap();
songs_dir
}
#[test]
fn verify_single_profile_with_track_mappings() {
let tmp = tempfile::tempdir().unwrap();
let songs_dir = create_songs_dir(tmp.path(), "Test Song", &["click", "cue"]);
let config_path = tmp.path().join("mtrack.yaml");
std::fs::write(
&config_path,
format!(
"songs: {}\naudio:\n device: mock\ntrack_mappings:\n click:\n - 1\n cue:\n - 2\n",
songs_dir.display()
),
)
.unwrap();
assert!(verify(config_path.to_str().unwrap(), None, None).is_ok());
}
#[test]
fn verify_with_specific_check_track_mappings() {
let tmp = tempfile::tempdir().unwrap();
let songs_dir = create_songs_dir(tmp.path(), "Test Song", &["click"]);
let config_path = tmp.path().join("mtrack.yaml");
std::fs::write(
&config_path,
format!(
"songs: {}\naudio:\n device: mock\ntrack_mappings:\n click:\n - 1\n",
songs_dir.display()
),
)
.unwrap();
assert!(verify(
config_path.to_str().unwrap(),
Some(vec!["track-mappings".to_string()]),
None
)
.is_ok());
}
#[test]
fn verify_with_unrelated_check_skips_track_mappings() {
let tmp = tempfile::tempdir().unwrap();
let songs_dir = create_songs_dir(tmp.path(), "Test Song", &["click"]);
let config_path = tmp.path().join("mtrack.yaml");
std::fs::write(
&config_path,
format!(
"songs: {}\naudio:\n device: mock\ntrack_mappings:\n click:\n - 1\n",
songs_dir.display()
),
)
.unwrap();
assert!(verify(
config_path.to_str().unwrap(),
Some(vec!["other-check".to_string()]),
None
)
.is_ok());
}
#[test]
fn verify_multi_profile_with_hostname_filter() {
let tmp = tempfile::tempdir().unwrap();
let songs_dir = create_songs_dir(tmp.path(), "Test Song", &["click", "cue"]);
let config_path = tmp.path().join("mtrack.yaml");
std::fs::write(
&config_path,
format!(
r#"songs: {}
profiles:
- hostname: pi-a
audio:
device: mock-a
track_mappings:
click:
- 1
cue:
- 2
- hostname: pi-b
audio:
device: mock-b
track_mappings:
click:
- 3
cue:
- 4
"#,
songs_dir.display()
),
)
.unwrap();
assert!(verify(
config_path.to_str().unwrap(),
None,
Some("pi-a".to_string())
)
.is_ok());
}
#[test]
fn verify_multi_profile_without_hostname() {
let tmp = tempfile::tempdir().unwrap();
let songs_dir = create_songs_dir(tmp.path(), "Test Song", &["click", "cue"]);
let config_path = tmp.path().join("mtrack.yaml");
std::fs::write(
&config_path,
format!(
r#"songs: {}
profiles:
- hostname: pi-a
audio:
device: mock-a
track_mappings:
click:
- 1
cue:
- 2
- hostname: pi-b
audio:
device: mock-b
track_mappings:
click:
- 3
cue:
- 4
"#,
songs_dir.display()
),
)
.unwrap();
assert!(verify(config_path.to_str().unwrap(), None, None).is_ok());
}
#[test]
fn verify_multi_profile_with_nonmatching_hostname() {
let tmp = tempfile::tempdir().unwrap();
let songs_dir = create_songs_dir(tmp.path(), "Test Song", &["click"]);
let config_path = tmp.path().join("mtrack.yaml");
std::fs::write(
&config_path,
format!(
r#"songs: {}
profiles:
- hostname: pi-a
audio:
device: mock-a
track_mappings:
click:
- 1
- hostname: pi-b
audio:
device: mock-b
track_mappings:
click:
- 3
"#,
songs_dir.display()
),
)
.unwrap();
assert!(verify(
config_path.to_str().unwrap(),
None,
Some("nonexistent-host".to_string())
)
.is_ok());
}
#[test]
fn verify_profile_without_audio_skips() {
let tmp = tempfile::tempdir().unwrap();
let songs_dir = create_songs_dir(tmp.path(), "Test Song", &["click"]);
let config_path = tmp.path().join("mtrack.yaml");
std::fs::write(
&config_path,
format!(
r#"songs: {}
profiles:
- hostname: lighting-node
dmx:
universes:
- universe: 1
name: light-show
- hostname: audio-node
audio:
device: mock
track_mappings:
click:
- 1
"#,
songs_dir.display()
),
)
.unwrap();
assert!(verify(config_path.to_str().unwrap(), None, None).is_ok());
}
#[test]
fn verify_profile_without_hostname_shows_any_host_label() {
let tmp = tempfile::tempdir().unwrap();
let songs_dir = create_songs_dir(tmp.path(), "Test Song", &["click"]);
let config_path = tmp.path().join("mtrack.yaml");
std::fs::write(
&config_path,
format!(
r#"songs: {}
profiles:
- hostname: pi-a
audio:
device: mock-a
track_mappings:
click:
- 1
- audio:
device: fallback
track_mappings:
click:
- 1
"#,
songs_dir.display()
),
)
.unwrap();
assert!(verify(config_path.to_str().unwrap(), None, None).is_ok());
}
}
}