- 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
206 lines
6.7 KiB
Python
206 lines
6.7 KiB
Python
"""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
|