feat: new value source types (HA entity, gradient map, strip extract) + UI fixes
Lint & Test / test (push) Successful in 1m27s

New value source types:
- ha_entity: reads numeric values from HA entity state/attribute, normalizes
  via min/max range, applies EMA smoothing. EntitySelect for HA connection
  and entity selection with live entity list fetching.
- gradient_map: maps a float value source (0-1) through a gradient entity.
  EntitySelect for both input source and gradient with inline previews.
- css_extract: extracts single color by averaging LED range from a color
  strip source. EntitySelect for source selection.

Value source type picker:
- Filter tabs (All / Numeric / Color) above the icon grid
- showTypePicker extended with filterTabs + onFilterChange support

Palette selectors converted to EntitySelect:
- Effect palette, gradient preset, and audio palette selectors now use
  command-palette style EntitySelect with gradient strip previews

Tab indicator fixes:
- Icon now updates on tab switch (was passing no args to updateTabIndicator)
- Visible with any background effect active, not just Noise Field
- Noise Field is the default background effect for new users

Dashboard section collapse fix:
- Split header into clickable toggle (chevron+label) and non-clickable
  actions area — buttons no longer trigger collapse/expand

Discriminated union fix (422 errors):
- source_type/target_type now always included in update payloads for:
  CSS editor, LED target, HA light target, simple calibration,
  advanced calibration
This commit is contained in:
2026-03-29 20:38:22 +03:00
parent ea812bb4d5
commit 384362ccf1
61 changed files with 5367 additions and 1620 deletions
+28 -20
View File
@@ -5,18 +5,20 @@ create device -> create target -> list -> update -> delete target -> cleanup dev
"""
class TestOutputTargetLifecycle:
"""A user wires up an output target to a device."""
def _create_device(self, client) -> str:
"""Helper: create a mock device and return its ID."""
resp = client.post("/api/v1/devices", json={
"name": "Target Test Device",
"url": "mock://target-test",
"device_type": "mock",
"led_count": 60,
})
resp = client.post(
"/api/v1/devices",
json={
"name": "Target Test Device",
"url": "mock://target-test",
"device_type": "mock",
"led_count": 60,
},
)
assert resp.status_code == 201
return resp.json()["id"]
@@ -44,7 +46,7 @@ class TestOutputTargetLifecycle:
assert target["name"] == "E2E Test Target"
assert target["device_id"] == device_id
assert target["target_type"] == "led"
assert target["fps"] == 30
assert target["fps"] == 30.0
assert target["protocol"] == "ddp"
# 3. List targets -- should contain the new target
@@ -62,12 +64,12 @@ class TestOutputTargetLifecycle:
# 5. Update the target -- change name and fps
resp = client.put(
f"/api/v1/output-targets/{target_id}",
json={"name": "Updated Target", "fps": 60},
json={"target_type": "led", "name": "Updated Target", "fps": 60},
)
assert resp.status_code == 200
updated = resp.json()
assert updated["name"] == "Updated Target"
assert updated["fps"] == 60
assert updated["fps"] == 60.0
# 6. Verify update via GET
resp = client.get(f"/api/v1/output-targets/{target_id}")
@@ -90,11 +92,14 @@ class TestOutputTargetLifecycle:
"""Deleting a device that has a target should return 409."""
device_id = self._create_device(client)
resp = client.post("/api/v1/output-targets", json={
"name": "Blocking Target",
"target_type": "led",
"device_id": device_id,
})
resp = client.post(
"/api/v1/output-targets",
json={
"name": "Blocking Target",
"target_type": "led",
"device_id": device_id,
},
)
assert resp.status_code == 201
target_id = resp.json()["id"]
@@ -111,11 +116,14 @@ class TestOutputTargetLifecycle:
def test_create_target_with_invalid_device_returns_422(self, client):
"""Creating a target with a non-existent device_id returns 422."""
resp = client.post("/api/v1/output-targets", json={
"name": "Orphan Target",
"target_type": "led",
"device_id": "nonexistent_device",
})
resp = client.post(
"/api/v1/output-targets",
json={
"name": "Orphan Target",
"target_type": "led",
"device_id": "nonexistent_device",
},
)
assert resp.status_code == 422
def test_get_nonexistent_target_returns_404(self, client):