use parking_lot::Mutex;
use std::{
collections::{HashMap, HashSet},
error::Error,
panic::AssertUnwindSafe,
sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
mpsc::{self, Receiver},
Arc,
},
thread::{self, JoinHandle},
time::Duration,
};
use super::midi_dmx_store::MidiDmxStore;
use super::ola_client::OlaClient;
use ola::DmxBuffer;
use tracing::{debug, error, info, warn};
use crate::{
config,
lighting::{system::LightingSystem, timeline::LightingTimeline, EffectEngine},
midi::playback::PrecomputedMidi,
playsync::CancelHandle,
};
use super::universe::Universe;
mod midi_playback;
mod playback;
mod timeline;
#[derive(Debug, PartialEq)]
pub(super) enum MidiDmxAction {
KeyVelocity { channel: u16, value: u8 },
Controller { channel: u16, value: u8 },
Dimming { duration: Duration },
Unrecognized,
}
pub(super) fn classify_midi_dmx_action(
midi_message: midly::MidiMessage,
dimming_speed_modifier: f64,
) -> MidiDmxAction {
match midi_message {
midly::MidiMessage::NoteOn { key, vel } | midly::MidiMessage::NoteOff { key, vel } => {
MidiDmxAction::KeyVelocity {
channel: (key.as_int() + 1).into(),
value: vel.as_int() * 2,
}
}
midly::MidiMessage::ProgramChange { program } => MidiDmxAction::Dimming {
duration: Duration::from_secs_f64(f64::from(program.as_int()) * dimming_speed_modifier),
},
midly::MidiMessage::Controller { controller, value } => MidiDmxAction::Controller {
channel: (controller.as_int() + 1).into(),
value: value.as_int() * 2,
},
_ => MidiDmxAction::Unrecognized,
}
}
pub struct Engine {
pub(super) dimming_speed_modifier: f64,
pub(super) playback_delay: Duration,
pub(super) universes: HashMap<u16, Universe>,
pub(super) universe_name_to_id: HashMap<String, u16>,
pub(super) cancel_handle: CancelHandle,
pub(super) client_handle: Option<JoinHandle<()>>,
pub(super) join_handles: Vec<JoinHandle<()>>,
pub(super) effect_engine: Arc<Mutex<EffectEngine>>,
pub(super) lighting_system: Option<Arc<Mutex<LightingSystem>>>,
pub(super) lighting_config: Option<config::Lighting>,
pub(super) effects_loop_handle: Mutex<Option<JoinHandle<()>>>,
pub(super) current_song_timeline: Arc<Mutex<Option<LightingTimeline>>>,
pub(super) current_song_time: Arc<AtomicU64>,
pub(super) timeline_finished: Arc<AtomicBool>,
pub(super) timeline_cancel_handle: Arc<Mutex<Option<CancelHandle>>>,
pub(super) broadcast_tx: Mutex<Option<tokio::sync::broadcast::Sender<String>>>,
pub(super) watcher_handle: Mutex<Option<super::watcher::WatcherHandle>>,
pub(super) midi_dmx_store: Arc<parking_lot::RwLock<MidiDmxStore>>,
pub(super) midi_dmx_playbacks: Mutex<Vec<MidiDmxPlayback>>,
pub(super) effects_loop_heartbeat: Arc<AtomicU64>,
pub(super) effects_loop_phase: Arc<AtomicU64>,
pub(super) update_subphase: Arc<AtomicU64>,
}
pub(super) struct MidiDmxPlayback {
pub(super) precomputed: PrecomputedMidi,
pub(super) cursor: usize,
pub(super) universe_id: u16,
pub(super) midi_channels: HashSet<u8>,
}
#[derive(Clone)]
pub(super) struct DmxMessage {
pub universe: u32,
pub buffer: DmxBuffer,
}
pub struct BroadcastHandles {
pub lighting_system: Option<Arc<Mutex<LightingSystem>>>,
}
impl Engine {
pub fn new(
config: &config::Dmx,
lighting_config: Option<&config::Lighting>,
base_path: Option<&std::path::Path>,
ola_client: Box<dyn OlaClient>,
) -> Result<Engine, Box<dyn Error>> {
let ola_client = Arc::new(Mutex::new(ola_client));
let (sender, receiver) = mpsc::channel::<DmxMessage>();
let ola_client_for_thread = ola_client.clone();
let client_handle = thread::spawn(move || {
Self::ola_thread(ola_client_for_thread, receiver);
});
let cancel_handle = CancelHandle::new();
let universes: HashMap<u16, Universe> = config
.universes()
.iter()
.map(|config| {
(
config.universe(),
Universe::new(config.clone(), cancel_handle.clone(), sender.clone()),
)
})
.collect();
let universe_name_to_id: HashMap<String, u16> = config
.universes()
.iter()
.map(|config| (config.name().to_string(), config.universe()))
.collect();
let join_handles: Vec<JoinHandle<()>> = universes
.values()
.map(|universe| universe.start_thread())
.collect();
let lighting_system =
if let (Some(lighting_config), Some(base_path)) = (lighting_config, base_path) {
let mut system = LightingSystem::new();
if let Err(_e) = system.load(lighting_config, base_path) {
None
} else {
Some(Arc::new(Mutex::new(system)))
}
} else {
None
};
let ee = EffectEngine::new();
let update_subphase = ee.update_subphase();
let effect_engine = Arc::new(Mutex::new(ee));
let current_song_timeline: Arc<Mutex<Option<LightingTimeline>>> =
Arc::new(Mutex::new(None));
let current_song_time = Arc::new(AtomicU64::new(0));
let timeline_finished = Arc::new(AtomicBool::new(true));
let timeline_cancel_handle: Arc<Mutex<Option<CancelHandle>>> = Arc::new(Mutex::new(None));
let midi_dmx_store = Arc::new(parking_lot::RwLock::new(MidiDmxStore::new()));
Ok(Engine {
dimming_speed_modifier: config.dimming_speed_modifier(),
playback_delay: config.playback_delay()?,
universes: universes.into_iter().collect(),
universe_name_to_id,
cancel_handle,
client_handle: Some(client_handle),
join_handles,
effect_engine,
effects_loop_handle: Mutex::new(None),
lighting_system,
lighting_config: lighting_config.cloned(),
current_song_timeline,
current_song_time,
timeline_finished,
timeline_cancel_handle,
broadcast_tx: Mutex::new(None),
watcher_handle: Mutex::new(None),
midi_dmx_store,
midi_dmx_playbacks: Mutex::new(Vec::new()),
effects_loop_heartbeat: Arc::new(AtomicU64::new(0)),
effects_loop_phase: Arc::new(AtomicU64::new(0)),
update_subphase,
})
}
pub fn start_persistent_effects_loop(engine: Arc<Engine>) {
let weak_engine = Arc::downgrade(&engine);
let heartbeat = engine.effects_loop_heartbeat.clone();
let handle = thread::spawn(move || {
info!("Effects loop started.");
let mut last_update = std::time::Instant::now();
let target_frame_time = Duration::from_secs_f64(1.0 / 44.0);
loop {
let Some(engine) = weak_engine.upgrade() else {
info!("Effects loop exiting: engine was dropped.");
break;
};
let now = std::time::Instant::now();
let dt = now.duration_since(last_update);
if dt >= target_frame_time {
let engine_ref = AssertUnwindSafe(&engine);
let result = std::panic::catch_unwind(move || {
Self::effects_loop_tick(&engine_ref);
});
if let Err(panic_info) = result {
let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = panic_info.downcast_ref::<String>() {
s.clone()
} else {
"unknown panic".to_string()
};
error!(
panic_message = msg,
"Effects loop caught panic! Continuing to prevent lighting freeze."
);
}
heartbeat.fetch_add(1, Ordering::Relaxed);
last_update = now;
}
drop(engine);
thread::sleep(Duration::from_millis(1));
}
info!("Effects loop exited.");
});
*engine.effects_loop_handle.lock() = Some(handle);
}
fn effects_loop_tick(&self) {
self.effects_loop_phase.store(1, Ordering::Relaxed);
self.midi_dmx_store.read().tick();
self.effects_loop_phase.store(2, Ordering::Relaxed);
self.advance_midi_dmx_playbacks();
self.effects_loop_phase.store(3, Ordering::Relaxed);
if let Err(e) = self.update_effects() {
error!("Error updating effects: {}", e);
}
self.effects_loop_phase.store(4, Ordering::Relaxed);
let song_time = self.get_song_time();
if let Err(e) = self.update_song_lighting(song_time) {
error!("Error updating song lighting: {}", e);
}
self.effects_loop_phase.store(5, Ordering::Relaxed);
if !self.timeline_finished.load(Ordering::Relaxed) {
let timeline_done = {
let timeline = self.current_song_timeline.lock();
timeline.as_ref().is_none_or(|tl| tl.is_finished())
};
let midi_dmx_done = self.midi_dmx_playbacks_finished();
if timeline_done && midi_dmx_done {
info!("Lighting timeline finished. Notifying barrier.");
self.timeline_finished.store(true, Ordering::Relaxed);
if let Some(ref cancel_handle) = *self.timeline_cancel_handle.lock() {
cancel_handle.notify();
}
}
}
self.effects_loop_phase.store(0, Ordering::Relaxed);
}
#[cfg(test)]
pub(crate) fn get_universe(&self, universe_id: u16) -> Option<&Universe> {
self.universes.get(&universe_id)
}
pub fn handle_midi_event(&self, universe_name: String, midi_message: midly::MidiMessage) {
if let Some(&universe_id) = self.universe_name_to_id.get(&universe_name) {
self.handle_midi_event_by_id(universe_id, midi_message);
}
}
fn handle_midi_event_by_id(&self, universe_id: u16, midi_message: midly::MidiMessage) {
match classify_midi_dmx_action(midi_message, self.dimming_speed_modifier) {
MidiDmxAction::KeyVelocity { channel, value } => {
self.update_universe_by_id(universe_id, channel, value, true);
}
MidiDmxAction::Controller { channel, value } => {
self.update_universe_by_id(universe_id, channel, value, false);
}
MidiDmxAction::Dimming { duration } => {
self.update_dimming_by_id(universe_id, duration);
}
MidiDmxAction::Unrecognized => {
debug!(
midi_event = format!("{:?}", midi_message),
"Unrecognized MIDI event"
);
}
}
}
fn update_dimming_by_id(&self, universe_id: u16, dimming_duration: Duration) {
debug!(
dimming = dimming_duration.as_secs_f64(),
"Dimming speed updated"
);
if let Some(universe) = self.universes.get(&universe_id) {
universe.update_dim_speed(dimming_duration);
}
let rate = if dimming_duration.is_zero() {
1.0
} else {
dimming_duration.as_secs_f64() * super::universe::TARGET_HZ
};
self.midi_dmx_store.read().set_dim_rate(universe_id, rate);
}
fn update_universe_by_id(&self, universe_id: u16, channel: u16, value: u8, dim: bool) {
let store = self.midi_dmx_store.read();
if store.lookup(universe_id, channel).is_some() {
store.write(universe_id, channel, value, dim);
} else {
if let Some(universe) = self.universes.get(&universe_id) {
universe.update_channel_data(channel, value, dim);
}
}
}
pub fn update_effects(&self) -> Result<(), Box<dyn std::error::Error>> {
let dt = Duration::from_secs_f64(1.0 / super::universe::TARGET_HZ);
self.update_subphase.store(1, Ordering::Relaxed);
let song_time = self.get_song_time();
self.update_subphase.store(2, Ordering::Relaxed);
let mut effect_engine = match self.effect_engine.try_lock_for(Duration::from_secs(2)) {
Some(guard) => guard,
None => {
error!(
"effect_engine lock blocked for >2s in update_effects — \
another holder is not releasing it"
);
self.effect_engine.lock()
}
};
let commands = effect_engine.update(dt, Some(song_time))?;
let mut universe_commands: std::collections::HashMap<u16, Vec<(u16, u8)>> =
std::collections::HashMap::new();
for command in commands {
universe_commands
.entry(command.universe)
.or_default()
.push((command.channel, command.value));
}
for (universe_id, commands) in universe_commands {
if let Some(universe) = self.universes.get(&universe_id) {
universe.update_effect_commands(commands);
}
}
Ok(())
}
pub fn start_effect(
&self,
effect: crate::lighting::EffectInstance,
) -> Result<(), Box<dyn std::error::Error>> {
let mut effect_engine = self.effect_engine.lock();
effect_engine.start_effect(effect)?;
Ok(())
}
pub fn register_venue_fixtures_safe(&self) -> Result<(), Box<dyn std::error::Error>> {
if let Some(lighting_system) = &self.lighting_system {
let lighting_system = lighting_system.lock();
let fixture_infos = lighting_system.get_current_venue_fixtures()?;
let mut effect_engine = self.effect_engine.lock();
let mut midi_dmx_store = self.midi_dmx_store.write();
for fixture_info in &fixture_infos {
for (channel_name, &offset) in &fixture_info.channels {
let dmx_channel = fixture_info.address + offset - 1;
midi_dmx_store.register_slot(
fixture_info.universe,
dmx_channel,
&fixture_info.name,
channel_name,
);
}
midi_dmx_store.register_universe(fixture_info.universe);
}
effect_engine.set_midi_dmx_store(self.midi_dmx_store.clone());
for fixture_info in fixture_infos {
effect_engine.register_fixture(fixture_info);
}
}
Ok(())
}
pub fn set_broadcast_tx(&self, tx: tokio::sync::broadcast::Sender<String>) {
*self.broadcast_tx.lock() = Some(tx);
}
pub fn broadcast_handles(&self) -> BroadcastHandles {
BroadcastHandles {
lighting_system: self.lighting_system.clone(),
}
}
pub fn effect_engine(&self) -> Arc<Mutex<EffectEngine>> {
self.effect_engine.clone()
}
#[cfg(test)]
pub fn effects_loop_heartbeat(&self) -> u64 {
self.effects_loop_heartbeat.load(Ordering::Relaxed)
}
pub fn format_active_effects(&self) -> String {
let effect_engine = self.effect_engine.lock();
effect_engine.format_active_effects()
}
fn ola_thread(client: Arc<Mutex<Box<dyn OlaClient>>>, receiver: Receiver<DmxMessage>) {
let mut disconnected = false;
let mut last_reconnect_attempt = std::time::Instant::now();
let reconnect_interval = Duration::from_secs(5);
loop {
match receiver.recv() {
Ok(message) => {
if disconnected {
let now = std::time::Instant::now();
if now.duration_since(last_reconnect_attempt) >= reconnect_interval {
last_reconnect_attempt = now;
let mut client = client.lock();
match client.reconnect() {
Ok(()) => {
info!("Reconnected to OLA");
disconnected = false;
if let Err(err) =
client.send_dmx(message.universe, &message.buffer)
{
error!("Lost connection to OLA: {}", err);
disconnected = true;
}
}
Err(err) => {
warn!("Failed to reconnect to OLA: {}", err);
}
}
}
} else {
let mut client = client.lock();
if let Err(err) = client.send_dmx(message.universe, &message.buffer) {
error!("Lost connection to OLA: {}", err);
disconnected = true;
last_reconnect_attempt = std::time::Instant::now();
}
}
}
Err(_) => return,
}
}
}
}
impl Drop for Engine {
fn drop(&mut self) {
self.cancel_handle.cancel();
if let Some(handle) = self.effects_loop_handle.lock().take() {
if handle.join().is_err() {
error!("Error joining effects loop handle");
}
}
self.join_handles.drain(..).for_each(|join_handle| {
if join_handle.join().is_err() {
error!("Error joining handle");
}
});
self.universes.drain();
if self
.client_handle
.take()
.expect("Expected client handle")
.join()
.is_err()
{
error!("Error joining handle");
}
}
}
#[cfg(test)]
mod test {
use std::{
collections::HashSet,
error::Error,
net::{Ipv4Addr, SocketAddr, TcpListener},
sync::Arc,
time::Duration,
};
use midly::num::u7;
use crate::playsync::CancelHandle;
use super::{config, Engine};
use crate::dmx::ola_client::OlaClientFactory;
use crate::lighting::effects::EffectType;
fn create_engine() -> Result<(Arc<Engine>, CancelHandle), Box<dyn Error>> {
let listener = TcpListener::bind(SocketAddr::new(
std::net::IpAddr::V4(Ipv4Addr::UNSPECIFIED),
0,
))?;
let port = listener.local_addr()?.port();
let ola_client = OlaClientFactory::create_mock_client();
let engine = Engine::new(
&config::Dmx::new(
None,
None,
Some(port),
vec![config::Universe::new(5, "universe1".to_string())],
None, ),
None,
None,
ola_client,
)?;
let cancel_handle = engine.cancel_handle.clone();
Ok((Arc::new(engine), cancel_handle))
}
#[test]
fn test_handle_midi_event_by_id() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
assert_eq!(engine.get_universe(5).unwrap().get_dim_speed(), 1.0);
engine.handle_midi_event_by_id(
5,
midly::MidiMessage::ProgramChange {
program: u7::new(1u8),
},
);
assert_eq!(engine.get_universe(5).unwrap().get_dim_speed(), 44.0);
Ok(())
}
#[test]
fn test_effects_integration() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
let fixture_info = {
let mut channels = std::collections::HashMap::new();
channels.insert("dimmer".to_string(), 1);
channels.insert("red".to_string(), 2);
channels.insert("green".to_string(), 3);
channels.insert("blue".to_string(), 4);
crate::lighting::effects::FixtureInfo::new(
"test_fixture".to_string(),
1,
1,
"RGBW_Par".to_string(),
channels,
None,
)
};
{
let mut effect_engine = engine.effect_engine.lock();
effect_engine.register_fixture(fixture_info);
}
let mut parameters = std::collections::HashMap::new();
parameters.insert("dimmer".to_string(), 0.5);
let effect = crate::lighting::EffectInstance::new(
"test_effect".to_string(),
EffectType::Static {
parameters: parameters.clone(),
duration: Duration::ZERO,
},
vec!["test_fixture".to_string()],
None,
None,
None,
);
engine.start_effect(effect).unwrap();
Ok(())
}
#[test]
fn test_lighting_system_integration() -> Result<(), Box<dyn Error>> {
let lighting_config = config::Lighting::new(
Some("test_venue".to_string()),
Some({
let mut fixtures = std::collections::HashMap::new();
fixtures.insert("Wash1".to_string(), "RGBW_Par @ 1:1".to_string());
fixtures
}),
Some({
let mut groups = std::collections::HashMap::new();
let front_wash_group = crate::config::lighting::LogicalGroup::new(
"front_wash".to_string(),
vec![crate::config::lighting::GroupConstraint::AllOf(vec![
"wash".to_string(),
"front".to_string(),
])],
);
groups.insert("front_wash".to_string(), front_wash_group);
groups
}),
None, );
assert!(lighting_config.current_venue().is_some());
assert_eq!(lighting_config.current_venue().unwrap(), "test_venue");
assert_eq!(lighting_config.fixtures().len(), 1);
assert!(lighting_config.fixtures().contains_key("Wash1"));
assert_eq!(lighting_config.groups().len(), 1);
assert!(lighting_config.groups().contains_key("front_wash"));
Ok(())
}
#[test]
fn test_lighting_system_without_config() -> Result<(), Box<dyn Error>> {
let dmx_config = config::Dmx::new(
None,
None,
Some(9090),
vec![config::Universe::new(1, "universe1".to_string())],
None,
);
assert!(dmx_config.lighting().is_none());
Ok(())
}
#[test]
fn test_register_venue_fixtures_without_lighting_system() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
engine.register_venue_fixtures_safe()?;
Ok(())
}
#[test]
fn test_effects_update_without_fixtures() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
engine.update_effects()?;
Ok(())
}
#[test]
fn test_effects_update_with_fixtures() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
let fixture_info = {
let mut channels = std::collections::HashMap::new();
channels.insert("dimmer".to_string(), 1);
channels.insert("red".to_string(), 2);
channels.insert("green".to_string(), 3);
channels.insert("blue".to_string(), 4);
crate::lighting::effects::FixtureInfo::new(
"test_fixture".to_string(),
1,
1,
"RGBW_Par".to_string(),
channels,
None,
)
};
{
let mut effect_engine = engine.effect_engine.lock();
effect_engine.register_fixture(fixture_info);
}
let mut parameters = std::collections::HashMap::new();
parameters.insert("dimmer".to_string(), 0.8);
parameters.insert("red".to_string(), 1.0);
let effect = crate::lighting::EffectInstance::new(
"test_effect".to_string(),
EffectType::Static {
parameters,
duration: Duration::ZERO,
},
vec!["test_fixture".to_string()],
None,
None,
None,
);
engine.start_effect(effect)?;
engine.update_effects()?;
Ok(())
}
#[test]
fn test_song_lighting_integration() -> Result<(), Box<dyn Error>> {
let song_config = config::Song::new(
"Test Song",
None,
None,
None,
None,
None, vec![],
std::collections::HashMap::new(),
Vec::new(),
);
assert!(song_config.lighting().is_none());
Ok(())
}
fn create_test_config() -> config::Dmx {
config::Dmx::new(
Some(1.0),
Some("0s".to_string()),
Some(9090),
vec![config::Universe::new(1, "test_universe".to_string())],
None,
)
}
fn create_test_engine() -> Result<Engine, Box<dyn std::error::Error>> {
let config = create_test_config();
let ola_client = OlaClientFactory::create_mock_client();
Engine::new(&config, None, None, ola_client)
}
#[test]
fn test_effect_builder_methods() {
let engine = create_test_engine().unwrap();
let mut channels = std::collections::HashMap::new();
channels.insert("dimmer".to_string(), 1);
channels.insert("red".to_string(), 2);
channels.insert("green".to_string(), 3);
channels.insert("blue".to_string(), 4);
let fixture_info = crate::lighting::effects::FixtureInfo::new(
"test_fixture".to_string(),
1,
1,
"RGB".to_string(),
channels,
None,
);
{
let mut effect_engine = engine.effect_engine.lock();
effect_engine.register_fixture(fixture_info);
}
let effect = crate::lighting::EffectInstance::new(
"test_effect".to_string(),
EffectType::Static {
parameters: std::collections::HashMap::new(),
duration: Duration::ZERO,
},
vec!["test_fixture".to_string()],
None,
None,
None,
)
.with_priority(5);
let result = engine.start_effect(effect);
assert!(result.is_ok());
}
#[test]
fn test_midi_dmx_channel_filtering() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
assert_eq!(engine.get_universe(5).unwrap().get_dim_speed(), 1.0);
engine.handle_midi_event_by_id(
5,
midly::MidiMessage::ProgramChange {
program: u7::new(1u8),
},
);
assert_eq!(engine.get_universe(5).unwrap().get_dim_speed(), 44.0);
use crate::midi::playback::{PrecomputedMidi, TimedMidiEvent};
let events = vec![TimedMidiEvent {
time: std::time::Duration::ZERO,
channel: 6, message: midly::MidiMessage::ProgramChange {
program: u7::new(0u8),
},
}];
let precomputed = PrecomputedMidi::from_events(events);
let mut midi_channels = HashSet::new();
midi_channels.insert(5);
{
let mut playbacks = engine.midi_dmx_playbacks.lock();
playbacks.push(super::MidiDmxPlayback {
precomputed,
cursor: 0,
universe_id: 5,
midi_channels,
});
}
engine.update_song_time(std::time::Duration::from_secs(1));
engine.advance_midi_dmx_playbacks();
assert_eq!(engine.get_universe(5).unwrap().get_dim_speed(), 44.0);
engine.midi_dmx_playbacks.lock().clear();
Ok(())
}
#[test]
fn test_group_resolution_in_dmx_engine() -> Result<(), Box<dyn std::error::Error>> {
use crate::lighting::{effects::EffectType, EffectInstance};
use std::collections::HashMap;
let config = create_test_config();
let lighting_config = Some(crate::config::Lighting::new(
Some("Test Venue".to_string()),
None,
None,
None,
));
let ola_client = OlaClientFactory::create_mock_client();
let engine = Engine::new(&config, lighting_config.as_ref(), None, ola_client)?;
let mut parameters = HashMap::new();
parameters.insert("dimmer".to_string(), 0.8);
parameters.insert("red".to_string(), 1.0);
let effect = EffectInstance::new(
"test_effect".to_string(),
EffectType::Static {
parameters,
duration: Duration::ZERO,
},
vec!["test_group".to_string()],
None,
None,
None,
);
let _result = engine.start_effect(effect);
Ok(())
}
#[test]
fn test_group_resolution_graceful_fallback() -> Result<(), Box<dyn std::error::Error>> {
use crate::lighting::{effects::EffectType, EffectInstance};
use std::collections::HashMap;
let config = create_test_config();
let ola_client = OlaClientFactory::create_mock_client();
let engine = Engine::new(&config, None, None, ola_client)?;
let mut parameters = HashMap::new();
parameters.insert("dimmer".to_string(), 0.5);
let effect = EffectInstance::new(
"test_effect".to_string(),
EffectType::Static {
parameters,
duration: Duration::ZERO,
},
vec!["unknown_group".to_string()],
None,
None,
None,
);
let _result = engine.start_effect(effect);
Ok(())
}
#[test]
fn test_effects_loop_with_timeline() -> Result<(), Box<dyn std::error::Error>> {
use std::sync::Arc;
let temp_path = std::path::Path::new("/tmp/test_song");
let song_config = crate::config::Song::new(
"Test Song",
None,
None,
None,
None,
None, vec![],
std::collections::HashMap::new(),
Vec::new(),
);
let song = crate::songs::Song::new(temp_path, &song_config)?;
let config = create_test_config();
let ola_client = OlaClientFactory::create_mock_client();
let engine = Arc::new(Engine::new(&config, None, None, ola_client)?);
let song_arc = Arc::new(song);
let cancel_handle = crate::playsync::CancelHandle::new();
let (ready_tx, _ready_rx) = std::sync::mpsc::channel::<()>();
let clock = crate::clock::PlaybackClock::wall();
clock.start();
Engine::play(
engine.clone(),
song_arc,
crate::playsync::PlaybackSync {
cancel_handle,
ready_tx: crate::playsync::ReadyGuard::new(ready_tx),
clock,
start_time: std::time::Duration::ZERO,
loop_control: crate::playsync::LoopControl::new(),
},
)?;
let _timeline = engine.current_song_timeline.lock();
Ok(())
}
#[test]
fn test_dsl_to_dmx_command_flow() -> Result<(), Box<dyn std::error::Error>> {
use crate::dmx::ola_client::{MockOlaClient, OlaClient};
use crate::lighting::{effects::EffectType, EffectInstance};
use parking_lot::Mutex;
use std::collections::HashMap;
let config = create_test_config();
let mock_client = Arc::new(Mutex::new(MockOlaClient::new()));
let _mock_client_for_engine = mock_client.clone();
let ola_client: Box<dyn OlaClient> = Box::new(MockOlaClient::new());
let engine = Engine::new(&config, None, None, ola_client)?;
let mut parameters = HashMap::new();
parameters.insert("dimmer".to_string(), 0.8);
parameters.insert("red".to_string(), 1.0);
let effect = EffectInstance::new(
"test_effect".to_string(),
EffectType::Static {
parameters,
duration: Duration::ZERO,
},
vec!["fixture1".to_string()],
None,
None,
None,
);
let _ = engine.start_effect(effect);
let _ = engine.update_effects();
let mock_client = mock_client.lock();
let _message = mock_client.get_last_message();
Ok(())
}
#[test]
fn test_midi_to_dmx_channel_mapping() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
engine.handle_midi_event(
"universe1".to_string(),
midly::MidiMessage::NoteOn {
key: 0.into(),
vel: 100.into(),
},
);
let universe = engine.get_universe(5).unwrap();
assert_eq!(
universe.get_target_value(0),
200.0,
"MIDI key 0 should map to DMX channel 1 (index 0)"
);
engine.handle_midi_event(
"universe1".to_string(),
midly::MidiMessage::NoteOn {
key: 1.into(),
vel: 50.into(),
},
);
assert_eq!(
universe.get_target_value(1),
100.0,
"MIDI key 1 should map to DMX channel 2 (index 1)"
);
engine.handle_midi_event(
"universe1".to_string(),
midly::MidiMessage::Controller {
controller: 2.into(),
value: 100.into(),
},
);
assert_eq!(
universe.get_target_value(2),
200.0,
"MIDI controller 2 should map to DMX channel 3 (index 2)"
);
engine.handle_midi_event(
"universe1".to_string(),
midly::MidiMessage::Controller {
controller: 3.into(),
value: 50.into(),
},
);
assert_eq!(
universe.get_target_value(3),
100.0,
"MIDI controller 3 should map to DMX channel 4 (index 3)"
);
Ok(())
}
#[test]
fn test_dmx_channel_numbering() -> Result<(), Box<dyn std::error::Error>> {
use crate::dmx::ola_client::{MockOlaClient, OlaClient};
use crate::lighting::effects::{EffectInstance, EffectType, FixtureInfo};
use parking_lot::Mutex;
use std::collections::HashMap;
let config = create_test_config();
let mock_client = Arc::new(Mutex::new(MockOlaClient::new()));
let _mock_client_for_engine = mock_client.clone();
let ola_client: Box<dyn OlaClient> = Box::new(MockOlaClient::new());
let engine = Engine::new(&config, None, None, ola_client)?;
let mut channels = HashMap::new();
channels.insert("red".to_string(), 1); channels.insert("green".to_string(), 2); channels.insert("blue".to_string(), 3); channels.insert("dimmer".to_string(), 4);
let fixture_info = FixtureInfo::new(
"test_fixture".to_string(),
1,
10,
"RGB_Par".to_string(),
channels,
None,
);
{
let mut effect_engine = engine.effect_engine.lock();
effect_engine.register_fixture(fixture_info);
}
let mut parameters = HashMap::new();
parameters.insert("red".to_string(), 1.0);
parameters.insert("green".to_string(), 0.5);
parameters.insert("blue".to_string(), 0.0);
let effect = EffectInstance::new(
"test_effect".to_string(),
EffectType::Static {
parameters,
duration: Duration::ZERO,
},
vec!["test_fixture".to_string()],
None,
None,
None,
);
engine.start_effect(effect)?;
engine.update_effects()?;
let _universe = engine.get_universe(1).unwrap();
Ok(())
}
fn seed_lighting_state(engine: &Engine) {
use crate::lighting::tempo::{TempoMap, TimeSignature};
use crate::lighting::timeline::LightingTimeline;
let tempo_map = TempoMap::new(
std::time::Duration::ZERO,
120.0,
TimeSignature::new(4, 4),
vec![],
);
{
let mut effect_engine = engine.effect_engine.lock();
effect_engine.set_tempo_map(Some(tempo_map));
}
{
let mut timeline = engine.current_song_timeline.lock();
*timeline = Some(LightingTimeline::new_with_cues(vec![]));
}
}
fn assert_lighting_state_cleared(engine: &Engine) {
let effect_engine = engine.effect_engine.lock();
assert!(
!effect_engine.has_tempo_map(),
"tempo map should be cleared"
);
let timeline = engine.current_song_timeline.lock();
assert!(timeline.is_none(), "timeline should be cleared");
}
#[test]
fn test_dsl_song_clears_previous_tempo_map() -> Result<(), Box<dyn std::error::Error>> {
let (engine, cancel_handle) = create_engine()?;
seed_lighting_state(&engine);
let tmp_dir = tempfile::tempdir()?;
let dsl_path = tmp_dir.path().join("no_tempo.dsl");
std::fs::write(
&dsl_path,
r#"show "no_tempo" {
@00:00.000
front_wash: static color: "blue", duration: 5s, dimmer: 100%
}"#,
)?;
let song_config = crate::config::Song::new(
"DSL No Tempo",
None,
None,
None,
None,
Some(vec![crate::config::LightingShow::new(
dsl_path.to_string_lossy().into_owned(),
)]),
vec![],
std::collections::HashMap::new(),
Vec::new(),
);
let song = Arc::new(crate::songs::Song::new(tmp_dir.path(), &song_config)?);
let (ready_tx, _ready_rx) = std::sync::mpsc::channel::<()>();
let clock = crate::clock::PlaybackClock::wall();
clock.start();
cancel_handle.cancel();
Engine::play(
engine.clone(),
song,
crate::playsync::PlaybackSync {
cancel_handle,
ready_tx: crate::playsync::ReadyGuard::new(ready_tx),
clock,
start_time: std::time::Duration::ZERO,
loop_control: crate::playsync::LoopControl::new(),
},
)?;
let effect_engine = engine.effect_engine.lock();
assert!(
!effect_engine.has_tempo_map(),
"DSL song without tempo block should clear previous tempo map"
);
let timeline = engine.current_song_timeline.lock();
assert!(
timeline.is_some(),
"DSL song should have set its own timeline"
);
Ok(())
}
#[test]
fn test_midi_dmx_song_clears_dsl_state() -> Result<(), Box<dyn std::error::Error>> {
let (engine, cancel_handle) = create_engine()?;
seed_lighting_state(&engine);
let assets_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets");
let song_config = crate::config::Song::new(
"MIDI DMX Song",
None,
None,
None,
Some(vec![crate::config::LightShow::new(
"nonexistent_universe".to_string(),
"song.mid".to_string(),
None,
)]),
None, vec![],
std::collections::HashMap::new(),
Vec::new(),
);
let song = Arc::new(crate::songs::Song::new(&assets_path, &song_config)?);
let (ready_tx, _ready_rx) = std::sync::mpsc::channel::<()>();
let clock = crate::clock::PlaybackClock::wall();
clock.start();
Engine::play(
engine.clone(),
song,
crate::playsync::PlaybackSync {
cancel_handle,
ready_tx: crate::playsync::ReadyGuard::new(ready_tx),
clock,
start_time: std::time::Duration::ZERO,
loop_control: crate::playsync::LoopControl::new(),
},
)?;
assert_lighting_state_cleared(&engine);
Ok(())
}
#[test]
fn test_midi_dmx_mirrors_to_effect_engine() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
let mut channels = std::collections::HashMap::new();
channels.insert("dimmer".to_string(), 1);
channels.insert("red".to_string(), 2);
channels.insert("green".to_string(), 3);
channels.insert("blue".to_string(), 4);
let fixture_info = crate::lighting::effects::FixtureInfo::new(
"test_fixture".to_string(),
5, 1, "RGBW_Par".to_string(),
channels,
None,
);
{
let mut store = engine.midi_dmx_store.write();
store.register_slot(5, 1, "test_fixture", "dimmer");
store.register_slot(5, 2, "test_fixture", "red");
store.register_slot(5, 3, "test_fixture", "green");
store.register_slot(5, 4, "test_fixture", "blue");
store.register_universe(5);
}
{
let mut effect_engine = engine.effect_engine.lock();
effect_engine.set_midi_dmx_store(engine.midi_dmx_store.clone());
effect_engine.register_fixture(fixture_info);
}
engine.handle_midi_event(
"universe1".to_string(),
midly::MidiMessage::NoteOn {
key: 0.into(),
vel: 127.into(),
},
);
engine.midi_dmx_store.read().tick();
{
let mut effect_engine = engine.effect_engine.lock();
let _commands = effect_engine
.update(std::time::Duration::from_millis(23), None)
.unwrap();
let states = effect_engine.get_fixture_states();
let fixture_state = states
.get("test_fixture")
.expect("test_fixture should have state in EffectEngine");
let dimmer = fixture_state
.channels
.get("dimmer")
.expect("dimmer channel should be present");
assert!(
(dimmer.value - 254.0 / 255.0).abs() < 0.01,
"dimmer should be ~0.996, got {}",
dimmer.value
);
}
Ok(())
}
#[test]
fn test_midi_dmx_unmapped_channel_no_mirror() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
let mut channels = std::collections::HashMap::new();
channels.insert("dimmer".to_string(), 1);
channels.insert("red".to_string(), 2);
channels.insert("green".to_string(), 3);
channels.insert("blue".to_string(), 4);
let fixture_info = crate::lighting::effects::FixtureInfo::new(
"test_fixture".to_string(),
5,
1,
"RGBW_Par".to_string(),
channels,
None,
);
{
let mut store = engine.midi_dmx_store.write();
store.register_slot(5, 1, "test_fixture", "dimmer");
store.register_slot(5, 2, "test_fixture", "red");
store.register_slot(5, 3, "test_fixture", "green");
store.register_slot(5, 4, "test_fixture", "blue");
store.register_universe(5);
}
{
let mut effect_engine = engine.effect_engine.lock();
effect_engine.set_midi_dmx_store(engine.midi_dmx_store.clone());
effect_engine.register_fixture(fixture_info);
}
engine.handle_midi_event(
"universe1".to_string(),
midly::MidiMessage::NoteOn {
key: 9.into(), vel: 100.into(),
},
);
let universe = engine.get_universe(5).unwrap();
assert_eq!(
universe.get_target_value(9),
200.0,
"Universe should have received the unmapped write"
);
engine.midi_dmx_store.read().tick();
{
let mut effect_engine = engine.effect_engine.lock();
let _commands = effect_engine
.update(std::time::Duration::from_millis(23), None)
.unwrap();
let states = effect_engine.get_fixture_states();
if let Some(fixture_state) = states.get("test_fixture") {
for channel_name in fixture_state.channels.keys() {
assert!(
["dimmer", "red", "green", "blue"].contains(&channel_name.as_str()),
"unexpected channel '{}' in fixture state",
channel_name
);
}
}
}
Ok(())
}
mod classify_midi_dmx_action_tests {
use super::super::{classify_midi_dmx_action, MidiDmxAction};
use midly::num::u7;
use std::time::Duration;
#[test]
fn note_on_converts_key_and_velocity() {
let action = classify_midi_dmx_action(
midly::MidiMessage::NoteOn {
key: u7::new(0),
vel: u7::new(127),
},
1.0,
);
assert_eq!(
action,
MidiDmxAction::KeyVelocity {
channel: 1,
value: 254
}
);
}
#[test]
fn note_off_converts_same_as_note_on() {
let action = classify_midi_dmx_action(
midly::MidiMessage::NoteOff {
key: u7::new(63),
vel: u7::new(0),
},
1.0,
);
assert_eq!(
action,
MidiDmxAction::KeyVelocity {
channel: 64,
value: 0
}
);
}
#[test]
fn note_on_max_key() {
let action = classify_midi_dmx_action(
midly::MidiMessage::NoteOn {
key: u7::new(127),
vel: u7::new(64),
},
1.0,
);
assert_eq!(
action,
MidiDmxAction::KeyVelocity {
channel: 128,
value: 128
}
);
}
#[test]
fn controller_converts_channel_and_value() {
let action = classify_midi_dmx_action(
midly::MidiMessage::Controller {
controller: u7::new(0),
value: u7::new(127),
},
1.0,
);
assert_eq!(
action,
MidiDmxAction::Controller {
channel: 1,
value: 254
}
);
}
#[test]
fn controller_mid_values() {
let action = classify_midi_dmx_action(
midly::MidiMessage::Controller {
controller: u7::new(10),
value: u7::new(50),
},
1.0,
);
assert_eq!(
action,
MidiDmxAction::Controller {
channel: 11,
value: 100
}
);
}
#[test]
fn program_change_with_default_modifier() {
let action = classify_midi_dmx_action(
midly::MidiMessage::ProgramChange {
program: u7::new(1),
},
1.0,
);
assert_eq!(
action,
MidiDmxAction::Dimming {
duration: Duration::from_secs(1)
}
);
}
#[test]
fn program_change_with_custom_modifier() {
let action = classify_midi_dmx_action(
midly::MidiMessage::ProgramChange {
program: u7::new(2),
},
0.5,
);
assert_eq!(
action,
MidiDmxAction::Dimming {
duration: Duration::from_secs(1)
}
);
}
#[test]
fn program_change_zero_gives_zero_duration() {
let action = classify_midi_dmx_action(
midly::MidiMessage::ProgramChange {
program: u7::new(0),
},
1.0,
);
assert_eq!(
action,
MidiDmxAction::Dimming {
duration: Duration::ZERO
}
);
}
#[test]
fn pitch_bend_is_unrecognized() {
let action = classify_midi_dmx_action(
midly::MidiMessage::PitchBend {
bend: midly::PitchBend(midly::num::u14::new(8192)),
},
1.0,
);
assert_eq!(action, MidiDmxAction::Unrecognized);
}
#[test]
fn channel_aftertouch_is_unrecognized() {
let action = classify_midi_dmx_action(
midly::MidiMessage::Aftertouch {
key: u7::new(60),
vel: u7::new(100),
},
1.0,
);
assert_eq!(action, MidiDmxAction::Unrecognized);
}
}
mod song_time_tests {
use super::*;
#[test]
fn song_time_defaults_to_zero() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
assert_eq!(engine.get_song_time(), std::time::Duration::ZERO);
Ok(())
}
#[test]
fn update_and_get_song_time() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
let t = std::time::Duration::from_millis(1500);
engine.update_song_time(t);
assert_eq!(engine.get_song_time(), t);
Ok(())
}
#[test]
fn song_time_can_be_overwritten() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
engine.update_song_time(std::time::Duration::from_secs(5));
engine.update_song_time(std::time::Duration::from_secs(10));
assert_eq!(engine.get_song_time(), std::time::Duration::from_secs(10));
Ok(())
}
}
mod timeline_cues_tests {
use super::*;
#[test]
fn no_timeline_returns_empty_cues() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
assert!(engine.get_timeline_cues().is_empty());
Ok(())
}
#[test]
fn with_empty_timeline_returns_empty_cues() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
{
let mut timeline = engine.current_song_timeline.lock();
*timeline = Some(crate::lighting::timeline::LightingTimeline::new_with_cues(
vec![],
));
}
assert!(engine.get_timeline_cues().is_empty());
Ok(())
}
}
mod midi_dmx_playbacks_finished_tests {
use super::*;
use crate::midi::playback::{PrecomputedMidi, TimedMidiEvent};
#[test]
fn no_playbacks_means_finished() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
assert!(engine.midi_dmx_playbacks_finished());
Ok(())
}
#[test]
fn playback_at_start_is_not_finished() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
let events = vec![TimedMidiEvent {
time: std::time::Duration::from_secs(1),
channel: 0,
message: midly::MidiMessage::NoteOn {
key: 0.into(),
vel: 100.into(),
},
}];
{
let mut playbacks = engine.midi_dmx_playbacks.lock();
playbacks.push(super::super::MidiDmxPlayback {
precomputed: PrecomputedMidi::from_events(events),
cursor: 0,
universe_id: 5,
midi_channels: HashSet::new(),
});
}
assert!(!engine.midi_dmx_playbacks_finished());
Ok(())
}
#[test]
fn playback_at_end_is_finished() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
let events = vec![TimedMidiEvent {
time: std::time::Duration::from_secs(1),
channel: 0,
message: midly::MidiMessage::NoteOn {
key: 0.into(),
vel: 100.into(),
},
}];
let len = events.len();
{
let mut playbacks = engine.midi_dmx_playbacks.lock();
playbacks.push(super::super::MidiDmxPlayback {
precomputed: PrecomputedMidi::from_events(events),
cursor: len,
universe_id: 5,
midi_channels: HashSet::new(),
});
}
assert!(engine.midi_dmx_playbacks_finished());
Ok(())
}
#[test]
fn mixed_playbacks_not_finished() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
let event = TimedMidiEvent {
time: std::time::Duration::ZERO,
channel: 0,
message: midly::MidiMessage::NoteOn {
key: 0.into(),
vel: 100.into(),
},
};
{
let mut playbacks = engine.midi_dmx_playbacks.lock();
playbacks.push(super::super::MidiDmxPlayback {
precomputed: PrecomputedMidi::from_events(vec![event.clone()]),
cursor: 1,
universe_id: 5,
midi_channels: HashSet::new(),
});
playbacks.push(super::super::MidiDmxPlayback {
precomputed: PrecomputedMidi::from_events(vec![event]),
cursor: 0,
universe_id: 5,
midi_channels: HashSet::new(),
});
}
assert!(!engine.midi_dmx_playbacks_finished());
Ok(())
}
}
mod advance_midi_dmx_playbacks_tests {
use super::*;
use crate::midi::playback::{PrecomputedMidi, TimedMidiEvent};
#[test]
fn playback_delay_prevents_advance() -> Result<(), Box<dyn Error>> {
let config = config::Dmx::new(
Some(1.0),
Some("2s".to_string()),
Some(9090),
vec![config::Universe::new(5, "universe1".to_string())],
None,
);
let ola_client = crate::dmx::ola_client::OlaClientFactory::create_mock_client();
let engine = Arc::new(Engine::new(&config, None, None, ola_client)?);
let events = vec![TimedMidiEvent {
time: std::time::Duration::ZERO,
channel: 0,
message: midly::MidiMessage::ProgramChange {
program: u7::new(3),
},
}];
{
let mut playbacks = engine.midi_dmx_playbacks.lock();
playbacks.push(super::super::MidiDmxPlayback {
precomputed: PrecomputedMidi::from_events(events),
cursor: 0,
universe_id: 5,
midi_channels: HashSet::new(),
});
}
engine.update_song_time(std::time::Duration::from_secs(1));
engine.advance_midi_dmx_playbacks();
let playbacks = engine.midi_dmx_playbacks.lock();
assert_eq!(playbacks[0].cursor, 0);
Ok(())
}
#[test]
fn advance_past_delay() -> Result<(), Box<dyn Error>> {
let config = config::Dmx::new(
Some(1.0),
Some("1s".to_string()),
Some(9090),
vec![config::Universe::new(5, "universe1".to_string())],
None,
);
let ola_client = crate::dmx::ola_client::OlaClientFactory::create_mock_client();
let engine = Arc::new(Engine::new(&config, None, None, ola_client)?);
let events = vec![TimedMidiEvent {
time: std::time::Duration::ZERO,
channel: 0,
message: midly::MidiMessage::ProgramChange {
program: u7::new(3),
},
}];
{
let mut playbacks = engine.midi_dmx_playbacks.lock();
playbacks.push(super::super::MidiDmxPlayback {
precomputed: PrecomputedMidi::from_events(events),
cursor: 0,
universe_id: 5,
midi_channels: HashSet::new(),
});
}
engine.update_song_time(std::time::Duration::from_secs(2));
engine.advance_midi_dmx_playbacks();
let playbacks = engine.midi_dmx_playbacks.lock();
assert_eq!(playbacks[0].cursor, 1);
Ok(())
}
#[test]
fn advance_respects_event_time() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
let events = vec![
TimedMidiEvent {
time: std::time::Duration::from_millis(500),
channel: 0,
message: midly::MidiMessage::NoteOn {
key: 0.into(),
vel: 100.into(),
},
},
TimedMidiEvent {
time: std::time::Duration::from_secs(2),
channel: 0,
message: midly::MidiMessage::NoteOn {
key: 1.into(),
vel: 50.into(),
},
},
];
{
let mut playbacks = engine.midi_dmx_playbacks.lock();
playbacks.push(super::super::MidiDmxPlayback {
precomputed: PrecomputedMidi::from_events(events),
cursor: 0,
universe_id: 5,
midi_channels: HashSet::new(),
});
}
engine.update_song_time(std::time::Duration::from_secs(1));
engine.advance_midi_dmx_playbacks();
let playbacks = engine.midi_dmx_playbacks.lock();
assert_eq!(
playbacks[0].cursor, 1,
"should advance past first event only"
);
Ok(())
}
#[test]
fn empty_channel_filter_accepts_all() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
let events = vec![
TimedMidiEvent {
time: std::time::Duration::ZERO,
channel: 3,
message: midly::MidiMessage::NoteOn {
key: 0.into(),
vel: 50.into(),
},
},
TimedMidiEvent {
time: std::time::Duration::ZERO,
channel: 7,
message: midly::MidiMessage::NoteOn {
key: 1.into(),
vel: 60.into(),
},
},
];
{
let mut playbacks = engine.midi_dmx_playbacks.lock();
playbacks.push(super::super::MidiDmxPlayback {
precomputed: PrecomputedMidi::from_events(events),
cursor: 0,
universe_id: 5,
midi_channels: HashSet::new(), });
}
engine.update_song_time(std::time::Duration::from_secs(1));
engine.advance_midi_dmx_playbacks();
let playbacks = engine.midi_dmx_playbacks.lock();
assert_eq!(playbacks[0].cursor, 2);
Ok(())
}
}
mod handle_midi_event_routing_tests {
use super::*;
#[test]
fn unknown_universe_name_is_ignored() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
engine.handle_midi_event(
"nonexistent_universe".to_string(),
midly::MidiMessage::NoteOn {
key: 0.into(),
vel: 100.into(),
},
);
let universe = engine.get_universe(5).unwrap();
assert_eq!(universe.get_target_value(0), 0.0);
Ok(())
}
#[test]
fn note_off_updates_universe() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
engine.handle_midi_event(
"universe1".to_string(),
midly::MidiMessage::NoteOff {
key: 5.into(),
vel: 50.into(),
},
);
let universe = engine.get_universe(5).unwrap();
assert_eq!(
universe.get_target_value(5),
100.0,
"NoteOff should update DMX channel 6 (index 5) with vel*2=100"
);
Ok(())
}
}
mod effects_loop_tick_tests {
use super::*;
#[test]
fn tick_updates_phase_diagnostics() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
engine.effects_loop_tick();
assert_eq!(
engine
.effects_loop_phase
.load(std::sync::atomic::Ordering::Relaxed),
0,
"phase should be 0 (idle) after tick completes"
);
Ok(())
}
#[test]
fn tick_with_finished_timeline_does_not_notify() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
assert!(engine
.timeline_finished
.load(std::sync::atomic::Ordering::Relaxed));
engine.effects_loop_tick();
Ok(())
}
#[test]
fn tick_detects_finished_timeline() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
engine
.timeline_finished
.store(false, std::sync::atomic::Ordering::Relaxed);
engine.effects_loop_tick();
assert!(
engine
.timeline_finished
.load(std::sync::atomic::Ordering::Relaxed),
"timeline_finished should be set to true when no timeline and no playbacks"
);
Ok(())
}
}
mod validate_song_lighting_tests {
use super::*;
#[test]
fn no_dsl_shows_returns_ok() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
let song_config = crate::config::Song::new(
"No Lighting",
None,
None,
None,
None,
None,
vec![],
std::collections::HashMap::new(),
Vec::new(),
);
let song = crate::songs::Song::new(std::path::Path::new("/tmp"), &song_config)?;
assert!(engine.validate_song_lighting(&song).is_ok());
Ok(())
}
#[test]
fn valid_dsl_show_passes_validation() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
let tmp_dir = tempfile::tempdir()?;
let dsl_path = tmp_dir.path().join("test.light");
std::fs::write(
&dsl_path,
r#"show "test" {
@00:00.000
front_wash: static color: "blue", duration: 5s, dimmer: 100%
}"#,
)?;
let song_config = crate::config::Song::new(
"With Lighting",
None,
None,
None,
None,
Some(vec![crate::config::LightingShow::new(
dsl_path.to_string_lossy().into_owned(),
)]),
vec![],
std::collections::HashMap::new(),
Vec::new(),
);
let song = crate::songs::Song::new(tmp_dir.path(), &song_config)?;
assert!(engine.validate_song_lighting(&song).is_ok());
Ok(())
}
#[test]
fn invalid_dsl_file_rejected_at_song_creation() {
let tmp_dir = tempfile::tempdir().unwrap();
let dsl_path = tmp_dir.path().join("bad.light");
std::fs::write(&dsl_path, "this is not valid DSL syntax {").unwrap();
let song_config = crate::config::Song::new(
"Bad Lighting",
None,
None,
None,
None,
Some(vec![crate::config::LightingShow::new(
dsl_path.to_string_lossy().into_owned(),
)]),
vec![],
std::collections::HashMap::new(),
Vec::new(),
);
assert!(crate::songs::Song::new(tmp_dir.path(), &song_config).is_err());
}
#[test]
fn missing_dsl_file_rejected_at_song_creation() {
let tmp_dir = tempfile::tempdir().unwrap();
let song_config = crate::config::Song::new(
"Missing File",
None,
None,
None,
None,
Some(vec![crate::config::LightingShow::new(
"/nonexistent/path.light".to_string(),
)]),
vec![],
std::collections::HashMap::new(),
Vec::new(),
);
assert!(crate::songs::Song::new(tmp_dir.path(), &song_config).is_err());
}
}
mod start_lighting_timeline_tests {
use super::*;
#[test]
fn start_at_zero_starts_timeline() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
{
let mut timeline = engine.current_song_timeline.lock();
*timeline = Some(crate::lighting::timeline::LightingTimeline::new_with_cues(
vec![],
));
}
engine.start_lighting_timeline_at(std::time::Duration::ZERO);
let timeline = engine.current_song_timeline.lock();
assert!(timeline.is_some());
Ok(())
}
#[test]
fn start_at_nonzero_applies_historical_state() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
let mut channels = std::collections::HashMap::new();
channels.insert("dimmer".to_string(), 1);
let fixture_info = crate::lighting::effects::FixtureInfo::new(
"front_wash".to_string(),
1,
1,
"Generic".to_string(),
channels,
None,
);
{
let mut effect_engine = engine.effect_engine.lock();
effect_engine.register_fixture(fixture_info);
}
use crate::lighting::parser::{Cue, Effect};
let effect = Effect {
sequence_name: None,
groups: vec!["front_wash".to_string()],
effect_type: crate::lighting::effects::EffectType::Static {
parameters: {
let mut p = std::collections::HashMap::new();
p.insert("dimmer".to_string(), 1.0);
p
},
duration: Duration::ZERO,
},
up_time: None,
hold_time: None,
down_time: None,
layer: None,
blend_mode: None,
};
let cue = Cue {
time: std::time::Duration::ZERO,
effects: vec![effect],
layer_commands: vec![],
stop_sequences: vec![],
start_sequences: vec![],
};
{
let mut timeline = engine.current_song_timeline.lock();
*timeline = Some(crate::lighting::timeline::LightingTimeline::new_with_cues(
vec![cue],
));
}
engine.start_lighting_timeline_at(std::time::Duration::from_secs(5));
let effect_engine = engine.effect_engine.lock();
let active = effect_engine.format_active_effects();
assert!(
!active.is_empty(),
"Historical cue effect should be active after seeking"
);
Ok(())
}
#[test]
fn start_without_timeline_is_noop() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
engine.start_lighting_timeline_at(std::time::Duration::from_secs(5));
let timeline = engine.current_song_timeline.lock();
assert!(timeline.is_none());
Ok(())
}
}
mod update_song_lighting_tests {
use super::*;
#[test]
fn update_with_no_timeline_returns_ok() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
assert!(engine
.update_song_lighting(std::time::Duration::from_secs(1))
.is_ok());
Ok(())
}
#[test]
fn update_with_timeline_processes_cues() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
let mut channels = std::collections::HashMap::new();
channels.insert("dimmer".to_string(), 1);
let fixture_info = crate::lighting::effects::FixtureInfo::new(
"test_fixture".to_string(),
1,
1,
"Generic".to_string(),
channels,
None,
);
{
let mut effect_engine = engine.effect_engine.lock();
effect_engine.register_fixture(fixture_info);
}
use crate::lighting::parser::{Cue, Effect};
let cue = Cue {
time: std::time::Duration::from_secs(1),
effects: vec![Effect {
sequence_name: None,
groups: vec!["test_fixture".to_string()],
effect_type: crate::lighting::effects::EffectType::Static {
parameters: {
let mut p = std::collections::HashMap::new();
p.insert("dimmer".to_string(), 0.5);
p
},
duration: Duration::ZERO,
},
up_time: None,
hold_time: None,
down_time: None,
layer: None,
blend_mode: None,
}],
layer_commands: vec![],
stop_sequences: vec![],
start_sequences: vec![],
};
{
let mut timeline = engine.current_song_timeline.lock();
let mut tl = crate::lighting::timeline::LightingTimeline::new_with_cues(vec![cue]);
tl.start();
*timeline = Some(tl);
}
engine.update_song_lighting(std::time::Duration::ZERO)?;
engine.update_song_lighting(std::time::Duration::from_secs(2))?;
let effect_engine = engine.effect_engine.lock();
let active = effect_engine.format_active_effects();
assert!(!active.is_empty(), "Cue effect should be active at t=2s");
Ok(())
}
}
mod song_time_tracker_tests {
use super::*;
#[test]
fn tracker_updates_song_time() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
let cancel = CancelHandle::new();
engine
.timeline_finished
.store(false, std::sync::atomic::Ordering::Relaxed);
let clock = crate::clock::PlaybackClock::wall();
clock.start();
let handle = Engine::start_song_time_tracker_from(
engine.clone(),
cancel.clone(),
std::time::Duration::from_secs(10),
clock,
);
std::thread::sleep(std::time::Duration::from_millis(50));
let song_time = engine.get_song_time();
assert!(
song_time >= std::time::Duration::from_secs(10),
"Song time should be at least start_offset (10s), got {:?}",
song_time
);
cancel.cancel();
handle.join().expect("tracker thread should join");
Ok(())
}
#[test]
fn tracker_stops_on_timeline_finished() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
let cancel = CancelHandle::new();
engine
.timeline_finished
.store(false, std::sync::atomic::Ordering::Relaxed);
let clock = crate::clock::PlaybackClock::wall();
clock.start();
let handle = Engine::start_song_time_tracker_from(
engine.clone(),
cancel,
std::time::Duration::ZERO,
clock,
);
std::thread::sleep(std::time::Duration::from_millis(30));
engine
.timeline_finished
.store(true, std::sync::atomic::Ordering::Relaxed);
handle.join().expect("tracker thread should join");
Ok(())
}
}
mod dimming_tests {
use super::*;
#[test]
fn zero_duration_sets_rate_one() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
engine.handle_midi_event_by_id(
5,
midly::MidiMessage::ProgramChange {
program: u7::new(2),
},
);
assert!(engine.get_universe(5).unwrap().get_dim_speed() > 1.0);
engine.handle_midi_event_by_id(
5,
midly::MidiMessage::ProgramChange {
program: u7::new(0),
},
);
assert_eq!(
engine.get_universe(5).unwrap().get_dim_speed(),
1.0,
"Zero dimming duration should set dim speed to 1.0"
);
Ok(())
}
#[test]
fn dimming_mirrors_to_midi_dmx_store() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
engine.midi_dmx_store.write().register_universe(5);
engine.handle_midi_event_by_id(
5,
midly::MidiMessage::ProgramChange {
program: u7::new(1),
},
);
let _store = engine.midi_dmx_store.read();
Ok(())
}
#[test]
fn dimming_unknown_universe_no_panic() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
engine.update_dimming_by_id(999, std::time::Duration::from_secs(1));
Ok(())
}
}
mod register_fixtures_tests {
use super::*;
#[test]
fn register_without_lighting_system_is_ok() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
engine.register_venue_fixtures_safe()?;
let effect_engine = engine.effect_engine.lock();
assert!(effect_engine.get_fixture_states().is_empty());
Ok(())
}
#[test]
fn register_with_lighting_system_but_no_venue() -> Result<(), Box<dyn Error>> {
let lighting_config = crate::config::Lighting::new(None, None, None, None);
let config = create_test_config();
let ola_client = OlaClientFactory::create_mock_client();
let engine = Engine::new(&config, Some(&lighting_config), None, ola_client)?;
let result = engine.register_venue_fixtures_safe();
let _ = result;
Ok(())
}
}
mod effects_loop_heartbeat_tests {
use super::*;
#[test]
fn heartbeat_getter_returns_value() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
assert_eq!(engine.effects_loop_heartbeat(), 0);
engine
.effects_loop_heartbeat
.fetch_add(42, std::sync::atomic::Ordering::Relaxed);
assert_eq!(engine.effects_loop_heartbeat(), 42);
Ok(())
}
}
mod stop_timeline_tests {
use super::*;
#[test]
fn stop_with_active_timeline() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
{
let mut timeline = engine.current_song_timeline.lock();
let mut tl = crate::lighting::timeline::LightingTimeline::new_with_cues(vec![]);
tl.start();
*timeline = Some(tl);
}
engine.stop_lighting_timeline();
let timeline = engine.current_song_timeline.lock();
assert!(timeline.is_some());
Ok(())
}
#[test]
fn stop_without_timeline_is_noop() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
engine.stop_lighting_timeline();
Ok(())
}
}
mod broadcast_handles_tests {
use super::*;
#[test]
fn returns_handles() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
let handles = engine.broadcast_handles();
assert!(handles.lighting_system.is_none());
Ok(())
}
#[test]
fn set_broadcast_tx() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
let (tx, _rx) = tokio::sync::broadcast::channel(16);
engine.set_broadcast_tx(tx);
let stored = engine.broadcast_tx.lock();
assert!(stored.is_some());
Ok(())
}
}
mod resolve_effect_groups_tests {
use super::*;
use crate::lighting::{effects::EffectType, EffectInstance};
#[test]
fn resolves_groups_with_lighting_system() -> Result<(), Box<dyn Error>> {
let lighting_config = crate::config::Lighting::new(
None, None, None, None,
);
let config = create_test_config();
let ola_client = OlaClientFactory::create_mock_client();
let engine = Engine::new(&config, Some(&lighting_config), None, ola_client)?;
let effect = EffectInstance::new(
"test".to_string(),
EffectType::Static {
parameters: std::collections::HashMap::new(),
duration: Duration::ZERO,
},
vec!["some_group".to_string()],
None,
None,
None,
);
let resolved = engine.resolve_effect_groups(effect);
assert!(
!resolved.target_fixtures.is_empty(),
"Graceful fallback should return something"
);
Ok(())
}
#[test]
fn no_lighting_system_passes_through() -> Result<(), Box<dyn Error>> {
let config = create_test_config();
let ola_client = OlaClientFactory::create_mock_client();
let engine = Engine::new(&config, None, None, ola_client)?;
let effect = EffectInstance::new(
"test".to_string(),
EffectType::Static {
parameters: std::collections::HashMap::new(),
duration: Duration::ZERO,
},
vec!["some_group".to_string()],
None,
None,
None,
);
let resolved = engine.resolve_effect_groups(effect);
assert_eq!(
resolved.target_fixtures,
vec!["some_group".to_string()],
"Without lighting system, groups should pass through unchanged"
);
Ok(())
}
}
mod wait_for_timeline_tests {
use super::*;
use std::sync::atomic::{AtomicBool, AtomicU64};
#[test]
fn exits_when_timeline_finished() {
let cancel = CancelHandle::new();
let finished = Arc::new(AtomicBool::new(false));
let heartbeat = AtomicU64::new(0);
let phase = AtomicU64::new(0);
let subphase = AtomicU64::new(0);
let finished_clone = finished.clone();
let setter = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(50));
finished_clone.store(true, std::sync::atomic::Ordering::Relaxed);
});
Engine::wait_for_timeline_with_heartbeat(
&cancel, finished, &heartbeat, &phase, &subphase,
);
setter.join().unwrap();
}
#[test]
fn exits_when_cancelled() {
let cancel = CancelHandle::new();
let finished = Arc::new(AtomicBool::new(false));
let heartbeat = AtomicU64::new(0);
let phase = AtomicU64::new(0);
let subphase = AtomicU64::new(0);
let cancel_clone = cancel.clone();
let setter = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(50));
cancel_clone.cancel();
});
Engine::wait_for_timeline_with_heartbeat(
&cancel, finished, &heartbeat, &phase, &subphase,
);
setter.join().unwrap();
}
}
mod effect_engine_accessor_tests {
use super::*;
#[test]
fn effect_engine_returns_arc() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
let ee = engine.effect_engine();
let locked = ee.lock();
let _ = locked.format_active_effects();
Ok(())
}
}
mod play_tests {
use super::*;
fn create_dsl_song_with_content(
dsl_content: &str,
) -> Result<(tempfile::TempDir, Arc<crate::songs::Song>), Box<dyn Error>> {
let tmp_dir = tempfile::tempdir()?;
let dsl_path = tmp_dir.path().join("show.light");
std::fs::write(&dsl_path, dsl_content)?;
let song_config = crate::config::Song::new(
"DSL Song",
None,
None,
None,
None,
Some(vec![crate::config::LightingShow::new(
dsl_path.to_string_lossy().into_owned(),
)]),
vec![],
std::collections::HashMap::new(),
Vec::new(),
);
let song = Arc::new(crate::songs::Song::new(tmp_dir.path(), &song_config)?);
Ok((tmp_dir, song))
}
#[test]
fn play_song_with_no_lighting_returns_ok() -> Result<(), Box<dyn Error>> {
let (engine, cancel_handle) = create_engine()?;
Engine::start_persistent_effects_loop(engine.clone());
let song_config = crate::config::Song::new(
"No Light",
None,
None,
None,
None,
None,
vec![],
std::collections::HashMap::new(),
Vec::new(),
);
let song = Arc::new(crate::songs::Song::new(
std::path::Path::new("/tmp"),
&song_config,
)?);
let (ready_tx, _ready_rx) = std::sync::mpsc::channel::<()>();
let clock = crate::clock::PlaybackClock::wall();
clock.start();
let result = Engine::play(
engine,
song,
crate::playsync::PlaybackSync {
cancel_handle,
ready_tx: crate::playsync::ReadyGuard::new(ready_tx),
clock,
start_time: std::time::Duration::ZERO,
loop_control: crate::playsync::LoopControl::new(),
},
);
assert!(result.is_ok());
Ok(())
}
#[test]
fn play_dsl_song_cancelled() -> Result<(), Box<dyn Error>> {
let (engine, cancel_handle) = create_engine()?;
Engine::start_persistent_effects_loop(engine.clone());
let (_tmp_dir, song) = create_dsl_song_with_content(
r#"show "test" {
@00:00.000
front_wash: static color: "blue", duration: 5s, dimmer: 100%
}"#,
)?;
let (ready_tx, _ready_rx) = std::sync::mpsc::channel::<()>();
let clock = crate::clock::PlaybackClock::wall();
clock.start();
cancel_handle.cancel();
let result = Engine::play(
engine.clone(),
song,
crate::playsync::PlaybackSync {
cancel_handle,
ready_tx: crate::playsync::ReadyGuard::new(ready_tx),
clock,
start_time: std::time::Duration::ZERO,
loop_control: crate::playsync::LoopControl::new(),
},
);
assert!(result.is_ok());
Ok(())
}
#[test]
fn play_dsl_song_with_start_time() -> Result<(), Box<dyn Error>> {
let (engine, cancel_handle) = create_engine()?;
Engine::start_persistent_effects_loop(engine.clone());
let (_tmp_dir, song) = create_dsl_song_with_content(
r#"show "test" {
@00:00.000
front_wash: static color: "red", duration: 5s, dimmer: 100%
@00:05.000
front_wash: static color: "blue", duration: 5s, dimmer: 50%
}"#,
)?;
let (ready_tx, _ready_rx) = std::sync::mpsc::channel::<()>();
let clock = crate::clock::PlaybackClock::wall();
clock.start();
cancel_handle.cancel();
let result = Engine::play(
engine.clone(),
song,
crate::playsync::PlaybackSync {
cancel_handle,
ready_tx: crate::playsync::ReadyGuard::new(ready_tx),
clock,
start_time: std::time::Duration::from_secs(3),
loop_control: crate::playsync::LoopControl::new(),
},
);
assert!(result.is_ok());
Ok(())
}
#[test]
fn play_midi_dmx_song_with_unmatched_universe() -> Result<(), Box<dyn Error>> {
let (engine, cancel_handle) = create_engine()?;
Engine::start_persistent_effects_loop(engine.clone());
let assets_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets");
let song_config = crate::config::Song::new(
"MIDI DMX Song",
None,
None,
None,
Some(vec![crate::config::LightShow::new(
"nonexistent_universe".to_string(),
"song.mid".to_string(),
None,
)]),
None,
vec![],
std::collections::HashMap::new(),
Vec::new(),
);
let song = Arc::new(crate::songs::Song::new(&assets_path, &song_config)?);
let (ready_tx, _ready_rx) = std::sync::mpsc::channel::<()>();
let clock = crate::clock::PlaybackClock::wall();
clock.start();
let result = Engine::play(
engine.clone(),
song,
crate::playsync::PlaybackSync {
cancel_handle,
ready_tx: crate::playsync::ReadyGuard::new(ready_tx),
clock,
start_time: std::time::Duration::ZERO,
loop_control: crate::playsync::LoopControl::new(),
},
);
assert!(result.is_ok());
Ok(())
}
#[test]
fn play_midi_dmx_song_multiple_unmatched_universes() -> Result<(), Box<dyn Error>> {
let (engine, cancel_handle) = create_engine()?;
Engine::start_persistent_effects_loop(engine.clone());
let assets_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets");
let song_config = crate::config::Song::new(
"MIDI DMX Multi",
None,
None,
None,
Some(vec![
crate::config::LightShow::new(
"nonexistent1".to_string(),
"song.mid".to_string(),
None,
),
crate::config::LightShow::new(
"nonexistent2".to_string(),
"song.mid".to_string(),
None,
),
]),
None,
vec![],
std::collections::HashMap::new(),
Vec::new(),
);
let song = Arc::new(crate::songs::Song::new(&assets_path, &song_config)?);
let (ready_tx, _ready_rx) = std::sync::mpsc::channel::<()>();
let clock = crate::clock::PlaybackClock::wall();
clock.start();
let result = Engine::play(
engine.clone(),
song,
crate::playsync::PlaybackSync {
cancel_handle,
ready_tx: crate::playsync::ReadyGuard::new(ready_tx),
clock,
start_time: std::time::Duration::ZERO,
loop_control: crate::playsync::LoopControl::new(),
},
);
assert!(result.is_ok(), "play failed: {:?}", result.err());
Ok(())
}
}
mod apply_timeline_update_tests {
use super::*;
use crate::lighting::parser::{LayerCommand, LayerCommandType};
#[test]
fn applies_layer_commands() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
let update = crate::lighting::timeline::TimelineUpdate {
effects: vec![],
effects_with_elapsed: std::collections::HashMap::new(),
layer_commands: vec![LayerCommand {
command_type: LayerCommandType::Clear,
layer: None,
fade_time: None,
intensity: None,
speed: None,
}],
stop_sequences: vec![],
};
assert!(engine.apply_timeline_update(update).is_ok());
Ok(())
}
#[test]
fn applies_stop_sequences() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
let update = crate::lighting::timeline::TimelineUpdate {
effects: vec![],
effects_with_elapsed: std::collections::HashMap::new(),
layer_commands: vec![],
stop_sequences: vec!["test_seq".to_string()],
};
assert!(engine.apply_timeline_update(update).is_ok());
Ok(())
}
#[test]
fn applies_effects_with_elapsed() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
let mut channels = std::collections::HashMap::new();
channels.insert("dimmer".to_string(), 1);
let fixture_info = crate::lighting::effects::FixtureInfo::new(
"test_fixture".to_string(),
1,
1,
"Generic".to_string(),
channels,
None,
);
{
let mut ee = engine.effect_engine.lock();
ee.register_fixture(fixture_info);
}
let effect = crate::lighting::EffectInstance::new(
"test_effect".to_string(),
crate::lighting::effects::EffectType::Static {
parameters: {
let mut p = std::collections::HashMap::new();
p.insert("dimmer".to_string(), 0.5);
p
},
duration: Duration::ZERO,
},
vec!["test_fixture".to_string()],
None,
None,
None,
);
let mut effects_with_elapsed = std::collections::HashMap::new();
effects_with_elapsed.insert(
"test_effect".to_string(),
(effect, std::time::Duration::from_secs(2)),
);
let update = crate::lighting::timeline::TimelineUpdate {
effects: vec![],
effects_with_elapsed,
layer_commands: vec![],
stop_sequences: vec![],
};
assert!(engine.apply_timeline_update(update).is_ok());
Ok(())
}
#[test]
fn applies_regular_effects() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
let mut channels = std::collections::HashMap::new();
channels.insert("dimmer".to_string(), 1);
let fixture_info = crate::lighting::effects::FixtureInfo::new(
"test_fixture".to_string(),
1,
1,
"Generic".to_string(),
channels,
None,
);
{
let mut ee = engine.effect_engine.lock();
ee.register_fixture(fixture_info);
}
let effect = crate::lighting::EffectInstance::new(
"seq_test".to_string(),
crate::lighting::effects::EffectType::Static {
parameters: {
let mut p = std::collections::HashMap::new();
p.insert("dimmer".to_string(), 1.0);
p
},
duration: Duration::ZERO,
},
vec!["test_fixture".to_string()],
None,
None,
None,
);
let update = crate::lighting::timeline::TimelineUpdate {
effects: vec![effect],
effects_with_elapsed: std::collections::HashMap::new(),
layer_commands: vec![],
stop_sequences: vec![],
};
assert!(engine.apply_timeline_update(update).is_ok());
Ok(())
}
}
mod format_active_effects_tests {
use super::*;
#[test]
fn no_effects_returns_empty_or_default() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
let result = engine.format_active_effects();
assert!(result.is_empty() || result.contains("No"));
Ok(())
}
}
mod ola_thread_tests {
use super::*;
use crate::dmx::ola_client::MockOlaClient;
use std::sync::mpsc;
#[test]
fn sends_message_successfully() {
let client: Box<dyn crate::dmx::ola_client::OlaClient> = Box::new(MockOlaClient::new());
let client = Arc::new(parking_lot::Mutex::new(client));
let (tx, rx) = mpsc::channel::<super::super::DmxMessage>();
let client_clone = client.clone();
let handle = std::thread::spawn(move || {
Engine::ola_thread(client_clone, rx);
});
let mut buffer = ola::DmxBuffer::new();
buffer.set_channel(0, 255);
tx.send(super::super::DmxMessage {
universe: 1,
buffer,
})
.unwrap();
drop(tx);
handle.join().unwrap();
}
#[test]
fn disconnect_and_reconnect() {
let mut mock = MockOlaClient::new();
mock.should_fail = true;
let client: Box<dyn crate::dmx::ola_client::OlaClient> = Box::new(mock);
let client = Arc::new(parking_lot::Mutex::new(client));
let (tx, rx) = mpsc::channel::<super::super::DmxMessage>();
let client_clone = client.clone();
let handle = std::thread::spawn(move || {
Engine::ola_thread(client_clone, rx);
});
let buffer = ola::DmxBuffer::new();
tx.send(super::super::DmxMessage {
universe: 1,
buffer: buffer.clone(),
})
.unwrap();
std::thread::sleep(std::time::Duration::from_millis(50));
tx.send(super::super::DmxMessage {
universe: 1,
buffer: buffer.clone(),
})
.unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
drop(tx);
handle.join().unwrap();
}
}
mod heartbeat_stale_tests {
use super::*;
use std::sync::atomic::{AtomicBool, AtomicU64};
#[test]
fn stale_heartbeat_forces_timeline_finished() {
let cancel = CancelHandle::new();
let finished = Arc::new(AtomicBool::new(false));
let heartbeat = AtomicU64::new(0);
let phase = AtomicU64::new(3); let subphase = AtomicU64::new(50);
let finished_clone = finished.clone();
let handle = std::thread::spawn(move || {
Engine::wait_for_timeline_with_heartbeat(
&cancel,
finished_clone,
&heartbeat,
&phase,
&subphase,
);
});
let result = handle.join();
assert!(result.is_ok(), "wait_for_timeline should have exited");
assert!(
finished.load(std::sync::atomic::Ordering::Relaxed),
"Stale heartbeat should force timeline_finished=true"
);
}
#[test]
fn advancing_heartbeat_resets_stale_counter() {
let cancel = CancelHandle::new();
let finished = Arc::new(AtomicBool::new(false));
let heartbeat = AtomicU64::new(0);
let phase = AtomicU64::new(0);
let subphase = AtomicU64::new(0);
let finished_clone = finished.clone();
let cancel_clone = cancel.clone();
let advancer = std::thread::spawn(move || {
for i in 1..=5 {
std::thread::sleep(std::time::Duration::from_millis(500));
heartbeat.store(i, std::sync::atomic::Ordering::Relaxed);
}
cancel_clone.cancel();
});
Engine::wait_for_timeline_with_heartbeat(
&cancel,
finished_clone,
&AtomicU64::new(0),
&phase,
&subphase,
);
cancel.cancel();
advancer.join().unwrap();
}
}
mod unrecognized_midi_tests {
use super::*;
#[test]
fn handle_midi_event_by_id_unrecognized() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
engine.handle_midi_event_by_id(
5,
midly::MidiMessage::PitchBend {
bend: midly::PitchBend(midly::num::u14::new(8192)),
},
);
let universe = engine.get_universe(5).unwrap();
assert_eq!(universe.get_dim_speed(), 1.0);
Ok(())
}
}
mod lighting_system_engine_tests {
use super::*;
#[test]
fn engine_with_lighting_system() -> Result<(), Box<dyn Error>> {
let lighting_config = crate::config::Lighting::new(None, None, None, None);
let config = create_test_config();
let ola_client = OlaClientFactory::create_mock_client();
let tmp_dir = tempfile::tempdir()?;
let engine = Engine::new(
&config,
Some(&lighting_config),
Some(tmp_dir.path()),
ola_client,
)?;
let handles = engine.broadcast_handles();
assert!(
handles.lighting_system.is_some(),
"Lighting system should be initialized with config + base_path"
);
Ok(())
}
#[test]
fn resolve_effect_groups_with_lighting_system() -> Result<(), Box<dyn Error>> {
let lighting_config = crate::config::Lighting::new(None, None, None, None);
let config = create_test_config();
let ola_client = OlaClientFactory::create_mock_client();
let tmp_dir = tempfile::tempdir()?;
let engine = Engine::new(
&config,
Some(&lighting_config),
Some(tmp_dir.path()),
ola_client,
)?;
let effect = crate::lighting::EffectInstance::new(
"test".to_string(),
crate::lighting::effects::EffectType::Static {
parameters: std::collections::HashMap::new(),
duration: Duration::ZERO,
},
vec!["some_group".to_string()],
None,
None,
None,
);
let _resolved = engine.resolve_effect_groups(effect);
Ok(())
}
#[test]
fn engine_with_lighting_system_load_failure() -> Result<(), Box<dyn Error>> {
let tmp_dir = tempfile::tempdir()?;
let file_path = tmp_dir.path().join("not_a_dir");
std::fs::write(&file_path, "I am a file, not a directory")?;
let dirs = crate::config::lighting::Directories::new(
Some(file_path.to_string_lossy().into_owned()),
None,
);
let lighting_config = crate::config::Lighting::new(None, None, None, Some(dirs));
let config = create_test_config();
let ola_client = OlaClientFactory::create_mock_client();
let engine = Engine::new(
&config,
Some(&lighting_config),
Some(tmp_dir.path()),
ola_client,
)?;
let handles = engine.broadcast_handles();
assert!(
handles.lighting_system.is_none(),
"Lighting system should be None when load fails"
);
Ok(())
}
#[test]
fn register_venue_fixtures_with_full_lighting_system() -> Result<(), Box<dyn Error>> {
let tmp_dir = tempfile::tempdir()?;
let ft_dir = tmp_dir.path().join("fixture_types");
std::fs::create_dir(&ft_dir)?;
std::fs::write(
ft_dir.join("dimmer.light"),
r#"fixture_type "Dimmer" {
channels: 1
channel_map: {
"dimmer": 1
}
}"#,
)?;
let venue_dir = tmp_dir.path().join("venues");
std::fs::create_dir(&venue_dir)?;
std::fs::write(
venue_dir.join("test.light"),
r#"venue "test_venue" {
fixture "Wash1" Dimmer @ 1:1
}"#,
)?;
let dirs = crate::config::lighting::Directories::new(
Some("fixture_types".to_string()),
Some("venues".to_string()),
);
let lighting_config = crate::config::Lighting::new(
Some("test_venue".to_string()),
None,
None,
Some(dirs),
);
let config = config::Dmx::new(
None,
None,
Some(9090),
vec![config::Universe::new(1, "universe1".to_string())],
None,
);
let ola_client = OlaClientFactory::create_mock_client();
let engine = Engine::new(
&config,
Some(&lighting_config),
Some(tmp_dir.path()),
ola_client,
)?;
let handles = engine.broadcast_handles();
assert!(handles.lighting_system.is_some());
let result = engine.register_venue_fixtures_safe();
assert!(
result.is_ok(),
"register_venue_fixtures_safe failed: {:?}",
result.err()
);
let effect_engine = engine.effect_engine.lock();
let registry = effect_engine.get_fixture_registry();
assert!(
registry.contains_key("Wash1"),
"Wash1 fixture should be registered, got: {:?}",
registry.keys().collect::<Vec<_>>()
);
Ok(())
}
#[test]
fn validate_song_lighting_with_lighting_config() -> Result<(), Box<dyn Error>> {
let lighting_config = crate::config::Lighting::new(
None,
Some({
let mut fixtures = std::collections::HashMap::new();
fixtures.insert("front_wash".to_string(), "Generic_Dimmer @ 1:1".to_string());
fixtures
}),
None,
None,
);
let config = create_test_config();
let ola_client = OlaClientFactory::create_mock_client();
let tmp_dir = tempfile::tempdir()?;
let engine = Engine::new(
&config,
Some(&lighting_config),
Some(tmp_dir.path()),
ola_client,
)?;
let dsl_path = tmp_dir.path().join("show.light");
std::fs::write(
&dsl_path,
r#"show "test" {
@00:00.000
front_wash: static color: "blue", duration: 5s, dimmer: 100%
}"#,
)?;
let song_config = crate::config::Song::new(
"With Lighting",
None,
None,
None,
None,
Some(vec![crate::config::LightingShow::new(
dsl_path.to_string_lossy().into_owned(),
)]),
vec![],
std::collections::HashMap::new(),
Vec::new(),
);
let song = crate::songs::Song::new(tmp_dir.path(), &song_config)?;
assert!(engine.validate_song_lighting(&song).is_ok());
Ok(())
}
}
mod validate_error_paths_tests {
use super::*;
#[test]
fn validate_validation_error_with_config() -> Result<(), Box<dyn Error>> {
let lighting_config = crate::config::Lighting::new(
None,
Some({
let mut fixtures = std::collections::HashMap::new();
fixtures.insert("front_wash".to_string(), "Generic_Dimmer @ 1:1".to_string());
fixtures
}),
None,
None,
);
let config = create_test_config();
let ola_client = OlaClientFactory::create_mock_client();
let tmp_dir = tempfile::tempdir()?;
let engine = Engine::new(
&config,
Some(&lighting_config),
Some(tmp_dir.path()),
ola_client,
)?;
let dsl_path = tmp_dir.path().join("show.light");
std::fs::write(
&dsl_path,
r#"show "test" {
@00:00.000
unknown_group: static color: "blue", duration: 5s, dimmer: 100%
}"#,
)?;
let song_config = crate::config::Song::new(
"Validate Config Error",
None,
None,
None,
None,
Some(vec![crate::config::LightingShow::new(
dsl_path.to_string_lossy().into_owned(),
)]),
vec![],
std::collections::HashMap::new(),
Vec::new(),
);
let song = crate::songs::Song::new(tmp_dir.path(), &song_config)?;
let result = engine.validate_song_lighting(&song);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Light show validation failed"));
Ok(())
}
}
mod play_matched_midi_dmx_tests {
use super::*;
fn create_valid_midi_file(path: &std::path::Path) {
let midi_bytes: Vec<u8> = vec![
0x4D, 0x54, 0x68, 0x64, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x01, 0x01, 0xE0, 0x4D, 0x54, 0x72, 0x6B, 0x00, 0x00, 0x00, 0x08, 0x00, 0xC0, 0x00, 0x00, 0xFF, 0x2F, 0x00, 0x00,
];
std::fs::write(path, midi_bytes).unwrap();
}
#[test]
fn play_midi_dmx_song_with_matching_universe() -> Result<(), Box<dyn Error>> {
let (engine, cancel_handle) = create_engine()?;
Engine::start_persistent_effects_loop(engine.clone());
let tmp_dir = tempfile::tempdir()?;
let midi_path = tmp_dir.path().join("light.mid");
create_valid_midi_file(&midi_path);
let song_config = crate::config::Song::new(
"MIDI DMX Matched",
None,
None,
None,
Some(vec![crate::config::LightShow::new(
"universe1".to_string(),
"light.mid".to_string(),
None,
)]),
None,
vec![],
std::collections::HashMap::new(),
Vec::new(),
);
let song = Arc::new(crate::songs::Song::new(tmp_dir.path(), &song_config)?);
let (ready_tx, _ready_rx) = std::sync::mpsc::channel::<()>();
let clock = crate::clock::PlaybackClock::wall();
clock.start();
let result = Engine::play(
engine.clone(),
song,
crate::playsync::PlaybackSync {
cancel_handle,
ready_tx: crate::playsync::ReadyGuard::new(ready_tx),
clock,
start_time: std::time::Duration::ZERO,
loop_control: crate::playsync::LoopControl::new(),
},
);
assert!(result.is_ok(), "play failed: {:?}", result.err());
Ok(())
}
#[test]
fn play_midi_dmx_song_matched_with_start_time() -> Result<(), Box<dyn Error>> {
let (engine, cancel_handle) = create_engine()?;
Engine::start_persistent_effects_loop(engine.clone());
let tmp_dir = tempfile::tempdir()?;
let midi_path = tmp_dir.path().join("light.mid");
create_valid_midi_file(&midi_path);
let song_config = crate::config::Song::new(
"MIDI DMX Seek",
None,
None,
None,
Some(vec![crate::config::LightShow::new(
"universe1".to_string(),
"light.mid".to_string(),
None,
)]),
None,
vec![],
std::collections::HashMap::new(),
Vec::new(),
);
let song = Arc::new(crate::songs::Song::new(tmp_dir.path(), &song_config)?);
let (ready_tx, _ready_rx) = std::sync::mpsc::channel::<()>();
let clock = crate::clock::PlaybackClock::wall();
clock.start();
let result = Engine::play(
engine.clone(),
song,
crate::playsync::PlaybackSync {
cancel_handle,
ready_tx: crate::playsync::ReadyGuard::new(ready_tx),
clock,
start_time: std::time::Duration::from_secs(10),
loop_control: crate::playsync::LoopControl::new(),
},
);
assert!(result.is_ok(), "play failed: {:?}", result.err());
Ok(())
}
#[test]
fn play_mixed_matched_and_unmatched() -> Result<(), Box<dyn Error>> {
let (engine, cancel_handle) = create_engine()?;
Engine::start_persistent_effects_loop(engine.clone());
let tmp_dir = tempfile::tempdir()?;
let midi_path = tmp_dir.path().join("light.mid");
create_valid_midi_file(&midi_path);
let song_config = crate::config::Song::new(
"Mixed",
None,
None,
None,
Some(vec![
crate::config::LightShow::new(
"universe1".to_string(), "light.mid".to_string(),
None,
),
crate::config::LightShow::new(
"nonexistent".to_string(), "light.mid".to_string(),
None,
),
]),
None,
vec![],
std::collections::HashMap::new(),
Vec::new(),
);
let song = Arc::new(crate::songs::Song::new(tmp_dir.path(), &song_config)?);
let (ready_tx, _ready_rx) = std::sync::mpsc::channel::<()>();
let clock = crate::clock::PlaybackClock::wall();
clock.start();
let result = Engine::play(
engine.clone(),
song,
crate::playsync::PlaybackSync {
cancel_handle,
ready_tx: crate::playsync::ReadyGuard::new(ready_tx),
clock,
start_time: std::time::Duration::ZERO,
loop_control: crate::playsync::LoopControl::new(),
},
);
assert!(result.is_ok(), "play failed: {:?}", result.err());
Ok(())
}
}
mod play_multi_universe_tests {
use super::*;
fn create_valid_midi(path: &std::path::Path) {
let midi_bytes: Vec<u8> = vec![
0x4D, 0x54, 0x68, 0x64, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x01, 0x01, 0xE0,
0x4D, 0x54, 0x72, 0x6B, 0x00, 0x00, 0x00, 0x08, 0x00, 0xC0, 0x00, 0x00, 0xFF, 0x2F,
0x00, 0x00,
];
std::fs::write(path, midi_bytes).unwrap();
}
#[test]
fn play_two_matched_midi_dmx_universes() -> Result<(), Box<dyn Error>> {
let config = config::Dmx::new(
None,
None,
Some(9090),
vec![
config::Universe::new(1, "uni_a".to_string()),
config::Universe::new(2, "uni_b".to_string()),
],
None,
);
let ola_client = OlaClientFactory::create_mock_client();
let engine = Arc::new(Engine::new(&config, None, None, ola_client)?);
let cancel_handle = engine.cancel_handle.clone();
Engine::start_persistent_effects_loop(engine.clone());
let tmp_dir = tempfile::tempdir()?;
let midi_path = tmp_dir.path().join("light.mid");
create_valid_midi(&midi_path);
let song_config = crate::config::Song::new(
"Multi Universe",
None,
None,
None,
Some(vec![
crate::config::LightShow::new(
"uni_a".to_string(),
"light.mid".to_string(),
None,
),
crate::config::LightShow::new(
"uni_b".to_string(),
"light.mid".to_string(),
None,
),
]),
None,
vec![],
std::collections::HashMap::new(),
Vec::new(),
);
let song = Arc::new(crate::songs::Song::new(tmp_dir.path(), &song_config)?);
let (ready_tx, _ready_rx) = std::sync::mpsc::channel::<()>();
let clock = crate::clock::PlaybackClock::wall();
clock.start();
let result = Engine::play(
engine.clone(),
song,
crate::playsync::PlaybackSync {
cancel_handle,
ready_tx: crate::playsync::ReadyGuard::new(ready_tx),
clock,
start_time: std::time::Duration::ZERO,
loop_control: crate::playsync::LoopControl::new(),
},
);
assert!(result.is_ok(), "play failed: {:?}", result.err());
Ok(())
}
#[test]
fn play_dsl_and_midi_dmx_combined() -> Result<(), Box<dyn Error>> {
let config = config::Dmx::new(
None,
None,
Some(9090),
vec![config::Universe::new(5, "universe1".to_string())],
None,
);
let ola_client = OlaClientFactory::create_mock_client();
let engine = Arc::new(Engine::new(&config, None, None, ola_client)?);
let cancel_handle = engine.cancel_handle.clone();
Engine::start_persistent_effects_loop(engine.clone());
let tmp_dir = tempfile::tempdir()?;
let midi_path = tmp_dir.path().join("light.mid");
create_valid_midi(&midi_path);
let dsl_path = tmp_dir.path().join("show.light");
std::fs::write(
&dsl_path,
r#"show "test" {
@00:00.000
front_wash: static color: "blue", duration: 5s, dimmer: 100%
}"#,
)?;
let song_config = crate::config::Song::new(
"Combined",
None,
None,
None,
Some(vec![crate::config::LightShow::new(
"universe1".to_string(),
"light.mid".to_string(),
None,
)]),
Some(vec![crate::config::LightingShow::new(
dsl_path.to_string_lossy().into_owned(),
)]),
vec![],
std::collections::HashMap::new(),
Vec::new(),
);
let song = Arc::new(crate::songs::Song::new(tmp_dir.path(), &song_config)?);
let (ready_tx, _ready_rx) = std::sync::mpsc::channel::<()>();
let clock = crate::clock::PlaybackClock::wall();
clock.start();
let result = Engine::play(
engine.clone(),
song,
crate::playsync::PlaybackSync {
cancel_handle,
ready_tx: crate::playsync::ReadyGuard::new(ready_tx),
clock,
start_time: std::time::Duration::ZERO,
loop_control: crate::playsync::LoopControl::new(),
},
);
assert!(result.is_ok(), "play failed: {:?}", result.err());
Ok(())
}
}
mod effects_loop_tick_notify_tests {
use super::*;
#[test]
fn tick_notifies_cancel_handle_when_finished() -> Result<(), Box<dyn Error>> {
let (engine, _cancel_handle) = create_engine()?;
engine
.timeline_finished
.store(false, std::sync::atomic::Ordering::Relaxed);
let song_cancel = CancelHandle::new();
{
let mut handle = engine.timeline_cancel_handle.lock();
*handle = Some(song_cancel.clone());
}
engine.effects_loop_tick();
assert!(
engine
.timeline_finished
.load(std::sync::atomic::Ordering::Relaxed),
"Should set timeline_finished=true"
);
Ok(())
}
}
mod play_dsl_with_lighting_config_tests {
use super::*;
#[test]
fn play_dsl_song_with_lighting_config() -> Result<(), Box<dyn Error>> {
let lighting_config = crate::config::Lighting::new(
None,
Some({
let mut fixtures = std::collections::HashMap::new();
fixtures.insert("front_wash".to_string(), "Generic_Dimmer @ 1:1".to_string());
fixtures
}),
None,
None,
);
let config = config::Dmx::new(
None,
None,
Some(9090),
vec![config::Universe::new(5, "universe1".to_string())],
None,
);
let ola_client = OlaClientFactory::create_mock_client();
let tmp_dir = tempfile::tempdir()?;
let engine = Arc::new(Engine::new(
&config,
Some(&lighting_config),
Some(tmp_dir.path()),
ola_client,
)?);
let cancel_handle = engine.cancel_handle.clone();
Engine::start_persistent_effects_loop(engine.clone());
let (tx, _rx) = tokio::sync::broadcast::channel(16);
engine.set_broadcast_tx(tx);
let dsl_path = tmp_dir.path().join("show.light");
std::fs::write(
&dsl_path,
r#"show "test" {
@00:00.000
front_wash: static color: "blue", duration: 5s, dimmer: 100%
}"#,
)?;
let song_config = crate::config::Song::new(
"DSL With Config",
None,
None,
None,
None,
Some(vec![crate::config::LightingShow::new(
dsl_path.to_string_lossy().into_owned(),
)]),
vec![],
std::collections::HashMap::new(),
Vec::new(),
);
let song = Arc::new(crate::songs::Song::new(tmp_dir.path(), &song_config)?);
let (ready_tx, _ready_rx) = std::sync::mpsc::channel::<()>();
let clock = crate::clock::PlaybackClock::wall();
clock.start();
cancel_handle.cancel();
let result = Engine::play(
engine.clone(),
song,
crate::playsync::PlaybackSync {
cancel_handle,
ready_tx: crate::playsync::ReadyGuard::new(ready_tx),
clock,
start_time: std::time::Duration::ZERO,
loop_control: crate::playsync::LoopControl::new(),
},
);
assert!(result.is_ok());
Ok(())
}
}
}