888f8fd16e
ruff --select UP007,UP045 --fix converted ~1760 sites across the backend: `Optional[T]` → `T | None`, `Union[X, Y]` → `X | Y`. The remaining module-level alias targets that ruff conservatively skips (BindableFloatInput, ColorList, DeviceConfig) were converted by hand earlier in the pass. black -formatted the result so the wider unions fit cleanly under the 100-char line budget. pyproject.toml now sets [tool.ruff.lint] extend-select = ["UP007", "UP045"] so future legacy imports fire CI on every push. The pre-commit ruff hook was bumped from v0.8.0 -> v0.15.12 to recognise UP045 (split off from UP007 in v0.13).
294 lines
9.1 KiB
Python
294 lines
9.1 KiB
Python
"""Tests for the standalone DDP LEDClient wrapper and provider."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from ledgrab.core.devices.ddp_led_client import (
|
|
DEFAULT_DDP_PORT,
|
|
DDPLEDClient,
|
|
parse_ddp_url,
|
|
)
|
|
from ledgrab.core.devices.ddp_provider import DDPDeviceProvider
|
|
from ledgrab.core.devices.device_config import DDPConfig
|
|
from ledgrab.core.devices.led_client import ProviderDeps
|
|
|
|
# ============================================================================
|
|
# parse_ddp_url
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"url,expected",
|
|
[
|
|
("ddp://192.168.1.50", ("192.168.1.50", DEFAULT_DDP_PORT)),
|
|
("ddp://192.168.1.50:4048", ("192.168.1.50", 4048)),
|
|
("ddp://192.168.1.50:5000", ("192.168.1.50", 5000)),
|
|
("192.168.1.50", ("192.168.1.50", DEFAULT_DDP_PORT)),
|
|
("192.168.1.50:4048", ("192.168.1.50", 4048)),
|
|
("pixelblaze.local", ("pixelblaze.local", DEFAULT_DDP_PORT)),
|
|
("ddp://pixelblaze.local:9999", ("pixelblaze.local", 9999)),
|
|
],
|
|
)
|
|
def test_parse_ddp_url_accepts_common_forms(url, expected):
|
|
assert parse_ddp_url(url) == expected
|
|
|
|
|
|
@pytest.mark.parametrize("url", ["", " ", "ddp://", "://192.168.1.1"])
|
|
def test_parse_ddp_url_rejects_empty_or_hostless(url):
|
|
with pytest.raises(ValueError):
|
|
parse_ddp_url(url)
|
|
|
|
|
|
# ============================================================================
|
|
# DDPLEDClient
|
|
# ============================================================================
|
|
|
|
|
|
def _make_connected_client(led_count: int = 3, rgbw: bool = False) -> DDPLEDClient:
|
|
"""Build a DDPLEDClient with its underlying DDPClient transport mocked."""
|
|
client = DDPLEDClient("ddp://127.0.0.1", led_count=led_count, rgbw=rgbw)
|
|
# Avoid real UDP socket creation — stub the DDPClient layer.
|
|
inner = MagicMock()
|
|
inner.send_pixels_numpy = MagicMock()
|
|
inner.close = AsyncMock()
|
|
client._ddp = inner
|
|
client._connected = True
|
|
return client
|
|
|
|
|
|
def test_supports_fast_send_is_true():
|
|
client = DDPLEDClient("ddp://127.0.0.1", led_count=1)
|
|
assert client.supports_fast_send is True
|
|
|
|
|
|
def test_send_pixels_fast_pushes_pixels():
|
|
client = _make_connected_client(led_count=3)
|
|
pixels = np.array([[10, 20, 30], [40, 50, 60], [70, 80, 90]], dtype=np.uint8)
|
|
|
|
client.send_pixels_fast(pixels)
|
|
|
|
client._ddp.send_pixels_numpy.assert_called_once()
|
|
sent = client._ddp.send_pixels_numpy.call_args[0][0]
|
|
np.testing.assert_array_equal(sent, pixels)
|
|
|
|
|
|
def test_send_pixels_fast_applies_brightness():
|
|
client = _make_connected_client(led_count=1)
|
|
pixels = np.array([[200, 100, 50]], dtype=np.uint8)
|
|
|
|
client.send_pixels_fast(pixels, brightness=128)
|
|
|
|
sent = client._ddp.send_pixels_numpy.call_args[0][0]
|
|
# 200 * 128 // 255 = 100, 100 * 128 // 255 = 50, 50 * 128 // 255 = 25
|
|
np.testing.assert_array_equal(sent, np.array([[100, 50, 25]], dtype=np.uint8))
|
|
|
|
|
|
def test_send_pixels_fast_brightness_full_is_passthrough():
|
|
client = _make_connected_client(led_count=1)
|
|
pixels = np.array([[200, 100, 50]], dtype=np.uint8)
|
|
|
|
client.send_pixels_fast(pixels, brightness=255)
|
|
|
|
sent = client._ddp.send_pixels_numpy.call_args[0][0]
|
|
np.testing.assert_array_equal(sent, pixels)
|
|
|
|
|
|
def test_send_pixels_fast_brightness_zero_blanks_all():
|
|
client = _make_connected_client(led_count=2)
|
|
pixels = np.array([[200, 100, 50], [255, 255, 255]], dtype=np.uint8)
|
|
|
|
client.send_pixels_fast(pixels, brightness=0)
|
|
|
|
sent = client._ddp.send_pixels_numpy.call_args[0][0]
|
|
np.testing.assert_array_equal(sent, np.zeros_like(pixels))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_send_pixels_accepts_list():
|
|
client = _make_connected_client(led_count=2)
|
|
|
|
await client.send_pixels([(1, 2, 3), (4, 5, 6)])
|
|
|
|
sent = client._ddp.send_pixels_numpy.call_args[0][0]
|
|
np.testing.assert_array_equal(sent, np.array([[1, 2, 3], [4, 5, 6]], dtype=np.uint8))
|
|
|
|
|
|
def test_send_pixels_fast_when_not_connected_raises():
|
|
client = DDPLEDClient("ddp://127.0.0.1", led_count=1)
|
|
with pytest.raises(RuntimeError, match="not connected"):
|
|
client.send_pixels_fast([(1, 2, 3)])
|
|
|
|
|
|
def test_send_pixels_fast_accepts_flat_array():
|
|
"""A flat 1-D RGB byte array reshapes to (N, 3) before sending."""
|
|
client = _make_connected_client(led_count=2)
|
|
flat = np.array([1, 2, 3, 4, 5, 6], dtype=np.uint8)
|
|
|
|
client.send_pixels_fast(flat)
|
|
|
|
sent = client._ddp.send_pixels_numpy.call_args[0][0]
|
|
np.testing.assert_array_equal(sent, np.array([[1, 2, 3], [4, 5, 6]], dtype=np.uint8))
|
|
|
|
|
|
# ============================================================================
|
|
# DDPDeviceProvider
|
|
# ============================================================================
|
|
|
|
|
|
def test_provider_device_type():
|
|
provider = DDPDeviceProvider()
|
|
assert provider.device_type == "ddp"
|
|
|
|
|
|
def test_provider_capabilities():
|
|
provider = DDPDeviceProvider()
|
|
caps = provider.capabilities
|
|
assert "manual_led_count" in caps
|
|
assert "health_check" in caps
|
|
# No native power / brightness — DDP has no reply channel.
|
|
assert "power_control" not in caps
|
|
assert "brightness_control" not in caps
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_provider_validate_device_accepts_bare_host():
|
|
provider = DDPDeviceProvider()
|
|
result = await provider.validate_device("192.168.1.50")
|
|
# validate_device returns the empty dict (no LED count to report)
|
|
assert result == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_provider_validate_device_rejects_empty_url():
|
|
provider = DDPDeviceProvider()
|
|
with pytest.raises(ValueError, match="Invalid DDP URL"):
|
|
await provider.validate_device("")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_provider_discover_returns_empty():
|
|
"""DDP has no native discovery — provider must return an empty list, not raise."""
|
|
provider = DDPDeviceProvider()
|
|
assert await provider.discover() == []
|
|
|
|
|
|
def test_provider_create_client_threads_config_fields():
|
|
provider = DDPDeviceProvider()
|
|
config = DDPConfig(
|
|
device_id="device_test",
|
|
device_url="ddp://192.168.1.50:9000",
|
|
led_count=144,
|
|
rgbw=True,
|
|
ddp_port=9000,
|
|
ddp_destination_id=2,
|
|
ddp_color_order=4, # BGR
|
|
)
|
|
|
|
client = provider.create_client(config, deps=ProviderDeps())
|
|
|
|
assert isinstance(client, DDPLEDClient)
|
|
assert client.host == "192.168.1.50"
|
|
assert client.port == 9000
|
|
assert client._led_count == 144
|
|
assert client._rgbw is True
|
|
assert client._destination_id == 2
|
|
assert client._color_order == 4
|
|
|
|
|
|
# ============================================================================
|
|
# DDPConfig round-trip via Device.to_config()
|
|
# ============================================================================
|
|
|
|
|
|
def test_device_to_config_round_trip_ddp():
|
|
"""A Device with device_type='ddp' yields a DDPConfig from to_config()."""
|
|
from ledgrab.storage.device_store import Device
|
|
|
|
device = Device(
|
|
device_id="device_abc12345",
|
|
name="My Pixelblaze",
|
|
url="ddp://192.168.1.42",
|
|
led_count=300,
|
|
device_type="ddp",
|
|
rgbw=False,
|
|
ddp_port=4048,
|
|
ddp_destination_id=1,
|
|
ddp_color_order=1,
|
|
)
|
|
|
|
config = device.to_config()
|
|
|
|
assert isinstance(config, DDPConfig)
|
|
assert config.device_type == "ddp"
|
|
assert config.device_url == "ddp://192.168.1.42"
|
|
assert config.led_count == 300
|
|
assert config.ddp_port == 4048
|
|
assert config.ddp_destination_id == 1
|
|
assert config.ddp_color_order == 1
|
|
|
|
|
|
def test_device_to_dict_omits_ddp_defaults():
|
|
"""A DDP device with default DDP fields shouldn't pollute the persisted JSON."""
|
|
from ledgrab.storage.device_store import Device
|
|
|
|
device = Device(
|
|
device_id="device_abc12345",
|
|
name="Defaults",
|
|
url="ddp://192.168.1.42",
|
|
led_count=10,
|
|
device_type="ddp",
|
|
)
|
|
|
|
payload = device.to_dict()
|
|
|
|
# Defaults: ddp_port=0, ddp_destination_id=1, ddp_color_order=1 — all omitted.
|
|
assert "ddp_port" not in payload
|
|
assert "ddp_destination_id" not in payload
|
|
assert "ddp_color_order" not in payload
|
|
|
|
|
|
def test_device_to_dict_preserves_non_default_ddp_fields():
|
|
from ledgrab.storage.device_store import Device
|
|
|
|
device = Device(
|
|
device_id="device_abc12345",
|
|
name="Custom",
|
|
url="ddp://192.168.1.42",
|
|
led_count=10,
|
|
device_type="ddp",
|
|
ddp_port=4048,
|
|
ddp_destination_id=2,
|
|
ddp_color_order=4,
|
|
)
|
|
|
|
payload = device.to_dict()
|
|
|
|
assert payload["ddp_port"] == 4048
|
|
assert payload["ddp_destination_id"] == 2
|
|
assert payload["ddp_color_order"] == 4
|
|
|
|
|
|
def test_device_from_dict_round_trip_ddp_fields():
|
|
from ledgrab.storage.device_store import Device
|
|
|
|
payload = {
|
|
"id": "device_abc12345",
|
|
"name": "Roundtrip",
|
|
"url": "ddp://192.168.1.42",
|
|
"led_count": 50,
|
|
"device_type": "ddp",
|
|
"ddp_port": 9999,
|
|
"ddp_destination_id": 5,
|
|
"ddp_color_order": 2,
|
|
}
|
|
|
|
restored = Device.from_dict(payload)
|
|
|
|
assert restored.ddp_port == 9999
|
|
assert restored.ddp_destination_id == 5
|
|
assert restored.ddp_color_order == 2
|