mod hardware;
mod navigation;
mod playback;
use midly::live::LiveEvent;
use parking_lot::RwLock;
use std::fmt;
use std::{
collections::HashMap,
error::Error,
path::{Path, PathBuf},
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
time::{Duration, SystemTime},
};
use tokio::{
sync::{oneshot, Mutex},
task::JoinHandle,
};
use tokio_util::sync::CancellationToken;
use tracing::{error, info, span, warn, Level, Span};
use crate::samples::SampleEngine;
use crate::songs::Songs;
use crate::trigger::TriggerEngine;
use crate::{
audio, config, dmx, midi,
playlist::{self, Playlist},
playsync::CancelHandle,
songs::Song,
};
enum PlaylistDirection {
Next,
Prev,
}
impl fmt::Display for PlaylistDirection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PlaylistDirection::Next => write!(f, "next"),
PlaylistDirection::Prev => write!(f, "previous"),
}
}
}
#[derive(Clone)]
enum ClockSource {
Audio {
sample_counter: Arc<std::sync::atomic::AtomicU64>,
sample_rate: u32,
},
Wall,
}
impl ClockSource {
fn new_clock(&self) -> crate::clock::PlaybackClock {
match self {
ClockSource::Audio {
sample_counter,
sample_rate,
} => crate::clock::PlaybackClock::from_sample_counter(
sample_counter.clone(),
*sample_rate,
),
ClockSource::Wall => crate::clock::PlaybackClock::wall(),
}
}
}
pub trait SongChangeNotifier: Send + Sync {
fn notify(&self, song: &Song);
}
#[derive(Clone)]
struct HardwareState {
device: Option<Arc<dyn audio::Device>>,
mappings: Option<Arc<HashMap<String, Vec<u16>>>>,
midi_device: Option<Arc<dyn midi::Device>>,
dmx_engine: Option<Arc<dmx::engine::Engine>>,
sample_engine: Option<Arc<RwLock<SampleEngine>>>,
trigger_engine: Option<Arc<TriggerEngine>>,
clock_source: ClockSource,
song_change_notifiers: Vec<Arc<dyn SongChangeNotifier>>,
profile_name: Option<String>,
hostname: Option<String>,
}
type StateTx = Arc<
parking_lot::Mutex<Option<Arc<tokio::sync::watch::Sender<Arc<crate::state::StateSnapshot>>>>>,
>;
struct PlayHandles {
join: JoinHandle<()>,
cancel: CancelHandle,
}
struct PlaybackContext {
device: Option<Arc<dyn audio::Device>>,
mappings: Option<Arc<HashMap<String, Vec<u16>>>>,
midi_device: Option<Arc<dyn midi::Device>>,
dmx_engine: Option<Arc<dmx::engine::Engine>>,
clock: crate::clock::PlaybackClock,
song: Arc<Song>,
cancel_handle: CancelHandle,
play_tx: oneshot::Sender<Result<(), String>>,
start_time: Duration,
play_start_time: Arc<Mutex<Option<SystemTime>>>,
loop_control: crate::playsync::LoopControl,
}
pub struct PlayerDevices {
pub audio: Option<Arc<dyn audio::Device>>,
pub mappings: Option<Arc<HashMap<String, Vec<u16>>>>,
pub midi: Option<Arc<dyn midi::Device>>,
pub dmx_engine: Option<Arc<dmx::engine::Engine>>,
pub sample_engine: Option<Arc<RwLock<SampleEngine>>>,
pub trigger_engine: Option<Arc<TriggerEngine>>,
}
#[derive(Clone, serde::Serialize)]
pub struct SubsystemStatus {
pub status: String,
pub name: Option<String>,
}
#[derive(Clone, serde::Serialize)]
pub struct HardwareStatusSnapshot {
pub init_done: bool,
pub hostname: Option<String>,
pub profile: Option<String>,
pub audio: SubsystemStatus,
pub midi: SubsystemStatus,
pub dmx: SubsystemStatus,
pub trigger: SubsystemStatus,
}
#[derive(Clone)]
pub struct Player {
hardware: Arc<parking_lot::RwLock<HardwareState>>,
base_path: Option<PathBuf>,
playlists: Arc<parking_lot::RwLock<HashMap<String, Arc<Playlist>>>>,
active_playlist: Arc<parking_lot::RwLock<String>>,
persisted_playlist: Arc<parking_lot::RwLock<String>>,
play_start_time: Arc<Mutex<Option<SystemTime>>>,
join: Arc<Mutex<Option<PlayHandles>>>,
stop_run: Arc<AtomicBool>,
span: Span,
config_store: Arc<parking_lot::Mutex<Option<Arc<config::ConfigStore>>>>,
init_cancel: Arc<parking_lot::Mutex<CancellationToken>>,
broadcast_tx: Arc<parking_lot::Mutex<Option<tokio::sync::broadcast::Sender<String>>>>,
init_done_tx: Arc<tokio::sync::watch::Sender<bool>>,
state_tx: StateTx,
locked: Arc<AtomicBool>,
controller: Arc<parking_lot::Mutex<Option<crate::controller::Controller>>>,
loop_break: Arc<AtomicBool>,
active_section: Arc<parking_lot::RwLock<Option<SectionBounds>>>,
section_loop_break: Arc<AtomicBool>,
loop_time_consumed: Arc<parking_lot::Mutex<Duration>>,
reactive_loop_state: Arc<parking_lot::RwLock<ReactiveLoopState>>,
notification_engine: Arc<crate::notification::NotificationEngine>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SectionBounds {
pub name: String,
pub start_time: Duration,
pub end_time: Duration,
}
#[derive(Debug, Clone, Default)]
pub enum ReactiveLoopState {
#[default]
Idle,
SectionOffered(SectionBounds),
LoopArmed(SectionBounds),
Looping(SectionBounds),
BreakRequested(SectionBounds),
}
impl Player {
pub fn new(
playlists: HashMap<String, Arc<Playlist>>,
active_playlist: String,
config: &config::Player,
base_path: Option<&Path>,
) -> Result<Arc<Player>, Box<dyn Error>> {
let devices = PlayerDevices {
audio: None,
mappings: None,
midi: None,
dmx_engine: None,
sample_engine: None,
trigger_engine: None,
};
let player = Arc::new(Self::new_with_devices(
devices,
playlists,
active_playlist,
base_path,
)?);
player.init_done_tx.send_modify(|v| *v = false);
let init_player = player.clone();
let config = config.clone();
let bp = base_path.map(Path::to_path_buf);
tokio::spawn(async move {
init_player.init_hardware_async(config, bp).await;
});
Ok(player)
}
pub fn new_with_devices(
devices: PlayerDevices,
playlists: HashMap<String, Arc<Playlist>>,
active_playlist: String,
base_path: Option<&Path>,
) -> Result<Player, Box<dyn Error>> {
let clock_source = match devices
.audio
.as_ref()
.and_then(|d| Some((d.sample_counter()?, d.sample_rate()?)))
{
Some((counter, rate)) => ClockSource::Audio {
sample_counter: counter,
sample_rate: rate,
},
None => ClockSource::Wall,
};
let hw = HardwareState {
device: devices.audio,
mappings: devices.mappings,
midi_device: devices.midi,
dmx_engine: devices.dmx_engine,
sample_engine: devices.sample_engine,
trigger_engine: devices.trigger_engine,
clock_source,
song_change_notifiers: Vec::new(),
profile_name: None,
hostname: None,
};
let (init_done_tx, _init_done_rx) = tokio::sync::watch::channel(true);
let resolved_active = if playlists.contains_key(&active_playlist) {
active_playlist
} else {
"all_songs".to_string()
};
Ok(Player {
hardware: Arc::new(parking_lot::RwLock::new(hw)),
base_path: base_path.map(Path::to_path_buf),
playlists: Arc::new(parking_lot::RwLock::new(playlists)),
active_playlist: Arc::new(parking_lot::RwLock::new(resolved_active.clone())),
persisted_playlist: Arc::new(parking_lot::RwLock::new(
if resolved_active == "all_songs" {
"playlist".to_string()
} else {
resolved_active
},
)),
play_start_time: Arc::new(Mutex::new(None)),
join: Arc::new(Mutex::new(None)),
stop_run: Arc::new(AtomicBool::new(false)),
span: span!(Level::INFO, "player"),
config_store: Arc::new(parking_lot::Mutex::new(None)),
init_cancel: Arc::new(parking_lot::Mutex::new(CancellationToken::new())),
broadcast_tx: Arc::new(parking_lot::Mutex::new(None)),
init_done_tx: Arc::new(init_done_tx),
state_tx: Arc::new(parking_lot::Mutex::new(None)),
locked: Arc::new(AtomicBool::new(true)),
controller: Arc::new(parking_lot::Mutex::new(None)),
loop_break: Arc::new(AtomicBool::new(false)),
active_section: Arc::new(parking_lot::RwLock::new(None)),
section_loop_break: Arc::new(AtomicBool::new(false)),
loop_time_consumed: Arc::new(parking_lot::Mutex::new(Duration::ZERO)),
reactive_loop_state: Arc::new(parking_lot::RwLock::new(ReactiveLoopState::Idle)),
notification_engine: Arc::new(crate::notification::NotificationEngine::with_defaults(
44100,
)),
})
}
#[cfg(test)]
pub async fn await_hardware_ready(&self) {
let mut rx = self.init_done_tx.subscribe();
while !*rx.borrow_and_update() {
if rx.changed().await.is_err() {
break;
}
}
}
#[cfg(test)]
pub fn audio_device(&self) -> Option<Arc<dyn audio::Device>> {
self.hardware.read().device.clone()
}
pub fn midi_device(&self) -> Option<Arc<dyn midi::Device>> {
self.hardware.read().midi_device.clone()
}
pub fn process_sample_trigger(&self, raw_event: &[u8]) {
let sample_engine = self.hardware.read().sample_engine.clone();
if let Some(ref sample_engine) = sample_engine {
let engine = sample_engine.read();
engine.process_midi_event(raw_event);
}
}
fn load_song_samples(&self, song: &Song) {
let sample_engine = self.hardware.read().sample_engine.clone();
if let Some(ref sample_engine) = sample_engine {
let samples_config = song.samples_config();
if !samples_config.samples().is_empty() || !samples_config.sample_triggers().is_empty()
{
let mut engine = sample_engine.write();
if let Err(e) = engine.load_song_config(samples_config, song.base_path()) {
warn!(
song = song.name(),
error = %e,
"Failed to load song sample config"
);
} else {
info!(
song = song.name(),
samples = samples_config.samples().len(),
triggers = samples_config.sample_triggers().len(),
"Loaded song sample config"
);
}
}
}
}
pub fn stop_samples(&self) {
let sample_engine = self.hardware.read().sample_engine.clone();
if let Some(ref sample_engine) = sample_engine {
let engine = sample_engine.read();
engine.stop_all();
}
}
#[cfg(test)]
pub fn dmx_engine(&self) -> Option<Arc<dmx::engine::Engine>> {
self.hardware.read().dmx_engine.clone()
}
pub fn get_cues(&self) -> Vec<(Duration, usize)> {
let dmx_engine = self.hardware.read().dmx_engine.clone();
if let Some(ref dmx_engine) = dmx_engine {
dmx_engine.get_timeline_cues()
} else {
Vec::new()
}
}
pub fn broadcast_handles(&self) -> Option<dmx::engine::BroadcastHandles> {
self.hardware
.read()
.dmx_engine
.clone()
.map(|e| e.broadcast_handles())
}
pub fn set_broadcast_tx(&self, tx: tokio::sync::broadcast::Sender<String>) {
let dmx_engine = self.hardware.read().dmx_engine.clone();
if let Some(ref engine) = dmx_engine {
engine.set_broadcast_tx(tx.clone());
}
*self.broadcast_tx.lock() = Some(tx);
}
pub fn set_state_tx(&self, tx: tokio::sync::watch::Sender<Arc<crate::state::StateSnapshot>>) {
*self.state_tx.lock() = Some(Arc::new(tx));
}
pub fn set_config_store(&self, store: Arc<config::ConfigStore>) {
*self.config_store.lock() = Some(store);
}
pub fn config_store(&self) -> Option<Arc<config::ConfigStore>> {
self.config_store.lock().clone()
}
pub fn track_mappings(&self) -> Option<Arc<HashMap<String, Vec<u16>>>> {
self.hardware.read().mappings.clone()
}
pub fn is_locked(&self) -> bool {
self.locked.load(Ordering::Relaxed)
}
pub fn set_locked(&self, locked: bool) {
self.locked.store(locked, Ordering::Relaxed);
}
#[cfg(test)]
pub fn effect_engine(&self) -> Option<Arc<parking_lot::Mutex<crate::lighting::EffectEngine>>> {
self.hardware
.read()
.dmx_engine
.clone()
.map(|e| e.effect_engine())
}
pub fn format_active_effects(&self) -> Option<String> {
self.hardware
.read()
.dmx_engine
.clone()
.map(|engine| engine.format_active_effects())
}
fn emit_song_change(&self, song: &Song) {
let hw = self.hardware.read();
let midi_device = hw.midi_device.clone();
let notifiers = hw.song_change_notifiers.clone();
drop(hw);
if let Some(ref device) = midi_device {
if let Err(e) = device.emit(song.midi_event()) {
error!("Error emitting MIDI event: {:?}", e);
}
}
for notifier in ¬ifiers {
notifier.notify(song);
}
}
}
pub struct StatusEvents {
off_events: Vec<LiveEvent<'static>>,
idling_events: Vec<LiveEvent<'static>>,
playing_events: Vec<LiveEvent<'static>>,
}
impl StatusEvents {
pub fn new(
config: Option<config::StatusEvents>,
) -> Result<Option<StatusEvents>, Box<dyn Error>> {
Ok(match config {
Some(config) => Some(StatusEvents {
off_events: config.off_events()?,
idling_events: config.idling_events()?,
playing_events: config.playing_events()?,
}),
None => None,
})
}
}
#[derive(Debug)]
enum PlaybackResult {
Success,
Failed(String),
SenderDropped,
}
#[derive(Debug, PartialEq)]
enum CleanupAction {
AdvancePlaylist,
StopCancelled,
LoopBreakAndPlay,
}
fn decide_cleanup_action(
result: PlaybackResult,
cancelled: bool,
loop_broken: bool,
) -> CleanupAction {
if loop_broken {
return CleanupAction::LoopBreakAndPlay;
}
if cancelled {
return CleanupAction::StopCancelled;
}
match &result {
PlaybackResult::Failed(e) => {
warn!(
err = %e,
"Advancing playlist despite playback failure so user is not stuck"
);
}
PlaybackResult::SenderDropped => {
error!("Error receiving playback signal (receiver dropped)");
}
PlaybackResult::Success => {}
}
CleanupAction::AdvancePlaylist
}
fn resolve_playback_outcome(
has_audio: bool,
audio_outcome: Option<Result<(), String>>,
) -> Result<(), String> {
if has_audio {
audio_outcome.unwrap_or_else(|| {
warn!(
"Audio thread did not set outcome (e.g. panicked before setting); \
treating as success so playlist is not stuck"
);
Ok(())
})
} else {
Ok(())
}
}
pub fn load_playlists(
playlists_dir: Option<&Path>,
legacy_playlist_path: Option<&Path>,
songs: Arc<Songs>,
) -> Result<HashMap<String, Arc<Playlist>>, Box<dyn Error>> {
let mut playlists = HashMap::new();
playlists.insert(
"all_songs".to_string(),
playlist::from_songs(songs.clone())?,
);
if let Some(dir) = playlists_dir {
if dir.is_dir() {
let mut entries: Vec<_> = std::fs::read_dir(dir)?
.filter_map(|e| e.ok())
.filter(|e| {
e.path().is_file()
&& e.path()
.extension()
.is_some_and(|ext| ext == "yaml" || ext == "yml")
})
.collect();
entries.sort_by_key(|e| e.file_name());
for entry in entries {
let path = entry.path();
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_string();
if name.is_empty() || name == "all_songs" {
continue;
}
match config::Playlist::deserialize(&path) {
Ok(playlist_config) => {
match Playlist::new(&name, &playlist_config, songs.clone()) {
Ok(pl) => {
info!(name = %name, "Loaded playlist from directory");
playlists.insert(name, pl);
}
Err(e) => {
warn!(name = %name, error = %e, "Playlist references missing songs, skipping");
}
}
}
Err(e) => {
warn!(path = ?path, error = %e, "Failed to parse playlist file, skipping");
}
}
}
}
}
if let Some(legacy_path) = legacy_playlist_path {
if !playlists.contains_key("playlist") {
match config::Playlist::deserialize(legacy_path) {
Ok(playlist_config) => {
match Playlist::new("playlist", &playlist_config, songs.clone()) {
Ok(pl) => {
info!("Loaded legacy playlist");
playlists.insert("playlist".to_string(), pl);
}
Err(e) => {
info!("Legacy playlist references missing songs ({}); skipping", e);
}
}
}
Err(_) => {
info!("Legacy playlist file not found or invalid; skipping");
}
}
}
}
Ok(playlists)
}
#[cfg(test)]
mod test {
use std::{collections::HashMap, error::Error, fs, path::Path, sync::Arc};
use crate::{
config,
playlist::Playlist,
songs,
testutil::{eventually, eventually_async},
};
use super::*;
fn test_playlists(
playlist: Arc<Playlist>,
songs: Arc<Songs>,
) -> HashMap<String, Arc<Playlist>> {
let mut playlists = HashMap::new();
playlists.insert(
"all_songs".to_string(),
playlist::from_songs(songs).unwrap(),
);
playlists.insert("playlist".to_string(), playlist);
playlists
}
#[tokio::test(flavor = "multi_thread")]
async fn test_player() -> Result<(), Box<dyn Error>> {
let songs = songs::get_all_songs(Path::new("assets/songs"))?;
let playlist = Playlist::new(
"playlist",
&config::Playlist::deserialize(Path::new("assets/playlist.yaml"))?,
songs.clone(),
)?;
let player = Player::new(
test_playlists(playlist, songs.clone()),
"playlist".to_string(),
&config::Player::new(
vec![],
Some(config::Audio::new("mock-device")),
Some(config::Midi::new("mock-midi-device", None)),
None,
HashMap::new(),
"assets/songs",
),
None,
)?;
player.await_hardware_ready().await;
let binding = player
.audio_device()
.expect("audio device should be present");
let device = binding.to_mock()?;
let midi_device = player
.midi_device()
.expect("MIDI should be present")
.to_mock()?;
println!("Playlist -> Song 1");
assert_eq!(player.get_playlist().current().unwrap().name(), "Song 1");
player.next().await;
println!("Playlist -> Song 3");
assert_eq!(player.get_playlist().current().unwrap().name(), "Song 3");
player.prev().await;
println!("Playlist -> Song 1");
assert_eq!(player.get_playlist().current().unwrap().name(), "Song 1");
println!("Switch to AllSongs");
player.switch_to_playlist("all_songs").await.unwrap();
assert_eq!(player.get_playlist().current().unwrap().name(), "Song 1");
player.next().await;
println!("AllSongs -> Song 10");
assert_eq!(player.get_playlist().current().unwrap().name(), "Song 10");
assert!(midi_device.get_emitted_event().is_none());
player.next().await;
println!("AllSongs -> Song 2");
assert_eq!(player.get_playlist().current().unwrap().name(), "Song 2");
let expected_event = midly::live::LiveEvent::Midi {
channel: 15.into(),
message: midly::MidiMessage::ProgramChange { program: 0.into() },
};
let actual_event_buf = midi_device
.get_emitted_event()
.expect("expected emitted event");
let actual_event = midly::live::LiveEvent::parse(&actual_event_buf)?;
assert_eq!(expected_event, actual_event);
midi_device.reset_emitted_event();
player.next().await;
println!("AllSongs -> Song 3");
assert_eq!(player.get_playlist().current().unwrap().name(), "Song 3");
assert!(midi_device.get_emitted_event().is_none());
player.switch_to_playlist("playlist").await.unwrap();
println!("Switch to Playlist");
assert_eq!(player.get_playlist().current().unwrap().name(), "Song 1");
player.next().await;
println!("Playlist -> Song 3");
assert_eq!(player.get_playlist().current().unwrap().name(), "Song 3");
player.play().await?;
eventually(
|| player.get_playlist().current().unwrap().name() == "Song 5",
format!(
"Song never moved to next, on song {}",
player.get_playlist().current().unwrap().name()
)
.as_str(),
);
let expected_event = midly::live::LiveEvent::Midi {
channel: 15.into(),
message: midly::MidiMessage::ProgramChange { program: 5.into() },
};
let actual_event_buf = midi_device
.get_emitted_event()
.expect("expected emitted event");
let actual_event = midly::live::LiveEvent::parse(&actual_event_buf)?;
assert_eq!(expected_event, actual_event);
midi_device.reset_emitted_event();
player.play().await?;
println!("Play Song 5.");
eventually(|| device.is_playing(), "Song never started playing");
player.stop().await;
eventually(|| !device.is_playing(), "Song never stopped playing");
assert_eq!(player.get_playlist().current().unwrap().name(), "Song 5");
assert!(midi_device.get_emitted_event().is_none());
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_player_rejects_invalid_lighting_shows() -> Result<(), Box<dyn Error>> {
let temp_dir = tempfile::tempdir()?;
let temp_path = temp_dir.path();
let lighting_show_content = r#"show "Test Show" {
@00:00.000
invalid_group: static color: "blue", duration: 5s, dimmer: 60%
}"#;
let lighting_file = temp_path.join("invalid_show.light");
fs::write(&lighting_file, lighting_show_content)?;
let song_config = config::Song::new(
"Test Song",
None,
None,
None,
None,
Some(vec![config::LightingShow::new(
lighting_file
.file_name()
.unwrap()
.to_str()
.unwrap()
.to_string(),
)]),
vec![],
HashMap::new(),
Vec::new(),
);
let mut groups = HashMap::new();
groups.insert(
"front_wash".to_string(),
config::lighting::LogicalGroup::new(
"front_wash".to_string(),
vec![config::lighting::GroupConstraint::AllOf(vec![
"wash".to_string(),
"front".to_string(),
])],
),
);
let lighting_config =
config::Lighting::new(Some("test_venue".to_string()), None, Some(groups), None);
let dmx_config = config::Dmx::new(
Some(1.0),
Some("0s".to_string()),
Some(9090),
vec![config::Universe::new(1, "test_universe".to_string())],
Some(lighting_config),
);
let playlist_songs = vec!["Test Song".to_string()];
let playlist_config = config::Playlist::new(&playlist_songs);
let song = songs::Song::new(temp_path, &song_config)?;
let songs_map = HashMap::from([("Test Song".to_string(), Arc::new(song))]);
let songs = Arc::new(songs::Songs::new(songs_map));
let playlist = Playlist::new("Test Playlist", &playlist_config, songs.clone())?;
let player = Player::new(
test_playlists(playlist, songs.clone()),
"playlist".to_string(),
&config::Player::new(
vec![],
Some(config::Audio::new("mock-device")),
Some(config::Midi::new("mock-midi-device", None)),
Some(dmx_config),
HashMap::new(),
temp_path.to_str().unwrap(),
),
Some(temp_path),
)?;
player.await_hardware_ready().await;
let result = player.play().await;
assert!(
result.is_err(),
"Player should reject song with invalid lighting show"
);
Ok(())
}
async fn make_test_player_with_config(
audio: Option<config::Audio>,
midi: Option<config::Midi>,
dmx: Option<config::Dmx>,
) -> Result<Arc<Player>, Box<dyn Error>> {
let songs = songs::get_all_songs(Path::new("assets/songs"))?;
let playlist = Playlist::new(
"playlist",
&config::Playlist::deserialize(Path::new("assets/playlist.yaml"))?,
songs.clone(),
)?;
let player = Player::new(
test_playlists(playlist, songs),
"playlist".to_string(),
&config::Player::new(vec![], audio, midi, dmx, HashMap::new(), "assets/songs"),
None,
)?;
player.await_hardware_ready().await;
Ok(player)
}
async fn make_test_player() -> Result<Arc<Player>, Box<dyn Error>> {
make_test_player_with_config(
Some(config::Audio::new("mock-device")),
Some(config::Midi::new("mock-midi-device", None)),
None,
)
.await
}
#[tokio::test(flavor = "multi_thread")]
async fn test_stop_when_not_playing() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
let result = player.stop().await;
assert!(result.is_none());
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_is_playing() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
let binding = player
.audio_device()
.expect("audio device should be present");
let device = binding.to_mock()?;
assert!(!player.is_playing().await);
player.play().await?;
eventually(|| device.is_playing(), "Song never started playing");
assert!(player.is_playing().await);
player.stop().await;
eventually(|| !device.is_playing(), "Song never stopped playing");
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_elapsed_stopped() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
let elapsed = player.elapsed().await?;
assert!(elapsed.is_none());
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_elapsed_while_playing() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
let binding = player
.audio_device()
.expect("audio device should be present");
let device = binding.to_mock()?;
player.play().await?;
eventually(|| device.is_playing(), "Song never started playing");
let deadline = std::time::Instant::now() + Duration::from_secs(3);
loop {
if player.elapsed().await?.is_some() {
break;
}
assert!(
std::time::Instant::now() < deadline,
"elapsed should have a value while playing"
);
tokio::time::sleep(Duration::from_millis(10)).await;
}
player.stop().await;
eventually(|| !device.is_playing(), "Song never stopped playing");
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_concurrent_play_returns_none() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
let binding = player
.audio_device()
.expect("audio device should be present");
let device = binding.to_mock()?;
let result = player.play().await?;
assert!(result.is_some());
eventually(|| device.is_playing(), "Song never started playing");
let result = player.play().await?;
assert!(result.is_none(), "play() while playing should return None");
player.stop().await;
eventually(|| !device.is_playing(), "Song never stopped playing");
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_navigation() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
assert_eq!(player.get_playlist().current().unwrap().name(), "Song 1");
let song = player.next().await.unwrap();
assert_eq!(song.name(), "Song 3");
assert_eq!(player.get_playlist().current().unwrap().name(), "Song 3");
let song = player.prev().await.unwrap();
assert_eq!(song.name(), "Song 1");
assert_eq!(player.get_playlist().current().unwrap().name(), "Song 1");
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_switch_playlists() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
assert_eq!(player.get_playlist().name(), "playlist");
player.switch_to_playlist("all_songs").await.unwrap();
assert_eq!(player.get_playlist().name(), "all_songs");
player.switch_to_playlist("playlist").await.unwrap();
assert_eq!(player.get_playlist().name(), "playlist");
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_get_all_songs_playlist() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
let all = player
.get_all_songs_playlist()
.expect("all_songs should be present in test");
assert_eq!(all.name(), "all_songs");
assert!(!all.songs().is_empty());
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_format_active_effects_no_dmx() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
assert!(player.format_active_effects().is_none());
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_dmx_engine_none_without_config() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
assert!(player.dmx_engine().is_none());
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_get_cues_empty_without_lighting() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
let cues = player.get_cues();
assert!(cues.is_empty());
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_player_rejects_song_with_multiple_invalid_groups() -> Result<(), Box<dyn Error>> {
let temp_dir = tempfile::tempdir()?;
let temp_path = temp_dir.path();
let lighting_show_content = r#"show "Test Show" {
@00:00.000
invalid_group_1: static color: "blue", duration: 5s, dimmer: 60%
invalid_group_2: static color: "red", duration: 5s, dimmer: 80%
}"#;
let lighting_file = temp_path.join("invalid_groups.light");
fs::write(&lighting_file, lighting_show_content)?;
let song_config = config::Song::new(
"Test Song",
None,
None,
None,
None,
Some(vec![config::LightingShow::new(
lighting_file
.file_name()
.unwrap()
.to_str()
.unwrap()
.to_string(),
)]),
vec![],
HashMap::new(),
Vec::new(),
);
let mut groups = HashMap::new();
groups.insert(
"front_wash".to_string(),
config::lighting::LogicalGroup::new(
"front_wash".to_string(),
vec![config::lighting::GroupConstraint::AllOf(vec![
"wash".to_string(),
"front".to_string(),
])],
),
);
let lighting_config =
config::Lighting::new(Some("test_venue".to_string()), None, Some(groups), None);
let dmx_config = config::Dmx::new(
Some(1.0),
Some("0s".to_string()),
Some(9090),
vec![config::Universe::new(1, "test_universe".to_string())],
Some(lighting_config),
);
let playlist_songs = vec!["Test Song".to_string()];
let playlist_config = config::Playlist::new(&playlist_songs);
let song = songs::Song::new(temp_path, &song_config)?;
let songs_map = HashMap::from([("Test Song".to_string(), Arc::new(song))]);
let songs = Arc::new(songs::Songs::new(songs_map));
let playlist = Playlist::new("Test Playlist", &playlist_config, songs.clone())?;
let player = Player::new(
test_playlists(playlist, songs.clone()),
"playlist".to_string(),
&config::Player::new(
vec![],
Some(config::Audio::new("mock-device")),
Some(config::Midi::new("mock-midi-device", None)),
Some(dmx_config),
HashMap::new(),
temp_path.to_str().unwrap(),
),
Some(temp_path),
)?;
player.await_hardware_ready().await;
let result = player.play().await;
assert!(
result.is_err(),
"Player should reject song with invalid lighting show groups"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_stop_returns_current_song() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
let binding = player
.audio_device()
.expect("audio device should be present");
let device = binding.to_mock()?;
player.play().await?;
eventually(|| device.is_playing(), "Song never started playing");
let song = player.stop().await;
assert!(song.is_some(), "stop() should return the current song");
assert_eq!(song.unwrap().name(), "Song 1");
eventually(|| !device.is_playing(), "Song never stopped playing");
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_play_from_nonzero_start() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
let binding = player
.audio_device()
.expect("audio device should be present");
let device = binding.to_mock()?;
let result = player.play_from(Duration::from_millis(100)).await?;
assert!(result.is_some(), "play_from should succeed");
eventually(|| device.is_playing(), "Song never started playing");
player.stop().await;
eventually(|| !device.is_playing(), "Song never stopped playing");
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_play_after_stop_restarts() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
let binding = player
.audio_device()
.expect("audio device should be present");
let device = binding.to_mock()?;
player.play().await?;
eventually(|| device.is_playing(), "Song never started playing");
player.stop().await;
eventually(|| !device.is_playing(), "Song never stopped playing");
let result = player.play().await?;
assert!(
result.is_some(),
"play() after stop should start a new song"
);
eventually(|| device.is_playing(), "Song never restarted");
player.stop().await;
eventually(|| !device.is_playing(), "Song never stopped playing");
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_audio_only_no_midi() -> Result<(), Box<dyn Error>> {
let player =
make_test_player_with_config(Some(config::Audio::new("mock-device")), None, None)
.await?;
let binding = player
.audio_device()
.expect("audio device should be present");
let device = binding.to_mock()?;
assert!(
player.midi_device().is_none(),
"MIDI device should be absent"
);
player.get_playlist().next(); player.get_playlist().next(); player.switch_to_playlist("all_songs").await.unwrap();
player.get_playlist().next(); player.get_playlist().next(); assert_eq!(player.get_playlist().current().unwrap().name(), "Song 2");
player.play().await?;
eventually(|| device.is_playing(), "Song never started playing");
player.stop().await;
eventually(|| !device.is_playing(), "Song never stopped playing");
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_midi_only_no_audio() -> Result<(), Box<dyn Error>> {
let player = make_test_player_with_config(
None,
Some(config::Midi::new("mock-midi-device", None)),
None,
)
.await?;
assert!(
player.audio_device().is_none(),
"Audio device should be absent"
);
assert_eq!(player.get_playlist().current().unwrap().name(), "Song 1");
player.play().await?;
eventually_async(
|| async { player.get_playlist().current().unwrap().name() != "Song 1" },
"Playlist never advanced after MIDI-only playback",
)
.await;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_no_subsystems_completes_immediately() -> Result<(), Box<dyn Error>> {
let songs = songs::get_all_songs(Path::new("assets/songs"))?;
let playlist = Playlist::new(
"playlist",
&config::Playlist::deserialize(Path::new("assets/playlist.yaml"))?,
songs.clone(),
)?;
let devices = PlayerDevices {
audio: None,
mappings: None,
midi: None,
dmx_engine: None,
sample_engine: None,
trigger_engine: None,
};
let player = Player::new_with_devices(
devices,
test_playlists(playlist, songs),
"playlist".to_string(),
None,
)?;
assert!(player.audio_device().is_none());
assert!(player.midi_device().is_none());
assert!(player.dmx_engine().is_none());
player.play().await?;
eventually_async(
|| async { player.get_playlist().current().unwrap().name() != "Song 1" },
"Playlist never advanced after no-subsystem playback",
)
.await;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_natural_finish_clears_play_state() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
player.play().await?;
eventually_async(
|| async { !player.is_playing().await },
"Player never stopped after natural finish",
)
.await;
let elapsed = player.elapsed().await?;
assert!(
elapsed.is_none(),
"elapsed() should be None after natural finish"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_play_with_dmx_engine() -> Result<(), Box<dyn Error>> {
let dmx_config = config::Dmx::new(
None,
None,
Some(9090),
vec![config::Universe::new(1, "test".to_string())],
None,
);
let player = make_test_player_with_config(
Some(config::Audio::new("mock-device")),
None,
Some(dmx_config),
)
.await?;
assert!(
player.dmx_engine().is_some(),
"DMX engine should be present"
);
let binding = player
.audio_device()
.expect("audio device should be present");
let device = binding.to_mock()?;
player.play().await?;
eventually(|| device.is_playing(), "Song never started playing");
player.stop().await;
eventually(|| !device.is_playing(), "Song never stopped playing");
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_switch_playlist_while_playing_stays() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
let binding = player
.audio_device()
.expect("audio device should be present");
let device = binding.to_mock()?;
assert_eq!(player.get_playlist().name(), "playlist");
player.play().await?;
eventually(|| device.is_playing(), "Song never started playing");
let result = player.switch_to_playlist("all_songs").await;
assert!(
result.is_err(),
"switch_to_playlist should fail while playing"
);
assert_eq!(
player.get_playlist().name(),
"playlist",
"playlist should not change while playing"
);
player.stop().await;
eventually(|| !device.is_playing(), "Song never stopped playing");
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_playlist_clamps_at_end_on_natural_finish() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
player.next().await; player.next().await; player.next().await; player.next().await; assert_eq!(player.get_playlist().current().unwrap().name(), "Song 9");
player.play().await?;
eventually_async(
|| async { !player.is_playing().await },
"Player never stopped after Song 9 finished",
)
.await;
assert_eq!(player.get_playlist().current().unwrap().name(), "Song 9");
Ok(())
}
#[test]
fn playback_outcome_no_audio() {
assert_eq!(resolve_playback_outcome(false, None), Ok(()));
}
#[test]
fn playback_outcome_audio_ok() {
assert_eq!(resolve_playback_outcome(true, Some(Ok(()))), Ok(()));
}
#[test]
fn playback_outcome_audio_err() {
let err_msg = "device disconnected".to_string();
assert_eq!(
resolve_playback_outcome(true, Some(Err(err_msg.clone()))),
Err(err_msg)
);
}
#[test]
fn playback_outcome_audio_none_panicked() {
assert_eq!(resolve_playback_outcome(true, None), Ok(()));
}
#[test]
fn cleanup_success_not_cancelled() {
assert_eq!(
decide_cleanup_action(PlaybackResult::Success, false, false),
CleanupAction::AdvancePlaylist
);
}
#[test]
fn cleanup_success_cancelled() {
assert_eq!(
decide_cleanup_action(PlaybackResult::Success, true, false),
CleanupAction::StopCancelled
);
}
#[test]
fn cleanup_failed_not_cancelled() {
assert_eq!(
decide_cleanup_action(PlaybackResult::Failed("err".into()), false, false),
CleanupAction::AdvancePlaylist
);
}
#[test]
fn cleanup_failed_cancelled() {
assert_eq!(
decide_cleanup_action(PlaybackResult::Failed("err".into()), true, false),
CleanupAction::StopCancelled
);
}
#[test]
fn cleanup_sender_dropped_not_cancelled() {
assert_eq!(
decide_cleanup_action(PlaybackResult::SenderDropped, false, false),
CleanupAction::AdvancePlaylist
);
}
#[test]
fn cleanup_loop_broken() {
assert_eq!(
decide_cleanup_action(PlaybackResult::Success, false, true),
CleanupAction::LoopBreakAndPlay
);
}
#[test]
fn cleanup_loop_broken_takes_priority_over_cancel() {
assert_eq!(
decide_cleanup_action(PlaybackResult::Success, true, true),
CleanupAction::LoopBreakAndPlay
);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_dmx_utility_methods() -> Result<(), Box<dyn Error>> {
let dmx_config = config::Dmx::new(
None,
None,
Some(9090),
vec![config::Universe::new(1, "test".to_string())],
None,
);
let player = make_test_player_with_config(
Some(config::Audio::new("mock-device")),
None,
Some(dmx_config),
)
.await?;
let cues = player.get_cues();
assert!(cues.is_empty());
assert!(
player.broadcast_handles().is_some(),
"broadcast_handles should be Some with DMX engine"
);
let (tx, _rx) = tokio::sync::broadcast::channel(1);
player.set_broadcast_tx(tx);
assert!(
player.effect_engine().is_some(),
"effect_engine should be Some with DMX engine"
);
assert!(
player.format_active_effects().is_some(),
"format_active_effects should be Some with DMX engine"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_track_mappings() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
assert!(
player.track_mappings().is_some(),
"track_mappings should be Some when audio is configured"
);
let player = make_test_player_with_config(
None,
Some(config::Midi::new("mock-midi-device", None)),
None,
)
.await?;
assert!(
player.track_mappings().is_none(),
"track_mappings should be None without audio"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_next_while_playing_returns_current() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
let binding = player
.audio_device()
.expect("audio device should be present");
let device = binding.to_mock()?;
assert_eq!(player.get_playlist().current().unwrap().name(), "Song 1");
player.play().await?;
eventually(|| device.is_playing(), "Song never started playing");
let song = player.next().await.unwrap();
assert_eq!(
song.name(),
"Song 1",
"next() while playing should return current song"
);
assert_eq!(
player.get_playlist().current().unwrap().name(),
"Song 1",
"playlist should not advance while playing"
);
let song = player.prev().await.unwrap();
assert_eq!(
song.name(),
"Song 1",
"prev() while playing should return current song"
);
player.stop().await;
eventually(|| !device.is_playing(), "Song never stopped playing");
Ok(())
}
#[test]
fn status_events_new_none() {
let result = StatusEvents::new(None).unwrap();
assert!(result.is_none());
}
fn make_bare_player() -> Result<Player, Box<dyn Error>> {
let songs = songs::get_all_songs(Path::new("assets/songs"))?;
let playlist = Playlist::new(
"playlist",
&config::Playlist::deserialize(Path::new("assets/playlist.yaml"))?,
songs.clone(),
)?;
let devices = PlayerDevices {
audio: None,
mappings: None,
midi: None,
dmx_engine: None,
sample_engine: None,
trigger_engine: None,
};
Player::new_with_devices(
devices,
test_playlists(playlist, songs),
"playlist".to_string(),
None,
)
}
#[tokio::test(flavor = "multi_thread")]
async fn test_process_sample_trigger_no_engine() -> Result<(), Box<dyn Error>> {
let player = make_bare_player()?;
player.process_sample_trigger(&[0x90, 60, 127]);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_stop_samples_no_engine() -> Result<(), Box<dyn Error>> {
let player = make_bare_player()?;
player.stop_samples();
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_broadcast_handles_no_dmx() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
assert!(player.broadcast_handles().is_none());
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_effect_engine_no_dmx() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
assert!(player.effect_engine().is_none());
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_set_broadcast_tx_no_dmx() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
let (tx, _rx) = tokio::sync::broadcast::channel(1);
player.set_broadcast_tx(tx);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn emit_song_change_no_device() -> Result<(), Box<dyn Error>> {
let player = make_test_player_with_config(None, None, None).await?;
let song = Song::new_for_test("test", &[]);
player.emit_song_change(&song);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_switch_to_playlist_while_playing_stays() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
let binding = player
.audio_device()
.expect("audio device should be present");
let device = binding.to_mock()?;
player.switch_to_playlist("all_songs").await.unwrap();
assert_eq!(player.get_playlist().name(), "all_songs");
player.play().await?;
eventually(|| device.is_playing(), "Song never started playing");
let result = player.switch_to_playlist("playlist").await;
assert!(
result.is_err(),
"switch_to_playlist should fail while playing"
);
assert_eq!(
player.get_playlist().name(),
"all_songs",
"playlist should not change while playing"
);
player.stop().await;
eventually(|| !device.is_playing(), "Song never stopped playing");
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_navigate_no_midi() -> Result<(), Box<dyn Error>> {
let player = make_test_player_with_config(None, None, None).await?;
player.next().await;
let song = player.prev().await.unwrap();
assert_eq!(song.name(), "Song 1");
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_new_with_devices_all_none() -> Result<(), Box<dyn Error>> {
let songs = songs::get_all_songs(Path::new("assets/songs"))?;
let playlist = Playlist::new(
"test",
&config::Playlist::deserialize(Path::new("assets/playlist.yaml"))?,
songs.clone(),
)?;
let devices = PlayerDevices {
audio: None,
mappings: None,
midi: None,
dmx_engine: None,
sample_engine: None,
trigger_engine: None,
};
let player = Player::new_with_devices(
devices,
test_playlists(playlist, songs),
"test".to_string(),
None,
)?;
assert!(player.audio_device().is_none());
assert!(player.midi_device().is_none());
assert!(player.dmx_engine().is_none());
assert!(player.track_mappings().is_none());
assert!(player.broadcast_handles().is_none());
assert!(player.effect_engine().is_none());
assert!(player.format_active_effects().is_none());
assert!(player.get_cues().is_empty());
assert!(!player.is_playing().await);
assert!(player.elapsed().await?.is_none());
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_play_from_while_playing_returns_none() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
let binding = player
.audio_device()
.expect("audio device should be present");
let device = binding.to_mock()?;
player.play().await?;
eventually(|| device.is_playing(), "Song never started playing");
let result = player.play_from(Duration::from_secs(1)).await?;
assert!(
result.is_none(),
"play_from while playing should return None"
);
player.stop().await;
eventually(|| !device.is_playing(), "Song never stopped playing");
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_no_subsystem_player_play_and_navigate() -> Result<(), Box<dyn Error>> {
let songs = songs::get_all_songs(Path::new("assets/songs"))?;
let playlist = Playlist::new(
"playlist",
&config::Playlist::deserialize(Path::new("assets/playlist.yaml"))?,
songs.clone(),
)?;
let devices = PlayerDevices {
audio: None,
mappings: None,
midi: None,
dmx_engine: None,
sample_engine: None,
trigger_engine: None,
};
let player = Player::new_with_devices(
devices,
test_playlists(playlist, songs),
"playlist".to_string(),
None,
)?;
player.process_sample_trigger(&[0x90, 60, 127]);
player.stop_samples();
let song = player.next().await.unwrap();
assert_eq!(song.name(), "Song 3");
let song = player.prev().await.unwrap();
assert_eq!(song.name(), "Song 1");
player.play().await?;
eventually_async(
|| async { !player.is_playing().await },
"Player never stopped after no-subsystem playback",
)
.await;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_midi_device_accessor() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
assert!(player.midi_device().is_some());
let player =
make_test_player_with_config(Some(config::Audio::new("mock-device")), None, None)
.await?;
assert!(player.midi_device().is_none());
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_config_store_getter_setter() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
assert!(player.config_store().is_none());
let dir = tempfile::tempdir()?;
let path = dir.path().join("config.yaml");
std::fs::write(&path, "songs: songs\n")?;
let cfg = config::Player::deserialize(&path)?;
let store = std::sync::Arc::new(config::ConfigStore::new(cfg, path));
player.set_config_store(store.clone());
let retrieved = player.config_store();
assert!(retrieved.is_some());
let (_, checksum1) = store.read_yaml().await?;
let (_, checksum2) = retrieved.unwrap().read_yaml().await?;
assert_eq!(checksum1, checksum2);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_reload_hardware_when_idle() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
assert!(player.audio_device().is_some());
let dir = tempfile::tempdir()?;
let path = dir.path().join("config.yaml");
let yaml = "songs: songs\nprofiles:\n - midi:\n device: mock-midi-device\n";
std::fs::write(&path, yaml)?;
let cfg = config::Player::deserialize(&path)?;
let store = std::sync::Arc::new(config::ConfigStore::new(cfg, path));
player.set_config_store(store);
player.reload_hardware().await?;
player.await_hardware_ready().await;
assert!(
player.audio_device().is_none(),
"Audio device should be None after reload with no audio profile"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_reload_hardware_during_playback_rejected() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
let binding = player
.audio_device()
.expect("audio device should be present");
let device = binding.to_mock()?;
let dir = tempfile::tempdir()?;
let path = dir.path().join("config.yaml");
let yaml = "songs: songs\nprofiles:\n - audio:\n device: mock-device\n track_mappings:\n click: [1]\n";
std::fs::write(&path, yaml)?;
let cfg = config::Player::deserialize(&path)?;
let store = std::sync::Arc::new(config::ConfigStore::new(cfg, path));
player.set_config_store(store);
player.play().await?;
eventually(|| device.is_playing(), "Song never started playing");
let result = player.reload_hardware().await;
assert!(
result.is_err(),
"reload_hardware should fail during playback"
);
assert!(
result.unwrap_err().to_string().contains("during playback"),
"error should mention playback"
);
player.stop().await;
eventually(|| !device.is_playing(), "Song never stopped playing");
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_reload_hardware_no_config_store() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
let result = player.reload_hardware().await;
assert!(
result.is_err(),
"reload_hardware should fail without config store"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_hardware_status_no_devices() -> Result<(), Box<dyn Error>> {
let player = make_test_player_with_config(None, None, None).await?;
let status = player.hardware_status();
assert!(status.init_done);
assert_eq!(status.audio.status, "not_connected");
assert_eq!(status.midi.status, "not_connected");
assert_eq!(status.dmx.status, "not_connected");
assert_eq!(status.trigger.status, "not_connected");
assert!(status.audio.name.is_none());
assert!(status.midi.name.is_none());
assert!(status.dmx.name.is_none());
assert!(status.trigger.name.is_none());
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_hardware_status_with_devices() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
let status = player.hardware_status();
assert!(status.init_done);
assert_eq!(status.audio.status, "connected");
assert!(status.audio.name.is_some());
assert_eq!(status.midi.status, "connected");
assert!(status.midi.name.is_some());
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_list_playlists() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
let names = player.list_playlists();
assert!(names.contains(&"all_songs".to_string()));
assert!(names.contains(&"playlist".to_string()));
assert_eq!(
names,
{
let mut sorted = names.clone();
sorted.sort();
sorted
},
"list_playlists should return sorted names"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_persisted_playlist_name() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
assert_eq!(player.persisted_playlist_name(), "playlist");
player.switch_to_playlist("all_songs").await.unwrap();
assert_eq!(player.persisted_playlist_name(), "playlist");
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_playlists_snapshot() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
let snapshot = player.playlists_snapshot();
assert!(snapshot.contains_key("all_songs"));
assert!(snapshot.contains_key("playlist"));
assert_eq!(snapshot.len(), 2);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_reload_songs() -> Result<(), Box<dyn Error>> {
let player = make_bare_player()?;
let initial_count = player.songs().len();
let temp_dir = tempfile::tempdir()?;
let songs_dir = temp_dir.path().join("songs");
fs::create_dir_all(&songs_dir)?;
let src = Path::new("assets/songs");
for entry in fs::read_dir(src)? {
let entry = entry?;
if entry.path().extension().is_some_and(|e| e == "yaml") {
fs::copy(entry.path(), songs_dir.join(entry.file_name()))?;
}
}
let assets = Path::new("assets");
for entry in fs::read_dir(assets)? {
let entry = entry?;
if entry.path().is_file() {
fs::copy(entry.path(), temp_dir.path().join(entry.file_name()))?;
}
}
let new_song_yaml =
"name: \"New Test Song\"\ntracks:\n - name: click\n file: ../1Channel44.1k.wav\n";
fs::write(songs_dir.join("newsong.yaml"), new_song_yaml)?;
player.reload_songs(&songs_dir, None, None);
assert!(
player.songs().len() > initial_count,
"reload_songs should discover the new song (was {}, now {})",
initial_count,
player.songs().len(),
);
Ok(())
}
#[test]
fn test_load_playlists_standalone() -> Result<(), Box<dyn Error>> {
let songs = songs::get_all_songs(Path::new("assets/songs"))?;
let playlists =
super::load_playlists(None, Some(Path::new("assets/playlist.yaml")), songs.clone())?;
assert!(playlists.contains_key("all_songs"));
assert!(playlists.contains_key("playlist"));
assert_eq!(playlists["playlist"].songs().len(), 5);
let playlists = super::load_playlists(None, None, songs.clone())?;
assert!(playlists.contains_key("all_songs"));
assert_eq!(playlists.len(), 1);
let temp_dir = tempfile::tempdir()?;
let pl_dir = temp_dir.path();
fs::write(pl_dir.join("my_set.yaml"), "songs:\n- Song 1\n- Song 3\n")?;
let playlists = super::load_playlists(Some(pl_dir), None, songs)?;
assert!(playlists.contains_key("all_songs"));
assert!(playlists.contains_key("my_set"));
assert_eq!(playlists["my_set"].songs().len(), 2);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn play_song_from_valid() -> Result<(), Box<dyn Error>> {
let songs = songs::get_all_songs(Path::new("assets/songs"))?;
let playlist = Playlist::new(
"playlist",
&config::Playlist::deserialize(Path::new("assets/playlist.yaml"))?,
songs.clone(),
)?;
let player = Player::new(
test_playlists(playlist, songs.clone()),
"playlist".to_string(),
&config::Player::new(
vec![],
Some(config::Audio::new("mock-device")),
Some(config::Midi::new("mock-midi-device", None)),
None,
HashMap::new(),
"assets/songs",
),
None,
)?;
player.await_hardware_ready().await;
let device = player.audio_device().expect("audio device").to_mock()?;
let result = player
.play_song_from("Song 2", std::time::Duration::ZERO)
.await?;
assert!(result.is_some());
assert_eq!(result.unwrap().name(), "Song 2");
eventually(|| device.is_playing(), "Song never started playing");
assert_eq!(player.get_playlist().name(), "all_songs");
player.stop().await;
eventually(|| !device.is_playing(), "Song never stopped");
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn play_song_from_not_found() -> Result<(), Box<dyn Error>> {
let songs = songs::get_all_songs(Path::new("assets/songs"))?;
let playlist = Playlist::new(
"playlist",
&config::Playlist::deserialize(Path::new("assets/playlist.yaml"))?,
songs.clone(),
)?;
let player = Player::new(
test_playlists(playlist, songs.clone()),
"playlist".to_string(),
&config::Player::new(
vec![],
Some(config::Audio::new("mock-device")),
Some(config::Midi::new("mock-midi-device", None)),
None,
HashMap::new(),
"assets/songs",
),
None,
)?;
player.await_hardware_ready().await;
let result = player
.play_song_from("Nonexistent Song", std::time::Duration::ZERO)
.await;
assert!(result.is_err());
let err = result.err().unwrap();
assert!(err.to_string().contains("not found"));
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn play_song_from_while_playing() -> Result<(), Box<dyn Error>> {
let songs = songs::get_all_songs(Path::new("assets/songs"))?;
let playlist = Playlist::new(
"playlist",
&config::Playlist::deserialize(Path::new("assets/playlist.yaml"))?,
songs.clone(),
)?;
let player = Player::new(
test_playlists(playlist, songs.clone()),
"playlist".to_string(),
&config::Player::new(
vec![],
Some(config::Audio::new("mock-device")),
Some(config::Midi::new("mock-midi-device", None)),
None,
HashMap::new(),
"assets/songs",
),
None,
)?;
player.await_hardware_ready().await;
let device = player.audio_device().expect("audio device").to_mock()?;
player.play().await?;
eventually(|| device.is_playing(), "Song never started playing");
let result = player
.play_song_from("Song 2", std::time::Duration::ZERO)
.await?;
assert!(result.is_none());
player.stop().await;
eventually(|| !device.is_playing(), "Song never stopped");
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_loop_section_not_playing() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
let result = player.loop_section("verse").await;
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("no song is playing"));
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_loop_section_not_found() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
let binding = player
.audio_device()
.expect("audio device should be present");
let device = binding.to_mock()?;
player.play().await?;
eventually(|| device.is_playing(), "Song never started playing");
let result = player.loop_section("nonexistent").await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
player.stop().await;
eventually(|| !device.is_playing(), "Song never stopped playing");
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_stop_section_loop_clears_state() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
assert!(player.active_section().is_none());
{
let mut active = player.active_section.write();
*active = Some(SectionBounds {
name: "test".to_string(),
start_time: Duration::from_secs(1),
end_time: Duration::from_secs(5),
});
}
assert!(player.active_section().is_some());
player.stop_section_loop();
assert!(player.active_section().is_none());
assert!(player.section_loop_break.load(Ordering::Relaxed));
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_add_loop_time_consumed() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
assert_eq!(*player.loop_time_consumed.lock(), Duration::ZERO);
player.add_loop_time_consumed(Duration::from_secs(2));
assert_eq!(*player.loop_time_consumed.lock(), Duration::from_secs(2));
player.add_loop_time_consumed(Duration::from_secs(3));
assert_eq!(*player.loop_time_consumed.lock(), Duration::from_secs(5));
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_active_section_getter() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
assert!(player.active_section().is_none());
let bounds = SectionBounds {
name: "chorus".to_string(),
start_time: Duration::from_secs(10),
end_time: Duration::from_secs(20),
};
*player.active_section.write() = Some(bounds.clone());
let active = player.active_section().unwrap();
assert_eq!(active.name, "chorus");
assert_eq!(active.start_time, Duration::from_secs(10));
assert_eq!(active.end_time, Duration::from_secs(20));
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_is_current_song_looping() -> Result<(), Box<dyn Error>> {
let player = make_test_player().await?;
assert!(!player.is_current_song_looping());
Ok(())
}
}