refactor: comprehensive code quality, security, and release readiness improvements
Some checks failed
Lint & Test / test (push) Failing after 48s
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:
0
server/tests/storage/__init__.py
Normal file
0
server/tests/storage/__init__.py
Normal file
255
server/tests/storage/test_automation_store.py
Normal file
255
server/tests/storage/test_automation_store.py
Normal 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)
|
||||
314
server/tests/storage/test_base_store.py
Normal file
314
server/tests/storage/test_base_store.py
Normal 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")
|
||||
285
server/tests/storage/test_device_store.py
Normal file
285
server/tests/storage/test_device_store.py
Normal 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
|
||||
211
server/tests/storage/test_output_target_store.py
Normal file
211
server/tests/storage/test_output_target_store.py
Normal 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"]
|
||||
160
server/tests/storage/test_sync_clock_store.py
Normal file
160
server/tests/storage/test_sync_clock_store.py
Normal 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
|
||||
259
server/tests/storage/test_value_source_store.py
Normal file
259
server/tests/storage/test_value_source_store.py
Normal 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
|
||||
Reference in New Issue
Block a user