dynamic dashboard initial
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, AsyncGenerator
|
from typing import Any, AsyncGenerator
|
||||||
@@ -16,6 +17,8 @@ from pydantic import BaseModel, ValidationError
|
|||||||
|
|
||||||
from packages.home_capabilities import CAP_VERSION, LightState
|
from packages.home_capabilities import CAP_VERSION, LightState
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Home Automation API",
|
title="Home Automation API",
|
||||||
description="API for home automation system",
|
description="API for home automation system",
|
||||||
@@ -71,6 +74,7 @@ class DeviceInfo(BaseModel):
|
|||||||
device_id: str
|
device_id: str
|
||||||
type: str
|
type: str
|
||||||
name: str
|
name: str
|
||||||
|
features: dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
# Configuration helpers
|
# Configuration helpers
|
||||||
@@ -145,19 +149,57 @@ async def get_devices() -> list[DeviceInfo]:
|
|||||||
"""Get list of available devices.
|
"""Get list of available devices.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list: List of device information
|
list: List of device information including features
|
||||||
"""
|
"""
|
||||||
devices = load_devices()
|
devices = load_devices()
|
||||||
return [
|
return [
|
||||||
DeviceInfo(
|
DeviceInfo(
|
||||||
device_id=device["device_id"],
|
device_id=device["device_id"],
|
||||||
type=device["type"],
|
type=device["type"],
|
||||||
name=device.get("name", device["device_id"])
|
name=device.get("name", device["device_id"]),
|
||||||
|
features=device.get("features", {})
|
||||||
)
|
)
|
||||||
for device in devices
|
for device in devices
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/layout")
|
||||||
|
async def get_layout() -> dict[str, Any]:
|
||||||
|
"""Get UI layout configuration.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Layout configuration with rooms and device tiles
|
||||||
|
"""
|
||||||
|
from packages.home_capabilities import load_layout
|
||||||
|
|
||||||
|
try:
|
||||||
|
layout = load_layout()
|
||||||
|
|
||||||
|
# Convert Pydantic models to dict
|
||||||
|
rooms = []
|
||||||
|
for room in layout.rooms:
|
||||||
|
devices = []
|
||||||
|
for tile in room.devices:
|
||||||
|
devices.append({
|
||||||
|
"device_id": tile.device_id,
|
||||||
|
"title": tile.title,
|
||||||
|
"icon": tile.icon,
|
||||||
|
"rank": tile.rank
|
||||||
|
})
|
||||||
|
|
||||||
|
rooms.append({
|
||||||
|
"name": room.name,
|
||||||
|
"devices": devices
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"rooms": rooms}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading layout: {e}")
|
||||||
|
# Return empty layout on error
|
||||||
|
return {"rooms": []}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/devices/{device_id}/set", status_code=status.HTTP_202_ACCEPTED)
|
@app.post("/devices/{device_id}/set", status_code=status.HTTP_202_ACCEPTED)
|
||||||
async def set_device(device_id: str, request: SetDeviceRequest) -> dict[str, str]:
|
async def set_device(device_id: str, request: SetDeviceRequest) -> dict[str, str]:
|
||||||
"""Set device state.
|
"""Set device state.
|
||||||
|
|||||||
116
apps/ui/api_client.py
Normal file
116
apps/ui/api_client.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"""
|
||||||
|
HTTP Client for fetching devices from the API Gateway.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_devices(api_base: str = "http://localhost:8001") -> list[dict]:
|
||||||
|
"""
|
||||||
|
Fetch devices from the API Gateway.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_base: Base URL of the API Gateway (default: http://localhost:8001)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of device dictionaries. Each device contains at least:
|
||||||
|
- device_id (str): Unique device identifier
|
||||||
|
- type (str): Device type (e.g., "light")
|
||||||
|
- name (str): Human-readable device name
|
||||||
|
|
||||||
|
Returns empty list on error.
|
||||||
|
"""
|
||||||
|
url = f"{api_base}/devices"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = httpx.get(url, timeout=3.0)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
devices = response.json()
|
||||||
|
|
||||||
|
# API returns a list directly
|
||||||
|
if not isinstance(devices, list):
|
||||||
|
logger.warning(f"Unexpected response format from {url}: expected list, got {type(devices)}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
logger.info(f"Fetched {len(devices)} devices from API Gateway")
|
||||||
|
return devices
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
logger.warning(f"Timeout while fetching devices from {url}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.warning(f"HTTP error {e.response.status_code} while fetching devices from {url}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.warning(f"Request error while fetching devices from {url}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Unexpected error while fetching devices from {url}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_layout(api_base: str = "http://localhost:8001") -> dict:
|
||||||
|
"""
|
||||||
|
Fetch UI layout from the API Gateway.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_base: Base URL of the API Gateway (default: http://localhost:8001)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Layout dictionary with structure:
|
||||||
|
{
|
||||||
|
"rooms": [
|
||||||
|
{
|
||||||
|
"name": str,
|
||||||
|
"devices": [
|
||||||
|
{
|
||||||
|
"device_id": str,
|
||||||
|
"title": str,
|
||||||
|
"icon": str,
|
||||||
|
"rank": int
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
Returns empty layout on error.
|
||||||
|
"""
|
||||||
|
url = f"{api_base}/layout"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = httpx.get(url, timeout=3.0)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
layout = response.json()
|
||||||
|
|
||||||
|
# API returns a dict with "rooms" key
|
||||||
|
if not isinstance(layout, dict) or "rooms" not in layout:
|
||||||
|
logger.warning(f"Unexpected response format from {url}: expected dict with 'rooms', got {type(layout)}")
|
||||||
|
return {"rooms": []}
|
||||||
|
|
||||||
|
logger.info(f"Fetched layout with {len(layout['rooms'])} rooms from API Gateway")
|
||||||
|
return layout
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
logger.warning(f"Timeout while fetching layout from {url}")
|
||||||
|
return {"rooms": []}
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.warning(f"HTTP error {e.response.status_code} while fetching layout from {url}")
|
||||||
|
return {"rooms": []}
|
||||||
|
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.warning(f"Request error while fetching layout from {url}: {e}")
|
||||||
|
return {"rooms": []}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Unexpected error while fetching layout from {url}: {e}")
|
||||||
|
return {"rooms": []}
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
"""UI main entry point."""
|
"""UI main entry point."""
|
||||||
|
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from apps.ui.api_client import fetch_devices, fetch_layout
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Initialize FastAPI app
|
# Initialize FastAPI app
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Home Automation UI",
|
title="Home Automation UI",
|
||||||
@@ -17,18 +23,91 @@ app = FastAPI(
|
|||||||
templates_dir = Path(__file__).parent / "templates"
|
templates_dir = Path(__file__).parent / "templates"
|
||||||
templates = Jinja2Templates(directory=str(templates_dir))
|
templates = Jinja2Templates(directory=str(templates_dir))
|
||||||
|
|
||||||
|
# Setup static files
|
||||||
|
static_dir = Path(__file__).parent / "static"
|
||||||
|
static_dir.mkdir(exist_ok=True)
|
||||||
|
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def index(request: Request) -> HTMLResponse:
|
async def index(request: Request) -> HTMLResponse:
|
||||||
"""Render the main UI page.
|
"""Redirect to dashboard.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: The FastAPI request object
|
request: The FastAPI request object
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
HTMLResponse: Rendered HTML template
|
HTMLResponse: Rendered dashboard
|
||||||
"""
|
"""
|
||||||
return templates.TemplateResponse("index.html", {"request": request})
|
return await dashboard(request)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/dashboard", response_class=HTMLResponse)
|
||||||
|
async def dashboard(request: Request) -> HTMLResponse:
|
||||||
|
"""Render the dashboard with rooms and devices.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The FastAPI request object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HTMLResponse: Rendered dashboard template
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Load layout from API
|
||||||
|
layout_data = fetch_layout()
|
||||||
|
|
||||||
|
# Fetch devices from API (now includes features)
|
||||||
|
api_devices = fetch_devices()
|
||||||
|
|
||||||
|
# Create device lookup by device_id
|
||||||
|
device_map = {d["device_id"]: d for d in api_devices}
|
||||||
|
|
||||||
|
# Build rooms with merged device data
|
||||||
|
rooms = []
|
||||||
|
for room in layout_data.get("rooms", []):
|
||||||
|
devices = []
|
||||||
|
for tile in room.get("devices", []):
|
||||||
|
# Merge tile data with API device data
|
||||||
|
device_data = {
|
||||||
|
"device_id": tile["device_id"],
|
||||||
|
"title": tile["title"],
|
||||||
|
"icon": tile["icon"],
|
||||||
|
"rank": tile["rank"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add type, name, and features from API if available
|
||||||
|
if tile["device_id"] in device_map:
|
||||||
|
api_device = device_map[tile["device_id"]]
|
||||||
|
device_data["type"] = api_device.get("type")
|
||||||
|
device_data["name"] = api_device.get("name")
|
||||||
|
device_data["features"] = api_device.get("features", {})
|
||||||
|
else:
|
||||||
|
device_data["features"] = {}
|
||||||
|
|
||||||
|
devices.append(device_data)
|
||||||
|
|
||||||
|
# Sort devices by rank (ascending)
|
||||||
|
devices.sort(key=lambda d: d["rank"])
|
||||||
|
|
||||||
|
rooms.append({
|
||||||
|
"name": room["name"],
|
||||||
|
"devices": devices
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(f"Rendering dashboard with {len(rooms)} rooms")
|
||||||
|
|
||||||
|
return templates.TemplateResponse("dashboard.html", {
|
||||||
|
"request": request,
|
||||||
|
"rooms": rooms
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error rendering dashboard: {e}", exc_info=True)
|
||||||
|
# Fallback to empty dashboard
|
||||||
|
return templates.TemplateResponse("dashboard.html", {
|
||||||
|
"request": request,
|
||||||
|
"rooms": []
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
|||||||
257
apps/ui/static/style.css
Normal file
257
apps/ui/static/style.css
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
/* Home Automation Dashboard Styles */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--primary-color: #3b82f6;
|
||||||
|
--primary-hover: #2563eb;
|
||||||
|
--bg-color: #f8fafc;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--text-primary: #1e293b;
|
||||||
|
--text-secondary: #64748b;
|
||||||
|
--border-color: #e2e8f0;
|
||||||
|
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
header {
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Content */
|
||||||
|
main {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Room Section */
|
||||||
|
.room {
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-bottom: 2px solid var(--border-color);
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Device Grid */
|
||||||
|
.devices-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tablet: 2 columns */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.devices-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: 1 column */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.devices-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Device Tile */
|
||||||
|
.device-tile {
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-tile:hover {
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-id {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: 'Monaco', 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Device State */
|
||||||
|
.device-state {
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-text {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Device Controls */
|
||||||
|
.device-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.625rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.025em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-on {
|
||||||
|
background-color: #10b981;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-on:hover {
|
||||||
|
background-color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-off {
|
||||||
|
background-color: #ef4444;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-off:hover {
|
||||||
|
background-color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty State */
|
||||||
|
.empty-state {
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 3rem 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state .hint {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
111
apps/ui/templates/dashboard.html
Normal file
111
apps/ui/templates/dashboard.html
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Home Automation Dashboard</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>🏠 Home Automation</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{% if rooms %}
|
||||||
|
{% for room in rooms %}
|
||||||
|
<section class="room">
|
||||||
|
<h2 class="room-title">{{ room.name }}</h2>
|
||||||
|
|
||||||
|
<div class="devices-grid">
|
||||||
|
{% for device in room.devices %}
|
||||||
|
<div class="device-tile" data-device-id="{{ device.device_id }}">
|
||||||
|
<div class="device-header">
|
||||||
|
<div class="device-icon">{{ device.icon }}</div>
|
||||||
|
<div class="device-info">
|
||||||
|
<h3 class="device-title">{{ device.title }}</h3>
|
||||||
|
<p class="device-id">{{ device.device_id }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="device-state">
|
||||||
|
<span id="state-{{ device.device_id }}" class="state-text">—</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if device.type == "light" and device.features.power %}
|
||||||
|
<div class="device-controls">
|
||||||
|
<button class="btn btn-on" onclick="setDeviceState('{{ device.device_id }}', 'on')">
|
||||||
|
AN
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-off" onclick="setDeviceState('{{ device.device_id }}', 'off')">
|
||||||
|
AUS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>Keine Räume oder Geräte konfiguriert.</p>
|
||||||
|
<p class="hint">Prüfe config/layout.yaml und das API-Gateway.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>Home Automation System v0.1.0</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Set device state via API
|
||||||
|
async function setDeviceState(deviceId, power) {
|
||||||
|
const stateSpan = document.getElementById(`state-${deviceId}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
stateSpan.textContent = `⏳ ${power}...`;
|
||||||
|
|
||||||
|
const response = await fetch(`http://localhost:8001/devices/${deviceId}/set`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: 'light',
|
||||||
|
payload: {
|
||||||
|
power: power
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
console.log('Device state set:', deviceId, power, result);
|
||||||
|
|
||||||
|
stateSpan.textContent = `✓ ${power}`;
|
||||||
|
|
||||||
|
// Reset to "—" after 2 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
stateSpan.textContent = '—';
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error setting device state:', error);
|
||||||
|
stateSpan.textContent = `✗ Error`;
|
||||||
|
|
||||||
|
// Reset after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
stateSpan.textContent = '—';
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -32,3 +32,12 @@ devices:
|
|||||||
topics:
|
topics:
|
||||||
set: "vendor/test_lampe_2/set"
|
set: "vendor/test_lampe_2/set"
|
||||||
state: "vendor/test_lampe_2/state"
|
state: "vendor/test_lampe_2/state"
|
||||||
|
- device_id: test_lampe_3
|
||||||
|
type: light
|
||||||
|
cap_version: "light@1.2.0"
|
||||||
|
technology: zigbee2mqtt
|
||||||
|
features:
|
||||||
|
power: true
|
||||||
|
topics:
|
||||||
|
set: "vendor/test_lampe_3/set"
|
||||||
|
state: "vendor/test_lampe_3/state"
|
||||||
|
|||||||
25
config/layout.yaml
Normal file
25
config/layout.yaml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# UI Layout Configuration
|
||||||
|
# Defines rooms and device tiles for the home automation UI
|
||||||
|
|
||||||
|
rooms:
|
||||||
|
- name: Wohnzimmer
|
||||||
|
devices:
|
||||||
|
- device_id: test_lampe_2
|
||||||
|
title: Deckenlampe
|
||||||
|
icon: "💡"
|
||||||
|
rank: 5
|
||||||
|
- device_id: test_lampe_1
|
||||||
|
title: Stehlampe
|
||||||
|
icon: "<22>"
|
||||||
|
rank: 10
|
||||||
|
|
||||||
|
- name: Schlafzimmer
|
||||||
|
devices:
|
||||||
|
- device_id: test_lampe_3
|
||||||
|
title: Nachttischlampe
|
||||||
|
icon: "🛏️"
|
||||||
|
rank: 10
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Home capabilities package."""
|
"""Home capabilities package."""
|
||||||
|
|
||||||
from packages.home_capabilities.light import CAP_VERSION, LightState
|
from packages.home_capabilities.light import CAP_VERSION, LightState
|
||||||
|
from packages.home_capabilities.layout import DeviceTile, Room, UiLayout, load_layout
|
||||||
|
|
||||||
__all__ = ["LightState", "CAP_VERSION"]
|
__all__ = ["LightState", "CAP_VERSION", "DeviceTile", "Room", "UiLayout", "load_layout"]
|
||||||
|
|||||||
158
packages/home_capabilities/layout.py
Normal file
158
packages/home_capabilities/layout.py
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
"""UI Layout models and loader for config/layout.yaml."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceTile(BaseModel):
|
||||||
|
"""Represents a device tile in the UI.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
device_id: Unique identifier of the device
|
||||||
|
title: Display title for the device
|
||||||
|
icon: Icon name or emoji for the device
|
||||||
|
rank: Sort order within the room (lower = first)
|
||||||
|
"""
|
||||||
|
|
||||||
|
device_id: str = Field(
|
||||||
|
...,
|
||||||
|
description="Unique device identifier"
|
||||||
|
)
|
||||||
|
|
||||||
|
title: str = Field(
|
||||||
|
...,
|
||||||
|
description="Display title for the device"
|
||||||
|
)
|
||||||
|
|
||||||
|
icon: str = Field(
|
||||||
|
...,
|
||||||
|
description="Icon name or emoji"
|
||||||
|
)
|
||||||
|
|
||||||
|
rank: int = Field(
|
||||||
|
...,
|
||||||
|
ge=0,
|
||||||
|
description="Sort order (lower values appear first)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Room(BaseModel):
|
||||||
|
"""Represents a room containing devices.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
name: Room name (e.g., "Wohnzimmer", "Küche")
|
||||||
|
devices: List of device tiles in this room
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str = Field(
|
||||||
|
...,
|
||||||
|
description="Room name"
|
||||||
|
)
|
||||||
|
|
||||||
|
devices: list[DeviceTile] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Device tiles in this room"
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator('devices')
|
||||||
|
@classmethod
|
||||||
|
def validate_devices(cls, v: list[DeviceTile]) -> list[DeviceTile]:
|
||||||
|
"""Validate that devices list is not empty if provided."""
|
||||||
|
if v is not None and len(v) == 0:
|
||||||
|
logger.warning("Room has empty devices list")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class UiLayout(BaseModel):
|
||||||
|
"""Represents the complete UI layout configuration.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
rooms: List of rooms in the layout
|
||||||
|
"""
|
||||||
|
|
||||||
|
rooms: list[Room] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Rooms in the layout"
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator('rooms')
|
||||||
|
@classmethod
|
||||||
|
def validate_rooms(cls, v: list[Room]) -> list[Room]:
|
||||||
|
"""Validate that rooms list is not empty."""
|
||||||
|
if not v or len(v) == 0:
|
||||||
|
raise ValueError("Layout must contain at least one room")
|
||||||
|
return v
|
||||||
|
|
||||||
|
def total_devices(self) -> int:
|
||||||
|
"""Calculate total number of devices across all rooms.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Total device count
|
||||||
|
"""
|
||||||
|
return sum(len(room.devices) for room in self.rooms)
|
||||||
|
|
||||||
|
|
||||||
|
def load_layout(path: Optional[str] = None) -> UiLayout:
|
||||||
|
"""Load UI layout from YAML configuration file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Optional path to layout.yaml. If None, uses default path
|
||||||
|
(apps/ui/config/layout.yaml relative to workspace root)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
UiLayout: Parsed and validated layout configuration
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileNotFoundError: If layout file doesn't exist
|
||||||
|
ValueError: If layout validation fails
|
||||||
|
yaml.YAMLError: If YAML parsing fails
|
||||||
|
"""
|
||||||
|
# Determine config path
|
||||||
|
if path is None:
|
||||||
|
# Default: config/layout.yaml at workspace root
|
||||||
|
# This file is assumed to be in packages/home_capabilities/
|
||||||
|
workspace_root = Path(__file__).parent.parent.parent
|
||||||
|
config_path = workspace_root / "config" / "layout.yaml"
|
||||||
|
else:
|
||||||
|
config_path = Path(path)
|
||||||
|
|
||||||
|
# Check if file exists
|
||||||
|
if not config_path.exists():
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"Layout configuration not found: {config_path}. "
|
||||||
|
f"Please create a layout.yaml file with room and device definitions."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load YAML
|
||||||
|
try:
|
||||||
|
with open(config_path, "r", encoding="utf-8") as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
raise yaml.YAMLError(f"Failed to parse YAML in {config_path}: {e}")
|
||||||
|
|
||||||
|
if data is None:
|
||||||
|
raise ValueError(f"Layout file is empty: {config_path}")
|
||||||
|
|
||||||
|
# Validate and parse with Pydantic
|
||||||
|
try:
|
||||||
|
layout = UiLayout(**data)
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid layout configuration in {config_path}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log summary
|
||||||
|
total_devices = layout.total_devices()
|
||||||
|
room_names = [room.name for room in layout.rooms]
|
||||||
|
logger.info(
|
||||||
|
f"Loaded layout: {len(layout.rooms)} rooms, "
|
||||||
|
f"{total_devices} total devices (Rooms: {', '.join(room_names)})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return layout
|
||||||
@@ -21,6 +21,8 @@ jinja2 = "^3.1.6"
|
|||||||
apscheduler = "^3.11.0"
|
apscheduler = "^3.11.0"
|
||||||
redis = "^7.0.1"
|
redis = "^7.0.1"
|
||||||
paho-mqtt = "^2.1.0"
|
paho-mqtt = "^2.1.0"
|
||||||
|
httpx = "^0.28.1"
|
||||||
|
beautifulsoup4 = "^4.14.2"
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
ruff = "^0.6.0"
|
ruff = "^0.6.0"
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ BROKER_HOST = os.environ.get("MQTT_HOST", "172.16.2.16")
|
|||||||
BROKER_PORT = int(os.environ.get("MQTT_PORT", "1883"))
|
BROKER_PORT = int(os.environ.get("MQTT_PORT", "1883"))
|
||||||
|
|
||||||
# Devices to simulate
|
# 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 (one per device)
|
||||||
device_states = {
|
device_states = {
|
||||||
@@ -39,6 +39,10 @@ device_states = {
|
|||||||
"test_lampe_2": {
|
"test_lampe_2": {
|
||||||
"power": "off",
|
"power": "off",
|
||||||
"brightness": 50
|
"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