Files
ledgrab/server/tests/storage/test_output_target_store.py
T
alexei.dolgolyov a79f4bf73c feat(ha-light): broadcast a single Color Value Source to all entities
HALightOutputTarget gains a `source_kind` field with two modes:
- `css` (existing): per-mapping LED segments averaged from a ColorStripSource.
- `color_vs` (new): one colour from a colour-returning ValueSource pushed to
  every mapped entity (mapping LED ranges are ignored in this mode).

Backend wiring:
- Schema/route: add `source_kind` + `color_value_source_id` to create/update/
  response payloads, with VS existence + return_type=color validation.
- Storage: persist new fields, with defensive `or ""` coalesce so legacy rows
  written via resolve_ref with None survive the str-typed response schema.
- Processor: ha_light_target_processor reworked to drive both source kinds
  (incl. update_target_settings hot-swap of source mode). New unit tests in
  tests/core/test_ha_light_target_processor.py and extended store tests.

Frontend:
- ha-light editor modal: collapsed Color Strip + Color VS into one
  "Color Source" picker with grouped headers; mappings list shows a
  mode-aware hint when broadcasting a single colour.
- EntityPalette: support non-selectable header rows (with keyboard / filter
  handling) for grouped source pickers.

Bundled UI polish (icon inheritance + cleanup):
- Custom card icons now flow into more surfaces: command palette, dashboard
  target cards, scene-preset target picker, calibration test-device picker,
  and the LED-target device picker. LED targets inherit their device's icon
  when none is set on the target itself.
- Empty mod-card icon plates render as a dashed "+" placeholder when an
  icon-picker hook is wired, so the action stays discoverable.
- Icon picker: distinct "HA light target" eyebrow label and supports
  HA-light cards (data-ha-target-id) for channel-colour resolution.
- Update banner: "View release" now opens the in-app Update settings tab
  instead of an external link; uses the sparkles icon.
- Color-strip delete: cleaner toast on 409 conflict.
2026-05-04 14:27:22 +03:00

287 lines
10 KiB
Python

"""Tests for OutputTargetStore — CRUD for LED and HA light targets."""
import pytest
from ledgrab.storage.ha_light_output_target import HALightMapping, HALightOutputTarget
from ledgrab.storage.output_target import OutputTarget
from ledgrab.storage.output_target_store import OutputTargetStore
from ledgrab.storage.wled_output_target import WledOutputTarget
@pytest.fixture
def store(tmp_db) -> OutputTargetStore:
return OutputTargetStore(tmp_db)
# ---------------------------------------------------------------------------
# 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_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_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.value == 60.0
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):
from ledgrab.storage.database import Database
db_path = str(tmp_path / "ot_persist.db")
db = Database(db_path)
s1 = OutputTargetStore(db)
t = s1.create_target(
"Persist",
"led",
device_id="dev_1",
fps=60,
tags=["tv"],
)
tid = t.id
s2 = OutputTargetStore(db)
loaded = s2.get_target(tid)
assert loaded.name == "Persist"
assert isinstance(loaded, WledOutputTarget)
assert loaded.tags == ["tv"]
db.close()
# ---------------------------------------------------------------------------
# HA-light dual-mode source: CSS and color value source
# ---------------------------------------------------------------------------
class TestHALightSourceModes:
def test_legacy_payload_defaults_to_css_kind(self):
"""Records persisted before source_kind existed must load as CSS mode."""
data = {
"id": "pt_legacy",
"name": "Legacy HA light",
"target_type": "ha_light",
"ha_source_id": "ha_1",
"color_strip_source_id": "css_1",
"light_mappings": [],
"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, HALightOutputTarget)
assert target.source_kind == "css"
assert target.color_strip_source_id == "css_1"
assert target.color_value_source_id == ""
def test_invalid_source_kind_falls_back_to_css(self):
data = {
"id": "pt_bad_kind",
"name": "Bad kind",
"target_type": "ha_light",
"ha_source_id": "ha_1",
"source_kind": "garbage",
"color_strip_source_id": "css_1",
"created_at": "2025-01-01T00:00:00+00:00",
"updated_at": "2025-01-01T00:00:00+00:00",
}
target = OutputTarget.from_dict(data)
assert target.source_kind == "css"
def test_create_ha_light_color_vs(self, store):
target = store.create_target(
"Mood lamp",
"ha_light",
ha_source_id="ha_1",
source_kind="color_vs",
color_value_source_id="vs_static_red",
ha_light_mappings=[HALightMapping(entity_id="light.lamp")],
)
assert isinstance(target, HALightOutputTarget)
assert target.source_kind == "color_vs"
assert target.color_value_source_id == "vs_static_red"
# Round-trip via dict
assert target.to_dict()["source_kind"] == "color_vs"
def test_round_trip_color_vs_persists(self, tmp_path):
from ledgrab.storage.database import Database
db_path = str(tmp_path / "ha_color_vs.db")
db = Database(db_path)
s1 = OutputTargetStore(db)
t = s1.create_target(
"Sunrise",
"ha_light",
ha_source_id="ha_1",
source_kind="color_vs",
color_value_source_id="vs_anim_1",
ha_light_mappings=[HALightMapping(entity_id="light.bed")],
)
s2 = OutputTargetStore(db)
loaded = s2.get_target(t.id)
assert isinstance(loaded, HALightOutputTarget)
assert loaded.source_kind == "color_vs"
assert loaded.color_value_source_id == "vs_anim_1"
db.close()
def test_update_switches_source_kind(self, store):
target = store.create_target(
"Switcher",
"ha_light",
ha_source_id="ha_1",
color_strip_source_id="css_1",
ha_light_mappings=[HALightMapping(entity_id="light.x")],
)
assert target.source_kind == "css"
updated = store.update_target(
target.id,
source_kind="color_vs",
color_value_source_id="vs_color_1",
)
assert isinstance(updated, HALightOutputTarget)
assert updated.source_kind == "color_vs"
assert updated.color_value_source_id == "vs_color_1"
# CSS id is preserved (non-destructive switch)
assert updated.color_strip_source_id == "css_1"