use crate::lighting::{
parser::{Cue, Effect, LayerCommand, LayerCommandType, LightShow},
EffectInstance,
};
use std::collections::HashSet;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
static EFFECT_ID_COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, Clone, Default)]
pub struct TimelineUpdate {
pub effects: Vec<EffectInstance>,
pub effects_with_elapsed: std::collections::HashMap<String, (EffectInstance, Duration)>,
pub layer_commands: Vec<LayerCommand>,
pub stop_sequences: Vec<String>,
}
pub struct LightingTimeline {
cues: Vec<Cue>,
current_time: Duration,
next_cue_index: usize,
is_playing: bool,
tempo_map: Option<crate::lighting::tempo::TempoMap>,
stopped_sequences: HashSet<String>,
}
impl LightingTimeline {
pub fn new(shows: Vec<LightShow>) -> Self {
let mut all_cues = Vec::new();
let mut tempo_map: Option<crate::lighting::tempo::TempoMap> = None;
for show in shows {
all_cues.extend(show.cues);
if tempo_map.is_none() {
tempo_map = show.tempo_map;
}
}
let mut timeline = Self::new_with_cues(all_cues);
timeline.tempo_map = tempo_map;
timeline
}
pub(crate) fn new_with_cues(cues: Vec<Cue>) -> Self {
let mut timeline = Self {
cues,
current_time: Duration::ZERO,
next_cue_index: 0,
is_playing: false,
tempo_map: None,
stopped_sequences: HashSet::new(),
};
timeline.sort_cues();
timeline
}
pub fn tempo_map(&self) -> Option<&crate::lighting::tempo::TempoMap> {
self.tempo_map.as_ref()
}
fn sort_cues(&mut self) {
self.cues.sort_by(|a, b| a.time.cmp(&b.time));
}
pub fn start(&mut self) {
self.is_playing = true;
self.current_time = Duration::ZERO;
self.next_cue_index = 0;
self.stopped_sequences.clear();
}
pub fn start_at(&mut self, start_time: Duration) -> TimelineUpdate {
self.is_playing = true;
self.current_time = start_time;
self.stopped_sequences.clear();
self.next_cue_index = self.find_cue_index_at(start_time);
let mut result = TimelineUpdate::default();
for i in 0..self.next_cue_index {
let cue = &self.cues[i];
for seq_name in &cue.start_sequences {
self.stopped_sequences.remove(seq_name);
}
result
.layer_commands
.extend(cue.layer_commands.iter().cloned());
for cmd in &cue.layer_commands {
if cmd.command_type == LayerCommandType::Clear {
if let Some(layer) = cmd.layer {
result
.effects_with_elapsed
.retain(|_, (effect, _)| effect.layer != layer);
} else {
result.effects_with_elapsed.clear();
}
}
}
process_stop_sequences(&mut self.stopped_sequences, cue, &mut result);
for seq_name in &cue.stop_sequences {
let prefix = format!("seq_{}_", seq_name);
result
.effects_with_elapsed
.retain(|id, _| !id.starts_with(&prefix));
}
for effect in &cue.effects {
if let Some(ref seq_name) = effect.sequence_name {
if self.stopped_sequences.contains(seq_name) {
continue;
}
}
let effect_instance = Self::create_effect_instance(effect, cue.time);
let effect_start_time = cue.time;
let elapsed_at_start = start_time.saturating_sub(effect_start_time);
let duration = effect_instance.total_duration();
let should_include = elapsed_at_start < duration;
if should_include {
result.effects_with_elapsed.insert(
effect_instance.id.clone(),
(effect_instance, elapsed_at_start),
);
}
}
}
let total_effects = result.effects.len() + result.effects_with_elapsed.len();
if total_effects > 20 {
tracing::warn!(
effects = result.effects.len(),
effects_with_elapsed = result.effects_with_elapsed.len(),
start_time_ms = start_time.as_millis(),
"start_at() yielded unusually large effect batch"
);
}
result
}
fn find_cue_index_at(&self, time: Duration) -> usize {
self.cues.partition_point(|cue| cue.time < time)
}
pub fn stop(&mut self) {
self.is_playing = false;
self.current_time = Duration::ZERO;
self.next_cue_index = 0;
self.stopped_sequences.clear();
}
pub fn is_finished(&self) -> bool {
self.next_cue_index >= self.cues.len()
}
pub fn update(&mut self, song_time: Duration) -> TimelineUpdate {
if !self.is_playing {
return TimelineUpdate::default();
}
self.current_time = song_time;
let mut result = TimelineUpdate::default();
while self.next_cue_index < self.cues.len() {
let cue = &self.cues[self.next_cue_index];
if cue.time <= song_time {
for seq_name in &cue.start_sequences {
self.stopped_sequences.remove(seq_name);
}
for effect in &cue.effects {
if let Some(ref seq_name) = effect.sequence_name {
if self.stopped_sequences.contains(seq_name) {
continue;
}
}
let effect_instance = Self::create_effect_instance(effect, cue.time);
result.effects.push(effect_instance);
}
result
.layer_commands
.extend(cue.layer_commands.iter().cloned());
process_stop_sequences(&mut self.stopped_sequences, cue, &mut result);
self.next_cue_index += 1;
} else {
break;
}
}
if result.effects.len() > 20 {
tracing::warn!(
effects = result.effects.len(),
cue_index = self.next_cue_index,
song_time_ms = song_time.as_millis(),
"Timeline yielded unusually large effect batch in single update"
);
}
result
}
pub fn cues(&self) -> Vec<(Duration, usize)> {
self.cues
.iter()
.enumerate()
.map(|(index, cue)| (cue.time, index))
.collect()
}
pub fn create_effect_instance(effect: &Effect, cue_time: Duration) -> EffectInstance {
let id = EFFECT_ID_COUNTER.fetch_add(1, Ordering::Relaxed);
let effect_id = if let Some(ref seq_name) = effect.sequence_name {
format!("seq_{}_effect_{}", seq_name, id)
} else {
format!("song_effect_{}", id)
};
let mut effect_instance = EffectInstance::new(
effect_id,
effect.effect_type.clone(),
effect.groups.clone(),
effect.up_time,
effect.hold_time,
effect.down_time,
);
effect_instance.cue_time = Some(cue_time);
if let Some(layer) = effect.layer {
effect_instance.layer = layer;
}
if let Some(blend_mode) = effect.blend_mode {
effect_instance.blend_mode = blend_mode;
}
effect_instance
}
}
fn process_stop_sequences(
stopped_sequences: &mut HashSet<String>,
cue: &crate::lighting::parser::Cue,
result: &mut TimelineUpdate,
) {
let cue_sequence_names: HashSet<&String> = cue
.effects
.iter()
.filter_map(|e| e.sequence_name.as_ref())
.collect();
for seq_name in &cue.stop_sequences {
if !cue_sequence_names.contains(seq_name) {
stopped_sequences.insert(seq_name.clone());
}
}
result
.stop_sequences
.extend(cue.stop_sequences.iter().cloned());
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lighting::effects::EffectType;
use crate::lighting::parser::Effect;
use std::collections::HashMap;
#[test]
fn test_timeline_creation() {
let cues = vec![];
let timeline = LightingTimeline::new_with_cues(cues);
assert_eq!(timeline.cues.len(), 0);
assert!(!timeline.is_playing);
}
#[test]
fn test_timeline_start_stop() {
let cues = vec![];
let mut timeline = LightingTimeline::new_with_cues(cues);
assert!(!timeline.is_playing);
timeline.start();
assert!(timeline.is_playing);
timeline.stop();
assert!(!timeline.is_playing);
}
#[test]
fn test_timeline_is_finished() {
use crate::lighting::parser::Cue;
let empty_timeline = LightingTimeline::new_with_cues(vec![]);
assert!(
empty_timeline.is_finished(),
"Empty timeline should be finished"
);
let effect = Effect {
sequence_name: None,
groups: vec!["test_group".to_string()],
effect_type: EffectType::Static {
parameters: HashMap::new(),
duration: Duration::from_secs(5),
},
layer: None,
blend_mode: None,
up_time: None,
hold_time: None,
down_time: None,
};
let cues = vec![Cue {
stop_sequences: vec![],
start_sequences: vec![],
time: Duration::from_millis(0),
effects: vec![effect],
layer_commands: vec![],
}];
let mut timeline = LightingTimeline::new_with_cues(cues);
assert!(
!timeline.is_finished(),
"Timeline with unprocessed cues should not be finished"
);
timeline.start();
let _ = timeline.update(Duration::from_secs(1));
assert!(
timeline.is_finished(),
"Timeline should be finished after all cues processed"
);
}
#[test]
fn test_timeline_with_dsl_cues() {
use crate::lighting::parser::Cue;
let mut parameters = HashMap::new();
parameters.insert("color".to_string(), "blue".to_string());
parameters.insert("dimmer".to_string(), "60%".to_string());
let effect = Effect {
sequence_name: None,
groups: vec!["front_wash".to_string()],
effect_type: EffectType::Static {
parameters: HashMap::new(),
duration: Duration::from_secs(5),
},
layer: None,
blend_mode: None,
up_time: None,
hold_time: None,
down_time: None,
};
let cues = vec![Cue {
stop_sequences: vec![],
start_sequences: vec![],
time: Duration::from_millis(0),
effects: vec![effect],
layer_commands: vec![],
}];
let mut timeline = LightingTimeline::new_with_cues(cues);
timeline.start();
let result = timeline.update(Duration::from_millis(0));
assert_eq!(result.effects.len(), 1);
assert_eq!(result.effects[0].target_fixtures, vec!["front_wash"]);
}
#[test]
fn test_timeline_cue_ordering() {
use crate::lighting::parser::Cue;
let mut parameters = HashMap::new();
parameters.insert("color".to_string(), "blue".to_string());
let effect = Effect {
sequence_name: None,
groups: vec!["test_group".to_string()],
effect_type: EffectType::Static {
parameters: HashMap::new(),
duration: Duration::from_secs(5),
},
layer: None,
blend_mode: None,
up_time: None,
hold_time: None,
down_time: None,
};
let cues = vec![
Cue {
time: Duration::from_millis(10000),
effects: vec![effect.clone()],
layer_commands: vec![],
stop_sequences: vec![],
start_sequences: vec![],
},
Cue {
time: Duration::from_millis(5000),
effects: vec![effect.clone()],
layer_commands: vec![],
stop_sequences: vec![],
start_sequences: vec![],
},
Cue {
time: Duration::from_millis(0),
effects: vec![effect],
layer_commands: vec![],
stop_sequences: vec![],
start_sequences: vec![],
},
];
let mut timeline = LightingTimeline::new_with_cues(cues);
timeline.start();
let result = timeline.update(Duration::from_millis(0));
assert_eq!(result.effects.len(), 1);
let result = timeline.update(Duration::from_millis(5000));
assert_eq!(result.effects.len(), 1);
let result = timeline.update(Duration::from_millis(10000));
assert_eq!(result.effects.len(), 1);
}
#[test]
fn test_timeline_edge_cases() {
let timeline = LightingTimeline::new_with_cues(vec![]);
assert_eq!(timeline.cues.len(), 0);
let mut parameters = HashMap::new();
parameters.insert("color".to_string(), "blue".to_string());
let effect = Effect {
sequence_name: None,
groups: vec!["test_group".to_string()],
effect_type: EffectType::Static {
parameters: HashMap::new(),
duration: Duration::from_secs(5),
},
layer: None,
blend_mode: None,
up_time: None,
hold_time: None,
down_time: None,
};
let cues = vec![
Cue {
time: Duration::from_millis(5000),
effects: vec![effect.clone()],
layer_commands: vec![],
stop_sequences: vec![],
start_sequences: vec![],
},
Cue {
time: Duration::from_millis(5000),
effects: vec![effect],
layer_commands: vec![],
stop_sequences: vec![],
start_sequences: vec![],
},
];
let mut timeline = LightingTimeline::new_with_cues(cues);
timeline.start();
let result = timeline.update(Duration::from_millis(5000));
assert_eq!(result.effects.len(), 2);
}
#[test]
fn test_timeline_stop_reset() {
let mut parameters = HashMap::new();
parameters.insert("color".to_string(), "red".to_string());
let effect1 = Effect {
sequence_name: None,
groups: vec!["fixture1".to_string()],
effect_type: EffectType::Static {
parameters: HashMap::new(),
duration: Duration::from_secs(5),
},
layer: None,
blend_mode: None,
up_time: None,
hold_time: None,
down_time: None,
};
let effect2 = Effect {
sequence_name: None,
groups: vec!["fixture2".to_string()],
effect_type: EffectType::Static {
parameters: HashMap::new(),
duration: Duration::from_secs(5),
},
layer: None,
blend_mode: None,
up_time: None,
hold_time: None,
down_time: None,
};
let cues = vec![
Cue {
time: Duration::from_secs(0),
effects: vec![effect1],
layer_commands: vec![],
stop_sequences: vec![],
start_sequences: vec![],
},
Cue {
time: Duration::from_secs(2),
effects: vec![effect2],
layer_commands: vec![],
stop_sequences: vec![],
start_sequences: vec![],
},
];
let mut timeline = LightingTimeline::new_with_cues(cues);
timeline.start();
let _effects_at_0s = timeline.update(Duration::from_secs(0));
let _effects_at_2s = timeline.update(Duration::from_secs(2));
timeline.stop();
timeline.start();
let result_at_0s_restart = timeline.update(Duration::from_secs(0));
assert_eq!(result_at_0s_restart.effects.len(), 1);
let result_at_2s_restart = timeline.update(Duration::from_secs(2));
assert_eq!(result_at_2s_restart.effects.len(), 1);
}
#[test]
fn test_timeline_layer_commands() {
use crate::lighting::effects::EffectLayer;
use crate::lighting::parser::{Cue, LayerCommand, LayerCommandType};
let cues = vec![
Cue {
time: Duration::from_secs(0),
effects: vec![],
layer_commands: vec![LayerCommand {
command_type: LayerCommandType::Clear,
layer: Some(EffectLayer::Foreground),
fade_time: None,
intensity: None,
speed: None,
}],
stop_sequences: vec![],
start_sequences: vec![],
},
Cue {
time: Duration::from_secs(1),
effects: vec![],
layer_commands: vec![LayerCommand {
command_type: LayerCommandType::Release,
layer: Some(EffectLayer::Background),
fade_time: Some(Duration::from_secs(2)),
intensity: None,
speed: None,
}],
stop_sequences: vec![],
start_sequences: vec![],
},
Cue {
time: Duration::from_secs(2),
effects: vec![],
layer_commands: vec![LayerCommand {
command_type: LayerCommandType::Master,
layer: Some(EffectLayer::Midground),
fade_time: None,
intensity: Some(0.5),
speed: Some(2.0),
}],
stop_sequences: vec![],
start_sequences: vec![],
},
];
let mut timeline = LightingTimeline::new_with_cues(cues);
timeline.start();
let result0 = timeline.update(Duration::from_secs(0));
assert_eq!(result0.effects.len(), 0);
assert_eq!(result0.layer_commands.len(), 1);
assert_eq!(
result0.layer_commands[0].command_type,
LayerCommandType::Clear
);
assert_eq!(
result0.layer_commands[0].layer,
Some(EffectLayer::Foreground)
);
let result1 = timeline.update(Duration::from_secs(1));
assert_eq!(result1.layer_commands.len(), 1);
assert_eq!(
result1.layer_commands[0].command_type,
LayerCommandType::Release
);
assert_eq!(
result1.layer_commands[0].fade_time,
Some(Duration::from_secs(2))
);
let result2 = timeline.update(Duration::from_secs(2));
assert_eq!(result2.layer_commands.len(), 1);
assert_eq!(
result2.layer_commands[0].command_type,
LayerCommandType::Master
);
assert!((result2.layer_commands[0].intensity.unwrap() - 0.5).abs() < 0.01);
assert!((result2.layer_commands[0].speed.unwrap() - 2.0).abs() < 0.01);
}
#[test]
fn test_timeline_mixed_effects_and_layer_commands() {
use crate::lighting::effects::EffectLayer;
use crate::lighting::parser::{Cue, LayerCommand, LayerCommandType};
let effect = Effect {
sequence_name: None,
groups: vec!["test_group".to_string()],
effect_type: EffectType::Static {
parameters: HashMap::new(),
duration: Duration::from_secs(5),
},
layer: None,
blend_mode: None,
up_time: None,
hold_time: None,
down_time: None,
};
let cues = vec![Cue {
stop_sequences: vec![],
start_sequences: vec![],
time: Duration::from_secs(0),
effects: vec![effect],
layer_commands: vec![LayerCommand {
command_type: LayerCommandType::Master,
layer: Some(EffectLayer::Background),
fade_time: None,
intensity: Some(0.75),
speed: None,
}],
}];
let mut timeline = LightingTimeline::new_with_cues(cues);
timeline.start();
let result = timeline.update(Duration::from_secs(0));
assert_eq!(result.effects.len(), 1);
assert_eq!(result.layer_commands.len(), 1);
assert_eq!(
result.layer_commands[0].command_type,
LayerCommandType::Master
);
}
#[test]
fn test_timeline_start_at() {
use crate::lighting::parser::Cue;
let effect = Effect {
sequence_name: None,
groups: vec!["test_group".to_string()],
effect_type: EffectType::Static {
parameters: HashMap::new(),
duration: Duration::from_secs(5),
},
layer: None,
blend_mode: None,
up_time: None,
hold_time: None,
down_time: None,
};
let cues = vec![
Cue {
time: Duration::from_secs(0),
effects: vec![effect.clone()],
layer_commands: vec![],
stop_sequences: vec![],
start_sequences: vec![],
},
Cue {
time: Duration::from_secs(5),
effects: vec![effect.clone()],
layer_commands: vec![],
stop_sequences: vec![],
start_sequences: vec![],
},
Cue {
time: Duration::from_secs(10),
effects: vec![effect],
layer_commands: vec![],
stop_sequences: vec![],
start_sequences: vec![],
},
];
let mut timeline = LightingTimeline::new_with_cues(cues);
let _historical_update = timeline.start_at(Duration::from_secs(5));
assert!(timeline.is_playing);
assert_eq!(timeline.current_time, Duration::from_secs(5));
let result = timeline.update(Duration::from_secs(5));
assert_eq!(result.effects.len(), 1);
let result = timeline.update(Duration::from_secs(10));
assert_eq!(result.effects.len(), 1);
}
#[test]
fn test_timeline_find_cue_index_at() {
use crate::lighting::parser::Cue;
let effect = Effect {
sequence_name: None,
groups: vec!["test_group".to_string()],
effect_type: EffectType::Static {
parameters: HashMap::new(),
duration: Duration::from_secs(5),
},
layer: None,
blend_mode: None,
up_time: None,
hold_time: None,
down_time: None,
};
let cues = vec![
Cue {
time: Duration::from_secs(0),
effects: vec![effect.clone()],
layer_commands: vec![],
stop_sequences: vec![],
start_sequences: vec![],
},
Cue {
time: Duration::from_secs(5),
effects: vec![effect.clone()],
layer_commands: vec![],
stop_sequences: vec![],
start_sequences: vec![],
},
Cue {
time: Duration::from_secs(10),
effects: vec![effect],
layer_commands: vec![],
stop_sequences: vec![],
start_sequences: vec![],
},
];
let mut timeline = LightingTimeline::new_with_cues(cues);
timeline.start_at(Duration::from_secs(0));
assert_eq!(timeline.next_cue_index, 0);
timeline.start_at(Duration::from_secs(3));
assert_eq!(timeline.next_cue_index, 1);
timeline.start_at(Duration::from_secs(5));
assert_eq!(timeline.next_cue_index, 1);
timeline.start_at(Duration::from_secs(7));
assert_eq!(timeline.next_cue_index, 2);
timeline.start_at(Duration::from_secs(15));
assert_eq!(timeline.next_cue_index, 3);
}
#[test]
fn test_timeline_cue_listing() {
use crate::lighting::parser::Cue;
let effect = Effect {
sequence_name: None,
groups: vec!["test_group".to_string()],
effect_type: EffectType::Static {
parameters: HashMap::new(),
duration: Duration::from_secs(5),
},
layer: None,
blend_mode: None,
up_time: None,
hold_time: None,
down_time: None,
};
let cues = vec![
Cue {
time: Duration::from_secs(0),
effects: vec![effect.clone()],
layer_commands: vec![],
stop_sequences: vec![],
start_sequences: vec![],
},
Cue {
time: Duration::from_secs(5),
effects: vec![effect.clone()],
layer_commands: vec![],
stop_sequences: vec![],
start_sequences: vec![],
},
Cue {
time: Duration::from_secs(10),
effects: vec![effect],
layer_commands: vec![],
stop_sequences: vec![],
start_sequences: vec![],
},
];
let timeline = LightingTimeline::new_with_cues(cues);
let cue_list = timeline.cues();
assert_eq!(cue_list.len(), 3);
assert_eq!(cue_list[0], (Duration::from_secs(0), 0));
assert_eq!(cue_list[1], (Duration::from_secs(5), 1));
assert_eq!(cue_list[2], (Duration::from_secs(10), 2));
}
#[test]
fn test_start_at_clears_purge_old_effects() {
use crate::lighting::effects::EffectLayer;
use crate::lighting::parser::{Cue, LayerCommand, LayerCommandType};
let bg_effect = Effect {
sequence_name: None,
groups: vec!["test_group".to_string()],
effect_type: EffectType::Static {
parameters: HashMap::new(),
duration: Duration::from_secs(60),
},
layer: Some(EffectLayer::Background),
blend_mode: None,
up_time: None,
hold_time: None,
down_time: None,
};
let fg_effect = Effect {
sequence_name: None,
groups: vec!["test_group".to_string()],
effect_type: EffectType::Static {
parameters: HashMap::new(),
duration: Duration::from_secs(60),
},
layer: Some(EffectLayer::Foreground),
blend_mode: None,
up_time: None,
hold_time: None,
down_time: None,
};
let cues = vec![
Cue {
time: Duration::from_secs(0),
effects: vec![bg_effect.clone(), fg_effect.clone()],
layer_commands: vec![],
stop_sequences: vec![],
start_sequences: vec![],
},
Cue {
time: Duration::from_secs(5),
effects: vec![],
layer_commands: vec![LayerCommand {
command_type: LayerCommandType::Clear,
layer: Some(EffectLayer::Foreground),
fade_time: None,
intensity: None,
speed: None,
}],
stop_sequences: vec![],
start_sequences: vec![],
},
Cue {
time: Duration::from_secs(10),
effects: vec![fg_effect],
layer_commands: vec![],
stop_sequences: vec![],
start_sequences: vec![],
},
];
let mut timeline = LightingTimeline::new_with_cues(cues);
let update = timeline.start_at(Duration::from_secs(12));
assert_eq!(
update.effects_with_elapsed.len(),
2,
"Should have 2 effects (bg from @0s + new fg from @10s), got {}: {:?}",
update.effects_with_elapsed.len(),
update
.effects_with_elapsed
.values()
.map(|(e, _)| &e.id)
.collect::<Vec<_>>()
);
let bg = update
.effects_with_elapsed
.values()
.find(|(e, _)| e.layer == EffectLayer::Background);
assert!(bg.is_some(), "Background effect should be present");
assert_eq!(bg.unwrap().1, Duration::from_secs(12));
let fg = update
.effects_with_elapsed
.values()
.find(|(e, _)| e.layer == EffectLayer::Foreground);
assert!(fg.is_some(), "Foreground effect should be present");
assert_eq!(fg.unwrap().1, Duration::from_secs(2));
}
#[test]
fn test_start_at_clear_all_purges_all_effects() {
use crate::lighting::parser::{Cue, LayerCommand, LayerCommandType};
let effect = Effect {
sequence_name: None,
groups: vec!["test_group".to_string()],
effect_type: EffectType::Static {
parameters: HashMap::new(),
duration: Duration::from_secs(60),
},
layer: None,
blend_mode: None,
up_time: None,
hold_time: None,
down_time: None,
};
let cues = vec![
Cue {
time: Duration::from_secs(0),
effects: vec![effect.clone(), effect.clone()],
layer_commands: vec![],
stop_sequences: vec![],
start_sequences: vec![],
},
Cue {
time: Duration::from_secs(5),
effects: vec![],
layer_commands: vec![LayerCommand {
command_type: LayerCommandType::Clear,
layer: None,
fade_time: None,
intensity: None,
speed: None,
}],
stop_sequences: vec![],
start_sequences: vec![],
},
Cue {
time: Duration::from_secs(10),
effects: vec![effect],
layer_commands: vec![],
stop_sequences: vec![],
start_sequences: vec![],
},
];
let mut timeline = LightingTimeline::new_with_cues(cues);
let update = timeline.start_at(Duration::from_secs(12));
assert_eq!(
update.effects_with_elapsed.len(),
1,
"Should have 1 effect after clear-all, got {}",
update.effects_with_elapsed.len()
);
}
#[test]
fn test_start_at_stopped_sequences_suppresses_future_effects() {
use crate::lighting::parser::Cue;
let seq_effect = |seq: &str| Effect {
sequence_name: Some(seq.to_string()),
groups: vec!["test_group".to_string()],
effect_type: EffectType::Static {
parameters: HashMap::new(),
duration: Duration::from_secs(60),
},
layer: None,
blend_mode: None,
up_time: None,
hold_time: None,
down_time: None,
};
let cues = vec![
Cue {
time: Duration::from_secs(0),
effects: vec![seq_effect("seqA"), seq_effect("seqB")],
layer_commands: vec![],
stop_sequences: vec![],
start_sequences: vec![],
},
Cue {
time: Duration::from_secs(5),
effects: vec![],
layer_commands: vec![],
stop_sequences: vec!["seqA".to_string()],
start_sequences: vec![],
},
Cue {
time: Duration::from_secs(8),
effects: vec![seq_effect("seqA")],
layer_commands: vec![],
stop_sequences: vec![],
start_sequences: vec![],
},
];
let mut timeline = LightingTimeline::new_with_cues(cues);
let update = timeline.start_at(Duration::from_secs(10));
let seq_names: Vec<_> = update
.effects_with_elapsed
.values()
.filter_map(|(e, _)| e.id.split("_effect_").next())
.collect();
assert_eq!(
update.effects_with_elapsed.len(),
1,
"Should have 1 effect (seqB only), got {}: {:?}",
update.effects_with_elapsed.len(),
seq_names
);
let surviving = update.effects_with_elapsed.values().next().unwrap();
assert!(
surviving.0.id.starts_with("seq_seqB_"),
"Surviving effect should be from seqB, got: {}",
surviving.0.id
);
}
#[test]
fn test_start_at_iteration_boundary_does_not_suppress() {
use crate::lighting::parser::Cue;
let seq_effect = |seq: &str| Effect {
sequence_name: Some(seq.to_string()),
groups: vec!["test_group".to_string()],
effect_type: EffectType::Static {
parameters: HashMap::new(),
duration: Duration::from_secs(60),
},
layer: None,
blend_mode: None,
up_time: None,
hold_time: None,
down_time: None,
};
let cues = vec![
Cue {
time: Duration::from_secs(0),
effects: vec![seq_effect("seqA")],
layer_commands: vec![],
stop_sequences: vec![],
start_sequences: vec!["seqA".to_string()],
},
Cue {
time: Duration::from_secs(5),
effects: vec![seq_effect("seqA")],
layer_commands: vec![],
stop_sequences: vec!["seqA".to_string()],
start_sequences: vec!["seqA".to_string()],
},
];
let mut timeline = LightingTimeline::new_with_cues(cues);
let update = timeline.start_at(Duration::from_secs(7));
assert_eq!(
update.effects_with_elapsed.len(),
1,
"Should have 1 effect (seqA iteration 2), got {}",
update.effects_with_elapsed.len()
);
let surviving = update.effects_with_elapsed.values().next().unwrap();
assert!(surviving.0.id.starts_with("seq_seqA_"));
assert_eq!(surviving.1, Duration::from_secs(2)); }
}