"""Host-side tests for firmware animation renderers. Verifies that the MicroPython animation ports produce correct output. Run with: python -m pytest tests/test_firmware_animations.py -v """ import sys import os import pytest sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'firmware')) from animations import ( SolidColorAnimation, ChaseAnimation, PulseAnimation, RainbowAnimation, StrobeAnimation, ColorWipeAnimation, FireAnimation, create_animation, _hsv_to_rgb ) class TestHSVtoRGB: def test_red(self): r, g, b = _hsv_to_rgb(0.0, 1.0, 1.0) assert (r, g, b) == (255, 0, 0) def test_green(self): r, g, b = _hsv_to_rgb(1/3, 1.0, 1.0) assert r == 0 and g == 255 and b == 0 def test_blue(self): r, g, b = _hsv_to_rgb(2/3, 1.0, 1.0) assert r == 0 and g == 0 and b == 255 def test_white(self): r, g, b = _hsv_to_rgb(0.0, 0.0, 1.0) assert (r, g, b) == (255, 255, 255) def test_black(self): r, g, b = _hsv_to_rgb(0.0, 0.0, 0.0) assert (r, g, b) == (0, 0, 0) class TestSolidColor: def test_uniform(self): anim = SolidColorAnimation((255, 0, 0)) pixels = anim.render(0.0, 10) assert len(pixels) == 10 assert all(p == (255, 0, 0) for p in pixels) def test_time_independent(self): anim = SolidColorAnimation((0, 255, 0)) assert anim.render(0.0, 5) == anim.render(100.0, 5) class TestChase: def test_length(self): anim = ChaseAnimation((255, 0, 0), (0, 0, 0), 0.5, 3, 7, False, 0) pixels = anim.render(0.0, 30) assert len(pixels) == 30 def test_has_both_colors(self): anim = ChaseAnimation((255, 0, 0), (0, 0, 0), 0.5, 3, 7, False, 0) pixels = anim.render(0.0, 30) assert (255, 0, 0) in pixels assert (0, 0, 0) in pixels def test_reverse_differs(self): fwd = ChaseAnimation((255, 0, 0), (0, 0, 0), 0.5, 3, 7, False, 0) rev = ChaseAnimation((255, 0, 0), (0, 0, 0), 0.5, 3, 7, True, 0) # At t=0.3 (not a multiple of period), forward and reverse differ assert fwd.render(0.3, 20) != rev.render(0.3, 20) class TestPulse: def test_length(self): anim = PulseAnimation((255, 255, 255), 0.5, 0, 255, 0) pixels = anim.render(0.0, 10) assert len(pixels) == 10 def test_values_in_range(self): anim = PulseAnimation((255, 128, 64), 0.5, 0, 255, 0) for t in [0.0, 0.5, 1.0, 2.5]: pixels = anim.render(t, 10) for r, g, b in pixels: assert 0 <= r <= 255 assert 0 <= g <= 255 assert 0 <= b <= 255 class TestRainbow: def test_length(self): anim = RainbowAnimation(0.5, 1.0) pixels = anim.render(0.0, 10) assert len(pixels) == 10 def test_distinct_hues(self): anim = RainbowAnimation(0.5, 1.0) pixels = anim.render(0.0, 10) # All 10 pixels should have distinct colors unique = set(pixels) assert len(unique) == 10 def test_values_in_range(self): anim = RainbowAnimation(0.5, 1.0) pixels = anim.render(0.0, 300) for r, g, b in pixels: assert 0 <= r <= 255 assert 0 <= g <= 255 assert 0 <= b <= 255 class TestStrobe: def test_length(self): anim = StrobeAnimation((255, 255, 255), 0.5, 0.5, 0) pixels = anim.render(0.0, 10) assert len(pixels) == 10 def test_on_phase(self): # At t=0.0, phase = 0.0 < duty_cycle=0.5, should be ON anim = StrobeAnimation((255, 255, 255), 0.5, 0.5, 0) pixels = anim.render(0.0, 5) assert all(p == (255, 255, 255) for p in pixels) class TestColorWipe: def test_length(self): anim = ColorWipeAnimation((255, 0, 0), 0.5, False, 0) pixels = anim.render(0.0, 10) assert len(pixels) == 10 def test_progressive(self): anim = ColorWipeAnimation((255, 0, 0), 1.0, False, 0) p1 = anim.render(0.5, 100) p2 = anim.render(1.0, 100) filled1 = sum(1 for p in p1 if p == (255, 0, 0)) filled2 = sum(1 for p in p2 if p == (255, 0, 0)) assert filled2 >= filled1 def test_at_zero(self): anim = ColorWipeAnimation((255, 0, 0), 0.5, False, 0) pixels = anim.render(0.0, 10) assert all(p == (0, 0, 0) for p in pixels) class TestFire: def test_length(self): anim = FireAnimation(55, 120, 0.5) pixels = anim.render(0.0, 30) assert len(pixels) == 30 def test_values_in_range(self): anim = FireAnimation(55, 120, 0.5) # Run several frames to build up heat for i in range(10): pixels = anim.render(i * 0.016, 30) for r, g, b in pixels: assert 0 <= r <= 255 assert 0 <= g <= 255 assert 0 <= b <= 255 def test_uses_bytearray(self): anim = FireAnimation(55, 120, 0.5) anim.render(0.0, 30) assert isinstance(anim._heat, bytearray) class TestCreateAnimation: def test_all_types(self): for name in ['solid_color', 'chase', 'pulse', 'rainbow', 'strobe', 'color_wipe', 'fire']: cmd = { 'animation': name, 'speed': 0.5, 'color': (255, 0, 0), 'bg_color': (0, 0, 0), 'white': 0, 'density': 0, 'reverse': False } anim = create_animation(cmd) pixels = anim.render(0.5, 10) assert len(pixels) == 10 def test_unknown_falls_back(self): cmd = { 'animation': 'nonexistent', 'speed': 0.5, 'color': (255, 0, 0), 'bg_color': (0, 0, 0), 'white': 0, 'density': 0, 'reverse': False } anim = create_animation(cmd) # Falls back to SolidColor assert isinstance(anim, SolidColorAnimation) class TestRoundTripWithServerEncoders: """Verify firmware animations can be created from server-encoded packets.""" def test_encode_decode_create_render(self): """Full round-trip: server encode -> firmware decode -> create animation -> render.""" sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) from lightsync.protocol.animation_cmd import encode_animation_cmd # Also need firmware protocol parser from protocol import parse_animation_cmd as fw_parse for anim_name in ['solid_color', 'chase', 'pulse', 'rainbow', 'strobe', 'color_wipe', 'fire']: params = {'color': [255, 128, 0], 'speed': 0.7, 'white': 32} packet = encode_animation_cmd(anim_name, params) cmd = fw_parse(packet) assert cmd is not None anim = create_animation(cmd) pixels = anim.render(0.5, 10) assert len(pixels) == 10