mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
* fix(errors): stop affected_plugins growing without bound Each repeat of an error pattern appended every plugin in the time window to the pattern's list again, so a plugin failing in a loop grew the display process's memory without limit: 3,000 errors from three plugins reached 2.5 million entries. Keep the list unique. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(fonts): load a BDF font at its native size instead of PIL's default FreeType rejects any size but a BDF strike's own, and FontManager answered that with ImageFont.load_default() -- a different typeface -- so 5x7.bdf requested at 8 or 10px rendered as PIL's default font. Retry at the native strike, as element_style already does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): plugin toggle failures no longer claim "operation in progress" Every exception in POST /plugins/toggle was mapped to PLUGIN_OPERATION_CONFLICT, so any failure told the user "A plugin operation is already in progress". Report the failure as what it is, and record the plugin id in the operation history for form posts too (it read a `data` variable that only the JSON path set). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): route plugin card clicks through handlePluginAction The document-level delegation checked `typeof handlePluginAction`, which is scoped inside the plugin-manager IIFE and so never visible to it. Every card click took a copied fallback that stopped propagation (the grid's own listener never ran), confirmed an uninstall twice, and sent Starlark app uninstalls to POST /plugins/uninstall instead of DELETE /starlark/apps/<id>. Expose the handler on window and delegate to it. Also run every test/js/unit suite under pytest: they need only node, but CI ran one of the eight. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(display): apply Rotation durations, WiFi messages and Vegas settings Three settings the web UI saves never reached the display: - Rotation & Durations: display.display_durations was never read. Every plugin inherits get_display_duration() and the plugin was asked first. A saved value now wins. The page shows unsaved screens blank with the plugin's own duration as a placeholder, and saving a blank removes the override, so one save no longer pins every screen. - WiFi status overlay: the controller looked for wifi_status.json one directory above the repo. Both sides now use wifi_manager.get_wifi_status_path(). The message is written by rename so the display never reads it half-written, and the resumed plugin redraws the whole panel afterwards. - Vegas: nothing called coordinator.update_config(), so saved Vegas settings never reached a running scroll. They are now queued when display.vegas_scroll changes, and applied while Vegas is stopped too, so a disable then re-enable works. The follower's scroll-speed default (75) now matches VegasModeConfig's (50). Also throttles Vegas's per-frame live-priority scan to 4Hz. It cost 139us per frame on a Pi 4 with two scoreboards (1.7% of a 125fps frame) and grows with each plugin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep affected_plugins order when serialized; guard non-Element targets ErrorPattern.to_dict() ran the now-ordered list through set(), so get_error_summary() listed plugins in an unstable order. The document-level card-action listener called event.target.closest() without checking the target is an Element. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
219 lines
8.5 KiB
Python
219 lines
8.5 KiB
Python
"""
|
|
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
|
|
|
|
# pages_v3 is a module-level Blueprint singleton shared by the whole test
|
|
# process (test_web_settings_ui.py mutates the same attributes) - save
|
|
# the originals and restore them on teardown so this fixture can't leak
|
|
# its mocks into tests that run afterward.
|
|
original_config_manager = getattr(pv.pages_v3, "config_manager", None)
|
|
original_plugin_manager = getattr(pv.pages_v3, "plugin_manager", None)
|
|
|
|
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")
|
|
try:
|
|
yield app.test_client()
|
|
finally:
|
|
pv.pages_v3.config_manager = original_config_manager
|
|
pv.pages_v3.plugin_manager = original_plugin_manager
|
|
|
|
|
|
# (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_widget_bundle_is_served_and_requested(client):
|
|
"""base.html loads every widget through one versioned bundle request."""
|
|
page = client.get("/").get_data(as_text=True)
|
|
assert "/assets/widgets.js?v=" in page
|
|
resp = client.get("/assets/widgets.js")
|
|
assert resp.status_code == 200
|
|
assert resp.mimetype == "application/javascript"
|
|
body = resp.get_data(as_text=True)
|
|
assert body.index("/* registry.js */") < body.index("/* notification.js */")
|
|
assert "/* json-file-manager.js */" in body
|
|
|
|
|
|
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_durations_page_leaves_unsaved_modes_blank(client):
|
|
"""A saved duration overrides the plugin's own, so the page must not
|
|
pre-fill one for every mode: the first save would pin them all. Unsaved
|
|
modes are blank, with the plugin's duration as the placeholder."""
|
|
import copy
|
|
from web_interface.blueprints import pages_v3 as pv
|
|
config = copy.deepcopy(SMOKE_CONFIG)
|
|
config["clock"]["display_duration"] = 20
|
|
config["display"]["display_durations"] = {"weather_current": 40}
|
|
pv.pages_v3.config_manager.load_config.return_value = config
|
|
body = client.get("/partials/durations").get_data(as_text=True)
|
|
|
|
def field(mode):
|
|
start = body.index(f'id="duration__{mode}"')
|
|
return body[start:body.index(">", start)]
|
|
|
|
assert 'value=""' in field("clock") and 'placeholder="20"' in field("clock")
|
|
assert 'value="40"' in field("weather_current")
|
|
assert 'value=""' in field("weather_daily")
|
|
assert 'placeholder="15"' in field("weather_daily")
|
|
|
|
|
|
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"
|