dynamic dashboard initial
This commit is contained in:
@@ -28,7 +28,7 @@ BROKER_HOST = os.environ.get("MQTT_HOST", "172.16.2.16")
|
||||
BROKER_PORT = int(os.environ.get("MQTT_PORT", "1883"))
|
||||
|
||||
# Devices to simulate
|
||||
DEVICES = ["test_lampe_1", "test_lampe_2"]
|
||||
DEVICES = ["test_lampe_1", "test_lampe_2", "test_lampe_3"]
|
||||
|
||||
# Device states (one per device)
|
||||
device_states = {
|
||||
@@ -39,6 +39,10 @@ device_states = {
|
||||
"test_lampe_2": {
|
||||
"power": "off",
|
||||
"brightness": 50
|
||||
},
|
||||
"test_lampe_3": {
|
||||
"power": "off",
|
||||
"brightness": 50
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
169
tools/test_api_client.py
Normal file
169
tools/test_api_client.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
Acceptance tests for API Client (fetch_devices).
|
||||
|
||||
Tests:
|
||||
1. API running: fetch_devices() returns list with test_lampe_1, test_lampe_2
|
||||
2. API down: fetch_devices() returns empty list without crash
|
||||
"""
|
||||
import sys
|
||||
import time
|
||||
import subprocess
|
||||
from apps.ui.api_client import fetch_devices
|
||||
|
||||
|
||||
def test_api_running():
|
||||
"""Test 1: API is running -> fetch_devices() returns devices."""
|
||||
print("Test 1: API running")
|
||||
print("-" * 60)
|
||||
|
||||
devices = fetch_devices("http://localhost:8001")
|
||||
|
||||
if not devices:
|
||||
print(" ✗ FAILED: Expected devices, got empty list")
|
||||
return False
|
||||
|
||||
print(f" ✓ Received {len(devices)} devices")
|
||||
|
||||
# Check structure
|
||||
for device in devices:
|
||||
device_id = device.get("device_id")
|
||||
device_type = device.get("type")
|
||||
name = device.get("name")
|
||||
|
||||
if not device_id:
|
||||
print(f" ✗ FAILED: Device missing 'device_id': {device}")
|
||||
return False
|
||||
|
||||
if not device_type:
|
||||
print(f" ✗ FAILED: Device missing 'type': {device}")
|
||||
return False
|
||||
|
||||
if not name:
|
||||
print(f" ✗ FAILED: Device missing 'name': {device}")
|
||||
return False
|
||||
|
||||
print(f" • {device_id} (type={device_type}, name={name})")
|
||||
|
||||
# Check for expected devices
|
||||
device_ids = {d["device_id"] for d in devices}
|
||||
expected = {"test_lampe_1", "test_lampe_2"}
|
||||
|
||||
if not expected.issubset(device_ids):
|
||||
missing = expected - device_ids
|
||||
print(f" ✗ FAILED: Missing devices: {missing}")
|
||||
return False
|
||||
|
||||
print(" ✓ All expected devices present")
|
||||
print()
|
||||
return True
|
||||
|
||||
|
||||
def test_api_down():
|
||||
"""Test 2: API is down -> fetch_devices() returns empty list without crash."""
|
||||
print("Test 2: API down (invalid port)")
|
||||
print("-" * 60)
|
||||
|
||||
try:
|
||||
devices = fetch_devices("http://localhost:9999")
|
||||
|
||||
if devices != []:
|
||||
print(f" ✗ FAILED: Expected empty list, got {len(devices)} devices")
|
||||
return False
|
||||
|
||||
print(" ✓ Returns empty list without crash")
|
||||
print()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ FAILED: Exception raised: {e}")
|
||||
print()
|
||||
return False
|
||||
|
||||
|
||||
def test_api_timeout():
|
||||
"""Test 3: API timeout -> fetch_devices() returns empty list."""
|
||||
print("Test 3: API timeout")
|
||||
print("-" * 60)
|
||||
|
||||
# Use httpbin.org delay endpoint to simulate slow API
|
||||
try:
|
||||
start = time.time()
|
||||
devices = fetch_devices("https://httpbin.org/delay/5") # 5s delay, but 3s timeout
|
||||
elapsed = time.time() - start
|
||||
|
||||
if devices != []:
|
||||
print(f" ✗ FAILED: Expected empty list, got {len(devices)} devices")
|
||||
return False
|
||||
|
||||
if elapsed >= 4.0:
|
||||
print(f" ✗ FAILED: Timeout not enforced (took {elapsed:.1f}s)")
|
||||
return False
|
||||
|
||||
print(f" ✓ Returns empty list after timeout ({elapsed:.1f}s)")
|
||||
print()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ FAILED: Exception raised: {e}")
|
||||
print()
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Run all acceptance tests."""
|
||||
print("=" * 60)
|
||||
print("Testing API Client (fetch_devices)")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
# Check if API is running
|
||||
print("Prerequisites: Checking if API is running on port 8001...")
|
||||
try:
|
||||
devices = fetch_devices("http://localhost:8001")
|
||||
if devices:
|
||||
print(f"✓ API is running ({len(devices)} devices found)")
|
||||
print()
|
||||
else:
|
||||
print("⚠ API might not be running (no devices returned)")
|
||||
print(" Start API with: poetry run uvicorn apps.api.main:app --port 8001")
|
||||
print()
|
||||
except Exception as e:
|
||||
print(f"✗ Cannot reach API: {e}")
|
||||
print(" Start API with: poetry run uvicorn apps.api.main:app --port 8001")
|
||||
print()
|
||||
sys.exit(1)
|
||||
|
||||
results = []
|
||||
|
||||
# Test 1: API running
|
||||
results.append(("API running", test_api_running()))
|
||||
|
||||
# Test 2: API down
|
||||
results.append(("API down", test_api_down()))
|
||||
|
||||
# Test 3: Timeout
|
||||
results.append(("API timeout", test_api_timeout()))
|
||||
|
||||
# Summary
|
||||
print("=" * 60)
|
||||
passed = sum(1 for _, result in results if result)
|
||||
total = len(results)
|
||||
print(f"Results: {passed}/{total} tests passed")
|
||||
print("=" * 60)
|
||||
|
||||
for name, result in results:
|
||||
status = "✓" if result else "✗"
|
||||
print(f" {status} {name}")
|
||||
|
||||
if passed == total:
|
||||
print()
|
||||
print("All tests passed!")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print()
|
||||
print("Some tests failed.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
210
tools/test_dashboard.py
Normal file
210
tools/test_dashboard.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""
|
||||
Acceptance tests for Dashboard Route.
|
||||
|
||||
Tests:
|
||||
1. GET /dashboard loads without errors
|
||||
2. Rooms are shown in layout.yaml order
|
||||
3. Devices within each room are sorted by rank (ascending)
|
||||
4. GET / redirects to dashboard
|
||||
"""
|
||||
import sys
|
||||
import httpx
|
||||
|
||||
|
||||
def test_dashboard_loads():
|
||||
"""Test 1: Dashboard loads without errors."""
|
||||
print("Test 1: Dashboard loads")
|
||||
print("-" * 60)
|
||||
|
||||
try:
|
||||
response = httpx.get("http://localhost:8002/dashboard", timeout=5.0)
|
||||
response.raise_for_status()
|
||||
|
||||
html = response.text
|
||||
|
||||
# Check for essential elements
|
||||
if "Home Automation" not in html:
|
||||
print(" ✗ FAILED: Missing title 'Home Automation'")
|
||||
return False
|
||||
|
||||
if "Wohnzimmer" not in html:
|
||||
print(" ✗ FAILED: Missing room 'Wohnzimmer'")
|
||||
return False
|
||||
|
||||
if "Schlafzimmer" not in html:
|
||||
print(" ✗ FAILED: Missing room 'Schlafzimmer'")
|
||||
return False
|
||||
|
||||
print(" ✓ Dashboard loads successfully")
|
||||
print(" ✓ Contains expected rooms")
|
||||
print()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ FAILED: {e}")
|
||||
print()
|
||||
return False
|
||||
|
||||
|
||||
def test_room_order():
|
||||
"""Test 2: Rooms appear in layout.yaml order."""
|
||||
print("Test 2: Room order matches layout.yaml")
|
||||
print("-" * 60)
|
||||
|
||||
try:
|
||||
response = httpx.get("http://localhost:8002/dashboard", timeout=5.0)
|
||||
response.raise_for_status()
|
||||
|
||||
html = response.text
|
||||
|
||||
# Find positions of room titles
|
||||
wohnzimmer_pos = html.find('class="room-title">Wohnzimmer<')
|
||||
schlafzimmer_pos = html.find('class="room-title">Schlafzimmer<')
|
||||
|
||||
if wohnzimmer_pos == -1:
|
||||
print(" ✗ FAILED: Room 'Wohnzimmer' not found")
|
||||
return False
|
||||
|
||||
if schlafzimmer_pos == -1:
|
||||
print(" ✗ FAILED: Room 'Schlafzimmer' not found")
|
||||
return False
|
||||
|
||||
# Wohnzimmer should appear before Schlafzimmer
|
||||
if wohnzimmer_pos > schlafzimmer_pos:
|
||||
print(" ✗ FAILED: Room order incorrect")
|
||||
print(f" Wohnzimmer at position {wohnzimmer_pos}")
|
||||
print(f" Schlafzimmer at position {schlafzimmer_pos}")
|
||||
return False
|
||||
|
||||
print(" ✓ Rooms appear in correct order:")
|
||||
print(" 1. Wohnzimmer")
|
||||
print(" 2. Schlafzimmer")
|
||||
print()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ FAILED: {e}")
|
||||
print()
|
||||
return False
|
||||
|
||||
|
||||
def test_device_rank_sorting():
|
||||
"""Test 3: Devices are sorted by rank (ascending)."""
|
||||
print("Test 3: Devices sorted by rank")
|
||||
print("-" * 60)
|
||||
|
||||
try:
|
||||
response = httpx.get("http://localhost:8002/dashboard", timeout=5.0)
|
||||
response.raise_for_status()
|
||||
|
||||
html = response.text
|
||||
|
||||
# In Wohnzimmer: Deckenlampe (rank=5) should come before Stehlampe (rank=10)
|
||||
deckenlampe_pos = html.find('device-title">Deckenlampe<')
|
||||
stehlampe_pos = html.find('device-title">Stehlampe<')
|
||||
|
||||
if deckenlampe_pos == -1:
|
||||
print(" ✗ FAILED: Device 'Deckenlampe' not found")
|
||||
return False
|
||||
|
||||
if stehlampe_pos == -1:
|
||||
print(" ✗ FAILED: Device 'Stehlampe' not found")
|
||||
return False
|
||||
|
||||
if deckenlampe_pos > stehlampe_pos:
|
||||
print(" ✗ FAILED: Devices not sorted by rank")
|
||||
print(f" Deckenlampe (rank=5) at position {deckenlampe_pos}")
|
||||
print(f" Stehlampe (rank=10) at position {stehlampe_pos}")
|
||||
return False
|
||||
|
||||
print(" ✓ Devices sorted by rank (ascending):")
|
||||
print(" Wohnzimmer: Deckenlampe (rank=5) → Stehlampe (rank=10)")
|
||||
print()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ FAILED: {e}")
|
||||
print()
|
||||
return False
|
||||
|
||||
|
||||
def test_root_redirect():
|
||||
"""Test 4: GET / shows dashboard."""
|
||||
print("Test 4: Root path (/) shows dashboard")
|
||||
print("-" * 60)
|
||||
|
||||
try:
|
||||
response = httpx.get("http://localhost:8002/", timeout=5.0, follow_redirects=True)
|
||||
response.raise_for_status()
|
||||
|
||||
html = response.text
|
||||
|
||||
# Should contain dashboard elements
|
||||
if "Home Automation" not in html:
|
||||
print(" ✗ FAILED: Root path doesn't show dashboard")
|
||||
return False
|
||||
|
||||
if "Wohnzimmer" not in html:
|
||||
print(" ✗ FAILED: Root path missing rooms")
|
||||
return False
|
||||
|
||||
print(" ✓ Root path shows dashboard")
|
||||
print()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ FAILED: {e}")
|
||||
print()
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Run all acceptance tests."""
|
||||
print("=" * 60)
|
||||
print("Testing Dashboard Route")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
# Check if UI is running
|
||||
print("Prerequisites: Checking if UI is running on port 8002...")
|
||||
try:
|
||||
response = httpx.get("http://localhost:8002/dashboard", timeout=3.0)
|
||||
print("✓ UI is running")
|
||||
print()
|
||||
except Exception as e:
|
||||
print(f"✗ Cannot reach UI: {e}")
|
||||
print(" Start UI with: poetry run uvicorn apps.ui.main:app --port 8002")
|
||||
print()
|
||||
sys.exit(1)
|
||||
|
||||
results = []
|
||||
|
||||
# Run tests
|
||||
results.append(("Dashboard loads", test_dashboard_loads()))
|
||||
results.append(("Room order", test_room_order()))
|
||||
results.append(("Device rank sorting", test_device_rank_sorting()))
|
||||
results.append(("Root redirect", test_root_redirect()))
|
||||
|
||||
# Summary
|
||||
print("=" * 60)
|
||||
passed = sum(1 for _, result in results if result)
|
||||
total = len(results)
|
||||
print(f"Results: {passed}/{total} tests passed")
|
||||
print("=" * 60)
|
||||
|
||||
for name, result in results:
|
||||
status = "✓" if result else "✗"
|
||||
print(f" {status} {name}")
|
||||
|
||||
if passed == total:
|
||||
print()
|
||||
print("All tests passed!")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print()
|
||||
print("Some tests failed.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
288
tools/test_layout_loader.py
Normal file
288
tools/test_layout_loader.py
Normal file
@@ -0,0 +1,288 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test layout loader and models."""
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from packages.home_capabilities.layout import DeviceTile, Room, UiLayout, load_layout
|
||||
|
||||
|
||||
def test_device_tile_creation():
|
||||
"""Test DeviceTile model creation."""
|
||||
print("Test 1: DeviceTile Creation")
|
||||
|
||||
tile = DeviceTile(
|
||||
device_id="test_lamp",
|
||||
title="Test Lamp",
|
||||
icon="💡",
|
||||
rank=10
|
||||
)
|
||||
|
||||
assert tile.device_id == "test_lamp"
|
||||
assert tile.title == "Test Lamp"
|
||||
assert tile.icon == "💡"
|
||||
assert tile.rank == 10
|
||||
|
||||
print(" ✓ DeviceTile created successfully")
|
||||
return True
|
||||
|
||||
|
||||
def test_room_creation():
|
||||
"""Test Room model creation."""
|
||||
print("\nTest 2: Room Creation")
|
||||
|
||||
tiles = [
|
||||
DeviceTile(device_id="lamp1", title="Lamp 1", icon="💡", rank=1),
|
||||
DeviceTile(device_id="lamp2", title="Lamp 2", icon="💡", rank=2)
|
||||
]
|
||||
|
||||
room = Room(name="Living Room", devices=tiles)
|
||||
|
||||
assert room.name == "Living Room"
|
||||
assert len(room.devices) == 2
|
||||
assert room.devices[0].rank == 1
|
||||
|
||||
print(" ✓ Room with 2 devices created")
|
||||
return True
|
||||
|
||||
|
||||
def test_ui_layout_creation():
|
||||
"""Test UiLayout model creation."""
|
||||
print("\nTest 3: UiLayout Creation")
|
||||
|
||||
rooms = [
|
||||
Room(
|
||||
name="Room 1",
|
||||
devices=[DeviceTile(device_id="d1", title="D1", icon="💡", rank=1)]
|
||||
),
|
||||
Room(
|
||||
name="Room 2",
|
||||
devices=[DeviceTile(device_id="d2", title="D2", icon="💡", rank=1)]
|
||||
)
|
||||
]
|
||||
|
||||
layout = UiLayout(rooms=rooms)
|
||||
|
||||
assert len(layout.rooms) == 2
|
||||
assert layout.total_devices() == 2
|
||||
|
||||
print(" ✓ UiLayout with 2 rooms created")
|
||||
print(f" ✓ Total devices: {layout.total_devices()}")
|
||||
return True
|
||||
|
||||
|
||||
def test_layout_validation_empty_rooms():
|
||||
"""Test that layout validation rejects empty rooms list."""
|
||||
print("\nTest 4: Validation - Empty Rooms")
|
||||
|
||||
try:
|
||||
UiLayout(rooms=[])
|
||||
print(" ✗ Should have raised ValueError")
|
||||
return False
|
||||
except ValueError as e:
|
||||
if "at least one room" in str(e):
|
||||
print(f" ✓ Correct validation error: {e}")
|
||||
return True
|
||||
else:
|
||||
print(f" ✗ Wrong error message: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_load_layout_from_file():
|
||||
"""Test loading layout from YAML file."""
|
||||
print("\nTest 5: Load Layout from YAML")
|
||||
|
||||
yaml_content = """
|
||||
rooms:
|
||||
- name: Wohnzimmer
|
||||
devices:
|
||||
- device_id: test_lampe_1
|
||||
title: Stehlampe
|
||||
icon: "💡"
|
||||
rank: 10
|
||||
- device_id: test_lampe_2
|
||||
title: Tischlampe
|
||||
icon: "💡"
|
||||
rank: 20
|
||||
|
||||
- name: Schlafzimmer
|
||||
devices:
|
||||
- device_id: test_lampe_3
|
||||
title: Nachttischlampe
|
||||
icon: "🛏️"
|
||||
rank: 5
|
||||
"""
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
|
||||
f.write(yaml_content)
|
||||
temp_path = Path(f.name)
|
||||
|
||||
try:
|
||||
layout = load_layout(str(temp_path))
|
||||
|
||||
assert len(layout.rooms) == 2
|
||||
assert layout.rooms[0].name == "Wohnzimmer"
|
||||
assert len(layout.rooms[0].devices) == 2
|
||||
assert layout.total_devices() == 3
|
||||
|
||||
# Check device sorting by rank
|
||||
wohnzimmer_devices = layout.rooms[0].devices
|
||||
assert wohnzimmer_devices[0].rank == 10
|
||||
assert wohnzimmer_devices[1].rank == 20
|
||||
|
||||
print(" ✓ Layout loaded from YAML")
|
||||
print(f" ✓ Rooms: {len(layout.rooms)}")
|
||||
print(f" ✓ Total devices: {layout.total_devices()}")
|
||||
print(f" ✓ Room order preserved: {[r.name for r in layout.rooms]}")
|
||||
|
||||
return True
|
||||
|
||||
finally:
|
||||
temp_path.unlink()
|
||||
|
||||
|
||||
def test_load_layout_missing_file():
|
||||
"""Test error handling for missing file."""
|
||||
print("\nTest 6: Missing File Error")
|
||||
|
||||
try:
|
||||
load_layout("/nonexistent/path/layout.yaml")
|
||||
print(" ✗ Should have raised FileNotFoundError")
|
||||
return False
|
||||
except FileNotFoundError as e:
|
||||
if "not found" in str(e):
|
||||
print(f" ✓ Correct error for missing file")
|
||||
return True
|
||||
else:
|
||||
print(f" ✗ Wrong error message: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_load_layout_invalid_yaml():
|
||||
"""Test error handling for invalid YAML."""
|
||||
print("\nTest 7: Invalid YAML Error")
|
||||
|
||||
yaml_content = """
|
||||
rooms:
|
||||
- name: Room1
|
||||
devices: [invalid yaml structure
|
||||
"""
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
|
||||
f.write(yaml_content)
|
||||
temp_path = Path(f.name)
|
||||
|
||||
try:
|
||||
load_layout(str(temp_path))
|
||||
print(" ✗ Should have raised YAMLError")
|
||||
temp_path.unlink()
|
||||
return False
|
||||
except Exception as e:
|
||||
temp_path.unlink()
|
||||
if "YAML" in str(type(e).__name__) or "parse" in str(e).lower():
|
||||
print(f" ✓ Correct YAML parsing error")
|
||||
return True
|
||||
else:
|
||||
print(f" ✗ Unexpected error: {type(e).__name__}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_load_layout_missing_required_fields():
|
||||
"""Test validation for missing required fields."""
|
||||
print("\nTest 8: Missing Required Fields")
|
||||
|
||||
yaml_content = """
|
||||
rooms:
|
||||
- name: Room1
|
||||
devices:
|
||||
- device_id: lamp1
|
||||
title: Lamp
|
||||
# Missing: icon, rank
|
||||
"""
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
|
||||
f.write(yaml_content)
|
||||
temp_path = Path(f.name)
|
||||
|
||||
try:
|
||||
load_layout(str(temp_path))
|
||||
print(" ✗ Should have raised ValueError")
|
||||
temp_path.unlink()
|
||||
return False
|
||||
except ValueError as e:
|
||||
temp_path.unlink()
|
||||
if "icon" in str(e) or "rank" in str(e) or "required" in str(e).lower():
|
||||
print(f" ✓ Validation error for missing fields")
|
||||
return True
|
||||
else:
|
||||
print(f" ✗ Wrong error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_load_default_layout():
|
||||
"""Test loading default layout from workspace."""
|
||||
print("\nTest 9: Load Default Layout (config/layout.yaml)")
|
||||
|
||||
try:
|
||||
layout = load_layout()
|
||||
|
||||
print(f" ✓ Default layout loaded")
|
||||
print(f" ✓ Rooms: {len(layout.rooms)} ({', '.join(r.name for r in layout.rooms)})")
|
||||
print(f" ✓ Total devices: {layout.total_devices()}")
|
||||
|
||||
for room in layout.rooms:
|
||||
print(f" - {room.name}: {len(room.devices)} devices")
|
||||
for device in room.devices:
|
||||
print(f" • {device.title} (id={device.device_id}, rank={device.rank})")
|
||||
|
||||
return True
|
||||
|
||||
except FileNotFoundError:
|
||||
print(" ⚠ Default layout.yaml not found (expected at config/layout.yaml)")
|
||||
return True # Not a failure if file doesn't exist yet
|
||||
except Exception as e:
|
||||
print(f" ✗ Error loading default layout: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Run all tests."""
|
||||
print("=" * 60)
|
||||
print("Testing Layout Loader and Models")
|
||||
print("=" * 60)
|
||||
|
||||
tests = [
|
||||
test_device_tile_creation,
|
||||
test_room_creation,
|
||||
test_ui_layout_creation,
|
||||
test_layout_validation_empty_rooms,
|
||||
test_load_layout_from_file,
|
||||
test_load_layout_missing_file,
|
||||
test_load_layout_invalid_yaml,
|
||||
test_load_layout_missing_required_fields,
|
||||
test_load_default_layout,
|
||||
]
|
||||
|
||||
results = []
|
||||
for test in tests:
|
||||
try:
|
||||
results.append(test())
|
||||
except Exception as e:
|
||||
print(f" ✗ Unexpected exception: {e}")
|
||||
results.append(False)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
passed = sum(results)
|
||||
total = len(results)
|
||||
print(f"Results: {passed}/{total} tests passed")
|
||||
print("=" * 60)
|
||||
|
||||
return 0 if all(results) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
296
tools/test_responsive_dashboard.py
Normal file
296
tools/test_responsive_dashboard.py
Normal file
@@ -0,0 +1,296 @@
|
||||
"""
|
||||
Acceptance tests for Responsive Dashboard.
|
||||
|
||||
Tests:
|
||||
1. Desktop: 4 columns grid
|
||||
2. Tablet: 2 columns grid
|
||||
3. Mobile: 1 column grid
|
||||
4. POST requests work (via API check)
|
||||
5. No horizontal scrolling on any viewport
|
||||
"""
|
||||
import sys
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
|
||||
def test_dashboard_html_structure():
|
||||
"""Test 1: Dashboard has correct HTML structure."""
|
||||
print("Test 1: Dashboard HTML Structure")
|
||||
print("-" * 60)
|
||||
|
||||
try:
|
||||
response = httpx.get("http://localhost:8002/dashboard", timeout=5.0)
|
||||
response.raise_for_status()
|
||||
|
||||
html = response.text
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
|
||||
# Check for grid container
|
||||
grid = soup.find('div', class_='devices-grid')
|
||||
if not grid:
|
||||
print(" ✗ FAILED: Missing .devices-grid container")
|
||||
return False
|
||||
|
||||
# Check for device tiles
|
||||
tiles = soup.find_all('div', class_='device-tile')
|
||||
if len(tiles) < 2:
|
||||
print(f" ✗ FAILED: Expected at least 2 device tiles, found {len(tiles)}")
|
||||
return False
|
||||
|
||||
# Check for state spans
|
||||
state_spans = soup.find_all('span', id=lambda x: x and x.startswith('state-'))
|
||||
if len(state_spans) < 2:
|
||||
print(f" ✗ FAILED: Expected state spans, found {len(state_spans)}")
|
||||
return False
|
||||
|
||||
# Check for ON/OFF buttons
|
||||
btn_on = soup.find_all('button', class_='btn-on')
|
||||
btn_off = soup.find_all('button', class_='btn-off')
|
||||
|
||||
if not btn_on or not btn_off:
|
||||
print(" ✗ FAILED: Missing ON/OFF buttons")
|
||||
return False
|
||||
|
||||
print(f" ✓ Found {len(tiles)} device tiles")
|
||||
print(f" ✓ Found {len(state_spans)} state indicators")
|
||||
print(f" ✓ Found {len(btn_on)} ON buttons and {len(btn_off)} OFF buttons")
|
||||
print()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ FAILED: {e}")
|
||||
print()
|
||||
return False
|
||||
|
||||
|
||||
def test_responsive_css():
|
||||
"""Test 2: CSS has responsive grid rules."""
|
||||
print("Test 2: Responsive CSS")
|
||||
print("-" * 60)
|
||||
|
||||
try:
|
||||
response = httpx.get("http://localhost:8002/static/style.css", timeout=5.0)
|
||||
response.raise_for_status()
|
||||
|
||||
css = response.text
|
||||
|
||||
# Check for desktop 4 columns
|
||||
if 'grid-template-columns: repeat(4, 1fr)' not in css:
|
||||
print(" ✗ FAILED: Missing desktop grid (4 columns)")
|
||||
return False
|
||||
|
||||
# Check for tablet media query (2 columns)
|
||||
if 'max-width: 1024px' not in css or 'repeat(2, 1fr)' not in css:
|
||||
print(" ✗ FAILED: Missing tablet media query (2 columns)")
|
||||
return False
|
||||
|
||||
# Check for mobile media query (1 column)
|
||||
if 'max-width: 640px' not in css:
|
||||
print(" ✗ FAILED: Missing mobile media query")
|
||||
return False
|
||||
|
||||
print(" ✓ Desktop: 4 columns (grid-template-columns: repeat(4, 1fr))")
|
||||
print(" ✓ Tablet: 2 columns (@media max-width: 1024px)")
|
||||
print(" ✓ Mobile: 1 column (@media max-width: 640px)")
|
||||
print()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ FAILED: {e}")
|
||||
print()
|
||||
return False
|
||||
|
||||
|
||||
def test_javascript_functions():
|
||||
"""Test 3: JavaScript POST function exists."""
|
||||
print("Test 3: JavaScript POST Function")
|
||||
print("-" * 60)
|
||||
|
||||
try:
|
||||
response = httpx.get("http://localhost:8002/dashboard", timeout=5.0)
|
||||
response.raise_for_status()
|
||||
|
||||
html = response.text
|
||||
|
||||
# Check for setDeviceState function
|
||||
if 'function setDeviceState' not in html and 'async function setDeviceState' not in html:
|
||||
print(" ✗ FAILED: Missing setDeviceState function")
|
||||
return False
|
||||
|
||||
# Check for fetch POST call
|
||||
if 'fetch(' not in html or 'method: \'POST\'' not in html:
|
||||
print(" ✗ FAILED: Missing fetch POST call")
|
||||
return False
|
||||
|
||||
# Check for correct API endpoint pattern
|
||||
if '/devices/${deviceId}/set' not in html and '/devices/' not in html:
|
||||
print(" ✗ FAILED: Missing correct API endpoint")
|
||||
return False
|
||||
|
||||
# Check for JSON payload
|
||||
if 'type: \'light\'' not in html and '"type":"light"' not in html:
|
||||
print(" ✗ FAILED: Missing correct JSON payload")
|
||||
return False
|
||||
|
||||
print(" ✓ setDeviceState function defined")
|
||||
print(" ✓ Uses fetch with POST method")
|
||||
print(" ✓ Correct endpoint: /devices/{deviceId}/set")
|
||||
print(" ✓ Correct payload: {type:'light', payload:{power:...}}")
|
||||
print()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ FAILED: {e}")
|
||||
print()
|
||||
return False
|
||||
|
||||
|
||||
def test_device_controls():
|
||||
"""Test 4: Devices have correct controls."""
|
||||
print("Test 4: Device Controls")
|
||||
print("-" * 60)
|
||||
|
||||
try:
|
||||
response = httpx.get("http://localhost:8002/dashboard", timeout=5.0)
|
||||
response.raise_for_status()
|
||||
|
||||
html = response.text
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
|
||||
# Find device tiles
|
||||
tiles = soup.find_all('div', class_='device-tile')
|
||||
|
||||
for tile in tiles:
|
||||
device_id = tile.get('data-device-id')
|
||||
|
||||
# Check for device header
|
||||
header = tile.find('div', class_='device-header')
|
||||
if not header:
|
||||
print(f" ✗ FAILED: Device {device_id} missing header")
|
||||
return False
|
||||
|
||||
# Check for icon and title
|
||||
icon = tile.find('div', class_='device-icon')
|
||||
title = tile.find('h3', class_='device-title')
|
||||
device_id_elem = tile.find('p', class_='device-id')
|
||||
|
||||
if not icon or not title or not device_id_elem:
|
||||
print(f" ✗ FAILED: Device {device_id} missing icon/title/id")
|
||||
return False
|
||||
|
||||
# Check for state indicator
|
||||
state_span = tile.find('span', id=f'state-{device_id}')
|
||||
if not state_span:
|
||||
print(f" ✗ FAILED: Device {device_id} missing state indicator")
|
||||
return False
|
||||
|
||||
print(f" ✓ All {len(tiles)} devices have:")
|
||||
print(" • Icon, title, and device_id")
|
||||
print(" • State indicator (span#state-{{device_id}})")
|
||||
print(" • ON/OFF buttons (for lights with power feature)")
|
||||
print()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ FAILED: {e}")
|
||||
print()
|
||||
return False
|
||||
|
||||
|
||||
def test_rank_sorting():
|
||||
"""Test 5: Devices sorted by rank."""
|
||||
print("Test 5: Device Rank Sorting")
|
||||
print("-" * 60)
|
||||
|
||||
try:
|
||||
response = httpx.get("http://localhost:8002/dashboard", timeout=5.0)
|
||||
response.raise_for_status()
|
||||
|
||||
html = response.text
|
||||
|
||||
# In Wohnzimmer: Deckenlampe (rank=5) should come before Stehlampe (rank=10)
|
||||
deckenlampe_pos = html.find('device-title">Deckenlampe<')
|
||||
stehlampe_pos = html.find('device-title">Stehlampe<')
|
||||
|
||||
if deckenlampe_pos == -1 or stehlampe_pos == -1:
|
||||
print(" ℹ INFO: Test devices not found (expected for test)")
|
||||
print()
|
||||
return True
|
||||
|
||||
if deckenlampe_pos > stehlampe_pos:
|
||||
print(" ✗ FAILED: Devices not sorted by rank")
|
||||
return False
|
||||
|
||||
print(" ✓ Devices sorted by rank (Deckenlampe before Stehlampe)")
|
||||
print()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ FAILED: {e}")
|
||||
print()
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Run all acceptance tests."""
|
||||
print("=" * 60)
|
||||
print("Testing Responsive Dashboard")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
# Check if UI is running
|
||||
print("Prerequisites: Checking if UI is running on port 8002...")
|
||||
try:
|
||||
response = httpx.get("http://localhost:8002/dashboard", timeout=3.0)
|
||||
print("✓ UI is running")
|
||||
print()
|
||||
except Exception as e:
|
||||
print(f"✗ Cannot reach UI: {e}")
|
||||
print(" Start UI with: poetry run uvicorn apps.ui.main:app --port 8002")
|
||||
print()
|
||||
sys.exit(1)
|
||||
|
||||
results = []
|
||||
|
||||
# Run tests
|
||||
results.append(("HTML Structure", test_dashboard_html_structure()))
|
||||
results.append(("Responsive CSS", test_responsive_css()))
|
||||
results.append(("JavaScript POST", test_javascript_functions()))
|
||||
results.append(("Device Controls", test_device_controls()))
|
||||
results.append(("Rank Sorting", test_rank_sorting()))
|
||||
|
||||
# Summary
|
||||
print("=" * 60)
|
||||
passed = sum(1 for _, result in results if result)
|
||||
total = len(results)
|
||||
print(f"Results: {passed}/{total} tests passed")
|
||||
print("=" * 60)
|
||||
|
||||
for name, result in results:
|
||||
status = "✓" if result else "✗"
|
||||
print(f" {status} {name}")
|
||||
|
||||
print()
|
||||
print("Manual Tests Required:")
|
||||
print(" 1. Open http://localhost:8002/dashboard in browser")
|
||||
print(" 2. Resize browser window to test responsive breakpoints:")
|
||||
print(" - Desktop (>1024px): Should show 4 columns")
|
||||
print(" - Tablet (640-1024px): Should show 2 columns")
|
||||
print(" - Mobile (<640px): Should show 1 column")
|
||||
print(" 3. Click ON/OFF buttons and check Network tab in DevTools")
|
||||
print(" - Should see POST to http://localhost:8001/devices/.../set")
|
||||
print(" - No JavaScript errors in Console")
|
||||
print(" 4. Verify no horizontal scrolling at any viewport size")
|
||||
|
||||
if passed == total:
|
||||
print()
|
||||
print("All automated tests passed!")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print()
|
||||
print("Some tests failed.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user