- AnimationBase ABC with render(t, led_count) and from_params() contract - SolidColorAnimation: time-independent uniform fill - ChaseAnimation: moving lit segment with forward/reverse support - PulseAnimation: sine-wave breathing effect (speed maps to period) - RainbowAnimation: HSV hue cycle across strip via colorsys.hsv_to_rgb - StrobeAnimation: on/off flashing at 1-20 Hz with configurable duty cycle - ColorWipeAnimation: progressive fill from start or end - FireAnimation: Fire2012 algorithm with cooling/sparking/heat-to-color mapping - ANIMATION_REGISTRY + create_animation() factory function - All 24 tests passing
344 lines
13 KiB
Python
344 lines
13 KiB
Python
"""Tests for the animation library — all 7 animation types."""
|
|
import pytest
|
|
from lightsync.animations import (
|
|
ANIMATION_REGISTRY,
|
|
create_animation,
|
|
AnimationBase,
|
|
SolidColorAnimation,
|
|
ChaseAnimation,
|
|
PulseAnimation,
|
|
RainbowAnimation,
|
|
StrobeAnimation,
|
|
ColorWipeAnimation,
|
|
FireAnimation,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def make_solid(color=(255, 0, 0)):
|
|
return SolidColorAnimation(color=tuple(color))
|
|
|
|
|
|
def make_chase(color=(255, 0, 0), bg_color=(0, 0, 0), speed=0.5, size=3, spacing=7, reverse=False):
|
|
return ChaseAnimation(color=tuple(color), bg_color=tuple(bg_color), speed=speed, size=size, spacing=spacing, reverse=reverse, white=0)
|
|
|
|
|
|
def make_pulse(color=(255, 0, 0), speed=0.5, min_brightness=0, max_brightness=255):
|
|
return PulseAnimation(color=tuple(color), speed=speed, min_brightness=min_brightness, max_brightness=max_brightness, white=0)
|
|
|
|
|
|
def make_rainbow(speed=0.5, period=1.0):
|
|
return RainbowAnimation(speed=speed, period=period)
|
|
|
|
|
|
def make_strobe(color=(255, 0, 0), speed=0.5, duty_cycle=0.5):
|
|
return StrobeAnimation(color=tuple(color), speed=speed, duty_cycle=duty_cycle, white=0)
|
|
|
|
|
|
def make_color_wipe(color=(255, 0, 0), speed=2.0, reverse=False):
|
|
return ColorWipeAnimation(color=tuple(color), speed=speed, reverse=reverse, white=0)
|
|
|
|
|
|
def make_fire(cooling=55, sparking=120, speed=0.5):
|
|
return FireAnimation(cooling=cooling, sparking=sparking, speed=speed)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Length invariant
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_all_render_length():
|
|
"""Every animation returns a list of length == led_count."""
|
|
led_count = 15
|
|
t = 1.0
|
|
animations = [
|
|
make_solid(),
|
|
make_chase(),
|
|
make_pulse(),
|
|
make_rainbow(),
|
|
make_strobe(),
|
|
make_color_wipe(),
|
|
make_fire(),
|
|
]
|
|
for anim in animations:
|
|
result = anim.render(t, led_count)
|
|
assert len(result) == led_count, f"{type(anim).__name__} returned {len(result)} pixels, expected {led_count}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Solid color
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_solid_color_uniform():
|
|
"""All pixels same color."""
|
|
color = (100, 150, 200)
|
|
anim = SolidColorAnimation(color=color)
|
|
frame = anim.render(0.0, 10)
|
|
assert len(frame) == 10
|
|
for px in frame:
|
|
assert px == color, f"Expected {color}, got {px}"
|
|
|
|
|
|
def test_solid_color_time_independent():
|
|
"""Solid color doesn't change over time."""
|
|
color = (50, 60, 70)
|
|
anim = SolidColorAnimation(color=color)
|
|
assert anim.render(0.0, 5) == anim.render(99.9, 5)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Chase
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_chase_pattern():
|
|
"""Some pixels match color, some match bg_color."""
|
|
color = (255, 0, 0)
|
|
bg = (0, 0, 100)
|
|
anim = ChaseAnimation(color=color, bg_color=bg, speed=0.5, size=3, spacing=7, reverse=False, white=0)
|
|
frame = anim.render(0.0, 20)
|
|
assert len(frame) == 20
|
|
# There should be at least one color and one bg pixel
|
|
has_color = any(px == color for px in frame)
|
|
has_bg = any(px == bg for px in frame)
|
|
assert has_color, "No color pixels found in chase pattern"
|
|
assert has_bg, "No background pixels found in chase pattern"
|
|
|
|
|
|
def test_chase_reverse():
|
|
"""Chase with reverse=True differs from reverse=False at same t (non-trivial t).
|
|
|
|
Choose t such that offset is not a multiple of (size+spacing) to avoid symmetry.
|
|
"""
|
|
color = (255, 0, 0)
|
|
bg = (0, 0, 0)
|
|
# period = size + spacing = 3 + 7 = 10
|
|
# offset = int(t * speed * 60); use t=0.05 -> offset=3, not multiple of 10
|
|
anim_fwd = ChaseAnimation(color=color, bg_color=bg, speed=1.0, size=3, spacing=7, reverse=False, white=0)
|
|
anim_rev = ChaseAnimation(color=color, bg_color=bg, speed=1.0, size=3, spacing=7, reverse=True, white=0)
|
|
t = 0.05 # offset=3, not a multiple of period=10, so fwd and rev patterns differ
|
|
frame_fwd = anim_fwd.render(t, 20)
|
|
frame_rev = anim_rev.render(t, 20)
|
|
assert frame_fwd != frame_rev, "Forward and reverse chase should differ when offset is not a multiple of period"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pulse
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_pulse_brightness_range():
|
|
"""Pulse pixel values stay within scaled range of color."""
|
|
anim = PulseAnimation(color=(200, 100, 50), speed=0.5, min_brightness=0, max_brightness=255, white=0)
|
|
frame = anim.render(0.0, 5)
|
|
for px in frame:
|
|
for ch in px:
|
|
assert 0 <= ch <= 255, f"Channel value {ch} out of 0-255 range"
|
|
|
|
|
|
def test_pulse_uniform_frame():
|
|
"""All pixels in a pulse frame are identical."""
|
|
anim = make_pulse()
|
|
frame = anim.render(1.0, 8)
|
|
assert len(set(frame)) == 1, "Pulse should return identical pixels per frame"
|
|
|
|
|
|
def test_pulse_varies_over_time():
|
|
"""Pulse brightness changes over time."""
|
|
anim = make_pulse(speed=1.0)
|
|
# Speed=1.0 means period=0.1s, so at t=0 and t=0.05 we should see different brightness
|
|
frame1 = anim.render(0.0, 5)
|
|
frame2 = anim.render(0.025, 5)
|
|
assert frame1 != frame2, "Pulse should vary over time"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Rainbow
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_rainbow_distinct_hues():
|
|
"""10-LED strip has distinct pixel values (hue spread)."""
|
|
anim = RainbowAnimation(speed=0.0, period=1.0)
|
|
frame = anim.render(0.0, 10)
|
|
assert len(frame) == 10
|
|
# With speed=0 and period=1, hues spread from 0 to ~0.9
|
|
unique = set(frame)
|
|
assert len(unique) > 5, f"Rainbow should have many distinct colors, got {len(unique)}"
|
|
|
|
|
|
def test_rainbow_all_values_valid():
|
|
"""All RGB values in 0-255 range."""
|
|
anim = make_rainbow()
|
|
frame = anim.render(1.5, 30)
|
|
for px in frame:
|
|
assert len(px) == 3
|
|
for ch in px:
|
|
assert 0 <= ch <= 255
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Strobe
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_strobe_on_phase():
|
|
"""Strobe returns color during on-phase (t=0, duty_cycle=0.5 -> on)."""
|
|
color = (255, 128, 64)
|
|
anim = StrobeAnimation(color=color, speed=0.5, duty_cycle=0.5, white=0)
|
|
# At t=0, phase=0.0, which is < duty_cycle=0.5, so should be ON
|
|
frame = anim.render(0.0, 5)
|
|
for px in frame:
|
|
assert px == color, f"Expected color {color} during on-phase, got {px}"
|
|
|
|
|
|
def test_strobe_off_phase():
|
|
"""Strobe returns black during off-phase."""
|
|
color = (255, 128, 64)
|
|
anim = StrobeAnimation(color=color, speed=0.5, duty_cycle=0.1, white=0)
|
|
# duty_cycle=0.1 means 90% of period is OFF
|
|
# freq = 1 + 0.5*19 = 10.5 Hz, period = ~0.095s
|
|
# At t=0.05, phase = (0.05 * 10.5) % 1.0 = 0.525, which > 0.1 so OFF
|
|
frame = anim.render(0.05, 5)
|
|
for px in frame:
|
|
assert px == (0, 0, 0), f"Expected black during off-phase, got {px}"
|
|
|
|
|
|
def test_strobe_on_off():
|
|
"""Strobe produces both on and off frames."""
|
|
color = (200, 100, 50)
|
|
anim = StrobeAnimation(color=color, speed=1.0, duty_cycle=0.5, white=0)
|
|
# High speed -> high frequency -> easy to find both on and off phases
|
|
frames_at_t = [anim.render(t / 100, 3) for t in range(100)]
|
|
on_count = sum(1 for f in frames_at_t if all(px == color for px in f))
|
|
off_count = sum(1 for f in frames_at_t if all(px == (0, 0, 0) for px in f))
|
|
assert on_count > 0, "Should have on-phase frames"
|
|
assert off_count > 0, "Should have off-phase frames"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Color wipe
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_color_wipe_progressive():
|
|
"""More pixels filled at larger t."""
|
|
color = (255, 0, 0)
|
|
anim = ColorWipeAnimation(color=color, speed=2.0, reverse=False, white=0)
|
|
led_count = 20
|
|
# At t=0.5, fill = min(20, int(0.5 * 2.0 * 60)) = min(20, 60) -> 20 but let's use smaller t
|
|
frame_early = anim.render(0.1, led_count) # fill_count = min(20, int(0.1*2*60)) = min(20,12) = 12
|
|
frame_late = anim.render(0.2, led_count) # fill_count = min(20, int(0.2*2*60)) = min(20,24) = 20
|
|
filled_early = sum(1 for px in frame_early if px == color)
|
|
filled_late = sum(1 for px in frame_late if px == color)
|
|
assert filled_late >= filled_early, "Later t should fill >= pixels"
|
|
|
|
|
|
def test_color_wipe_reverse():
|
|
"""Reverse wipe fills from the end."""
|
|
color = (0, 255, 0)
|
|
led_count = 10
|
|
anim = ColorWipeAnimation(color=color, speed=1.0, reverse=True, white=0)
|
|
# At t=0.1, fill_count = min(10, int(0.1*1.0*60)) = min(10,6) = 6
|
|
frame = anim.render(0.1, led_count)
|
|
# Last 6 pixels should be color
|
|
assert frame[-1] == color, "Last pixel should be filled in reverse wipe"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fire
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_fire_output_range():
|
|
"""All R,G,B values in 0-255 range."""
|
|
anim = FireAnimation(cooling=55, sparking=120, speed=0.5)
|
|
frame = anim.render(1.0, 30)
|
|
for px in frame:
|
|
assert len(px) == 3
|
|
for ch in px:
|
|
assert 0 <= ch <= 255, f"Fire channel value {ch} out of 0-255 range"
|
|
|
|
|
|
def test_fire_length():
|
|
"""Output length == led_count."""
|
|
anim = FireAnimation(cooling=55, sparking=120, speed=0.5)
|
|
for n in [5, 20, 50, 100]:
|
|
frame = anim.render(1.0, n)
|
|
assert len(frame) == n, f"Fire returned {len(frame)} pixels for led_count={n}"
|
|
|
|
|
|
def test_fire_produces_some_color():
|
|
"""Fire should produce non-black output after multiple renders."""
|
|
anim = FireAnimation(cooling=20, sparking=200, speed=1.0) # hot fire
|
|
led_count = 30
|
|
# Run a few frames to build up heat
|
|
for _ in range(10):
|
|
frame = anim.render(0.1, led_count)
|
|
has_color = any(any(ch > 0 for ch in px) for px in frame)
|
|
assert has_color, "Fire animation should produce non-black pixels with high sparking"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Factory functions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_create_animation_factory():
|
|
"""create_animation returns correct type."""
|
|
anim = create_animation("chase", {"color": [255, 0, 0], "speed": 0.5})
|
|
assert isinstance(anim, ChaseAnimation)
|
|
|
|
|
|
def test_create_animation_unknown():
|
|
"""create_animation raises ValueError for unknown type."""
|
|
with pytest.raises(ValueError, match="Unknown animation"):
|
|
create_animation("unknown_type", {})
|
|
|
|
|
|
def test_animation_registry_size():
|
|
"""ANIMATION_REGISTRY contains all 7 types."""
|
|
assert len(ANIMATION_REGISTRY) == 7
|
|
expected = {"solid_color", "chase", "pulse", "rainbow", "strobe", "color_wipe", "fire"}
|
|
assert set(ANIMATION_REGISTRY.keys()) == expected
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# from_params for all 7 types
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_from_params_all_types():
|
|
"""from_params works for all 7 types with default-ish params."""
|
|
params_map = {
|
|
"solid_color": {"color": [200, 100, 50], "white": 0},
|
|
"chase": {"color": [255, 0, 0], "bg_color": [0, 0, 0], "speed": 0.5, "size": 3, "spacing": 7, "reverse": False, "white": 0},
|
|
"pulse": {"color": [0, 255, 0], "speed": 0.5, "min_brightness": 0, "max_brightness": 255, "white": 0},
|
|
"rainbow": {"speed": 0.5, "period": 1.0},
|
|
"strobe": {"color": [0, 0, 255], "speed": 0.5, "duty_cycle": 0.1, "white": 0},
|
|
"color_wipe": {"color": [255, 255, 0], "speed": 0.5, "reverse": False, "white": 0},
|
|
"fire": {"cooling": 55, "sparking": 120, "speed": 0.5},
|
|
}
|
|
for name, params in params_map.items():
|
|
anim = ANIMATION_REGISTRY[name].from_params(params)
|
|
assert isinstance(anim, AnimationBase), f"{name}.from_params() did not return AnimationBase instance"
|
|
frame = anim.render(1.0, 10)
|
|
assert len(frame) == 10, f"{name} render returned {len(frame)} pixels"
|
|
|
|
|
|
def test_from_params_defaults():
|
|
"""from_params handles missing params by using defaults."""
|
|
# Minimal params — should not raise
|
|
anim = SolidColorAnimation.from_params({})
|
|
frame = anim.render(0.0, 5)
|
|
assert len(frame) == 5
|
|
|
|
anim_fire = FireAnimation.from_params({})
|
|
frame_fire = anim_fire.render(0.5, 10)
|
|
assert len(frame_fire) == 10
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ABC contract
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_animation_base_is_abc():
|
|
"""AnimationBase cannot be instantiated directly."""
|
|
with pytest.raises(TypeError):
|
|
AnimationBase() # type: ignore[abstract]
|