Berry animation simplify sequence_manager (#24293)

This commit is contained in:
s-hadinger 2026-01-02 20:24:57 +01:00 committed by GitHub
parent 36424dd8e7
commit 3503cee120
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 1043 additions and 908 deletions

View File

@ -6,10 +6,11 @@ template animation shutter_bidir {
param colors type palette
param period default 2s
param inout type bool default true # define to true to enable 'inout' part
param outin type bool default true # define to true to enable 'outin' part
param outin type bool default true # define to true to enable 'outin' part
# since 'strip_length()' is a value provider, it must be assigned to a variable before being used
set strip_len = strip_length()
set strip_len_2 = (strip_len + 1) / 2 # half length rounded
# Define animated value for the size of the shutter, evolving linearly in time (sawtooth from 0% to 100%)
set shutter_size = sawtooth(min_value = 0, max_value = strip_len, duration = period)
@ -21,22 +22,18 @@ template animation shutter_bidir {
# Shutter moving in in-out
animation shutter_inout_animation = beacon_animation(
color = col2
back_color = col1
pos = 0
color = col2 # Use two rotating colors
back_color = col1 # Use two rotating colors
pos = strip_len_2 - (shutter_size + 1) / 2
beacon_size = shutter_size
slew_size = 0
priority = 5
)
# shutter moving in out-in
animation shutter_outin_animation = beacon_animation(
color = col1
back_color = col2
pos = 0
pos = strip_len_2 - (strip_len - shutter_size + 1) / 2
beacon_size = strip_len - shutter_size
slew_size = 0
priority = 5
)
# this is the overall sequence composed of two sub-sequences

View File

@ -1186,62 +1186,65 @@ run main
### 8.3 Advanced Template with Conditional Flags
<a href="https://tasmota.github.io/docs/Tasmota-Berry-emulator/index.html?example=chap_8_30_template_shutter" target="_blank"><img src="../../_media/berry_animation/chap_8_30.png" alt="Template Shutter"></a>
<a href="https://tasmota.github.io/docs/Tasmota-Berry-emulator/index.html?example=chap_8_30_template_shutter_bidir_flags" target="_blank"><img src="../../_media/berry_animation/chap_8_30.png" alt="Template Shutter"></a>
Templates support `bool` parameters that can be used with `if` statements inside sequences. This allows users to enable or disable parts of the animation at instantiation time. Here we create a bidirectional shutter that can optionally run in-out, out-in, or both directions.
Templates support `bool` parameters that can be used with `if` statements inside sequences. This allows users to enable or disable parts of the animation at instantiation time. Here we create a bidirectional shutter (based on chapter 6.4) that can optionally run in-out, out-in, or both directions.
**Dynamic parameter changes:**
You can also modify template parameters at runtime using Berry code. Note that DSL variable names get an underscore suffix in Berry to avoid collisions with reserved words (e.g., `main` becomes `main_`). For example: `main_.inout = false` disables the in-out animation while it's running.
```berry
# Template with conditional flags for bidirectional shutter
# Template to illustrate the parameters and flags
# Define a template to package the shutter in-out-in from 6.40
# with flags to enable or disable in-out or out-in
template animation shutter_bidir {
param colors type palette
param period default 2s
param inout type bool default true # Enable in-out animation
param outin type bool default true # Enable out-in animation
param inout type bool default true # define to true to enable 'inout' part
param outin type bool default true # define to true to enable 'outin' part
# since 'strip_length()' is a value provider, it must be assigned to a variable before being used
set strip_len = strip_length()
set strip_len_2 = (strip_len + 1) / 2 # half length rounded
# Define animated value for the size of the shutter, evolving linearly in time (sawtooth from 0% to 100%)
set shutter_size = sawtooth(min_value = 0, max_value = strip_len, duration = period)
# Two rotating color providers
# Define two rotating palettes, shifted by one color
color col1 = color_cycle(colors=colors, period=0)
color col2 = color_cycle(colors=colors, period=0)
col2.next = 1
col2.next = 1 # move 'col2' to the next color so it's shifte by one compared to 'col1'
# In-out shutter
# Shutter moving in in-out
animation shutter_inout_animation = beacon_animation(
color = col2
back_color = col1
pos = 0
color = col2 # Use two rotating colors
back_color = col1 # Use two rotating colors
pos = strip_len_2 - (shutter_size + 1) / 2
beacon_size = shutter_size
slew_size = 0
priority = 5
)
# Out-in shutter
# shutter moving in out-in
animation shutter_outin_animation = beacon_animation(
color = col1
back_color = col2
pos = 0
pos = strip_len_2 - (strip_len - shutter_size + 1) / 2
beacon_size = strip_len - shutter_size
slew_size = 0
priority = 5
)
# Sequence with conditional blocks
# this is the overall sequence composed of two sub-sequences
# the first in ascending mode, the second in descending
sequence shutter_seq repeat forever {
if inout { # Only if inout is true
repeat col1.palette_size times {
restart shutter_size
play shutter_inout_animation for period
col1.next = 1
if inout { # un only if 'ascending' is true
repeat col1.palette_size times { # run the shutter animation
restart shutter_size # resync all times for this animation, to avoid temporal drift
play shutter_inout_animation for period # run the animation
col1.next = 1 # then move to next color for both palettes
col2.next = 1
}
}
if outin { # Only if outin is true
if outin { # run only if 'descending' is true
repeat col1.palette_size times {
restart shutter_size
play shutter_outin_animation for period
@ -1253,7 +1256,7 @@ template animation shutter_bidir {
run shutter_seq
}
# Define palette
# define a palette of rainbow colors including white with constant brightness
palette rainbow_with_white = [
0xFC0000 # Red
0xFF8000 # Orange
@ -1265,7 +1268,6 @@ palette rainbow_with_white = [
0xCCCCCC # White
]
# Use the template
animation main = shutter_bidir(colors = rainbow_with_white, period = 1.5s)
run main
```

View File

@ -4,16 +4,29 @@
#
# Extends ParameterizedObject to provide parameter management and playable interface,
# allowing sequences to be treated uniformly with animations by the engine.
#
# Memory-optimized: Uses two parallel arrays instead of array of maps
# - step_durations: encodes type and duration in a single integer
# - step_refs: stores references (animation, closure, sequence_manager, or nil)
#
# Duration encoding:
# >= 0: play (ref=animation) or wait (ref=nil)
# -2: closure (ref=closure function)
# -3: subsequence (ref=sequence_manager)
import "./core/param_encoder" as encode_constraints
class SequenceManager : animation.parameterized_object
# static var DURATION_CLOSURE = -2
# static var DURATION_SUBSEQUENCE = -3
# Non-parameter instance variables
var active_sequence # Currently running sequence
var sequence_state # Current sequence execution state
var step_index # Current step in the sequence
var step_start_time # When current step started
var steps # List of sequence steps
var step_durations # List of duration/type values (int)
var step_refs # List of references (animation, closure, sequence_manager, or nil)
# Repeat-specific properties
var repeat_count # Number of times to repeat this sequence (-1 for forever, 0 for no repeat)
@ -29,7 +42,8 @@ class SequenceManager : animation.parameterized_object
self.sequence_state = {}
self.step_index = 0
self.step_start_time = 0
self.steps = []
self.step_durations = []
self.step_refs = []
# Repeat logic
self.repeat_count = repeat_count != nil ? repeat_count : 1 # Default: run once (can be function or number)
@ -37,51 +51,35 @@ class SequenceManager : animation.parameterized_object
self.is_repeat_sequence = repeat_count != nil && repeat_count != 1
end
# Add a step to this sequence
def push_step(step)
self.steps.push(step)
return self
end
# Add a play step directly
def push_play_step(animation_ref, duration)
self.steps.push({
"type": "play",
"animation": animation_ref,
"duration": duration != nil ? duration : 0
})
self.step_durations.push(duration != nil ? duration : 0)
self.step_refs.push(animation_ref)
return self
end
# Add a wait step directly
def push_wait_step(duration)
self.steps.push({
"type": "wait",
"duration": duration
})
self.step_durations.push(duration)
self.step_refs.push(nil)
return self
end
# Add a closure step directly (used for both assign and log steps)
def push_closure_step(closure)
self.steps.push({
"type": "closure",
"closure": closure
})
self.step_durations.push(-2 #-self.DURATION_CLOSURE-#)
self.step_refs.push(closure)
return self
end
# Add a repeat subsequence step directly
def push_repeat_subsequence(sequence_manager)
self.steps.push({
"type": "subsequence",
"sequence_manager": sequence_manager
})
self.step_durations.push(-3 #-self.DURATION_SUBSEQUENCE-#)
self.step_refs.push(sequence_manager)
return self
end
# Start this sequence
# FIXED: More conservative engine clearing to avoid black frames
def start(time_ms)
# Stop any current sequence
if self.is_running
@ -113,23 +111,19 @@ class SequenceManager : animation.parameterized_object
end
# Start executing if we have steps
if size(self.steps) > 0
var num_steps = size(self.step_durations)
if num_steps > 0
# Execute all consecutive closure steps at the beginning atomically
while self.step_index < size(self.steps)
var step = self.steps[self.step_index]
if step["type"] == "closure"
var closure_func = step["closure"]
if closure_func != nil
closure_func(self.engine)
end
self.step_index += 1
else
break
while self.step_index < num_steps && self.step_durations[self.step_index] == -2 #-self.DURATION_CLOSURE-#
var closure_func = self.step_refs[self.step_index]
if closure_func != nil
closure_func(self.engine)
end
self.step_index += 1
end
# Now execute the next non-closure step (usually play)
if self.step_index < size(self.steps)
if self.step_index < num_steps
self.execute_current_step(time_ms)
end
end
@ -148,14 +142,15 @@ class SequenceManager : animation.parameterized_object
end
# Stop any currently playing animations
if self.step_index < size(self.steps)
var current_step = self.steps[self.step_index]
if current_step["type"] == "play"
var anim = current_step["animation"]
self.engine.remove(anim)
elif current_step["type"] == "subsequence"
var sub_seq = current_step["sequence_manager"]
sub_seq.stop()
var num_steps = size(self.step_durations)
if self.step_index < num_steps
var dur = self.step_durations[self.step_index]
var ref = self.step_refs[self.step_index]
if dur == -3 #-self.DURATION_SUBSEQUENCE-#
ref.stop()
elif dur != -2 #-self.DURATION_CLOSURE-# && ref != nil
# play step (dur is >= 0 or function, ref is animation)
self.engine.remove(ref)
end
end
@ -167,215 +162,198 @@ class SequenceManager : animation.parameterized_object
# Stop all sub-sequences in our steps
def stop_all_subsequences()
for step : self.steps
if step["type"] == "subsequence"
var sub_seq = step["sequence_manager"]
sub_seq.stop()
var num_steps = size(self.step_durations)
var i = 0
while i < num_steps
if self.step_durations[i] == -3 #-self.DURATION_SUBSEQUENCE-#
self.step_refs[i].stop()
end
i += 1
end
return self
end
# Update sequence state - called from fast_loop
def update(current_time)
if !self.is_running || size(self.steps) == 0
var num_steps = size(self.step_durations)
if !self.is_running || num_steps == 0 || self.step_index >= num_steps
return
end
# Safety check: ensure step_index is valid
if self.step_index >= size(self.steps)
return
end
var dur = self.step_durations[self.step_index]
var current_step = self.steps[self.step_index]
# Handle different step types
if current_step["type"] == "subsequence"
# Handle sub-sequence (including repeat sequences)
var sub_seq = current_step["sequence_manager"]
# Only closure/subsequence have negative int markers, play/wait have >= 0 or function
if dur == -3 #-self.DURATION_SUBSEQUENCE-#
# Handle sub-sequence
var sub_seq = self.step_refs[self.step_index]
sub_seq.update(current_time)
if !sub_seq.is_running
# Sub-sequence finished, advance to next step
self.advance_to_next_step(current_time)
end
elif current_step["type"] == "closure"
# Closure steps are handled in batches by advance_to_next_step
# This should not happen in normal flow, but handle it just in case
elif dur == -2 #-self.DURATION_CLOSURE-#
# Closure steps are handled in batches
self.execute_closure_steps_batch(current_time)
else
# Handle regular steps with duration
if current_step.contains("duration") && current_step["duration"] != nil
# Resolve duration - it can be a number or a closure
var duration_value = current_step["duration"]
if type(duration_value) == "function"
# Duration is a closure - call it to get the actual value
duration_value = duration_value(self.engine)
end
# Handle regular steps with duration (play or wait)
# dur is either int >= 0, function, or bool
var duration_value = dur
if type(dur) == "function"
duration_value = dur(self.engine)
end
duration_value = int(duration_value) # Force int, works for bool
if duration_value > 0
var elapsed = current_time - self.step_start_time
if elapsed >= duration_value
self.advance_to_next_step(current_time)
end
else
# Duration is 0 or nil - complete immediately
if duration_value > 0
if current_time - self.step_start_time >= duration_value
self.advance_to_next_step(current_time)
end
else
# Steps without duration complete immediately
# Duration is 0 - complete immediately
self.advance_to_next_step(current_time)
end
end
end
# Execute the current step
def execute_current_step(current_time)
if self.step_index >= size(self.steps)
self.complete_iteration(current_time)
# skip_budget limits how many consecutive immediate skips to prevent infinite loops
def execute_current_step(current_time, skip_budget)
if skip_budget == nil
skip_budget = size(self.step_durations) + 1 # Default: allow skipping all steps once
end
var num_steps = size(self.step_durations)
if self.step_index >= num_steps
self.complete_iteration(current_time, skip_budget)
return
end
var step = self.steps[self.step_index]
var dur = self.step_durations[self.step_index]
var ref = self.step_refs[self.step_index]
if step["type"] == "play"
var anim = step["animation"]
# Check if animation is nil (safety check)
if anim == nil
if dur == -3 #-self.DURATION_SUBSEQUENCE-#
# Start sub-sequence
ref.start(current_time)
# If subsequence immediately completed (e.g., repeat_count resolved to 0),
# advance to next step immediately to avoid black frame (if we have budget)
if !ref.is_running && skip_budget > 0
self.advance_to_next_step(current_time, skip_budget - 1)
return
end
# Check if animation is already in the engine (avoid duplicate adds)
elif dur == -2 #-self.DURATION_CLOSURE-#
# Closure steps should be handled in batches
if ref != nil
ref(self.engine)
end
elif ref != nil
# Play step (dur >= 0 and ref is animation)
# Check if animation is already in the engine
var animations = self.engine.get_animations()
var already_added = false
for existing_anim : animations
if existing_anim == anim
if existing_anim == ref
already_added = true
break
end
end
if !already_added
self.engine.add(anim)
self.engine.add(ref)
end
# Always restart the animation to ensure proper timing
anim.start(current_time)
elif step["type"] == "wait"
# Wait steps are handled by the update loop checking duration
# No animation needed for wait
elif step["type"] == "stop"
var anim = step["animation"]
self.engine.remove(anim)
elif step["type"] == "closure"
# Closure steps should be handled in batches by execute_closure_steps_batch
# This should not happen in normal flow, but handle it for safety
var closure_func = step["closure"]
if closure_func != nil
closure_func(self.engine)
end
elif step["type"] == "subsequence"
# Start sub-sequence (including repeat sequences)
var sub_seq = step["sequence_manager"]
sub_seq.start(current_time)
ref.start(current_time)
end
# else: wait step (dur >= 0 and ref is nil) - nothing to do
self.step_start_time = current_time
end
# Advance to the next step in the sequence
# FIXED: Atomic transition to eliminate black frames
def advance_to_next_step(current_time)
# Get current step info BEFORE advancing
var current_step = self.steps[self.step_index]
var current_anim = nil
def advance_to_next_step(current_time, skip_budget)
var num_steps = size(self.step_durations)
# Store reference to current animation but DON'T remove it yet
if current_step["type"] == "play" && current_step.contains("duration")
current_anim = current_step["animation"]
if skip_budget == nil
skip_budget = num_steps + 1
end
# Get current animation ref BEFORE advancing (for atomic transition)
# If dur is not a special marker and ref is not nil, it's a play step
var dur = self.step_durations[self.step_index]
var ref = self.step_refs[self.step_index]
var current_anim = nil
if dur != -2 #-self.DURATION_CLOSURE-# && dur != -3 #-self.DURATION_SUBSEQUENCE-# && ref != nil
current_anim = ref
end
self.step_index += 1
if self.step_index >= size(self.steps)
# Only remove animation when completing iteration
if self.step_index >= num_steps
if current_anim != nil
self.engine.remove(current_anim)
end
self.complete_iteration(current_time)
self.complete_iteration(current_time, skip_budget)
else
# Execute closures and start next animation BEFORE removing current one
self.execute_closure_steps_batch_atomic(current_time, current_anim)
self.execute_closure_steps_batch_atomic(current_time, current_anim, skip_budget)
end
end
# Execute all consecutive closure steps in a batch to avoid black frames
def execute_closure_steps_batch(current_time)
var num_steps = size(self.step_durations)
# Execute all consecutive closure steps
while self.step_index < size(self.steps)
var step = self.steps[self.step_index]
if step["type"] == "closure"
# Execute closure function
var closure_func = step["closure"]
if closure_func != nil
closure_func(self.engine)
end
self.step_index += 1
else
break
while self.step_index < num_steps && self.step_durations[self.step_index] == -2 #-self.DURATION_CLOSURE-#
var closure_func = self.step_refs[self.step_index]
if closure_func != nil
closure_func(self.engine)
end
self.step_index += 1
end
# Now execute the next non-closure step
if self.step_index < size(self.steps)
if self.step_index < num_steps
self.execute_current_step(current_time)
else
self.complete_iteration(current_time)
end
end
# ADDED: Atomic batch execution to eliminate black frames
def execute_closure_steps_batch_atomic(current_time, previous_anim)
# Execute all consecutive closure steps
while self.step_index < size(self.steps)
var step = self.steps[self.step_index]
if step["type"] == "closure"
var closure_func = step["closure"]
if closure_func != nil
closure_func(self.engine)
end
self.step_index += 1
else
break
end
# Atomic batch execution to eliminate black frames
def execute_closure_steps_batch_atomic(current_time, previous_anim, skip_budget)
if skip_budget == nil
skip_budget = size(self.step_durations) + 1
end
# CRITICAL FIX: Handle the case where the next step is the SAME animation
# This prevents removing and re-adding the same animation, which causes black frames
var next_step = nil
var is_same_animation = false
var num_steps = size(self.step_durations)
if self.step_index < size(self.steps)
next_step = self.steps[self.step_index]
if next_step["type"] == "play" && previous_anim != nil
is_same_animation = (next_step["animation"] == previous_anim)
# Execute all consecutive closure steps
while self.step_index < num_steps && self.step_durations[self.step_index] == -2 #-self.DURATION_CLOSURE-#
var closure_func = self.step_refs[self.step_index]
if closure_func != nil
closure_func(self.engine)
end
self.step_index += 1
end
# Check if the next step is the SAME animation
var is_same_animation = false
if self.step_index < num_steps && previous_anim != nil
var next_dur = self.step_durations[self.step_index]
var next_ref = self.step_refs[self.step_index]
# If not a special marker and same animation ref
if next_dur != -2 #-self.DURATION_CLOSURE-# && next_dur != -3 #-self.DURATION_SUBSEQUENCE-# && next_ref == previous_anim
is_same_animation = true
end
end
if is_same_animation
# Same animation continuing - don't remove/re-add, but DO restart for timing sync
self.step_start_time = current_time
# CRITICAL: Still need to restart the animation to sync with sequence timing
previous_anim.start(current_time)
else
# Different animation or no next animation
# Start the next animation BEFORE removing the previous one
if self.step_index < size(self.steps)
self.execute_current_step(current_time)
if self.step_index < num_steps
self.execute_current_step(current_time, skip_budget)
end
# NOW it's safe to remove the previous animation (no gap)
@ -385,14 +363,17 @@ class SequenceManager : animation.parameterized_object
end
# Handle completion
if self.step_index >= size(self.steps)
self.complete_iteration(current_time)
if self.step_index >= num_steps
self.complete_iteration(current_time, skip_budget)
end
end
# Complete current iteration and check if we should repeat
# FIXED: Ensure atomic transitions during repeat iterations
def complete_iteration(current_time)
def complete_iteration(current_time, skip_budget)
if skip_budget == nil
skip_budget = size(self.step_durations) + 1
end
self.current_iteration += 1
# Update iteration context in engine stack if this is a repeat sequence
@ -405,26 +386,29 @@ class SequenceManager : animation.parameterized_object
# Check if we should continue repeating
if resolved_repeat_count == -1 || self.current_iteration < resolved_repeat_count
# If we've exhausted skip budget, stop here and let next update() continue
# This prevents infinite loops when all steps are skipped
if skip_budget <= 0
self.step_index = 0
return
end
# Start next iteration - execute all initial closures atomically
self.step_index = 0
var num_steps = size(self.step_durations)
# Execute all consecutive closure steps at the beginning atomically
while self.step_index < size(self.steps)
var step = self.steps[self.step_index]
if step["type"] == "closure"
var closure_func = step["closure"]
if closure_func != nil
closure_func(self.engine)
end
self.step_index += 1
else
break
while self.step_index < num_steps && self.step_durations[self.step_index] == -2 #-self.DURATION_CLOSURE-#
var closure_func = self.step_refs[self.step_index]
if closure_func != nil
closure_func(self.engine)
end
self.step_index += 1
end
# Now execute the next non-closure step (usually play)
if self.step_index < size(self.steps)
self.execute_current_step(current_time)
if self.step_index < num_steps
self.execute_current_step(current_time, skip_budget - 1)
end
else
# All iterations complete
@ -455,23 +439,6 @@ class SequenceManager : animation.parameterized_object
def is_sequence_running()
return self.is_running
end
# # Get current step info for debugging
# def get_current_step_info()
# if !self.is_running || self.step_index >= size(self.steps)
# return nil
# end
# return {
# "step_index": self.step_index,
# "total_steps": size(self.steps),
# "current_step": self.steps[self.step_index],
# "elapsed_ms": self.engine.time_ms - self.step_start_time,
# "repeat_count": self.repeat_count,
# "current_iteration": self.current_iteration,
# "is_repeat_sequence": self.is_repeat_sequence
# }
# end
end
return {'sequence_manager': SequenceManager }

View File

@ -21,8 +21,10 @@ def test_sequence_manager_basic()
# Test initialization
var seq_manager = animation.sequence_manager(engine)
assert(seq_manager.engine == engine, "Engine should be set correctly")
assert(seq_manager.steps != nil, "Steps list should be initialized")
assert(seq_manager.steps.size() == 0, "Steps list should be empty initially")
assert(seq_manager.step_durations != nil, "Step durations list should be initialized")
assert(seq_manager.step_refs != nil, "Step refs list should be initialized")
assert(size(seq_manager.step_durations) == 0, "Step durations list should be empty initially")
assert(size(seq_manager.step_refs) == 0, "Step refs list should be empty initially")
assert(seq_manager.step_index == 0, "Step index should be 0 initially")
assert(seq_manager.is_running == false, "Sequence should not be running initially")
@ -48,26 +50,22 @@ def test_sequence_manager_step_creation()
# Test push_play_step
seq_manager.push_play_step(test_anim, 5000)
assert(seq_manager.steps.size() == 1, "Should have one step after push_play_step")
var play_step = seq_manager.steps[0]
assert(play_step["type"] == "play", "Play step should have correct type")
assert(play_step["animation"] == test_anim, "Play step should have correct animation")
assert(play_step["duration"] == 5000, "Play step should have correct duration")
assert(size(seq_manager.step_durations) == 1, "Should have one step after push_play_step")
assert(seq_manager.step_durations[0] == 5000, "Play step should have correct duration")
assert(seq_manager.step_refs[0] == test_anim, "Play step should have correct animation")
# Test push_wait_step
seq_manager.push_wait_step(2000)
assert(seq_manager.steps.size() == 2, "Should have two steps after push_wait_step")
var wait_step = seq_manager.steps[1]
assert(wait_step["type"] == "wait", "Wait step should have correct type")
assert(wait_step["duration"] == 2000, "Wait step should have correct duration")
assert(size(seq_manager.step_durations) == 2, "Should have two steps after push_wait_step")
assert(seq_manager.step_durations[1] == 2000, "Wait step should have correct duration")
assert(seq_manager.step_refs[1] == nil, "Wait step should have nil ref")
# Test push_closure_step
var test_closure = def (engine) test_anim.opacity = 128 end
seq_manager.push_closure_step(test_closure)
assert(seq_manager.steps.size() == 3, "Should have three steps after push_closure_step")
var assign_step = seq_manager.steps[2]
assert(assign_step["type"] == "closure", "Assign step should have correct type")
assert(assign_step["closure"] == test_closure, "Assign step should have correct closure")
assert(size(seq_manager.step_durations) == 3, "Should have three steps after push_closure_step")
assert(seq_manager.step_durations[2] == -2, "Closure step should have correct duration marker (-2)")
assert(seq_manager.step_refs[2] == test_closure, "Closure step should have correct closure")
print("✓ Step creation tests passed")
end
@ -109,7 +107,7 @@ def test_sequence_manager_execution()
seq_manager.start()
assert(seq_manager.is_running == true, "Sequence should be running after start")
assert(seq_manager.steps.size() == 3, "Sequence should have 3 steps")
assert(size(seq_manager.step_durations) == 3, "Sequence should have 3 steps")
assert(seq_manager.step_index == 0, "Should start at step 0")
# Check that first animation was started
@ -921,6 +919,189 @@ def test_sequence_manager_boolean_repeat_counts()
print("✓ Boolean repeat count tests passed")
end
def test_sequence_manager_false_conditional_immediate_skip()
print("=== SequenceManager False Conditional Immediate Skip Tests ===")
# This test verifies that when a conditional subsequence (if block) has a false condition,
# the parent sequence immediately advances to the next step without waiting for a tick.
# This prevents the "black frame" issue where no animation runs for one tick.
# Create strip and engine
var strip = global.Leds(30)
var engine = animation.create_engine(strip)
# Create test animations
var color_provider1 = animation.static_color(engine)
color_provider1.color = 0xFFFF0000 # Red
var anim1 = animation.solid(engine)
anim1.color = color_provider1
anim1.priority = 0
anim1.duration = 0
anim1.loop = true
var color_provider2 = animation.static_color(engine)
color_provider2.color = 0xFF00FF00 # Green
var anim2 = animation.solid(engine)
anim2.color = color_provider2
anim2.priority = 0
anim2.duration = 0
anim2.loop = true
# Create a parent sequence with:
# 1. A conditional subsequence that is FALSE (should skip immediately)
# 2. A play step for anim2
# Create the false conditional subsequence (simulating "if false { ... }")
var false_condition = def (engine) return false end
var false_subseq = animation.sequence_manager(engine, false_condition)
false_subseq.push_play_step(anim1, 1000) # This should never execute
# Create the parent sequence
var parent_seq = animation.sequence_manager(engine, 1) # Run once
parent_seq.push_repeat_subsequence(false_subseq) # This should skip immediately
parent_seq.push_play_step(anim2, 500) # This should start immediately after the skip
# Start the parent sequence
tasmota.set_millis(200000)
engine.add(parent_seq)
engine.run()
engine.on_tick(200000)
# At this point, the false subsequence should have been skipped immediately,
# and anim2 should already be playing (step_index should be 1)
assert(parent_seq.is_running == true, "Parent sequence should be running")
assert(parent_seq.step_index == 1, f"Parent should have advanced past false conditional to step 1, got {parent_seq.step_index}")
# Verify anim2 is in the engine (not anim1)
var animations = engine.get_animations()
var anim2_found = false
var anim1_found = false
for anim : animations
if anim == anim2
anim2_found = true
end
if anim == anim1
anim1_found = true
end
end
assert(anim2_found == true, "anim2 should be playing after false conditional skip")
assert(anim1_found == false, "anim1 should NOT be playing (false conditional was skipped)")
# Test 2: Multiple consecutive false conditionals should all skip immediately
# Create fresh engine and animations
var strip2 = global.Leds(30)
var engine2 = animation.create_engine(strip2)
var color_provider3 = animation.static_color(engine2)
color_provider3.color = 0xFFFF0000 # Red
var anim3 = animation.solid(engine2)
anim3.color = color_provider3
anim3.priority = 0
anim3.duration = 0
anim3.loop = true
var color_provider4 = animation.static_color(engine2)
color_provider4.color = 0xFF00FF00 # Green
var anim4 = animation.solid(engine2)
anim4.color = color_provider4
anim4.priority = 0
anim4.duration = 0
anim4.loop = true
var false_condition2 = def (engine) return false end
var false_subseq2 = animation.sequence_manager(engine2, false_condition2)
false_subseq2.push_play_step(anim3, 1000)
var false_subseq3 = animation.sequence_manager(engine2, false_condition2)
false_subseq3.push_play_step(anim3, 1000)
var parent_seq2 = animation.sequence_manager(engine2, 1)
parent_seq2.push_repeat_subsequence(false_subseq2) # Skip
parent_seq2.push_repeat_subsequence(false_subseq3) # Skip
parent_seq2.push_play_step(anim4, 500) # Should start immediately
engine2.add(parent_seq2)
tasmota.set_millis(201000)
engine2.run()
engine2.on_tick(201000)
# Should have skipped both false conditionals and be at step 2
assert(parent_seq2.is_running == true, "Parent sequence 2 should be running")
assert(parent_seq2.step_index == 2, f"Parent should have advanced past both false conditionals to step 2, got {parent_seq2.step_index}")
print("✓ False conditional immediate skip tests passed")
end
def test_sequence_manager_all_false_conditionals_no_infinite_loop()
print("=== SequenceManager All False Conditionals No Infinite Loop Tests ===")
# This test verifies that when ALL conditional subsequences in a repeat-forever
# sequence are false, the sequence doesn't go into an infinite loop.
# Instead, it should yield control back after one pass through all steps.
# Create strip and engine
var strip = global.Leds(30)
var engine = animation.create_engine(strip)
# Create test animation (should never be used)
var color_provider = animation.static_color(engine)
color_provider.color = 0xFFFF0000
var test_anim = animation.solid(engine)
test_anim.color = color_provider
test_anim.priority = 0
test_anim.duration = 0
test_anim.loop = true
# Track how many times closures are executed
var closure_count = 0
# Create two false conditional subsequences (simulating "if inout" and "if outin" both false)
var false_condition = def (engine) return false end
var false_subseq1 = animation.sequence_manager(engine, false_condition)
false_subseq1.push_play_step(test_anim, 1000)
var false_subseq2 = animation.sequence_manager(engine, false_condition)
false_subseq2.push_play_step(test_anim, 1000)
# Create parent sequence that repeats forever with both false conditionals
var parent_seq = animation.sequence_manager(engine, -1) # Repeat forever
parent_seq.push_closure_step(def (engine) closure_count += 1 end) # Track iterations
parent_seq.push_repeat_subsequence(false_subseq1) # if inout (false)
parent_seq.push_repeat_subsequence(false_subseq2) # if outin (false)
# Start the sequence
tasmota.set_millis(300000)
engine.add(parent_seq)
engine.run()
engine.on_tick(300000)
# The sequence should be running but not in an infinite loop
assert(parent_seq.is_running == true, "Parent sequence should still be running")
# The closure should have been executed a limited number of times (not infinite)
# With skip_budget, it should execute at most a few times before yielding
assert(closure_count < 100, f"Closure should not execute infinitely, got {closure_count}")
# Simulate a few update cycles - should not hang
var update_count = 0
var max_updates = 10
while update_count < max_updates
tasmota.set_millis(300000 + update_count * 100)
engine.on_tick(300000 + update_count * 100)
parent_seq.update(300000 + update_count * 100)
update_count += 1
end
# Should still be running (repeat forever) but not have executed infinitely
assert(parent_seq.is_running == true, "Parent sequence should still be running after updates")
assert(closure_count < 1000, f"Closure count should be bounded, got {closure_count}")
print("✓ All false conditionals no infinite loop tests passed")
end
# Run all tests
def run_all_sequence_manager_tests()
print("Starting SequenceManager Unit Tests...")
@ -943,6 +1124,8 @@ def run_all_sequence_manager_tests()
test_sequence_manager_zero_iterations()
test_sequence_manager_zero_palette_size()
test_sequence_manager_boolean_repeat_counts()
test_sequence_manager_false_conditional_immediate_skip()
test_sequence_manager_all_false_conditionals_no_infinite_loop()
print("\n🎉 All SequenceManager tests passed!")
return true
@ -970,5 +1153,7 @@ return {
"test_sequence_manager_complex_parametric_scenario": test_sequence_manager_complex_parametric_scenario,
"test_sequence_manager_zero_iterations": test_sequence_manager_zero_iterations,
"test_sequence_manager_zero_palette_size": test_sequence_manager_zero_palette_size,
"test_sequence_manager_boolean_repeat_counts": test_sequence_manager_boolean_repeat_counts
"test_sequence_manager_boolean_repeat_counts": test_sequence_manager_boolean_repeat_counts,
"test_sequence_manager_false_conditional_immediate_skip": test_sequence_manager_false_conditional_immediate_skip,
"test_sequence_manager_all_false_conditionals_no_infinite_loop": test_sequence_manager_all_false_conditionals_no_infinite_loop
}