mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
test(web): smoke tests + static-analysis audits for the web UI
Guardrails so this branch's fix classes can't regress silently:
- test_web_smoke.py (24 tests): boots the pages blueprint with the same
dual registration app.py uses and asserts every page/partial returns 200
with its load-bearing markers (nav wiring, getting-started card, advanced
section, rotation order card, per-mode duration inputs), the /v3 legacy
alias serves everything, all critical static assets (incl. vendored
fontawesome/codemirror, PWA manifest/icons) are served, durations group
per plugin with the leftover bucket, and the advanced-hardware section
really contains the tuning fields. Would have caught this session's
unreachable-durations-page and orphaned-tab bugs instantly.
- test_web_static_audit.py (3 tests): (1) every responsive utility class
referenced in templates is actually defined in app.css - the
silently-no-op class bug that left the header search box invisible at
every width; (2) every url_for('static', ...) reference points to a real
file; (3) any JS file calling the debugLog global declares it in a
/* global */ header.
All 40 web tests pass (24 + 3 new, 13 existing) under pytest + Flask.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
69863f70cd
commit
23dcdc2d49
@@ -0,0 +1,173 @@
|
|||||||
|
"""
|
||||||
|
Web-UI smoke tests: every page, partial, and critical static asset must render.
|
||||||
|
|
||||||
|
These boot the pages blueprint with the same dual registration app.py uses
|
||||||
|
(un-prefixed primary + /v3 legacy alias) and assert each surface returns 200
|
||||||
|
with its load-bearing markers present. They exist to catch, in CI, the class
|
||||||
|
of regression that only shows up when a real request renders a real template:
|
||||||
|
a broken partial, a missing tab wiring, a renamed element id that JS depends
|
||||||
|
on, or a static asset that stopped being served.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
SMOKE_CONFIG = {
|
||||||
|
"web_display_autostart": True,
|
||||||
|
"timezone": "America/Chicago",
|
||||||
|
"location": {"city": "Dallas", "state": "Texas", "country": "US"},
|
||||||
|
"plugin_system": {
|
||||||
|
"auto_discover": True,
|
||||||
|
"auto_load_enabled": True,
|
||||||
|
"development_mode": False,
|
||||||
|
"plugins_directory": "plugin-repos",
|
||||||
|
},
|
||||||
|
"schedule": {},
|
||||||
|
"dim_schedule": {"dim_brightness": 30},
|
||||||
|
"sync": {"role": "standalone", "port": 5765, "follower_position": "left"},
|
||||||
|
"clock": {"enabled": True},
|
||||||
|
"ledmatrix-weather": {"enabled": True},
|
||||||
|
"display": {
|
||||||
|
"hardware": {
|
||||||
|
"rows": 32, "cols": 64, "chain_length": 2, "parallel": 1,
|
||||||
|
"brightness": 95, "hardware_mapping": "adafruit-hat-pwm",
|
||||||
|
"led_rgb_sequence": "RGB", "multiplexing": 0, "panel_type": "",
|
||||||
|
"row_address_type": 0, "scan_mode": 0, "pwm_bits": 9,
|
||||||
|
"pwm_dither_bits": 1, "pwm_lsb_nanoseconds": 130,
|
||||||
|
"limit_refresh_rate_hz": 120, "disable_hardware_pulsing": False,
|
||||||
|
"inverse_colors": False, "show_refresh_rate": False,
|
||||||
|
},
|
||||||
|
"runtime": {"gpio_slowdown": 3, "rp1_rio": 0},
|
||||||
|
"double_sided": {"enabled": False, "copies": 2, "axis": "horizontal"},
|
||||||
|
"use_short_date_format": False,
|
||||||
|
"dynamic_duration": {"max_duration_seconds": 180},
|
||||||
|
"vegas_scroll": {
|
||||||
|
"enabled": False, "scroll_speed": 50, "separator_width": 32,
|
||||||
|
"target_fps": 125, "buffer_ahead": 2,
|
||||||
|
"plugin_order": [], "excluded_plugins": [],
|
||||||
|
},
|
||||||
|
"display_durations": {"stale_saved_mode": 45},
|
||||||
|
"plugin_rotation_order": ["ledmatrix-weather", "clock"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
PLUGIN_MODES = {
|
||||||
|
"clock": ["clock"],
|
||||||
|
"ledmatrix-weather": ["weather_current", "weather_daily"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
base = PROJECT_ROOT / "web_interface"
|
||||||
|
app = Flask(
|
||||||
|
__name__,
|
||||||
|
template_folder=str(base / "templates"),
|
||||||
|
static_folder=str(base / "static"),
|
||||||
|
)
|
||||||
|
app.config["TESTING"] = True
|
||||||
|
|
||||||
|
from web_interface.blueprints import pages_v3 as pv
|
||||||
|
|
||||||
|
mock_cm = MagicMock()
|
||||||
|
mock_cm.load_config.return_value = SMOKE_CONFIG
|
||||||
|
mock_cm.get_raw_file_content.return_value = SMOKE_CONFIG
|
||||||
|
mock_cm.get_config_path.return_value = "config/config.json"
|
||||||
|
mock_cm.get_secrets_path.return_value = "config/config_secrets.json"
|
||||||
|
pv.pages_v3.config_manager = mock_cm
|
||||||
|
|
||||||
|
mock_pm = MagicMock()
|
||||||
|
mock_pm.plugins = {}
|
||||||
|
mock_pm.get_all_plugin_info.return_value = [
|
||||||
|
{"id": "clock", "name": "Clock"},
|
||||||
|
{"id": "ledmatrix-weather", "name": "Weather"},
|
||||||
|
]
|
||||||
|
mock_pm.get_plugin_display_modes.side_effect = (
|
||||||
|
lambda pid: PLUGIN_MODES.get(pid, [])
|
||||||
|
)
|
||||||
|
pv.pages_v3.plugin_manager = mock_pm
|
||||||
|
|
||||||
|
# Same dual registration as web_interface/app.py: un-prefixed primary,
|
||||||
|
# /v3 kept as a working legacy alias.
|
||||||
|
app.register_blueprint(pv.pages_v3, url_prefix="")
|
||||||
|
app.register_blueprint(pv.pages_v3, url_prefix="/v3", name="pages_v3_legacy")
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
# (path, [markers that must appear in the body])
|
||||||
|
PAGES = [
|
||||||
|
("/", ["site-nav", "mobileNavOpen", 'rel="manifest"',
|
||||||
|
"restart-pending-banner", "activeTab = 'durations'"]),
|
||||||
|
("/partials/overview", ["getting-started-card", "displayImage"]),
|
||||||
|
("/partials/general", ["timezone"]),
|
||||||
|
("/partials/display", ["display-section-advanced-hardware",
|
||||||
|
"display-resolution-value", "vegas_scroll_label"]),
|
||||||
|
("/partials/durations", ["rotation_plugin_order", "duration__clock",
|
||||||
|
"duration__weather_current",
|
||||||
|
"duration__stale_saved_mode"]),
|
||||||
|
("/partials/schedule", ["schedule"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("path,markers", PAGES, ids=[p for p, _ in PAGES])
|
||||||
|
def test_page_renders_with_markers(client, path, markers):
|
||||||
|
resp = client.get(path)
|
||||||
|
assert resp.status_code == 200, f"{path} -> {resp.status_code}"
|
||||||
|
body = resp.get_data(as_text=True)
|
||||||
|
for marker in markers:
|
||||||
|
assert marker in body, f"{path}: missing marker {marker!r}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("path", [p for p, _ in PAGES if p != "/"])
|
||||||
|
def test_legacy_v3_alias_serves_the_same_partials(client, path):
|
||||||
|
assert client.get("/v3" + path).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
STATIC_ASSETS = [
|
||||||
|
"/static/v3/app.css",
|
||||||
|
"/static/v3/app.js",
|
||||||
|
"/static/v3/manifest.json",
|
||||||
|
"/static/v3/icons/icon-192.png",
|
||||||
|
"/static/v3/js/app-shell.js",
|
||||||
|
"/static/v3/js/app-early.js",
|
||||||
|
"/static/v3/js/htmx-config.js",
|
||||||
|
"/static/v3/js/widgets/plugin-order-list.js",
|
||||||
|
"/static/v3/js/widgets/notification.js",
|
||||||
|
"/static/v3/vendor/fontawesome/css/all.min.css",
|
||||||
|
"/static/v3/vendor/codemirror/codemirror.min.js",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("asset", STATIC_ASSETS)
|
||||||
|
def test_static_asset_served(client, asset):
|
||||||
|
resp = client.get(asset)
|
||||||
|
assert resp.status_code == 200, f"{asset} -> {resp.status_code}"
|
||||||
|
assert len(resp.data) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_durations_page_groups_by_plugin(client):
|
||||||
|
"""One duration input per display mode of each enabled plugin, plus the
|
||||||
|
leftover group for saved keys no enabled plugin owns."""
|
||||||
|
body = client.get("/partials/durations").get_data(as_text=True)
|
||||||
|
assert body.count("duration__") >= 2 * len(
|
||||||
|
[m for modes in PLUGIN_MODES.values() for m in modes]
|
||||||
|
) # each mode: id= and name=
|
||||||
|
assert "Other saved entries" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_display_advanced_section_contains_tuning_fields(client):
|
||||||
|
body = client.get("/partials/display").get_data(as_text=True)
|
||||||
|
adv = body.find('id="display-section-advanced-hardware"')
|
||||||
|
adv_close = body.find("/#display-section-advanced-hardware")
|
||||||
|
assert 0 < adv < adv_close
|
||||||
|
for field in ["multiplexing", "pwm_bits", "inverse_colors"]:
|
||||||
|
pos = body.find(f'name="{field}"')
|
||||||
|
assert adv < pos < adv_close, f"{field} not inside the advanced section"
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""
|
||||||
|
Static-analysis audits for the web UI, as tests so CI enforces them.
|
||||||
|
|
||||||
|
1. Breakpoint utility audit: app.css hand-maintains a Tailwind-style utility
|
||||||
|
subset, so a template can reference a responsive class (e.g. sm:block)
|
||||||
|
that no CSS rule defines — it silently no-ops. This once left the header
|
||||||
|
search box and system stats invisible at every screen width. The audit
|
||||||
|
diffs classes used in templates against classes defined in app.css.
|
||||||
|
|
||||||
|
2. Asset reference audit: every url_for('static', filename=...) in the
|
||||||
|
templates must point to a file that exists, so a renamed/moved asset
|
||||||
|
can't ship as a broken <script>/<link>/<img>.
|
||||||
|
|
||||||
|
3. debugLog globals audit: any static JS file calling debugLog() (a global
|
||||||
|
defined in base.html) must declare it in a /* global */ header so linting
|
||||||
|
stays clean and the dependency is explicit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent
|
||||||
|
WEB = PROJECT_ROOT / "web_interface"
|
||||||
|
TEMPLATES = WEB / "templates"
|
||||||
|
STATIC = WEB / "static"
|
||||||
|
APP_CSS = STATIC / "v3" / "app.css"
|
||||||
|
|
||||||
|
BP_PREFIXES = ("sm", "md", "lg", "xl", "2xl")
|
||||||
|
|
||||||
|
|
||||||
|
def _template_files():
|
||||||
|
return sorted(TEMPLATES.rglob("*.html"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_used_breakpoint_class_is_defined():
|
||||||
|
used = set()
|
||||||
|
class_attr = re.compile(r'class="([^"]*)"')
|
||||||
|
bp_class = re.compile(r"\b(%s):[A-Za-z0-9_.-]+" % "|".join(BP_PREFIXES))
|
||||||
|
for path in _template_files():
|
||||||
|
for attr in class_attr.findall(path.read_text()):
|
||||||
|
for m in bp_class.finditer(attr):
|
||||||
|
used.add(m.group(0))
|
||||||
|
|
||||||
|
css = APP_CSS.read_text()
|
||||||
|
defined = {
|
||||||
|
m.group(0).lstrip(".").replace("\\:", ":")
|
||||||
|
for m in re.finditer(
|
||||||
|
r"\.(%s)\\:[A-Za-z0-9_-]+" % "|".join(BP_PREFIXES), css
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
missing = sorted(used - defined)
|
||||||
|
assert not missing, (
|
||||||
|
"Responsive utility classes referenced in templates but never defined "
|
||||||
|
f"in app.css (they silently no-op): {missing}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_static_url_for_points_to_a_real_file():
|
||||||
|
ref = re.compile(
|
||||||
|
r"url_for\(\s*['\"]static['\"]\s*,\s*filename\s*=\s*['\"]([^'\"]+)['\"]"
|
||||||
|
)
|
||||||
|
missing = []
|
||||||
|
for path in _template_files():
|
||||||
|
for filename in ref.findall(path.read_text()):
|
||||||
|
if not (STATIC / filename).is_file():
|
||||||
|
missing.append(f"{path.relative_to(PROJECT_ROOT)}: {filename}")
|
||||||
|
assert not missing, f"Templates reference missing static assets: {missing}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_js_files_calling_debuglog_declare_the_global():
|
||||||
|
undeclared = []
|
||||||
|
for path in sorted((STATIC / "v3").rglob("*.js")):
|
||||||
|
if "vendor" in path.parts:
|
||||||
|
continue
|
||||||
|
text = path.read_text()
|
||||||
|
# Calls debugLog( but neither defines it nor declares the global
|
||||||
|
calls = re.search(r"(?<![.\w])debugLog\(", text)
|
||||||
|
defines = "window.debugLog" in text
|
||||||
|
declares = re.search(r"/\*\s*global[^*]*\bdebugLog\b", text)
|
||||||
|
if calls and not defines and not declares:
|
||||||
|
undeclared.append(str(path.relative_to(PROJECT_ROOT)))
|
||||||
|
assert not undeclared, (
|
||||||
|
f"JS files call debugLog() without a /* global debugLog */ header: {undeclared}"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user