Files
led2/tests/test_animations.py
Claude f4da9564d4 feat(03-02): implement animation library — 7 types with registry and factory
- 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
2026-04-06 21:47:39 +00:00

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]