From 920243fcae66b12ef21f2fb1207c55d872eaabb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 21:45:54 +0000 Subject: [PATCH] test(03-02): add failing tests for animation library - Tests for all 7 animation types (solid_color, chase, pulse, rainbow, strobe, color_wipe, fire) - Tests for AnimationBase ABC contract - Tests for ANIMATION_REGISTRY and create_animation() factory - Tests for from_params() on all types - Length invariant test covering all animations --- tests/test_animations.py | 338 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 tests/test_animations.py diff --git a/tests/test_animations.py b/tests/test_animations.py new file mode 100644 index 0000000..56b2f98 --- /dev/null +++ b/tests/test_animations.py @@ -0,0 +1,338 @@ +"""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).""" + color = (255, 0, 0) + bg = (0, 0, 0) + 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 = 1.5 # Non-zero t to show movement + 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 at t=1.5" + + +# --------------------------------------------------------------------------- +# 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]