feat: new value source types (HA entity, gradient map, strip extract) + UI fixes
Lint & Test / test (push) Successful in 1m27s
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:
@@ -4,19 +4,21 @@ Tests creating, listing, updating, cloning, and deleting color strip sources.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
class TestColorStripSourceLifecycle:
|
||||
"""A user manages color strip sources for LED effects."""
|
||||
|
||||
def test_static_and_gradient_crud(self, client):
|
||||
# 1. Create a static color strip source
|
||||
resp = client.post("/api/v1/color-strip-sources", json={
|
||||
"name": "Red Static",
|
||||
"source_type": "static",
|
||||
"color": [255, 0, 0],
|
||||
"led_count": 60,
|
||||
"tags": ["e2e", "static"],
|
||||
})
|
||||
resp = client.post(
|
||||
"/api/v1/color-strip-sources",
|
||||
json={
|
||||
"name": "Red Static",
|
||||
"source_type": "static",
|
||||
"color": [255, 0, 0],
|
||||
"led_count": 60,
|
||||
"tags": ["e2e", "static"],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, f"Create static failed: {resp.text}"
|
||||
static = resp.json()
|
||||
static_id = static["id"]
|
||||
@@ -25,15 +27,18 @@ class TestColorStripSourceLifecycle:
|
||||
assert static["color"] == [255, 0, 0]
|
||||
|
||||
# 2. Create a gradient color strip source
|
||||
resp = client.post("/api/v1/color-strip-sources", json={
|
||||
"name": "Blue-Green Gradient",
|
||||
"source_type": "gradient",
|
||||
"stops": [
|
||||
{"position": 0.0, "color": [0, 0, 255]},
|
||||
{"position": 1.0, "color": [0, 255, 0]},
|
||||
],
|
||||
"led_count": 60,
|
||||
})
|
||||
resp = client.post(
|
||||
"/api/v1/color-strip-sources",
|
||||
json={
|
||||
"name": "Blue-Green Gradient",
|
||||
"source_type": "gradient",
|
||||
"stops": [
|
||||
{"position": 0.0, "color": [0, 0, 255]},
|
||||
{"position": 1.0, "color": [0, 255, 0]},
|
||||
],
|
||||
"led_count": 60,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, f"Create gradient failed: {resp.text}"
|
||||
gradient = resp.json()
|
||||
gradient_id = gradient["id"]
|
||||
@@ -53,7 +58,7 @@ class TestColorStripSourceLifecycle:
|
||||
# 4. Update the static source -- change color
|
||||
resp = client.put(
|
||||
f"/api/v1/color-strip-sources/{static_id}",
|
||||
json={"color": [0, 255, 0]},
|
||||
json={"source_type": "static", "color": [0, 255, 0]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["color"] == [0, 255, 0]
|
||||
@@ -64,12 +69,15 @@ class TestColorStripSourceLifecycle:
|
||||
assert resp.json()["color"] == [0, 255, 0]
|
||||
|
||||
# 6. Clone by creating another source with same data, different name
|
||||
resp = client.post("/api/v1/color-strip-sources", json={
|
||||
"name": "Cloned Static",
|
||||
"source_type": "static",
|
||||
"color": [0, 255, 0],
|
||||
"led_count": 60,
|
||||
})
|
||||
resp = client.post(
|
||||
"/api/v1/color-strip-sources",
|
||||
json={
|
||||
"name": "Cloned Static",
|
||||
"source_type": "static",
|
||||
"color": [0, 255, 0],
|
||||
"led_count": 60,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
clone_id = resp.json()["id"]
|
||||
assert clone_id != static_id
|
||||
@@ -87,17 +95,20 @@ class TestColorStripSourceLifecycle:
|
||||
|
||||
def test_update_name(self, client):
|
||||
"""Renaming a color strip source persists."""
|
||||
resp = client.post("/api/v1/color-strip-sources", json={
|
||||
"name": "Original Name",
|
||||
"source_type": "static",
|
||||
"color": [100, 100, 100],
|
||||
"led_count": 10,
|
||||
})
|
||||
resp = client.post(
|
||||
"/api/v1/color-strip-sources",
|
||||
json={
|
||||
"name": "Original Name",
|
||||
"source_type": "static",
|
||||
"color": [100, 100, 100],
|
||||
"led_count": 10,
|
||||
},
|
||||
)
|
||||
source_id = resp.json()["id"]
|
||||
|
||||
resp = client.put(
|
||||
f"/api/v1/color-strip-sources/{source_id}",
|
||||
json={"name": "New Name"},
|
||||
json={"source_type": "static", "name": "New Name"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "New Name"
|
||||
@@ -126,12 +137,15 @@ class TestColorStripSourceLifecycle:
|
||||
|
||||
def test_color_cycle_source(self, client):
|
||||
"""Color cycle sources store and return their color list."""
|
||||
resp = client.post("/api/v1/color-strip-sources", json={
|
||||
"name": "Rainbow Cycle",
|
||||
"source_type": "color_cycle",
|
||||
"colors": [[255, 0, 0], [0, 255, 0], [0, 0, 255]],
|
||||
"led_count": 30,
|
||||
})
|
||||
resp = client.post(
|
||||
"/api/v1/color-strip-sources",
|
||||
json={
|
||||
"name": "Rainbow Cycle",
|
||||
"source_type": "color_cycle",
|
||||
"colors": [[255, 0, 0], [0, 255, 0], [0, 0, 255]],
|
||||
"led_count": 30,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["source_type"] == "color_cycle"
|
||||
@@ -139,14 +153,17 @@ class TestColorStripSourceLifecycle:
|
||||
|
||||
def test_effect_source(self, client):
|
||||
"""Effect sources store their effect parameters."""
|
||||
resp = client.post("/api/v1/color-strip-sources", json={
|
||||
"name": "Fire Effect",
|
||||
"source_type": "effect",
|
||||
"effect_type": "fire",
|
||||
"palette": "fire",
|
||||
"intensity": 1.5,
|
||||
"led_count": 60,
|
||||
})
|
||||
resp = client.post(
|
||||
"/api/v1/color-strip-sources",
|
||||
json={
|
||||
"name": "Fire Effect",
|
||||
"source_type": "effect",
|
||||
"effect_type": "fire",
|
||||
"palette": "fire",
|
||||
"intensity": 1.5,
|
||||
"led_count": 60,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["source_type"] == "effect"
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user