diff --git a/config/config.template.json b/config/config.template.json index 07a72b60..b3f63589 100644 --- a/config/config.template.json +++ b/config/config.template.json @@ -121,6 +121,7 @@ "axis": "horizontal" }, "display_durations": {}, + "plugin_rotation_order": [], "use_short_date_format": true, "vegas_scroll": { "enabled": false, diff --git a/docs/widget-guide.md b/docs/widget-guide.md index dadda4cf..7a1ed850 100644 --- a/docs/widget-guide.md +++ b/docs/widget-guide.md @@ -206,6 +206,40 @@ To use an existing widget in your plugin's `config_schema.json`, simply add the The widget will be automatically rendered when the plugin configuration form is loaded. +## Marking Fields as Advanced (`x-advanced`) + +Add `"x-advanced": true` to any top-level, non-object property to move it out +of the main form and into a single collapsed **Advanced Settings** section at +the bottom of the plugin's configuration page: + +```json +{ + "properties": { + "city": { + "type": "string", + "title": "City" + }, + "request_timeout": { + "type": "integer", + "default": 10, + "description": "HTTP timeout in seconds", + "x-advanced": true + } + } +} +``` + +Guidelines: + +- Use it for fine-tuning knobs most users never touch (timeouts, retry + behavior, cache TTLs, styling overrides). Anything a first-time user must + set to get the plugin working should stay basic. +- Nothing is hidden permanently — the section expands on click, and the + settings search finds and auto-expands advanced fields like any others. +- The flag is ignored on `object`-type properties (they already render as + their own collapsible sections) and is safely ignored by older cores, so + adding it never breaks compatibility. + ## Creating Custom Widgets ### Step 1: Create Widget File diff --git a/src/display_controller.py b/src/display_controller.py index ace55b58..5a341902 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -381,6 +381,10 @@ class DisplayController: logger.debug("%d plugin(s) disabled in config", disabled_count) logger.info("Plugin system initialized in %.3f seconds", time.time() - plugin_time) + # Parallel loading appends modes in load-completion order, which + # varies between restarts; apply the user's configured rotation + # order (no-op when not configured). + self._apply_plugin_rotation_order() logger.info("Total available modes: %d", len(self.available_modes)) logger.info("Available modes: %s", self.available_modes) @@ -2843,11 +2847,52 @@ class DisplayController: except Exception as e: logger.error("Plugin reconcile: error enabling %s: %s", plugin_id, e, exc_info=True) + # Newly enabled plugins were appended at the end; put them in the + # configured rotation slot before resyncing the index. + self._apply_plugin_rotation_order() self._resync_mode_index_after_change(previous_mode) - logger.info("Plugin reconcile complete: +%s -%s (%d modes)", + logger.info("[DisplayController] Plugin reconcile complete: +%s -%s (%d modes)", sorted(to_add), sorted(to_remove), len(self.available_modes)) return True + def _apply_plugin_rotation_order(self) -> None: + """Reorder available_modes to follow display.plugin_rotation_order. + + The configured value is a list of plugin ids; their modes rotate in + that order (each plugin's own modes keep their declared order), with + any enabled-but-unlisted plugins appended afterwards in their current + relative order. An empty/missing list leaves available_modes exactly + as built (today's behavior). Mirrors vegas_mode/config.py's + get_ordered_plugins() semantics for the primary rotation. + """ + configured = (self.config.get("display", {}) or {}).get("plugin_rotation_order", []) or [] + # Defensive: hand-edited or migrated configs may hold a non-list or + # non-string entries; keep the existing rotation rather than applying + # a garbage order. + if not isinstance(configured, list): + logger.warning("[DisplayController] Ignoring invalid plugin_rotation_order (not a list): %r", + type(configured).__name__) + return + configured = [p for p in configured if isinstance(p, str)] + if not configured or not self.available_modes: + return + + ordered_ids = [p for p in configured if p in self.plugin_display_modes] + new_modes: List[str] = [] + for plugin_id in ordered_ids: + for mode in self.plugin_display_modes[plugin_id]: + if mode in self.available_modes and mode not in new_modes: + new_modes.append(mode) + # Unlisted plugins' modes (and any mode not attributable to a plugin) + # follow in their existing relative order. + for mode in self.available_modes: + if mode not in new_modes: + new_modes.append(mode) + if new_modes != self.available_modes: + self.available_modes = new_modes + logger.info("[DisplayController] Applied plugin rotation order %s -> modes: %s", + configured, self.available_modes) + def _resync_mode_index_after_change(self, previous_mode: Optional[str]) -> None: """Clamp rotation state after available_modes changed. Stays on the previous mode if it survived, otherwise restarts cleanly within range.""" diff --git a/test/test_web_smoke.py b/test/test_web_smoke.py new file mode 100644 index 00000000..d0fd30de --- /dev/null +++ b/test/test_web_smoke.py @@ -0,0 +1,184 @@ +""" +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_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" diff --git a/test/test_web_static_audit.py b/test/test_web_static_audit.py new file mode 100644 index 00000000..4d9bae1f --- /dev/null +++ b/test/test_web_static_audit.py @@ -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 - - - - - - - - + - + - + - - - - + + + + - - + + @@ -874,14 +296,25 @@
Show the same content on every panel in the chain — e.g. two 64×32 panels mirrored, or four panels as two identical screens. Rendered once and duplicated, so it adds no extra CPU. Takes effect after a display restart.
+ +No plugins available
'; - return; - } - - // Get current order and exclusions - const orderInput = document.getElementById('vegas_plugin_order_value'); - const excludedInput = document.getElementById('vegas_excluded_plugins_value'); - let currentOrder = []; - let excluded = []; - - try { - currentOrder = JSON.parse(orderInput.value || '[]'); - excluded = JSON.parse(excludedInput.value || '[]'); - } catch (e) { - console.error('Error parsing vegas config:', e); - } - - // Build ordered plugin list (only enabled plugins) - const plugins = allPlugins.filter(p => p.enabled); - const orderedPlugins = []; - - // First add plugins in current order - currentOrder.forEach(id => { - const plugin = plugins.find(p => p.id === id); - if (plugin) orderedPlugins.push(plugin); - }); - - // Then add remaining plugins - plugins.forEach(plugin => { - if (!orderedPlugins.find(p => p.id === plugin.id)) { - orderedPlugins.push(plugin); - } - }); - - // Build HTML with display mode indicators - let html = ''; - orderedPlugins.forEach((plugin, index) => { - const isExcluded = excluded.includes(plugin.id); - // Determine display mode (from plugin config or default) - const vegasMode = plugin.vegas_mode || plugin.vegas_content_type || 'fixed'; - const modeLabels = { - 'scroll': { label: 'Scroll', icon: 'fa-scroll', color: 'text-blue-600' }, - 'fixed': { label: 'Fixed', icon: 'fa-square', color: 'text-green-600' }, - 'static': { label: 'Static', icon: 'fa-pause', color: 'text-orange-600' } - }; - const modeInfo = modeLabels[vegasMode] || modeLabels['fixed']; - // Escape plugin metadata to prevent XSS - const safePluginId = escapeAttr(plugin.id); - const safePluginName = escapeHtml(plugin.name || plugin.id); - html += ` -No enabled plugins
'; - - // Setup drag and drop - setupDragAndDrop(container); - - // Setup checkbox handlers - container.querySelectorAll('.vegas-plugin-include').forEach(checkbox => { - checkbox.addEventListener('change', updatePluginConfig); - }); - - // Initialize hidden inputs with current state - updatePluginConfig(); - }) - .catch(error => { - console.error('Error fetching plugins:', error); - container.innerHTML = 'Error loading plugins
'; - }); - } - - function setupDragAndDrop(container) { - let draggedItem = null; - - container.querySelectorAll('.vegas-plugin-item').forEach(item => { - item.addEventListener('dragstart', function(e) { - draggedItem = this; - this.style.opacity = '0.5'; - e.dataTransfer.effectAllowed = 'move'; - }); - - item.addEventListener('dragend', function() { - this.style.opacity = '1'; - draggedItem = null; - updatePluginConfig(); - }); - - item.addEventListener('dragover', function(e) { - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - - const rect = this.getBoundingClientRect(); - const midY = rect.top + rect.height / 2; - - if (e.clientY < midY) { - this.style.borderTop = '2px solid #3b82f6'; - this.style.borderBottom = ''; - } else { - this.style.borderBottom = '2px solid #3b82f6'; - this.style.borderTop = ''; - } - }); - - item.addEventListener('dragleave', function() { - this.style.borderTop = ''; - this.style.borderBottom = ''; - }); - - item.addEventListener('drop', function(e) { - e.preventDefault(); - this.style.borderTop = ''; - this.style.borderBottom = ''; - - if (draggedItem && draggedItem !== this) { - const rect = this.getBoundingClientRect(); - const midY = rect.top + rect.height / 2; - - if (e.clientY < midY) { - container.insertBefore(draggedItem, this); - } else { - container.insertBefore(draggedItem, this.nextSibling); - } - } - }); - }); - } - - function updatePluginConfig() { - const container = document.getElementById('vegas_plugin_order'); - const orderInput = document.getElementById('vegas_plugin_order_value'); - const excludedInput = document.getElementById('vegas_excluded_plugins_value'); - - if (!container || !orderInput || !excludedInput) return; - - const order = []; - const excluded = []; - - container.querySelectorAll('.vegas-plugin-item').forEach(item => { - const pluginId = item.dataset.pluginId; - const checkbox = item.querySelector('.vegas-plugin-include'); - - order.push(pluginId); - if (checkbox && !checkbox.checked) { - excluded.push(pluginId); + if (!window.PluginOrderList) { + // Widget script is deferred; retry briefly, then surface a real + // error instead of waiting forever. + if ((attempt || 0) < 50) { + setTimeout(function() { initPluginOrderList((attempt || 0) + 1); }, 100); + } else { + container.textContent = 'Could not load the reorder widget — reload the page to try again.'; + container.className = 'text-sm text-red-500'; } + return; + } + window.PluginOrderList.init({ + containerId: 'vegas_plugin_order', + orderInputId: 'vegas_plugin_order_value', + excludedInputId: 'vegas_excluded_plugins_value', + showVegasModeBadge: true }); - - orderInput.value = JSON.stringify(order); - excludedInput.value = JSON.stringify(excluded); } + // Initialize on DOM ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initPluginOrderList); diff --git a/web_interface/templates/v3/partials/durations.html b/web_interface/templates/v3/partials/durations.html index 0a1f05aa..e05f8aa1 100644 --- a/web_interface/templates/v3/partials/durations.html +++ b/web_interface/templates/v3/partials/durations.html @@ -1,8 +1,8 @@ {% import 'v3/partials/_macros.html' as ui %}Configure how long each screen is shown before switching. Values in seconds.
+Set the order plugins rotate on the display and how long each screen is shown. Durations are in seconds.
Drag plugins to set the order they rotate on the display. Each plugin's screens keep their own order within its turn. Takes effect after saving and restarting the display.
+Loading plugins…
+How long each screen stays on before rotating to the next one, in seconds (5–600, default 30).
+No enabled plugins found — enable a plugin in the Plugin Manager to set its screen durations here.
+