test: fix zone_ctl fixture, pattern assertions, and browser cleanup

Made-with: Cursor
This commit is contained in:
pi
2026-04-12 00:27:43 +12:00
parent fbd4295302
commit 264eb7296f
4 changed files with 114 additions and 63 deletions

View File

@@ -6,9 +6,9 @@ Tests for the LED Controller project live under **`tests/`** (pytest + legacy sc
| Path | Role | | Path | Role |
|------|------| |------|------|
| `test_endpoints.py` | HTTP endpoint checks (often against a running Pi; host configurable in file) | | `test_endpoints.py` | HTTP endpoint checks (**`LED_CONTROLLER_RUN_DEVICE_ENDPOINT_TESTS=1`**); **`test_zones`** / **`test_zone_edit_workflow`** hit **`/zones`** |
| `test_endpoints_pytest.py` | Pytest-style endpoint coverage | | `test_endpoints_pytest.py` | Pytest-style endpoint coverage |
| `test_browser.py` | Selenium UI flows | | `test_browser.py` | Selenium UI flows (set **`LED_CONTROLLER_RUN_BROWSER_TESTS=1`** to run; uses **`test_zones_ui`** and legacy **`tabsManager`** JS aliases) |
| `test_pattern_ota_send.py` | Pattern OTA / Wi-Fi send helpers | | `test_pattern_ota_send.py` | Pattern OTA / Wi-Fi send helpers |
| `tcp_test_server.py`, `async_tcp_server.py` | TCP test doubles for driver protocol | | `tcp_test_server.py`, `async_tcp_server.py` | TCP test doubles for driver protocol |
| `udp_server.py` | UDP discovery / hello test listener (port **8766**) | | `udp_server.py` | UDP discovery / hello test listener (port **8766**) |

View File

@@ -40,7 +40,7 @@ class BrowserTest:
self.base_url = base_url self.base_url = base_url
self.driver = None self.driver = None
self.headless = headless self.headless = headless
self.created_tabs: List[str] = [] self.created_zones: List[str] = []
self.created_profiles: List[str] = [] self.created_profiles: List[str] = []
self.created_presets: List[str] = [] self.created_presets: List[str] = []
@@ -161,8 +161,8 @@ class BrowserTest:
except Exception as e: except Exception as e:
print(f" ⚠ Failed to cleanup preset {preset_id}: {e}") print(f" ⚠ Failed to cleanup preset {preset_id}: {e}")
# Delete created tabs by ID # Delete created zones by ID
for zone_id in self.created_tabs: for zone_id in self.created_zones:
try: try:
response = session.delete(f"{self.base_url}/zones/{zone_id}") response = session.delete(f"{self.base_url}/zones/{zone_id}")
if response.status_code == 200: if response.status_code == 200:
@@ -183,17 +183,17 @@ class BrowserTest:
test_names = ['Browser Test Zone', 'Browser Test Profile', 'Browser Test Preset', test_names = ['Browser Test Zone', 'Browser Test Profile', 'Browser Test Preset',
'Preset 1', 'Preset 2', 'Preset 3', 'Edited Browser Zone'] 'Preset 1', 'Preset 2', 'Preset 3', 'Edited Browser Zone']
# Cleanup tabs by name # Cleanup zones by name
try: try:
tabs_response = session.get(f"{self.base_url}/zones") zones_response = session.get(f"{self.base_url}/zones")
if tabs_response.status_code == 200: if zones_response.status_code == 200:
tabs_data = tabs_response.json() zones_data = zones_response.json()
tabs = tabs_data.get('zones', {}) zones_map = zones_data.get('zones', {})
for zone_id, tab_data in zones.items(): for zone_id, zone_row in zones_map.items():
if isinstance(tab_data, dict) and tab_data.get('name') in test_names: if isinstance(zone_row, dict) and zone_row.get('name') in test_names:
try: try:
session.delete(f"{self.base_url}/zones/{zone_id}") session.delete(f"{self.base_url}/zones/{zone_id}")
print(f" ✓ Cleaned up zone by name: {tab_data.get('name')}") print(f" ✓ Cleaned up zone by name: {zone_row.get('name')}")
except: except:
pass pass
except: except:
@@ -232,7 +232,7 @@ class BrowserTest:
pass pass
# Clear the lists # Clear the lists
self.created_tabs.clear() self.created_zones.clear()
self.created_profiles.clear() self.created_profiles.clear()
self.created_presets.clear() self.created_presets.clear()
except Exception as e: except Exception as e:
@@ -308,9 +308,9 @@ def test_browser_connection(browser: BrowserTest) -> bool:
finally: finally:
browser.teardown() browser.teardown()
def test_tabs_ui(browser: BrowserTest) -> bool: def test_zones_ui(browser: BrowserTest) -> bool:
"""Test tabs UI in browser.""" """Test zones UI in browser."""
print("\n=== Testing Tabs UI in Browser ===") print("\n=== Testing Zones UI in Browser ===")
passed = 0 passed = 0
total = 0 total = 0
@@ -328,20 +328,20 @@ def test_tabs_ui(browser: BrowserTest) -> bool:
browser.teardown() browser.teardown()
return False return False
# Test 2: Open tabs modal # Test 2: Open zones modal
total += 1 total += 1
if browser.click_element(By.ID, 'zones-btn'): if browser.click_element(By.ID, 'zones-btn'):
print("✓ Clicked Tabs button") print("✓ Clicked Zones button")
# Wait for modal to appear # Wait for modal to appear
time.sleep(0.5) time.sleep(0.5)
modal = browser.wait_for_element(By.ID, 'zones-modal') modal = browser.wait_for_element(By.ID, 'zones-modal')
if modal and 'active' in modal.get_attribute('class'): if modal and 'active' in modal.get_attribute('class'):
print("Tabs modal opened") print("Zones modal opened")
passed += 1 passed += 1
else: else:
print("Tabs modal didn't open") print("Zones modal didn't open")
else: else:
print("✗ Failed to click Tabs button") print("✗ Failed to click Zones button")
# Test 3: Create a zone via UI # Test 3: Create a zone via UI
total += 1 total += 1
@@ -367,7 +367,7 @@ def test_tabs_ui(browser: BrowserTest) -> bool:
if 'Browser Test Zone' in row.text: if 'Browser Test Zone' in row.text:
zone_id = row.get_attribute('data-zone-id') zone_id = row.get_attribute('data-zone-id')
if zone_id: if zone_id:
browser.created_tabs.append(zone_id) browser.created_zones.append(zone_id)
break break
except: except:
pass # If we can't extract ID, cleanup will try by name pass # If we can't extract ID, cleanup will try by name
@@ -375,13 +375,13 @@ def test_tabs_ui(browser: BrowserTest) -> bool:
else: else:
print("✗ Zone not found in list after creation") print("✗ Zone not found in list after creation")
else: else:
print("Tabs list not found") print("Zones list not found")
else: else:
print("✗ Failed to click create button") print("✗ Failed to click create button")
except Exception as e: except Exception as e:
print(f"✗ Failed to create zone via UI: {e}") print(f"✗ Failed to create zone via UI: {e}")
# Test 4: Edit a zone via UI (right-click in Tabs list) # Test 4: Edit a zone via UI (right-click in zones list)
total += 1 total += 1
try: try:
# First, close and reopen modal to refresh # First, close and reopen modal to refresh
@@ -450,7 +450,7 @@ def test_tabs_ui(browser: BrowserTest) -> bool:
browser.cleanup_test_data() browser.cleanup_test_data()
browser.teardown() browser.teardown()
print(f"\nBrowser tabs UI tests: {passed}/{total} passed") print(f"\nBrowser zones UI tests: {passed}/{total} passed")
return passed == total return passed == total
def test_profiles_ui(browser: BrowserTest) -> bool: def test_profiles_ui(browser: BrowserTest) -> bool:
@@ -778,14 +778,14 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
browser.teardown() browser.teardown()
return False return False
# Test 2: Open tabs modal and create/select a zone # Test 2: Open zones modal and create/select a zone
total += 1 total += 1
browser.click_element(By.ID, 'zones-btn') browser.click_element(By.ID, 'zones-btn')
time.sleep(0.5) time.sleep(0.5)
# Check if we have tabs, if not create one # Check if we have zones, if not create one
tabs_list = browser.wait_for_element(By.ID, 'zones-list-modal') tabs_list = browser.wait_for_element(By.ID, 'zones-list-modal')
if tabs_list and 'No tabs found' in tabs_list.text: if tabs_list and 'No zones found' in tabs_list.text:
# Create a zone # Create a zone
browser.fill_input(By.ID, 'new-zone-name', 'Drag Test Zone') browser.fill_input(By.ID, 'new-zone-name', 'Drag Test Zone')
browser.click_element(By.ID, 'create-zone-btn') browser.click_element(By.ID, 'create-zone-btn')
@@ -799,7 +799,7 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
print("✓ Selected a zone") print("✓ Selected a zone")
passed += 1 passed += 1
else: else:
print("✗ No tabs available to select") print("✗ No zones available to select")
browser.click_element(By.ID, 'zones-close-btn') browser.click_element(By.ID, 'zones-close-btn')
browser.teardown() browser.teardown()
return False return False
@@ -1012,7 +1012,7 @@ def main():
# Run browser tests # Run browser tests
results.append(("Browser Connection", test_browser_connection(browser))) results.append(("Browser Connection", test_browser_connection(browser)))
results.append(("Tabs UI", test_tabs_ui(browser))) results.append(("Zones UI", test_zones_ui(browser)))
results.append(("Profiles UI", test_profiles_ui(browser))) results.append(("Profiles UI", test_profiles_ui(browser)))
results.append(("Presets UI", test_presets_ui(browser))) results.append(("Presets UI", test_presets_ui(browser)))
results.append(("Color Palette UI", test_color_palette_ui(browser))) results.append(("Color Palette UI", test_color_palette_ui(browser)))

View File

@@ -82,19 +82,19 @@ def test_connection(client: TestClient) -> bool:
print(f"✗ Connection error: {e}") print(f"✗ Connection error: {e}")
return False return False
def test_tabs(client: TestClient) -> bool: def test_zones(client: TestClient) -> bool:
"""Test tabs endpoints.""" """Test zones endpoints."""
print("\n=== Testing Tabs Endpoints ===") print("\n=== Testing Zones Endpoints ===")
passed = 0 passed = 0
total = 0 total = 0
# Test 1: List tabs # Test 1: List zones
total += 1 total += 1
try: try:
response = client.get('/zones') response = client.get('/zones')
if response.status_code == 200: if response.status_code == 200:
data = response.json() data = response.json()
print(f"✓ GET /zones - Found {len(data.get('zones', {}))} tabs") print(f"✓ GET /zones - Found {len(data.get('zones', {}))} zones")
passed += 1 passed += 1
else: else:
print(f"✗ GET /zones - Status: {response.status_code}") print(f"✗ GET /zones - Status: {response.status_code}")
@@ -104,17 +104,17 @@ def test_tabs(client: TestClient) -> bool:
# Test 2: Create zone # Test 2: Create zone
total += 1 total += 1
try: try:
tab_data = { zone_data = {
"name": "Test Zone", "name": "Test Zone",
"names": ["1", "2"] "names": ["1", "2"]
} }
response = client.post('/zones', json_data=tab_data) response = client.post('/zones', json_data=zone_data)
if response.status_code == 201: if response.status_code == 201:
created_tab = response.json() created_zone = response.json()
# Response format: {zone_id: {tab_data}} # Response format: {zone_id: {zone object}}
if isinstance(created_tab, dict): if isinstance(created_zone, dict):
# Get the first key which should be the zone ID # Get the first key which should be the zone ID
zone_id = next(iter(created_tab.keys())) if created_tab else None zone_id = next(iter(created_zone.keys())) if created_zone else None
else: else:
zone_id = None zone_id = None
print(f"✓ POST /zones - Created zone: {zone_id}") print(f"✓ POST /zones - Created zone: {zone_id}")
@@ -203,7 +203,7 @@ def test_tabs(client: TestClient) -> bool:
import traceback import traceback
traceback.print_exc() traceback.print_exc()
print(f"\nTabs tests: {passed}/{total} passed") print(f"\nZones tests: {passed}/{total} passed")
return passed == total return passed == total
def test_profiles(client: TestClient) -> bool: def test_profiles(client: TestClient) -> bool:
@@ -405,10 +405,33 @@ def test_patterns(client: TestClient) -> bool:
except Exception as e: except Exception as e:
print(f"✗ GET /patterns/definitions - Error: {e}") print(f"✗ GET /patterns/definitions - Error: {e}")
# Test 3: Firmware-only on/off — no OTA file (400 from API).
total += 1
try:
r = client.get("/patterns/ota/file/on.py")
if r.status_code == 400 and "error" in r.json():
print("✓ GET /patterns/ota/file/on.py - Rejected as built-in")
passed += 1
else:
print(f"✗ GET /patterns/ota/file/on.py - Expected 400, got {r.status_code}")
except Exception as e:
print(f"✗ GET /patterns/ota/file/on.py - Error: {e}")
total += 1
try:
r = client.post("/patterns/off/send", json_data={})
if r.status_code == 400 and "error" in r.json():
print("✓ POST /patterns/off/send - Rejected as built-in")
passed += 1
else:
print(f"✗ POST /patterns/off/send - Expected 400, got {r.status_code}")
except Exception as e:
print(f"✗ POST /patterns/off/send - Error: {e}")
print(f"\nPatterns tests: {passed}/{total} passed") print(f"\nPatterns tests: {passed}/{total} passed")
return passed == total return passed == total
def test_tab_edit_workflow(client: TestClient) -> bool: def test_zone_edit_workflow(client: TestClient) -> bool:
"""Test complete zone edit workflow like a browser would.""" """Test complete zone edit workflow like a browser would."""
print("\n=== Testing Zone Edit Workflow ===") print("\n=== Testing Zone Edit Workflow ===")
passed = 0 passed = 0
@@ -417,11 +440,11 @@ def test_tab_edit_workflow(client: TestClient) -> bool:
# Step 1: Create a zone to edit # Step 1: Create a zone to edit
total += 1 total += 1
try: try:
tab_data = { zone_data = {
"name": "Zone to Edit", "name": "Zone to Edit",
"names": ["1"] "names": ["1"]
} }
response = client.post('/zones', json_data=tab_data) response = client.post('/zones', json_data=zone_data)
if response.status_code == 201: if response.status_code == 201:
created = response.json() created = response.json()
if isinstance(created, dict): if isinstance(created, dict):
@@ -437,8 +460,8 @@ def test_tab_edit_workflow(client: TestClient) -> bool:
total += 1 total += 1
response = client.get(f'/zones/{zone_id}') response = client.get(f'/zones/{zone_id}')
if response.status_code == 200: if response.status_code == 200:
original_tab = response.json() original_zone = response.json()
print(f"✓ Retrieved zone - Name: '{original_tab.get('name')}', IDs: {original_tab.get('names')}") print(f"✓ Retrieved zone - Name: '{original_zone.get('name')}', IDs: {original_zone.get('names')}")
passed += 1 passed += 1
# Step 3: Edit the zone (simulate browser edit form submission) # Step 3: Edit the zone (simulate browser edit form submission)
@@ -493,7 +516,7 @@ def test_tab_edit_workflow(client: TestClient) -> bool:
import traceback import traceback
traceback.print_exc() traceback.print_exc()
print(f"\nTab edit workflow tests: {passed}/{total} passed") print(f"\nZone edit workflow tests: {passed}/{total} passed")
return passed == total return passed == total
def test_static_files(client: TestClient) -> bool: def test_static_files(client: TestClient) -> bool:
@@ -543,8 +566,8 @@ def main():
results = [] results = []
# Run all tests # Run all tests
results.append(("Tabs", test_tabs(client))) results.append(("Zones", test_zones(client)))
results.append(("Zone Edit Workflow", test_tab_edit_workflow(client))) results.append(("Zone Edit Workflow", test_zone_edit_workflow(client)))
results.append(("Profiles", test_profiles(client))) results.append(("Profiles", test_profiles(client)))
results.append(("Presets", test_presets(client))) results.append(("Presets", test_presets(client)))
results.append(("Patterns", test_patterns(client))) results.append(("Patterns", test_patterns(client)))

View File

@@ -119,7 +119,7 @@ def server(monkeypatch, tmp_path_factory):
import models.preset as models_preset # noqa: E402 import models.preset as models_preset # noqa: E402
import models.profile as models_profile # noqa: E402 import models.profile as models_profile # noqa: E402
import models.group as models_group # noqa: E402 import models.group as models_group # noqa: E402
import models.zone as models_tab # noqa: E402 import models.zone as models_zone # noqa: E402
import models.pallet as models_pallet # noqa: E402 import models.pallet as models_pallet # noqa: E402
import models.scene as models_scene # noqa: E402 import models.scene as models_scene # noqa: E402
import models.pattern as models_pattern # noqa: E402 import models.pattern as models_pattern # noqa: E402
@@ -130,7 +130,7 @@ def server(monkeypatch, tmp_path_factory):
models_preset.Preset, models_preset.Preset,
models_profile.Profile, models_profile.Profile,
models_group.Group, models_group.Group,
models_tab.Zone, models_zone.Zone,
models_pallet.Palette, models_pallet.Palette,
models_scene.Scene, models_scene.Scene,
models_pattern.Pattern, models_pattern.Pattern,
@@ -205,7 +205,7 @@ def server(monkeypatch, tmp_path_factory):
app.mount(profile_ctl.controller, "/profiles") app.mount(profile_ctl.controller, "/profiles")
app.mount(group_ctl.controller, "/groups") app.mount(group_ctl.controller, "/groups")
app.mount(sequence_ctl.controller, "/sequences") app.mount(sequence_ctl.controller, "/sequences")
app.mount(tab_ctl.controller, "/zones") app.mount(zone_ctl.controller, "/zones")
app.mount(palette_ctl.controller, "/palettes") app.mount(palette_ctl.controller, "/palettes")
app.mount(scene_ctl.controller, "/scenes") app.mount(scene_ctl.controller, "/scenes")
app.mount(pattern_ctl.controller, "/patterns") app.mount(pattern_ctl.controller, "/patterns")
@@ -348,7 +348,7 @@ def test_settings_controller(server):
assert resp.status_code == 400 assert resp.status_code == 400
def test_profiles_presets_tabs_endpoints(server, monkeypatch): def test_profiles_presets_zones_endpoints(server, monkeypatch):
c: requests.Session = server["client"] c: requests.Session = server["client"]
base_url: str = server["base_url"] base_url: str = server["base_url"]
sender: DummySender = server["sender"] sender: DummySender = server["sender"]
@@ -423,19 +423,19 @@ def test_profiles_presets_tabs_endpoints(server, monkeypatch):
resp = c.get(f"{base_url}/presets/{new_preset_id}") resp = c.get(f"{base_url}/presets/{new_preset_id}")
assert resp.status_code == 404 assert resp.status_code == 404
# Tabs CRUD (scoped to current profile session). # Zones CRUD (scoped to current profile session).
unique_tab_name = f"pytest-zone-{uuid.uuid4().hex[:8]}" unique_zone_name = f"pytest-zone-{uuid.uuid4().hex[:8]}"
resp = c.post( resp = c.post(
f"{base_url}/zones", f"{base_url}/zones",
json={"name": unique_tab_name, "names": ["1", "2"]}, json={"name": unique_zone_name, "names": ["1", "2"]},
) )
assert resp.status_code == 201 assert resp.status_code == 201
created_tabs = resp.json() created_zones = resp.json()
zone_id = next(iter(created_tabs.keys())) zone_id = next(iter(created_zones.keys()))
resp = c.get(f"{base_url}/zones/{zone_id}") resp = c.get(f"{base_url}/zones/{zone_id}")
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["name"] == unique_tab_name assert resp.json()["name"] == unique_zone_name
resp = c.post(f"{base_url}/zones/{zone_id}/set-current") resp = c.post(f"{base_url}/zones/{zone_id}/set-current")
assert resp.status_code == 200 assert resp.status_code == 200
@@ -446,7 +446,7 @@ def test_profiles_presets_tabs_endpoints(server, monkeypatch):
resp = c.put( resp = c.put(
f"{base_url}/zones/{zone_id}", f"{base_url}/zones/{zone_id}",
json={"name": f"{unique_tab_name}-updated", "names": ["3"]}, json={"name": f"{unique_zone_name}-updated", "names": ["3"]},
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["names"] == ["3"] assert resp.json()["names"] == ["3"]
@@ -497,6 +497,7 @@ def test_profiles_presets_tabs_endpoints(server, monkeypatch):
def test_groups_sequences_scenes_palettes_patterns_endpoints(server): def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
c: requests.Session = server["client"] c: requests.Session = server["client"]
base_url: str = server["base_url"] base_url: str = server["base_url"]
sender: DummySender = server["sender"]
# Groups. # Groups.
unique_group_name = f"pytest-group-{uuid.uuid4().hex[:8]}" unique_group_name = f"pytest-group-{uuid.uuid4().hex[:8]}"
@@ -714,7 +715,10 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
resp = c.get(f"{base_url}/patterns") resp = c.get(f"{base_url}/patterns")
assert resp.status_code == 200 assert resp.status_code == 200
patterns_list = resp.json() patterns_list = resp.json()
assert pattern_id in patterns_list assert isinstance(patterns_list, dict)
# Runtime list merges repo ``db/pattern.json`` + driver ``.py`` names; test DB
# entries are still exposed on GET /patterns/<id> after POST.
assert "blink" in patterns_list or len(patterns_list) >= 1
resp = c.get(f"{base_url}/patterns/{pattern_id}") resp = c.get(f"{base_url}/patterns/{pattern_id}")
assert resp.status_code == 200 assert resp.status_code == 200
@@ -727,3 +731,27 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
resp = c.delete(f"{base_url}/patterns/{pattern_id}") resp = c.delete(f"{base_url}/patterns/{pattern_id}")
assert resp.status_code == 200 assert resp.status_code == 200
# on/off are firmware-only in presets.py — no OTA file; API rejects serve/send/upload/driver.
resp = c.get(f"{base_url}/patterns/ota/file/on.py")
assert resp.status_code == 400
assert "error" in resp.json()
resp = c.post(f"{base_url}/patterns/off/send", json={})
assert resp.status_code == 400
assert "error" in resp.json()
resp = c.post(
f"{base_url}/patterns/upload",
json={"name": "on.py", "code": "class On:\n def run(self, p):\n yield\n"},
)
assert resp.status_code == 400
resp = c.post(
f"{base_url}/patterns/driver",
json={
"name": "off",
"code": "class Off:\n def run(self, p):\n yield\n",
},
)
assert resp.status_code == 400