test(02-02): add failing tests for ESP32Transport UDP client
- Tests for send_command() v:1 injection, field preservation - Tests for stop/brightness commands, pre-connection silent drop - Tests for 512-byte payload limit enforcement - Tests for STATUS ok response → connected=True + last_status - Tests for malformed JSON silent handling, on_status callback - Tests for send_status_ping, connection_lost, create_esp32_transport factory - Tests for led_sync.transport package re-exports
This commit is contained in:
216
tests/test_transport.py
Normal file
216
tests/test_transport.py
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
"""
|
||||||
|
Tests for ESP32 UDP transport client.
|
||||||
|
Protocol: docs/protocol.md — v1, port 4210, fire-and-forget JSON datagrams.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
class MockTransport:
|
||||||
|
"""Minimal mock for asyncio.DatagramTransport."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.sent = []
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
def sendto(self, data: bytes, addr=None):
|
||||||
|
self.sent.append(json.loads(data.decode("utf-8")))
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
# --- send_command() tests ---
|
||||||
|
|
||||||
|
def test_send_command_injects_v1():
|
||||||
|
"""send_command() must always inject 'v':1 per protocol spec."""
|
||||||
|
from led_sync.transport.udp_client import ESP32Transport
|
||||||
|
mock = MockTransport()
|
||||||
|
proto = ESP32Transport()
|
||||||
|
proto.connection_made(mock)
|
||||||
|
proto.send_command({"zone": "wand", "animation": "chase", "params": {"speed": 0.5}})
|
||||||
|
assert mock.sent[-1]["v"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_command_preserves_fields():
|
||||||
|
"""send_command() must pass through all caller-supplied fields."""
|
||||||
|
from led_sync.transport.udp_client import ESP32Transport
|
||||||
|
mock = MockTransport()
|
||||||
|
proto = ESP32Transport()
|
||||||
|
proto.connection_made(mock)
|
||||||
|
proto.send_command({"zone": "schrank", "animation": "breathe", "params": {"intensity": 0.8}})
|
||||||
|
payload = mock.sent[-1]
|
||||||
|
assert payload["zone"] == "schrank"
|
||||||
|
assert payload["animation"] == "breathe"
|
||||||
|
assert payload["params"]["intensity"] == 0.8
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_command_stop():
|
||||||
|
"""Stop command must include 'v':1, cmd, zone."""
|
||||||
|
from led_sync.transport.udp_client import ESP32Transport
|
||||||
|
mock = MockTransport()
|
||||||
|
proto = ESP32Transport()
|
||||||
|
proto.connection_made(mock)
|
||||||
|
proto.send_command({"cmd": "stop", "zone": "all"})
|
||||||
|
payload = mock.sent[-1]
|
||||||
|
assert payload["v"] == 1
|
||||||
|
assert payload["cmd"] == "stop"
|
||||||
|
assert payload["zone"] == "all"
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_command_brightness():
|
||||||
|
"""Brightness command must include 'v':1, cmd, zone, value."""
|
||||||
|
from led_sync.transport.udp_client import ESP32Transport
|
||||||
|
mock = MockTransport()
|
||||||
|
proto = ESP32Transport()
|
||||||
|
proto.connection_made(mock)
|
||||||
|
proto.send_command({"cmd": "brightness", "zone": "all", "value": 40})
|
||||||
|
payload = mock.sent[-1]
|
||||||
|
assert payload["v"] == 1
|
||||||
|
assert payload["cmd"] == "brightness"
|
||||||
|
assert payload["value"] == 40
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_command_silent_drop_before_connection():
|
||||||
|
"""send_command() must silently drop packets when transport is None (pre-connection)."""
|
||||||
|
from led_sync.transport.udp_client import ESP32Transport
|
||||||
|
proto = ESP32Transport()
|
||||||
|
# Must not raise — no transport set
|
||||||
|
proto.send_command({"zone": "all", "cmd": "stop"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_command_drops_oversized_payload():
|
||||||
|
"""Payloads exceeding 512 bytes must be silently dropped."""
|
||||||
|
from led_sync.transport.udp_client import ESP32Transport
|
||||||
|
mock = MockTransport()
|
||||||
|
proto = ESP32Transport()
|
||||||
|
proto.connection_made(mock)
|
||||||
|
# Build a command that produces >512 bytes
|
||||||
|
big_colors = [[255, 0, 0, 0]] * 100
|
||||||
|
proto.send_command({"zone": "wand", "animation": "color_wash", "params": {"colors": big_colors}})
|
||||||
|
# No packet sent (or zero packets sent after this drop)
|
||||||
|
assert len(mock.sent) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# --- STATUS response handling ---
|
||||||
|
|
||||||
|
def test_datagram_received_status_ok_sets_connected():
|
||||||
|
"""STATUS ok response must set connected=True and store last_status."""
|
||||||
|
from led_sync.transport.udp_client import ESP32Transport
|
||||||
|
proto = ESP32Transport()
|
||||||
|
proto.connection_made(MockTransport())
|
||||||
|
assert not proto.connected
|
||||||
|
status_msg = {"v": 1, "status": "ok", "wand": "chase", "schrank": "breathe", "brightness": 40}
|
||||||
|
proto.datagram_received(json.dumps(status_msg).encode(), ("192.168.1.100", 4210))
|
||||||
|
assert proto.connected
|
||||||
|
assert proto.last_status is not None
|
||||||
|
assert proto.last_status["wand"] == "chase"
|
||||||
|
assert proto.last_status["schrank"] == "breathe"
|
||||||
|
assert proto.last_status["brightness"] == 40
|
||||||
|
|
||||||
|
|
||||||
|
def test_datagram_received_status_ok_invokes_callback():
|
||||||
|
"""STATUS ok response must invoke on_status callback if provided."""
|
||||||
|
from led_sync.transport.udp_client import ESP32Transport
|
||||||
|
received = []
|
||||||
|
proto = ESP32Transport(on_status=received.append)
|
||||||
|
proto.connection_made(MockTransport())
|
||||||
|
status_msg = {"v": 1, "status": "ok", "wand": "rainbow", "schrank": "pulse", "brightness": 20}
|
||||||
|
proto.datagram_received(json.dumps(status_msg).encode(), ("192.168.1.100", 4210))
|
||||||
|
assert len(received) == 1
|
||||||
|
assert received[0]["wand"] == "rainbow"
|
||||||
|
|
||||||
|
|
||||||
|
def test_datagram_received_non_ok_status_does_not_set_connected():
|
||||||
|
"""Only 'status':'ok' should set connected=True."""
|
||||||
|
from led_sync.transport.udp_client import ESP32Transport
|
||||||
|
proto = ESP32Transport()
|
||||||
|
proto.connection_made(MockTransport())
|
||||||
|
# Send a message that has a 'status' key but not 'ok'
|
||||||
|
msg = {"v": 1, "status": "error", "reason": "unknown"}
|
||||||
|
proto.datagram_received(json.dumps(msg).encode(), ("192.168.1.100", 4210))
|
||||||
|
assert not proto.connected
|
||||||
|
|
||||||
|
|
||||||
|
def test_datagram_received_malformed_json_silent():
|
||||||
|
"""Malformed JSON in datagram_received() must not raise."""
|
||||||
|
from led_sync.transport.udp_client import ESP32Transport
|
||||||
|
proto = ESP32Transport()
|
||||||
|
proto.connection_made(MockTransport())
|
||||||
|
proto.datagram_received(b"not json at all {{{", ("192.168.1.100", 4210))
|
||||||
|
# Must not raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_datagram_received_empty_bytes_silent():
|
||||||
|
"""Empty datagram must not raise."""
|
||||||
|
from led_sync.transport.udp_client import ESP32Transport
|
||||||
|
proto = ESP32Transport()
|
||||||
|
proto.connection_made(MockTransport())
|
||||||
|
proto.datagram_received(b"", ("192.168.1.100", 4210))
|
||||||
|
|
||||||
|
|
||||||
|
# --- send_status_ping ---
|
||||||
|
|
||||||
|
def test_send_status_ping():
|
||||||
|
"""send_status_ping() must send {'v':1,'cmd':'status'}."""
|
||||||
|
from led_sync.transport.udp_client import ESP32Transport
|
||||||
|
mock = MockTransport()
|
||||||
|
proto = ESP32Transport()
|
||||||
|
proto.connection_made(mock)
|
||||||
|
proto.send_status_ping()
|
||||||
|
assert len(mock.sent) == 1
|
||||||
|
assert mock.sent[0]["v"] == 1
|
||||||
|
assert mock.sent[0]["cmd"] == "status"
|
||||||
|
|
||||||
|
|
||||||
|
# --- connection_lost ---
|
||||||
|
|
||||||
|
def test_connection_lost_sets_disconnected():
|
||||||
|
"""connection_lost() must set connected=False and clear transport."""
|
||||||
|
from led_sync.transport.udp_client import ESP32Transport
|
||||||
|
mock = MockTransport()
|
||||||
|
proto = ESP32Transport()
|
||||||
|
proto.connection_made(mock)
|
||||||
|
# Simulate a connected state
|
||||||
|
status_msg = {"v": 1, "status": "ok", "wand": "chase", "schrank": "breathe", "brightness": 40}
|
||||||
|
proto.datagram_received(json.dumps(status_msg).encode(), ("192.168.1.100", 4210))
|
||||||
|
assert proto.connected
|
||||||
|
proto.connection_lost(None)
|
||||||
|
assert not proto.connected
|
||||||
|
|
||||||
|
|
||||||
|
# --- create_esp32_transport factory ---
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_esp32_transport_returns_instance():
|
||||||
|
"""create_esp32_transport() must return an ESP32Transport instance."""
|
||||||
|
from led_sync.transport.udp_client import ESP32Transport, create_esp32_transport
|
||||||
|
# We can't actually send UDP in unit tests, but we can test the coroutine is awaitable
|
||||||
|
# by patching the loop endpoint creation
|
||||||
|
import unittest.mock as mock
|
||||||
|
|
||||||
|
fake_protocol = ESP32Transport()
|
||||||
|
fake_mock_transport = MockTransport()
|
||||||
|
fake_protocol.connection_made(fake_mock_transport)
|
||||||
|
|
||||||
|
with mock.patch("asyncio.get_running_loop") as mock_loop:
|
||||||
|
loop_instance = mock.AsyncMock()
|
||||||
|
loop_instance.create_datagram_endpoint = mock.AsyncMock(
|
||||||
|
return_value=(fake_mock_transport, fake_protocol)
|
||||||
|
)
|
||||||
|
mock_loop.return_value = loop_instance
|
||||||
|
|
||||||
|
result = await create_esp32_transport("192.168.1.100", port=4210)
|
||||||
|
assert isinstance(result, ESP32Transport)
|
||||||
|
# Factory sends an initial status ping
|
||||||
|
assert any(p.get("cmd") == "status" for p in fake_mock_transport.sent)
|
||||||
|
|
||||||
|
|
||||||
|
# --- __init__.py re-export ---
|
||||||
|
|
||||||
|
def test_package_re_exports():
|
||||||
|
"""led_sync.transport must re-export ESP32Transport and create_esp32_transport."""
|
||||||
|
from led_sync.transport import ESP32Transport, create_esp32_transport
|
||||||
|
assert ESP32Transport is not None
|
||||||
|
assert create_esp32_transport is not None
|
||||||
Reference in New Issue
Block a user