"""Tests for DeviceStore — device CRUD, persistence, name uniqueness, thread safety.""" from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path import pytest from wled_controller.storage.device_store import Device, DeviceStore # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def temp_storage(tmp_path) -> Path: return tmp_path / "devices.json" @pytest.fixture def store(temp_storage) -> DeviceStore: return DeviceStore(temp_storage) # --------------------------------------------------------------------------- # Device model # --------------------------------------------------------------------------- class TestDeviceModel: def test_creation_defaults(self): d = Device(device_id="d1", name="D", url="http://1.2.3.4", led_count=100) assert d.id == "d1" assert d.enabled is True assert d.device_type == "wled" assert d.software_brightness == 255 assert d.rgbw is False assert d.zone_mode == "combined" assert d.tags == [] def test_creation_with_all_fields(self): d = Device( device_id="d2", name="Full", url="http://1.2.3.4", led_count=300, enabled=False, device_type="adalight", baud_rate=115200, software_brightness=128, auto_shutdown=True, send_latency_ms=10, rgbw=True, zone_mode="individual", tags=["living", "tv"], dmx_protocol="sacn", dmx_start_universe=1, dmx_start_channel=5, ) assert d.enabled is False assert d.baud_rate == 115200 assert d.rgbw is True assert d.tags == ["living", "tv"] assert d.dmx_protocol == "sacn" def test_to_dict_round_trip(self): original = Device( device_id="rt1", name="RoundTrip", url="http://10.0.0.1", led_count=60, rgbw=True, tags=["test"], ) data = original.to_dict() restored = Device.from_dict(data) assert restored.id == original.id assert restored.name == original.name assert restored.url == original.url assert restored.led_count == original.led_count assert restored.rgbw == original.rgbw assert restored.tags == original.tags def test_to_dict_omits_defaults(self): """Fields at their default value should be omitted from to_dict for compactness.""" d = Device(device_id="d", name="D", url="http://x", led_count=10) data = d.to_dict() assert "baud_rate" not in data assert "rgbw" not in data assert "tags" not in data def test_to_dict_includes_non_defaults(self): d = Device( device_id="d", name="D", url="http://x", led_count=10, rgbw=True, tags=["a"], software_brightness=100, ) data = d.to_dict() assert data["rgbw"] is True assert data["tags"] == ["a"] assert data["software_brightness"] == 100 def test_from_dict_missing_optional_fields(self): """from_dict should handle minimal data gracefully.""" data = {"id": "m1", "name": "Minimal", "url": "http://x", "led_count": 10} d = Device.from_dict(data) assert d.enabled is True assert d.device_type == "wled" assert d.tags == [] # --------------------------------------------------------------------------- # DeviceStore CRUD # --------------------------------------------------------------------------- class TestDeviceStoreCRUD: def test_init_empty(self, store): assert store.count() == 0 def test_create_device(self, store): d = store.create_device(name="Test", url="http://1.2.3.4", led_count=100) assert d.id.startswith("device_") assert d.name == "Test" assert store.count() == 1 def test_create_device_with_options(self, store): d = store.create_device( name="Full", url="http://1.2.3.4", led_count=200, device_type="adalight", baud_rate=115200, auto_shutdown=True, rgbw=True, tags=["bedroom"], ) assert d.device_type == "adalight" assert d.baud_rate == 115200 assert d.auto_shutdown is True assert d.rgbw is True assert d.tags == ["bedroom"] def test_create_mock_device_url(self, store): d = store.create_device( name="MockDev", url="http://whatever", led_count=10, device_type="mock" ) assert d.url.startswith("mock://") def test_get_device(self, store): created = store.create_device(name="Get", url="http://x", led_count=50) got = store.get_device(created.id) assert got.name == "Get" assert got.led_count == 50 def test_get_device_not_found(self, store): with pytest.raises(ValueError, match="not found"): store.get_device("no_such_id") def test_get_all_devices(self, store): store.create_device("A", "http://a", 10) store.create_device("B", "http://b", 20) all_devices = store.get_all_devices() assert len(all_devices) == 2 names = {d.name for d in all_devices} assert names == {"A", "B"} def test_update_device(self, store): d = store.create_device(name="Old", url="http://x", led_count=100) updated = store.update_device(d.id, name="New", led_count=200) assert updated.name == "New" assert updated.led_count == 200 assert updated.id == d.id def test_update_device_ignores_none(self, store): d = store.create_device(name="Keep", url="http://x", led_count=100) updated = store.update_device(d.id, name=None, led_count=200) assert updated.name == "Keep" assert updated.led_count == 200 def test_update_device_ignores_unknown_fields(self, store): d = store.create_device(name="Unk", url="http://x", led_count=100) updated = store.update_device(d.id, bogus_field="ignored") assert updated.name == "Unk" def test_update_device_not_found(self, store): with pytest.raises(ValueError, match="not found"): store.update_device("missing", name="X") def test_delete_device(self, store): d = store.create_device(name="Del", url="http://x", led_count=50) store.delete_device(d.id) assert store.count() == 0 with pytest.raises(ValueError, match="not found"): store.get_device(d.id) def test_delete_device_not_found(self, store): with pytest.raises(ValueError, match="not found"): store.delete_device("missing") def test_device_exists(self, store): d = store.create_device(name="E", url="http://x", led_count=10) assert store.device_exists(d.id) is True assert store.device_exists("nope") is False def test_clear(self, store): store.create_device("A", "http://a", 10) store.create_device("B", "http://b", 20) store.clear() assert store.count() == 0 # --------------------------------------------------------------------------- # Name uniqueness # --------------------------------------------------------------------------- class TestDeviceNameUniqueness: def test_duplicate_name_on_create(self, store): store.create_device(name="Same", url="http://a", led_count=10) with pytest.raises(ValueError, match="already exists"): store.create_device(name="Same", url="http://b", led_count=10) def test_duplicate_name_on_update(self, store): store.create_device(name="First", url="http://a", led_count=10) d2 = store.create_device(name="Second", url="http://b", led_count=10) with pytest.raises(ValueError, match="already exists"): store.update_device(d2.id, name="First") def test_rename_to_own_name_ok(self, store): d = store.create_device(name="Self", url="http://a", led_count=10) updated = store.update_device(d.id, name="Self", led_count=99) assert updated.led_count == 99 # --------------------------------------------------------------------------- # Persistence # --------------------------------------------------------------------------- class TestDevicePersistence: def test_persistence_across_instances(self, temp_storage): s1 = DeviceStore(temp_storage) d = s1.create_device(name="Persist", url="http://p", led_count=77) did = d.id s2 = DeviceStore(temp_storage) loaded = s2.get_device(did) assert loaded.name == "Persist" assert loaded.led_count == 77 def test_update_persists(self, temp_storage): s1 = DeviceStore(temp_storage) d = s1.create_device(name="Before", url="http://x", led_count=10) s1.update_device(d.id, name="After") s2 = DeviceStore(temp_storage) assert s2.get_device(d.id).name == "After" # --------------------------------------------------------------------------- # Thread safety # --------------------------------------------------------------------------- class TestDeviceThreadSafety: def test_concurrent_creates(self, tmp_path): s = DeviceStore(tmp_path / "conc.json") errors = [] def _create(i): try: s.create_device(name=f"Dev {i}", url=f"http://{i}", led_count=10) except Exception as e: errors.append(e) with ThreadPoolExecutor(max_workers=8) as pool: futures = [pool.submit(_create, i) for i in range(25)] for f in as_completed(futures): f.result() assert len(errors) == 0 assert s.count() == 25