refactor: comprehensive code quality, security, and release readiness improvements
Some checks failed
Lint & Test / test (push) Failing after 48s

Security: tighten CORS defaults, add webhook rate limiting, fix XSS in
automations, guard WebSocket JSON.parse, validate ADB address input,
seal debug exception leak, URL-encode WS tokens, CSS.escape in selectors.

Code quality: add Pydantic models for brightness/power endpoints, fix
thread safety and name uniqueness in DeviceStore, immutable update
pattern, split 6 oversized files into 16 focused modules, enable
TypeScript strictNullChecks (741→102 errors), type state variables,
add dom-utils helper, migrate 3 modules from inline onclick to event
delegation, ProcessorDependencies dataclass.

Performance: async store saves, health endpoint log level, command
palette debounce, optimized entity-events comparison, fix service
worker precache list.

Testing: expand from 45 to 293 passing tests — add store tests (141),
route tests (25), core logic tests (42), E2E flow tests (33), organize
into tests/api/, tests/storage/, tests/core/, tests/e2e/.

DevOps: CI test pipeline, pre-commit config, Dockerfile multi-stage
build with non-root user and health check, docker-compose improvements,
version bump to 0.2.0.

Docs: rewrite CLAUDE.md (202→56 lines), server/CLAUDE.md (212→76),
create contexts/server-operations.md, fix .js→.ts references, fix env
var prefix in README, rewrite INSTALLATION.md, add CONTRIBUTING.md and
.env.example.
This commit is contained in:
2026-03-22 00:38:28 +03:00
parent 07bb89e9b7
commit f2871319cb
115 changed files with 9808 additions and 5818 deletions

View File

View File

@@ -0,0 +1,255 @@
"""Tests for AutomationStore — CRUD, conditions, name uniqueness."""
import pytest
from wled_controller.storage.automation import (
AlwaysCondition,
ApplicationCondition,
Automation,
Condition,
DisplayStateCondition,
MQTTCondition,
StartupCondition,
SystemIdleCondition,
TimeOfDayCondition,
WebhookCondition,
)
from wled_controller.storage.automation_store import AutomationStore
@pytest.fixture
def store(tmp_path) -> AutomationStore:
return AutomationStore(str(tmp_path / "automations.json"))
# ---------------------------------------------------------------------------
# Condition models
# ---------------------------------------------------------------------------
class TestConditionModels:
def test_always_round_trip(self):
c = AlwaysCondition()
data = c.to_dict()
restored = Condition.from_dict(data)
assert isinstance(restored, AlwaysCondition)
def test_application_round_trip(self):
c = ApplicationCondition(apps=["chrome.exe", "firefox.exe"], match_type="topmost")
data = c.to_dict()
restored = Condition.from_dict(data)
assert isinstance(restored, ApplicationCondition)
assert restored.apps == ["chrome.exe", "firefox.exe"]
assert restored.match_type == "topmost"
def test_time_of_day_round_trip(self):
c = TimeOfDayCondition(start_time="22:00", end_time="06:00")
data = c.to_dict()
restored = Condition.from_dict(data)
assert isinstance(restored, TimeOfDayCondition)
assert restored.start_time == "22:00"
assert restored.end_time == "06:00"
def test_system_idle_round_trip(self):
c = SystemIdleCondition(idle_minutes=10, when_idle=False)
data = c.to_dict()
restored = Condition.from_dict(data)
assert isinstance(restored, SystemIdleCondition)
assert restored.idle_minutes == 10
assert restored.when_idle is False
def test_display_state_round_trip(self):
c = DisplayStateCondition(state="off")
data = c.to_dict()
restored = Condition.from_dict(data)
assert isinstance(restored, DisplayStateCondition)
assert restored.state == "off"
def test_mqtt_round_trip(self):
c = MQTTCondition(topic="home/tv", payload="on", match_mode="contains")
data = c.to_dict()
restored = Condition.from_dict(data)
assert isinstance(restored, MQTTCondition)
assert restored.topic == "home/tv"
assert restored.match_mode == "contains"
def test_webhook_round_trip(self):
c = WebhookCondition(token="abc123")
data = c.to_dict()
restored = Condition.from_dict(data)
assert isinstance(restored, WebhookCondition)
assert restored.token == "abc123"
def test_startup_round_trip(self):
c = StartupCondition()
data = c.to_dict()
restored = Condition.from_dict(data)
assert isinstance(restored, StartupCondition)
def test_unknown_condition_type_raises(self):
with pytest.raises(ValueError, match="Unknown condition type"):
Condition.from_dict({"condition_type": "nonexistent"})
# ---------------------------------------------------------------------------
# Automation model
# ---------------------------------------------------------------------------
class TestAutomationModel:
def test_round_trip(self, make_automation):
auto = make_automation(
name="Test Auto",
conditions=[AlwaysCondition(), WebhookCondition(token="tok1")],
scene_preset_id="sp_123",
deactivation_mode="revert",
)
data = auto.to_dict()
restored = Automation.from_dict(data)
assert restored.id == auto.id
assert restored.name == "Test Auto"
assert len(restored.conditions) == 2
assert isinstance(restored.conditions[0], AlwaysCondition)
assert isinstance(restored.conditions[1], WebhookCondition)
assert restored.scene_preset_id == "sp_123"
assert restored.deactivation_mode == "revert"
def test_from_dict_skips_unknown_conditions(self):
data = {
"id": "a1",
"name": "Skip",
"enabled": True,
"condition_logic": "or",
"conditions": [
{"condition_type": "always"},
{"condition_type": "future_unknown"},
],
"scene_preset_id": None,
"deactivation_mode": "none",
"deactivation_scene_preset_id": None,
"created_at": "2025-01-01T00:00:00+00:00",
"updated_at": "2025-01-01T00:00:00+00:00",
}
auto = Automation.from_dict(data)
assert len(auto.conditions) == 1 # unknown was skipped
# ---------------------------------------------------------------------------
# AutomationStore CRUD
# ---------------------------------------------------------------------------
class TestAutomationStoreCRUD:
def test_create(self, store):
a = store.create_automation(name="Auto A")
assert a.id.startswith("auto_")
assert a.name == "Auto A"
assert a.enabled is True
assert a.condition_logic == "or"
assert store.count() == 1
def test_create_with_conditions(self, store):
conditions = [
AlwaysCondition(),
WebhookCondition(token="secret123"),
]
a = store.create_automation(
name="Full",
enabled=False,
condition_logic="and",
conditions=conditions,
scene_preset_id="sp_001",
deactivation_mode="fallback_scene",
deactivation_scene_preset_id="sp_002",
tags=["test"],
)
assert a.enabled is False
assert a.condition_logic == "and"
assert len(a.conditions) == 2
assert a.scene_preset_id == "sp_001"
assert a.tags == ["test"]
def test_get_all(self, store):
store.create_automation("A")
store.create_automation("B")
assert len(store.get_all_automations()) == 2
def test_get(self, store):
created = store.create_automation("Get")
got = store.get_automation(created.id)
assert got.name == "Get"
def test_delete(self, store):
a = store.create_automation("Del")
store.delete_automation(a.id)
assert store.count() == 0
def test_delete_not_found(self, store):
with pytest.raises(ValueError, match="not found"):
store.delete_automation("nope")
def test_update(self, store):
a = store.create_automation(name="Old", enabled=True)
updated = store.update_automation(a.id, name="New", enabled=False)
assert updated.name == "New"
assert updated.enabled is False
def test_update_conditions(self, store):
a = store.create_automation(name="Conds")
new_conds = [ApplicationCondition(apps=["notepad.exe"])]
updated = store.update_automation(a.id, conditions=new_conds)
assert len(updated.conditions) == 1
assert isinstance(updated.conditions[0], ApplicationCondition)
def test_update_scene_preset_id_clear(self, store):
a = store.create_automation(name="SP", scene_preset_id="sp_1")
updated = store.update_automation(a.id, scene_preset_id="")
assert updated.scene_preset_id is None
def test_update_partial(self, store):
a = store.create_automation(name="Partial", enabled=True, tags=["orig"])
updated = store.update_automation(a.id, tags=["new"])
assert updated.name == "Partial"
assert updated.enabled is True
assert updated.tags == ["new"]
# ---------------------------------------------------------------------------
# Name uniqueness
# ---------------------------------------------------------------------------
class TestAutomationNameUniqueness:
def test_duplicate_name_create(self, store):
store.create_automation("Dup")
with pytest.raises(ValueError, match="already exists"):
store.create_automation("Dup")
def test_duplicate_name_update(self, store):
store.create_automation("First")
a2 = store.create_automation("Second")
with pytest.raises(ValueError, match="already exists"):
store.update_automation(a2.id, name="First")
# ---------------------------------------------------------------------------
# Persistence
# ---------------------------------------------------------------------------
class TestAutomationPersistence:
def test_persist_and_reload(self, tmp_path):
path = str(tmp_path / "auto_persist.json")
s1 = AutomationStore(path)
a = s1.create_automation(
name="Persist",
conditions=[WebhookCondition(token="t1")],
)
aid = a.id
s2 = AutomationStore(path)
loaded = s2.get_automation(aid)
assert loaded.name == "Persist"
assert len(loaded.conditions) == 1
assert isinstance(loaded.conditions[0], WebhookCondition)

View File

@@ -0,0 +1,314 @@
"""Tests for BaseJsonStore — the shared data-layer base class."""
import json
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from pathlib import Path
import pytest
from wled_controller.storage.base_store import BaseJsonStore, EntityNotFoundError
# ---------------------------------------------------------------------------
# Minimal concrete store for testing the base class
# ---------------------------------------------------------------------------
@dataclass
class _Item:
id: str
name: str
value: int = 0
def to_dict(self) -> dict:
return {"id": self.id, "name": self.name, "value": self.value}
@staticmethod
def from_dict(data: dict) -> "_Item":
return _Item(id=data["id"], name=data["name"], value=data.get("value", 0))
class _TestStore(BaseJsonStore[_Item]):
_json_key = "items"
_entity_name = "Item"
def __init__(self, file_path: str):
super().__init__(file_path, _Item.from_dict)
def add(self, item: _Item) -> None:
with self._lock:
self._check_name_unique(item.name)
self._items[item.id] = item
self._save()
class _LegacyStore(BaseJsonStore[_Item]):
"""Store that supports legacy JSON keys for migration testing."""
_json_key = "items_v2"
_entity_name = "Item"
_legacy_json_keys = ["items_v1", "old_items"]
def __init__(self, file_path: str):
super().__init__(file_path, _Item.from_dict)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def store_file(tmp_path) -> Path:
return tmp_path / "test_store.json"
@pytest.fixture
def store(store_file) -> _TestStore:
return _TestStore(str(store_file))
# ---------------------------------------------------------------------------
# Initialization
# ---------------------------------------------------------------------------
class TestInit:
def test_empty_init(self, store):
assert store.count() == 0
assert store.get_all() == []
def test_file_not_found_starts_empty(self, tmp_path):
s = _TestStore(str(tmp_path / "missing.json"))
assert s.count() == 0
def test_load_from_existing_file(self, store_file):
data = {
"version": "1.0.0",
"items": {
"a": {"id": "a", "name": "Alpha", "value": 1},
"b": {"id": "b", "name": "Beta", "value": 2},
},
}
store_file.write_text(json.dumps(data), encoding="utf-8")
s = _TestStore(str(store_file))
assert s.count() == 2
assert s.get("a").name == "Alpha"
assert s.get("b").value == 2
def test_load_skips_corrupt_items(self, store_file):
"""Items that fail deserialization are skipped, not fatal."""
data = {
"version": "1.0.0",
"items": {
"good": {"id": "good", "name": "OK"},
"bad": {"missing_required": True},
},
}
store_file.write_text(json.dumps(data), encoding="utf-8")
s = _TestStore(str(store_file))
assert s.count() == 1
assert s.get("good").name == "OK"
def test_load_corrupt_json_raises(self, store_file):
"""Completely invalid JSON file raises on load."""
store_file.write_text("{bad json", encoding="utf-8")
with pytest.raises(Exception):
_TestStore(str(store_file))
# ---------------------------------------------------------------------------
# CRUD operations
# ---------------------------------------------------------------------------
class TestCRUD:
def test_get_all_returns_list(self, store):
store.add(_Item(id="x", name="X"))
items = store.get_all()
assert isinstance(items, list)
assert len(items) == 1
def test_get_existing(self, store):
store.add(_Item(id="x", name="X", value=42))
item = store.get("x")
assert item.id == "x"
assert item.value == 42
def test_get_not_found_raises(self, store):
with pytest.raises(EntityNotFoundError, match="not found"):
store.get("nonexistent")
def test_delete_existing(self, store):
store.add(_Item(id="x", name="X"))
store.delete("x")
assert store.count() == 0
def test_delete_not_found_raises(self, store):
with pytest.raises(EntityNotFoundError, match="not found"):
store.delete("nonexistent")
def test_count(self, store):
assert store.count() == 0
store.add(_Item(id="a", name="A"))
assert store.count() == 1
store.add(_Item(id="b", name="B"))
assert store.count() == 2
store.delete("a")
assert store.count() == 1
# ---------------------------------------------------------------------------
# Persistence (save/load round-trip)
# ---------------------------------------------------------------------------
class TestPersistence:
def test_save_and_reload(self, store_file):
s1 = _TestStore(str(store_file))
s1.add(_Item(id="p1", name="Persisted", value=99))
# Load fresh from the same file
s2 = _TestStore(str(store_file))
assert s2.count() == 1
assert s2.get("p1").value == 99
def test_delete_persists(self, store_file):
s1 = _TestStore(str(store_file))
s1.add(_Item(id="del", name="ToDelete"))
s1.delete("del")
s2 = _TestStore(str(store_file))
assert s2.count() == 0
def test_json_file_structure(self, store, store_file):
store.add(_Item(id="s1", name="Struct", value=7))
raw = json.loads(store_file.read_text(encoding="utf-8"))
assert "version" in raw
assert "items" in raw
assert raw["items"]["s1"]["name"] == "Struct"
# ---------------------------------------------------------------------------
# Name uniqueness
# ---------------------------------------------------------------------------
class TestNameUniqueness:
def test_duplicate_name_raises(self, store):
store.add(_Item(id="a", name="Unique"))
with pytest.raises(ValueError, match="already exists"):
store.add(_Item(id="b", name="Unique"))
def test_different_names_ok(self, store):
store.add(_Item(id="a", name="Alpha"))
store.add(_Item(id="b", name="Beta"))
assert store.count() == 2
def test_empty_name_raises(self, store):
with pytest.raises(ValueError, match="required"):
store._check_name_unique("")
def test_whitespace_name_raises(self, store):
with pytest.raises(ValueError, match="required"):
store._check_name_unique(" ")
def test_exclude_id_allows_self(self, store):
store.add(_Item(id="a", name="Alpha"))
# Checking uniqueness for a rename of item "a" — should not conflict with itself
store._check_name_unique("Alpha", exclude_id="a") # should not raise
# ---------------------------------------------------------------------------
# Thread safety
# ---------------------------------------------------------------------------
class TestThreadSafety:
def test_concurrent_reads(self, store):
for i in range(20):
store.add(_Item(id=f"t{i}", name=f"Thread {i}"))
results = []
def _read():
return store.count()
with ThreadPoolExecutor(max_workers=8) as pool:
futures = [pool.submit(_read) for _ in range(50)]
results = [f.result() for f in as_completed(futures)]
assert all(r == 20 for r in results)
def test_concurrent_add_and_read(self, tmp_path):
"""Concurrent adds should not lose items or corrupt state."""
s = _TestStore(str(tmp_path / "concurrent.json"))
errors = []
def _add(index):
try:
s.add(_Item(id=f"c{index}", name=f"Conc {index}"))
except Exception as e:
errors.append(e)
with ThreadPoolExecutor(max_workers=8) as pool:
futures = [pool.submit(_add, i) for i in range(30)]
for f in as_completed(futures):
f.result()
assert len(errors) == 0
assert s.count() == 30
# ---------------------------------------------------------------------------
# Legacy key migration
# ---------------------------------------------------------------------------
class TestLegacyKeyMigration:
def test_loads_from_legacy_key(self, store_file):
data = {
"version": "1.0.0",
"items_v1": {
"old1": {"id": "old1", "name": "Legacy"},
},
}
store_file.write_text(json.dumps(data), encoding="utf-8")
s = _LegacyStore(str(store_file))
assert s.count() == 1
assert s.get("old1").name == "Legacy"
def test_primary_key_takes_precedence(self, store_file):
data = {
"version": "1.0.0",
"items_v2": {"new": {"id": "new", "name": "Primary"}},
"items_v1": {"old": {"id": "old", "name": "Legacy"}},
}
store_file.write_text(json.dumps(data), encoding="utf-8")
s = _LegacyStore(str(store_file))
assert s.count() == 1
assert s.get("new").name == "Primary"
# ---------------------------------------------------------------------------
# Async delete
# ---------------------------------------------------------------------------
class TestAsyncDelete:
@pytest.mark.asyncio
async def test_async_delete(self, store):
store.add(_Item(id="ad", name="AsyncDel"))
assert store.count() == 1
await store.async_delete("ad")
assert store.count() == 0
@pytest.mark.asyncio
async def test_async_delete_not_found(self, store):
with pytest.raises(EntityNotFoundError, match="not found"):
await store.async_delete("nope")

View File

@@ -0,0 +1,285 @@
"""Tests for DeviceStore — device CRUD, persistence, name uniqueness, thread safety."""
import threading
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

View File

@@ -0,0 +1,211 @@
"""Tests for OutputTargetStore — CRUD for LED and key_colors targets."""
import pytest
from wled_controller.storage.output_target import OutputTarget
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.storage.wled_output_target import WledOutputTarget
from wled_controller.storage.key_colors_output_target import (
KeyColorsOutputTarget,
KeyColorsSettings,
)
@pytest.fixture
def store(tmp_path) -> OutputTargetStore:
return OutputTargetStore(str(tmp_path / "output_targets.json"))
# ---------------------------------------------------------------------------
# OutputTarget model dispatching
# ---------------------------------------------------------------------------
class TestOutputTargetModel:
def test_led_from_dict(self):
data = {
"id": "pt_1",
"name": "LED Target",
"target_type": "led",
"device_id": "dev_1",
"color_strip_source_id": "css_1",
"fps": 30,
"protocol": "ddp",
"created_at": "2025-01-01T00:00:00+00:00",
"updated_at": "2025-01-01T00:00:00+00:00",
}
target = OutputTarget.from_dict(data)
assert isinstance(target, WledOutputTarget)
assert target.device_id == "dev_1"
def test_key_colors_from_dict(self):
data = {
"id": "pt_2",
"name": "KC Target",
"target_type": "key_colors",
"picture_source_id": "ps_1",
"settings": {},
"created_at": "2025-01-01T00:00:00+00:00",
"updated_at": "2025-01-01T00:00:00+00:00",
}
target = OutputTarget.from_dict(data)
assert isinstance(target, KeyColorsOutputTarget)
def test_unknown_type_raises(self):
data = {
"id": "pt_3",
"name": "Bad",
"target_type": "nonexistent",
"created_at": "2025-01-01T00:00:00+00:00",
"updated_at": "2025-01-01T00:00:00+00:00",
}
with pytest.raises(ValueError, match="Unknown target type"):
OutputTarget.from_dict(data)
# ---------------------------------------------------------------------------
# OutputTargetStore CRUD
# ---------------------------------------------------------------------------
class TestOutputTargetStoreCRUD:
def test_create_led_target(self, store):
t = store.create_target(
name="LED 1",
target_type="led",
device_id="dev_1",
color_strip_source_id="css_1",
fps=60,
protocol="ddp",
)
assert t.id.startswith("pt_")
assert isinstance(t, WledOutputTarget)
assert t.name == "LED 1"
assert store.count() == 1
def test_create_key_colors_target(self, store):
t = store.create_target(
name="KC 1",
target_type="key_colors",
picture_source_id="ps_1",
)
assert isinstance(t, KeyColorsOutputTarget)
assert t.picture_source_id == "ps_1"
def test_create_invalid_type(self, store):
with pytest.raises(ValueError, match="Invalid target type"):
store.create_target(name="Bad", target_type="invalid")
def test_get_all(self, store):
store.create_target("A", "led")
store.create_target("B", "led")
assert len(store.get_all_targets()) == 2
def test_get(self, store):
created = store.create_target("Get", "led")
got = store.get_target(created.id)
assert got.name == "Get"
def test_delete(self, store):
t = store.create_target("Del", "led")
store.delete_target(t.id)
assert store.count() == 0
def test_delete_not_found(self, store):
with pytest.raises(ValueError, match="not found"):
store.delete_target("nope")
# ---------------------------------------------------------------------------
# Update
# ---------------------------------------------------------------------------
class TestOutputTargetUpdate:
def test_update_name(self, store):
t = store.create_target("Old", "led")
updated = store.update_target(t.id, name="New")
assert updated.name == "New"
def test_update_led_fields(self, store):
t = store.create_target("LED", "led", fps=30, protocol="ddp")
updated = store.update_target(t.id, fps=60, protocol="drgb")
assert isinstance(updated, WledOutputTarget)
assert updated.fps == 60
assert updated.protocol == "drgb"
def test_update_not_found(self, store):
with pytest.raises(ValueError, match="not found"):
store.update_target("nope", name="X")
def test_update_tags(self, store):
t = store.create_target("Tags", "led", tags=["old"])
updated = store.update_target(t.id, tags=["new", "tags"])
assert updated.tags == ["new", "tags"]
# ---------------------------------------------------------------------------
# Name uniqueness
# ---------------------------------------------------------------------------
class TestOutputTargetNameUniqueness:
def test_duplicate_name_create(self, store):
store.create_target("Same", "led")
with pytest.raises(ValueError, match="already exists"):
store.create_target("Same", "led")
def test_duplicate_name_update(self, store):
store.create_target("First", "led")
t2 = store.create_target("Second", "led")
with pytest.raises(ValueError, match="already exists"):
store.update_target(t2.id, name="First")
# ---------------------------------------------------------------------------
# Query helpers
# ---------------------------------------------------------------------------
class TestOutputTargetQueries:
def test_get_targets_for_device(self, store):
store.create_target("T1", "led", device_id="dev_a")
store.create_target("T2", "led", device_id="dev_b")
store.create_target("T3", "led", device_id="dev_a")
results = store.get_targets_for_device("dev_a")
assert len(results) == 2
assert all(isinstance(t, WledOutputTarget) for t in results)
def test_get_targets_for_device_empty(self, store):
assert store.get_targets_for_device("nonexistent") == []
def test_get_targets_referencing_css(self, store):
store.create_target("T1", "led", color_strip_source_id="css_x")
store.create_target("T2", "led", color_strip_source_id="css_y")
names = store.get_targets_referencing_css("css_x")
assert names == ["T1"]
# ---------------------------------------------------------------------------
# Persistence
# ---------------------------------------------------------------------------
class TestOutputTargetPersistence:
def test_persist_and_reload(self, tmp_path):
path = str(tmp_path / "ot_persist.json")
s1 = OutputTargetStore(path)
t = s1.create_target(
"Persist", "led",
device_id="dev_1",
fps=60,
tags=["tv"],
)
tid = t.id
s2 = OutputTargetStore(path)
loaded = s2.get_target(tid)
assert loaded.name == "Persist"
assert isinstance(loaded, WledOutputTarget)
assert loaded.tags == ["tv"]

View File

@@ -0,0 +1,160 @@
"""Tests for SyncClockStore — CRUD, speed clamping, name uniqueness."""
import pytest
from wled_controller.storage.sync_clock import SyncClock
from wled_controller.storage.sync_clock_store import SyncClockStore
@pytest.fixture
def store(tmp_path) -> SyncClockStore:
return SyncClockStore(str(tmp_path / "sync_clocks.json"))
# ---------------------------------------------------------------------------
# SyncClock model
# ---------------------------------------------------------------------------
class TestSyncClockModel:
def test_to_dict_round_trip(self, make_sync_clock):
clock = make_sync_clock(name="RT", speed=2.5, description="test", tags=["a"])
data = clock.to_dict()
restored = SyncClock.from_dict(data)
assert restored.id == clock.id
assert restored.name == "RT"
assert restored.speed == 2.5
assert restored.description == "test"
assert restored.tags == ["a"]
def test_from_dict_defaults(self):
data = {
"id": "sc_1",
"name": "Default",
"created_at": "2025-01-01T00:00:00+00:00",
"updated_at": "2025-01-01T00:00:00+00:00",
}
clock = SyncClock.from_dict(data)
assert clock.speed == 1.0
assert clock.description is None
assert clock.tags == []
# ---------------------------------------------------------------------------
# SyncClockStore CRUD
# ---------------------------------------------------------------------------
class TestSyncClockStoreCRUD:
def test_create_clock(self, store):
c = store.create_clock(name="Clock A")
assert c.id.startswith("sc_")
assert c.name == "Clock A"
assert c.speed == 1.0
assert store.count() == 1
def test_create_clock_with_options(self, store):
c = store.create_clock(
name="Fast", speed=5.0, description="speedy", tags=["anim"]
)
assert c.speed == 5.0
assert c.description == "speedy"
assert c.tags == ["anim"]
def test_get_clock(self, store):
created = store.create_clock(name="Get")
got = store.get_clock(created.id)
assert got.name == "Get"
def test_get_all_clocks(self, store):
store.create_clock("A")
store.create_clock("B")
assert len(store.get_all_clocks()) == 2
def test_delete_clock(self, store):
c = store.create_clock("Del")
store.delete_clock(c.id)
assert store.count() == 0
def test_delete_not_found(self, store):
with pytest.raises(ValueError, match="not found"):
store.delete_clock("nope")
def test_update_clock(self, store):
c = store.create_clock(name="Old", speed=1.0)
updated = store.update_clock(c.id, name="New", speed=3.0)
assert updated.name == "New"
assert updated.speed == 3.0
def test_update_clock_partial(self, store):
c = store.create_clock(name="Keep", speed=2.0, description="orig")
updated = store.update_clock(c.id, speed=4.0)
assert updated.name == "Keep"
assert updated.speed == 4.0
assert updated.description == "orig"
# ---------------------------------------------------------------------------
# Speed clamping
# ---------------------------------------------------------------------------
class TestSpeedClamping:
def test_create_clamps_low(self, store):
c = store.create_clock(name="Low", speed=0.01)
assert c.speed == 0.1
def test_create_clamps_high(self, store):
c = store.create_clock(name="High", speed=100.0)
assert c.speed == 10.0
def test_update_clamps_low(self, store):
c = store.create_clock(name="UL", speed=1.0)
updated = store.update_clock(c.id, speed=-5.0)
assert updated.speed == 0.1
def test_update_clamps_high(self, store):
c = store.create_clock(name="UH", speed=1.0)
updated = store.update_clock(c.id, speed=999.0)
assert updated.speed == 10.0
# ---------------------------------------------------------------------------
# Name uniqueness
# ---------------------------------------------------------------------------
class TestSyncClockNameUniqueness:
def test_duplicate_name_create(self, store):
store.create_clock("Same")
with pytest.raises(ValueError, match="already exists"):
store.create_clock("Same")
def test_duplicate_name_update(self, store):
store.create_clock("First")
c2 = store.create_clock("Second")
with pytest.raises(ValueError, match="already exists"):
store.update_clock(c2.id, name="First")
def test_rename_to_own_name_ok(self, store):
c = store.create_clock("Self")
updated = store.update_clock(c.id, name="Self", speed=9.0)
assert updated.speed == 9.0
# ---------------------------------------------------------------------------
# Persistence
# ---------------------------------------------------------------------------
class TestSyncClockPersistence:
def test_persist_and_reload(self, tmp_path):
path = str(tmp_path / "sc_persist.json")
s1 = SyncClockStore(path)
c = s1.create_clock(name="Persist", speed=2.5)
cid = c.id
s2 = SyncClockStore(path)
loaded = s2.get_clock(cid)
assert loaded.name == "Persist"
assert loaded.speed == 2.5

View File

@@ -0,0 +1,259 @@
"""Tests for ValueSourceStore — CRUD for all source types."""
import pytest
from wled_controller.storage.value_source import (
AdaptiveValueSource,
AnimatedValueSource,
AudioValueSource,
DaylightValueSource,
StaticValueSource,
ValueSource,
)
from wled_controller.storage.value_source_store import ValueSourceStore
@pytest.fixture
def store(tmp_path) -> ValueSourceStore:
return ValueSourceStore(str(tmp_path / "value_sources.json"))
# ---------------------------------------------------------------------------
# ValueSource model round-trips
# ---------------------------------------------------------------------------
class TestValueSourceModels:
def test_static_round_trip(self):
data = {
"id": "vs_1",
"name": "Static",
"source_type": "static",
"value": 0.75,
"created_at": "2025-01-01T00:00:00+00:00",
"updated_at": "2025-01-01T00:00:00+00:00",
}
src = ValueSource.from_dict(data)
assert isinstance(src, StaticValueSource)
assert src.value == 0.75
restored = ValueSource.from_dict(src.to_dict())
assert restored.value == 0.75
def test_animated_round_trip(self):
data = {
"id": "vs_2",
"name": "Wave",
"source_type": "animated",
"waveform": "triangle",
"speed": 30.0,
"min_value": 0.2,
"max_value": 0.8,
"created_at": "2025-01-01T00:00:00+00:00",
"updated_at": "2025-01-01T00:00:00+00:00",
}
src = ValueSource.from_dict(data)
assert isinstance(src, AnimatedValueSource)
assert src.waveform == "triangle"
assert src.speed == 30.0
def test_audio_round_trip(self):
data = {
"id": "vs_3",
"name": "Audio",
"source_type": "audio",
"audio_source_id": "as_1",
"mode": "peak",
"sensitivity": 2.0,
"smoothing": 0.5,
"auto_gain": True,
"created_at": "2025-01-01T00:00:00+00:00",
"updated_at": "2025-01-01T00:00:00+00:00",
}
src = ValueSource.from_dict(data)
assert isinstance(src, AudioValueSource)
assert src.mode == "peak"
assert src.auto_gain is True
def test_adaptive_time_round_trip(self):
data = {
"id": "vs_4",
"name": "Time",
"source_type": "adaptive_time",
"schedule": [
{"time": "00:00", "value": 0.1},
{"time": "12:00", "value": 1.0},
],
"created_at": "2025-01-01T00:00:00+00:00",
"updated_at": "2025-01-01T00:00:00+00:00",
}
src = ValueSource.from_dict(data)
assert isinstance(src, AdaptiveValueSource)
assert len(src.schedule) == 2
def test_adaptive_scene_round_trip(self):
data = {
"id": "vs_5",
"name": "Scene",
"source_type": "adaptive_scene",
"picture_source_id": "ps_1",
"scene_behavior": "match",
"created_at": "2025-01-01T00:00:00+00:00",
"updated_at": "2025-01-01T00:00:00+00:00",
}
src = ValueSource.from_dict(data)
assert isinstance(src, AdaptiveValueSource)
assert src.scene_behavior == "match"
def test_daylight_round_trip(self):
data = {
"id": "vs_6",
"name": "Daylight",
"source_type": "daylight",
"speed": 2.0,
"use_real_time": True,
"latitude": 55.0,
"created_at": "2025-01-01T00:00:00+00:00",
"updated_at": "2025-01-01T00:00:00+00:00",
}
src = ValueSource.from_dict(data)
assert isinstance(src, DaylightValueSource)
assert src.use_real_time is True
assert src.latitude == 55.0
def test_unknown_type_defaults_to_static(self):
data = {
"id": "vs_u",
"name": "Unknown",
"source_type": "unknown_future",
"value": 0.5,
"created_at": "2025-01-01T00:00:00+00:00",
"updated_at": "2025-01-01T00:00:00+00:00",
}
src = ValueSource.from_dict(data)
assert isinstance(src, StaticValueSource)
# ---------------------------------------------------------------------------
# ValueSourceStore CRUD
# ---------------------------------------------------------------------------
class TestValueSourceStoreCRUD:
def test_create_static(self, store):
s = store.create_source(name="S1", source_type="static", value=0.5)
assert s.id.startswith("vs_")
assert isinstance(s, StaticValueSource)
assert s.value == 0.5
assert store.count() == 1
def test_create_animated(self, store):
s = store.create_source(
name="A1", source_type="animated",
waveform="sawtooth", speed=20.0,
)
assert isinstance(s, AnimatedValueSource)
assert s.waveform == "sawtooth"
assert s.speed == 20.0
def test_create_audio(self, store):
s = store.create_source(
name="Au1", source_type="audio",
audio_source_id="as_1", mode="beat",
)
assert isinstance(s, AudioValueSource)
assert s.mode == "beat"
def test_create_adaptive_time(self, store):
schedule = [
{"time": "08:00", "value": 0.5},
{"time": "20:00", "value": 1.0},
]
s = store.create_source(
name="AT", source_type="adaptive_time", schedule=schedule,
)
assert isinstance(s, AdaptiveValueSource)
assert len(s.schedule) == 2
def test_create_adaptive_time_insufficient_schedule(self, store):
with pytest.raises(ValueError, match="at least 2 points"):
store.create_source(
name="Bad", source_type="adaptive_time",
schedule=[{"time": "12:00", "value": 0.5}],
)
def test_create_daylight(self, store):
s = store.create_source(
name="DL", source_type="daylight",
speed=2.0, use_real_time=True, latitude=48.0,
)
assert isinstance(s, DaylightValueSource)
assert s.use_real_time is True
def test_create_invalid_type(self, store):
with pytest.raises(ValueError, match="Invalid source type"):
store.create_source(name="Bad", source_type="invalid")
def test_get_all(self, store):
store.create_source("A", "static")
store.create_source("B", "static")
assert len(store.get_all_sources()) == 2
def test_get(self, store):
created = store.create_source("Get", "static", value=0.3)
got = store.get_source(created.id)
assert got.name == "Get"
def test_delete(self, store):
s = store.create_source("Del", "static")
store.delete_source(s.id)
assert store.count() == 0
def test_update_static(self, store):
s = store.create_source("Stat", "static", value=0.5)
updated = store.update_source(s.id, value=0.9)
assert isinstance(updated, StaticValueSource)
assert updated.value == 0.9
def test_update_name(self, store):
s = store.create_source("Old", "static")
updated = store.update_source(s.id, name="New")
assert updated.name == "New"
def test_update_animated_fields(self, store):
s = store.create_source("Anim", "animated", waveform="sine", speed=10.0)
updated = store.update_source(s.id, waveform="square", speed=30.0)
assert isinstance(updated, AnimatedValueSource)
assert updated.waveform == "square"
assert updated.speed == 30.0
# ---------------------------------------------------------------------------
# Name uniqueness
# ---------------------------------------------------------------------------
class TestValueSourceNameUniqueness:
def test_duplicate_name(self, store):
store.create_source("Same", "static")
with pytest.raises(ValueError, match="already exists"):
store.create_source("Same", "animated")
# ---------------------------------------------------------------------------
# Persistence
# ---------------------------------------------------------------------------
class TestValueSourcePersistence:
def test_persist_and_reload(self, tmp_path):
path = str(tmp_path / "vs_persist.json")
s1 = ValueSourceStore(path)
src = s1.create_source("Persist", "static", value=0.42)
sid = src.id
s2 = ValueSourceStore(path)
loaded = s2.get_source(sid)
assert loaded.name == "Persist"
assert isinstance(loaded, StaticValueSource)
assert loaded.value == 0.42