"""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]