feat(07-02): add animation test suite and re-apply main.py render wiring
- Create tests/test_firmware_animations.py with 30+ tests for all 7 animations - Re-apply animation rendering to firmware/main.py (07-01 parallel agent reverted) - Tests cover: _hsv_to_rgb, all animation classes, create_animation factory - Full round-trip test: server encode -> firmware parse -> create -> render
This commit is contained in:
205
tests/test_firmware_animations.py
Normal file
205
tests/test_firmware_animations.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user