test(03-01): add failing tests for protocol encoders and animation command

- DRGB/DRGBW/DNRGB encoder behavior tests (byte layout, MTU, truncation)
- DNRGB start index big-endian byte order tests
- Animation command 0xAC packet tests (all 7 types, roundtrip, flags)
- Tests fail: lightsync.protocol.drgb and animation_cmd modules do not exist yet
This commit is contained in:
Claude
2026-04-06 21:40:02 +00:00
parent 75ec1158b7
commit ffc4ef6dc6
3 changed files with 257 additions and 0 deletions

View File

0
tests/__init__.py Normal file
View File

257
tests/test_protocol.py Normal file
View File

@@ -0,0 +1,257 @@
"""Tests for lightsync.protocol package — DRGB/DRGBW/DNRGB encoders and animation command."""
import struct
import pytest
from lightsync.protocol.drgb import (
PROTOCOL_DRGB,
PROTOCOL_DRGBW,
PROTOCOL_DNRGB,
TIMEOUT_BYTE,
DRGB_MAX_PX,
DRGBW_MAX_PX,
DNRGB_MAX_PX,
encode_drgb,
encode_drgbw,
encode_dnrgb_packets,
)
from lightsync.protocol.animation_cmd import (
ANIM_CMD_MARKER,
ANIMATION_IDS,
ANIMATION_NAMES,
encode_animation_cmd,
decode_animation_cmd,
)
# ---------------------------------------------------------------------------
# DRGB tests
# ---------------------------------------------------------------------------
def test_encode_drgb_basic():
"""DRGB header = [0x02, timeout], body = 3 bytes per pixel."""
pixels = [(255, 0, 0), (0, 255, 0)]
result = encode_drgb(pixels)
expected = bytes([0x02, 2, 255, 0, 0, 0, 255, 0])
assert result == expected, f"Got: {result.hex()}"
def test_encode_drgb_max_pixels_exact():
"""490 pixels produces exactly 1472 bytes (2 header + 490*3)."""
pixels = [(255, 0, 0)] * 490
result = encode_drgb(pixels)
assert len(result) == 1472
def test_encode_drgb_truncates_over_max():
"""491 pixels truncated to 490 — stays under MTU."""
pixels = [(255, 0, 0)] * 491
result = encode_drgb(pixels)
assert len(result) == 1472 # same as 490 pixels
def test_encode_drgb_header_bytes():
"""DRGB header byte 0 == 0x02, byte 1 == TIMEOUT_BYTE."""
result = encode_drgb([(0, 0, 0)])
assert result[0] == PROTOCOL_DRGB == 2
assert result[1] == TIMEOUT_BYTE == 2
# ---------------------------------------------------------------------------
# DRGBW tests
# ---------------------------------------------------------------------------
def test_encode_drgbw_basic():
"""DRGBW header = [0x03, timeout], body = 4 bytes per pixel (R,G,B,W)."""
pixels = [(255, 0, 0, 128)]
result = encode_drgbw(pixels)
expected = bytes([0x03, 2, 255, 0, 0, 128])
assert result == expected, f"Got: {result.hex()}"
def test_encode_drgbw_max_pixels_exact():
"""367 pixels produces exactly 1470 bytes (2 header + 367*4)."""
pixels = [(255, 0, 0, 0)] * 367
result = encode_drgbw(pixels)
assert len(result) == 1470
def test_encode_drgbw_truncates_over_max():
"""368 pixels truncated to 367."""
pixels = [(255, 0, 0, 0)] * 368
result = encode_drgbw(pixels)
assert len(result) == 1470 # same as 367 pixels
def test_encode_drgbw_header_bytes():
"""DRGBW header byte 0 == 0x03."""
result = encode_drgbw([(0, 0, 0, 0)])
assert result[0] == PROTOCOL_DRGBW == 3
assert result[1] == TIMEOUT_BYTE == 2
# ---------------------------------------------------------------------------
# DNRGB multi-packet tests
# ---------------------------------------------------------------------------
def test_dnrgb_split_600_pixels():
"""600 pixels splits into 2 packets (489 + 111 LEDs)."""
pixels = [(100, 150, 200)] * 600
packets = encode_dnrgb_packets(pixels)
assert len(packets) == 2
# First packet: 4 header + 489*3 = 1471 bytes
assert len(packets[0]) == 4 + 489 * 3
# Second packet: 4 header + 111*3 = 337 bytes
assert len(packets[1]) == 4 + 111 * 3
def test_dnrgb_start_index_first_packet():
"""First packet start index at bytes 2-3 (big-endian) == 0."""
pixels = [(0, 0, 0)] * 600
packets = encode_dnrgb_packets(pixels)
start = struct.unpack_from(">H", packets[0], 2)[0]
assert start == 0
def test_dnrgb_start_index_second_packet():
"""Second packet start index == 489 (big-endian at bytes 2-3)."""
pixels = [(0, 0, 0)] * 600
packets = encode_dnrgb_packets(pixels)
start = struct.unpack_from(">H", packets[1], 2)[0]
assert start == 489
def test_dnrgb_packets_under_mtu():
"""All DNRGB packets must be <= 1472 bytes."""
pixels = [(255, 128, 0)] * 1000
packets = encode_dnrgb_packets(pixels)
for i, pkt in enumerate(packets):
assert len(pkt) <= 1472, f"Packet {i} exceeds MTU: {len(pkt)} bytes"
def test_dnrgb_header_type_byte():
"""DNRGB type byte == 0x04."""
packets = encode_dnrgb_packets([(0, 0, 0)] * 10)
assert packets[0][0] == PROTOCOL_DNRGB == 4
def test_dnrgb_with_start_offset():
"""start_index parameter shifts all packet indices."""
pixels = [(0, 0, 0)] * 600
packets = encode_dnrgb_packets(pixels, start_index=100)
start0 = struct.unpack_from(">H", packets[0], 2)[0]
start1 = struct.unpack_from(">H", packets[1], 2)[0]
assert start0 == 100
assert start1 == 100 + 489
def test_dnrgb_single_packet_under_max():
"""Under DNRGB_MAX_PX produces exactly 1 packet."""
pixels = [(0, 0, 0)] * 100
packets = encode_dnrgb_packets(pixels)
assert len(packets) == 1
# ---------------------------------------------------------------------------
# Animation command tests
# ---------------------------------------------------------------------------
def test_animation_cmd_chase_basic():
"""encode_animation_cmd returns 16-byte packet starting with 0xAC, 0x01 (chase)."""
result = encode_animation_cmd("chase", {"color": [255, 0, 0], "white": 0, "speed": 0.5, "reverse": False})
assert len(result) == 16
assert result[0] == 0xAC
assert result[1] == 1 # version
assert result[2] == ANIMATION_IDS["chase"] == 1
def test_animation_cmd_all_type_ids():
"""All 7 animation types have correct IDs."""
expected = {
"solid_color": 0, "chase": 1, "pulse": 2, "rainbow": 3,
"strobe": 4, "color_wipe": 5, "fire": 6,
}
assert ANIMATION_IDS == expected
def test_animation_cmd_marker_byte():
"""Byte 0 == 0xAC for all animation types."""
for anim_name in ANIMATION_IDS:
result = encode_animation_cmd(anim_name, {})
assert result[0] == 0xAC, f"Failed for {anim_name}: {result.hex()}"
def test_animation_cmd_packet_length():
"""All animation commands produce exactly 16 bytes."""
for anim_name in ANIMATION_IDS:
result = encode_animation_cmd(anim_name, {})
assert len(result) == 16, f"Wrong length for {anim_name}: {len(result)}"
def test_animation_cmd_unknown_raises():
"""Unknown animation type raises ValueError."""
with pytest.raises(ValueError):
encode_animation_cmd("nonexistent_animation", {})
def test_animation_cmd_roundtrip_all_types():
"""decode_animation_cmd round-trips with encode_animation_cmd for all 7 types."""
for anim_name in ANIMATION_IDS:
params = {"color": [10, 20, 30], "bg_color": [5, 5, 5], "speed": 0.75, "white": 64, "reverse": True, "density": 100}
encoded = encode_animation_cmd(anim_name, params)
decoded = decode_animation_cmd(encoded)
assert decoded["animation"] == anim_name
assert decoded["params"]["color"] == [10, 20, 30]
assert decoded["params"]["bg_color"] == [5, 5, 5]
assert decoded["params"]["white"] == 64
assert decoded["params"]["reverse"] is True
assert decoded["params"]["density"] == 100
def test_animation_cmd_speed_encoding():
"""Speed is packed as big-endian float32 at bytes 4-7."""
result = encode_animation_cmd("chase", {"speed": 0.5})
speed = struct.unpack_from(">f", result, 4)[0]
assert abs(speed - 0.5) < 1e-6
def test_animation_cmd_reverse_flag():
"""Reverse flag sets bit 0 of flags byte (byte 3)."""
result_rev = encode_animation_cmd("chase", {"reverse": True})
result_nrev = encode_animation_cmd("chase", {"reverse": False})
assert result_rev[3] & 0x01 == 1
assert result_nrev[3] & 0x01 == 0
def test_animation_cmd_color_fields():
"""Primary color at bytes 8-10, secondary (bg_color) at bytes 11-13."""
result = encode_animation_cmd("chase", {"color": [100, 150, 200], "bg_color": [10, 20, 30]})
assert result[8] == 100
assert result[9] == 150
assert result[10] == 200
assert result[11] == 10
assert result[12] == 20
assert result[13] == 30
def test_animation_cmd_white_byte():
"""White channel at byte 14."""
result = encode_animation_cmd("solid_color", {"white": 200})
assert result[14] == 200
def test_animation_cmd_density_byte():
"""Density at byte 15 (checks density param, falls back to size/cooling)."""
result_density = encode_animation_cmd("fire", {"density": 55})
assert result_density[15] == 55
result_cooling = encode_animation_cmd("fire", {"cooling": 80})
assert result_cooling[15] == 80
def test_animation_cmd_defaults():
"""encode_animation_cmd works with empty params (uses defaults)."""
result = encode_animation_cmd("solid_color", {})
assert len(result) == 16
assert result[0] == 0xAC
assert result[8] == 0 # default color [0,0,0]
assert result[14] == 0 # default white