From 264eb7296f6e3f7f7b89d62f61af18ffce7e06f2 Mon Sep 17 00:00:00 2001 From: pi Date: Sun, 12 Apr 2026 00:27:43 +1200 Subject: [PATCH] test: fix zone_ctl fixture, pattern assertions, and browser cleanup Made-with: Cursor --- tests/README.md | 4 +-- tests/test_browser.py | 58 +++++++++++++++---------------- tests/test_endpoints.py | 63 +++++++++++++++++++++++----------- tests/test_endpoints_pytest.py | 52 +++++++++++++++++++++------- 4 files changed, 114 insertions(+), 63 deletions(-) diff --git a/tests/README.md b/tests/README.md index d376955..ec04e87 100644 --- a/tests/README.md +++ b/tests/README.md @@ -6,9 +6,9 @@ Tests for the LED Controller project live under **`tests/`** (pytest + legacy sc | 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_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 | | `tcp_test_server.py`, `async_tcp_server.py` | TCP test doubles for driver protocol | | `udp_server.py` | UDP discovery / hello test listener (port **8766**) | diff --git a/tests/test_browser.py b/tests/test_browser.py index fa7d952..123ee70 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -40,7 +40,7 @@ class BrowserTest: self.base_url = base_url self.driver = None self.headless = headless - self.created_tabs: List[str] = [] + self.created_zones: List[str] = [] self.created_profiles: List[str] = [] self.created_presets: List[str] = [] @@ -161,8 +161,8 @@ class BrowserTest: except Exception as e: print(f" ⚠ Failed to cleanup preset {preset_id}: {e}") - # Delete created tabs by ID - for zone_id in self.created_tabs: + # Delete created zones by ID + for zone_id in self.created_zones: try: response = session.delete(f"{self.base_url}/zones/{zone_id}") if response.status_code == 200: @@ -183,17 +183,17 @@ class BrowserTest: test_names = ['Browser Test Zone', 'Browser Test Profile', 'Browser Test Preset', 'Preset 1', 'Preset 2', 'Preset 3', 'Edited Browser Zone'] - # Cleanup tabs by name + # Cleanup zones by name try: - tabs_response = session.get(f"{self.base_url}/zones") - if tabs_response.status_code == 200: - tabs_data = tabs_response.json() - tabs = tabs_data.get('zones', {}) - for zone_id, tab_data in zones.items(): - if isinstance(tab_data, dict) and tab_data.get('name') in test_names: + zones_response = session.get(f"{self.base_url}/zones") + if zones_response.status_code == 200: + zones_data = zones_response.json() + zones_map = zones_data.get('zones', {}) + for zone_id, zone_row in zones_map.items(): + if isinstance(zone_row, dict) and zone_row.get('name') in test_names: try: 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: pass except: @@ -232,7 +232,7 @@ class BrowserTest: pass # Clear the lists - self.created_tabs.clear() + self.created_zones.clear() self.created_profiles.clear() self.created_presets.clear() except Exception as e: @@ -308,9 +308,9 @@ def test_browser_connection(browser: BrowserTest) -> bool: finally: browser.teardown() -def test_tabs_ui(browser: BrowserTest) -> bool: - """Test tabs UI in browser.""" - print("\n=== Testing Tabs UI in Browser ===") +def test_zones_ui(browser: BrowserTest) -> bool: + """Test zones UI in browser.""" + print("\n=== Testing Zones UI in Browser ===") passed = 0 total = 0 @@ -328,20 +328,20 @@ def test_tabs_ui(browser: BrowserTest) -> bool: browser.teardown() return False - # Test 2: Open tabs modal + # Test 2: Open zones modal total += 1 if browser.click_element(By.ID, 'zones-btn'): - print("✓ Clicked Tabs button") + print("✓ Clicked Zones button") # Wait for modal to appear time.sleep(0.5) modal = browser.wait_for_element(By.ID, 'zones-modal') if modal and 'active' in modal.get_attribute('class'): - print("✓ Tabs modal opened") + print("✓ Zones modal opened") passed += 1 else: - print("✗ Tabs modal didn't open") + print("✗ Zones modal didn't open") else: - print("✗ Failed to click Tabs button") + print("✗ Failed to click Zones button") # Test 3: Create a zone via UI total += 1 @@ -367,7 +367,7 @@ def test_tabs_ui(browser: BrowserTest) -> bool: if 'Browser Test Zone' in row.text: zone_id = row.get_attribute('data-zone-id') if zone_id: - browser.created_tabs.append(zone_id) + browser.created_zones.append(zone_id) break except: pass # If we can't extract ID, cleanup will try by name @@ -375,13 +375,13 @@ def test_tabs_ui(browser: BrowserTest) -> bool: else: print("✗ Zone not found in list after creation") else: - print("✗ Tabs list not found") + print("✗ Zones list not found") else: print("✗ Failed to click create button") except Exception as 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 try: # First, close and reopen modal to refresh @@ -450,7 +450,7 @@ def test_tabs_ui(browser: BrowserTest) -> bool: browser.cleanup_test_data() browser.teardown() - print(f"\nBrowser tabs UI tests: {passed}/{total} passed") + print(f"\nBrowser zones UI tests: {passed}/{total} passed") return passed == total def test_profiles_ui(browser: BrowserTest) -> bool: @@ -778,14 +778,14 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool: browser.teardown() return False - # Test 2: Open tabs modal and create/select a zone + # Test 2: Open zones modal and create/select a zone total += 1 browser.click_element(By.ID, 'zones-btn') 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') - 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 browser.fill_input(By.ID, 'new-zone-name', 'Drag Test Zone') 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") passed += 1 else: - print("✗ No tabs available to select") + print("✗ No zones available to select") browser.click_element(By.ID, 'zones-close-btn') browser.teardown() return False @@ -1012,7 +1012,7 @@ def main(): # Run browser tests 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(("Presets UI", test_presets_ui(browser))) results.append(("Color Palette UI", test_color_palette_ui(browser))) diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index 329c4d5..cba77a9 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -82,19 +82,19 @@ def test_connection(client: TestClient) -> bool: print(f"✗ Connection error: {e}") return False -def test_tabs(client: TestClient) -> bool: - """Test tabs endpoints.""" - print("\n=== Testing Tabs Endpoints ===") +def test_zones(client: TestClient) -> bool: + """Test zones endpoints.""" + print("\n=== Testing Zones Endpoints ===") passed = 0 total = 0 - # Test 1: List tabs + # Test 1: List zones total += 1 try: response = client.get('/zones') if response.status_code == 200: 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 else: print(f"✗ GET /zones - Status: {response.status_code}") @@ -104,17 +104,17 @@ def test_tabs(client: TestClient) -> bool: # Test 2: Create zone total += 1 try: - tab_data = { + zone_data = { "name": "Test Zone", "names": ["1", "2"] } - response = client.post('/zones', json_data=tab_data) + response = client.post('/zones', json_data=zone_data) if response.status_code == 201: - created_tab = response.json() - # Response format: {zone_id: {tab_data}} - if isinstance(created_tab, dict): + created_zone = response.json() + # Response format: {zone_id: {zone object}} + if isinstance(created_zone, dict): # 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: zone_id = None print(f"✓ POST /zones - Created zone: {zone_id}") @@ -203,7 +203,7 @@ def test_tabs(client: TestClient) -> bool: import traceback traceback.print_exc() - print(f"\nTabs tests: {passed}/{total} passed") + print(f"\nZones tests: {passed}/{total} passed") return passed == total def test_profiles(client: TestClient) -> bool: @@ -405,10 +405,33 @@ def test_patterns(client: TestClient) -> bool: except Exception as 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") 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.""" print("\n=== Testing Zone Edit Workflow ===") passed = 0 @@ -417,11 +440,11 @@ def test_tab_edit_workflow(client: TestClient) -> bool: # Step 1: Create a zone to edit total += 1 try: - tab_data = { + zone_data = { "name": "Zone to Edit", "names": ["1"] } - response = client.post('/zones', json_data=tab_data) + response = client.post('/zones', json_data=zone_data) if response.status_code == 201: created = response.json() if isinstance(created, dict): @@ -437,8 +460,8 @@ def test_tab_edit_workflow(client: TestClient) -> bool: total += 1 response = client.get(f'/zones/{zone_id}') if response.status_code == 200: - original_tab = response.json() - print(f"✓ Retrieved zone - Name: '{original_tab.get('name')}', IDs: {original_tab.get('names')}") + original_zone = response.json() + print(f"✓ Retrieved zone - Name: '{original_zone.get('name')}', IDs: {original_zone.get('names')}") passed += 1 # Step 3: Edit the zone (simulate browser edit form submission) @@ -493,7 +516,7 @@ def test_tab_edit_workflow(client: TestClient) -> bool: import traceback traceback.print_exc() - print(f"\nTab edit workflow tests: {passed}/{total} passed") + print(f"\nZone edit workflow tests: {passed}/{total} passed") return passed == total def test_static_files(client: TestClient) -> bool: @@ -543,8 +566,8 @@ def main(): results = [] # Run all tests - results.append(("Tabs", test_tabs(client))) - results.append(("Zone Edit Workflow", test_tab_edit_workflow(client))) + results.append(("Zones", test_zones(client))) + results.append(("Zone Edit Workflow", test_zone_edit_workflow(client))) results.append(("Profiles", test_profiles(client))) results.append(("Presets", test_presets(client))) results.append(("Patterns", test_patterns(client))) diff --git a/tests/test_endpoints_pytest.py b/tests/test_endpoints_pytest.py index 3ac7a8f..2fcc7a8 100644 --- a/tests/test_endpoints_pytest.py +++ b/tests/test_endpoints_pytest.py @@ -119,7 +119,7 @@ def server(monkeypatch, tmp_path_factory): import models.preset as models_preset # noqa: E402 import models.profile as models_profile # 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.scene as models_scene # noqa: E402 import models.pattern as models_pattern # noqa: E402 @@ -130,7 +130,7 @@ def server(monkeypatch, tmp_path_factory): models_preset.Preset, models_profile.Profile, models_group.Group, - models_tab.Zone, + models_zone.Zone, models_pallet.Palette, models_scene.Scene, models_pattern.Pattern, @@ -205,7 +205,7 @@ def server(monkeypatch, tmp_path_factory): app.mount(profile_ctl.controller, "/profiles") app.mount(group_ctl.controller, "/groups") 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(scene_ctl.controller, "/scenes") app.mount(pattern_ctl.controller, "/patterns") @@ -348,7 +348,7 @@ def test_settings_controller(server): 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"] base_url: str = server["base_url"] 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}") assert resp.status_code == 404 - # Tabs CRUD (scoped to current profile session). - unique_tab_name = f"pytest-zone-{uuid.uuid4().hex[:8]}" + # Zones CRUD (scoped to current profile session). + unique_zone_name = f"pytest-zone-{uuid.uuid4().hex[:8]}" resp = c.post( 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 - created_tabs = resp.json() - zone_id = next(iter(created_tabs.keys())) + created_zones = resp.json() + zone_id = next(iter(created_zones.keys())) resp = c.get(f"{base_url}/zones/{zone_id}") 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") assert resp.status_code == 200 @@ -446,7 +446,7 @@ def test_profiles_presets_tabs_endpoints(server, monkeypatch): resp = c.put( 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.json()["names"] == ["3"] @@ -497,6 +497,7 @@ def test_profiles_presets_tabs_endpoints(server, monkeypatch): def test_groups_sequences_scenes_palettes_patterns_endpoints(server): c: requests.Session = server["client"] base_url: str = server["base_url"] + sender: DummySender = server["sender"] # Groups. 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") assert resp.status_code == 200 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/ after POST. + assert "blink" in patterns_list or len(patterns_list) >= 1 resp = c.get(f"{base_url}/patterns/{pattern_id}") 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}") 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 +