diff --git a/test/test_web_settings_ui.py b/test/test_web_settings_ui.py new file mode 100644 index 00000000..d6852447 --- /dev/null +++ b/test/test_web_settings_ui.py @@ -0,0 +1,184 @@ +""" +Smoke tests for the settings tooltips + search UI. + +These render the settings partials through Flask and assert that every settings +field carries: + - a stable search anchor id (`id="setting-..."` on its .form-group), and + - an info tooltip (`class="help-tip"` emitted by the help_tip macro). + +They guard against macro/import breakage and against fields losing their anchor +or tooltip when partials are edited. See web_interface/static/v3/js/tooltips.js +and settings-search.js for the consumers of this markup. +""" + +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)) + + +# A realistic-enough config so the hand-written partials render every field. +REALISTIC_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"}, + "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": {"clock": 15, "weather": 30}, + }, +} + + +@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 = REALISTIC_CONFIG + mock_cm.get_raw_file_content.return_value = REALISTIC_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 + pv.pages_v3.plugin_manager = MagicMock(plugins={}) + + app.register_blueprint(pv.pages_v3, url_prefix="/v3") + return app.test_client() + + +# Settings tabs that must expose searchable, tooltipped fields. +SETTINGS_TABS = ["general", "display", "durations", "schedule", "wifi"] + + +@pytest.mark.parametrize("tab", SETTINGS_TABS) +def test_settings_partial_has_tooltips_and_anchors(client, tab): + resp = client.get(f"/v3/partials/{tab}") + assert resp.status_code == 200, f"{tab} partial failed to render" + body = resp.get_data(as_text=True) + + assert 'class="help-tip"' in body, f"{tab}: no tooltips rendered" + assert 'id="setting-' in body, f"{tab}: no search anchors rendered" + # Every settings field should be both anchored and tooltipped; tooltip count + # should not exceed anchor count (each field has at most one help_tip). + anchors = body.count('id="setting-') + tips = body.count('class="help-tip"') + assert tips >= 1 and anchors >= 1 + assert tips <= anchors, f"{tab}: more tooltips ({tips}) than anchors ({anchors})" + + +@pytest.mark.parametrize("tab", SETTINGS_TABS) +def test_settings_partial_has_per_tab_filter(client, tab): + body = client.get(f"/v3/partials/{tab}").get_data(as_text=True) + assert 'class="settings-filter' in body, f"{tab}: per-tab filter box missing" + + +def test_display_tooltip_carries_rich_text(client): + # The brightness tooltip should include the authored guidance, not just a label. + body = client.get("/v3/partials/display").get_data(as_text=True) + assert 'id="setting-display-brightness"' in body + assert "Recommended:" in body # rich detail authored into a tooltip + + +def test_search_index_endpoint(client): + """The server-built search index powers the global settings search.""" + resp = client.get("/v3/settings/search-index") + assert resp.status_code == 200 + data = resp.get_json() + assert isinstance(data, dict) and isinstance(data.get("fields"), list) + fields = data["fields"] + assert len(fields) >= 40, "expected the core settings fields to be indexed" + + by_id = {f["anchorId"]: f for f in fields} + # Representative fields across tabs must be present with usable text. + for anchor in ("setting-general-timezone", "setting-display-brightness", + "setting-wifi-password", "setting-durations-clock"): + assert anchor in by_id, f"{anchor} missing from search index" + entry = by_id[anchor] + assert entry["label"], f"{anchor} has no label" + assert entry["help"], f"{anchor} has no tooltip help" + assert entry["tab"] and entry["tabLabel"] + + # Every entry must carry a non-empty label and a stable anchor id. + assert all(f["label"] and f["anchorId"].startswith("setting-") for f in fields) + # Section context is captured for grouped fields (e.g. Display hardware). + assert by_id["setting-display-brightness"]["section"] == "Hardware Configuration" + + +def test_plugin_config_partial_has_filter_and_nested_anchors(): + """Plugin config tabs expose the per-tab filter and anchor nested fields. + + The client fixture has no installed plugins, so render the partial directly + with a schema that includes a nested section (render_nested_section). + """ + from jinja2 import Environment, FileSystemLoader, select_autoescape + + env = Environment( + loader=FileSystemLoader(str(PROJECT_ROOT / "web_interface" / "templates")), + autoescape=select_autoescape(["html"]), + ) + plugin = { + "id": "demo-plugin", "name": "Demo Plugin", "description": "A demo", + "enabled": True, "author": "me", "version": "1.0.0", + } + schema = { + "type": "object", + "properties": { + "title_text": {"type": "string", "title": "Title Text", + "description": "The heading."}, + "advanced": { + "type": "object", "title": "Advanced Options", + "description": "Nested options.", + "properties": { + "scroll_speed": {"type": "integer", "title": "Scroll Speed", + "description": "Pixels per second."}, + }, + }, + }, + } + config = {"title_text": "Hi", "advanced": {"scroll_speed": 50}} + html = env.get_template("v3/partials/plugin_config.html").render( + plugin=plugin, schema=schema, config=config + ) + + assert 'class="settings-filter' in html, "plugin config: per-tab filter box missing" + assert "nested-content" in html, "plugin config: nested section not rendered" + assert 'id="setting-' in html, "plugin config: no search anchors rendered" + assert 'class="help-tip"' in html, "plugin config: no tooltips rendered" diff --git a/web_interface/blueprints/pages_v3.py b/web_interface/blueprints/pages_v3.py index b011480a..8c3c1bbc 100644 --- a/web_interface/blueprints/pages_v3.py +++ b/web_interface/blueprints/pages_v3.py @@ -1,6 +1,7 @@ -from flask import Blueprint, render_template, flash +from flask import Blueprint, render_template, flash, jsonify from jinja2 import TemplateNotFound from markupsafe import escape +from html.parser import HTMLParser import json import logging import os @@ -22,6 +23,114 @@ pages_v3 = Blueprint('pages_v3', __name__) + +class _SettingsIndexParser(HTMLParser): + """Extract searchable settings fields from a rendered partial's HTML. + + Captures one entry per ``
``: the + anchor id, ``data-setting-key``, the field's ``
-
- +
+ Hardware Configuration min="1" max="128" class="form-control"> -

Number of LED columns

-
- +
+ Hardware Configuration min="1" max="24" class="form-control"> -

Number of LED panels chained together

-
- +
+ Hardware Configuration min="1" max="4" class="form-control"> -

Number of parallel chains

-
- +
+
Hardware Configuration class="flex-1"> {{ main_config.display.hardware.brightness or 95 }}
-

LED brightness: {{ main_config.display.hardware.brightness or 95 }}%

-
- +
+ @@ -124,11 +122,10 @@

Hardware Configuration

-

Color channel order for your LED panels

-
- +
+ -

Multiplexing scheme for your LED panels

-
- +
+ -

Special panel chipset initialization (use Standard unless your panel requires it)

-
- +
+ -

Row addressing scheme — leave at Default (0) unless your panel requires a specific type

-
- +
+ Hardware Configuration min="0" max="10" class="form-control"> -

Pi 3: 1–2 · Pi 4: 2–4 · Pi 5 PIO: 1–3. Increase if display shows garbage; in RIO mode higher values may improve performance.

-
+
-

Pi 5 RP1 coprocessor mode. Ignored on Pi 3/4.

-
- +
+ Hardware Configuration min="0" max="1" class="form-control"> -

Scan mode for LED matrix (0-1)

-
- +
+ Hardware Configuration min="1" max="11" class="form-control"> -

PWM bits for brightness control (1-11)

-
- +
+ Hardware Configuration min="0" max="4" class="form-control"> -

PWM dither bits (0-4)

-
- +
+ Hardware Configuration min="50" max="500" class="form-control"> -

PWM LSB nanoseconds (50-500)

-
- +
+ Hardware Configuration min="1" max="1000" class="form-control"> -

Limit refresh rate in Hz (1-1000)

@@ -276,7 +263,7 @@

Double-Sided Display

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.

-
+
-

Mirror one screen across all panels.

-
- +
+ Double-Sided Display min="2" max="8" class="form-control"> -

Number of identical screens. Must divide the panel evenly.

-
- +
+ -

Horizontal splits the chain; vertical splits parallel outputs.

@@ -317,7 +302,7 @@

Double-Sided Display

Display Options

-
+
-
+
-
+
-
+
@@ -366,8 +355,8 @@

Display Options

Dynamic Duration

-
- +
+ Dynamic Duration min="30" max="1800" class="form-control"> -

Maximum time plugins can extend display duration (30-1800 seconds)

@@ -405,8 +393,8 @@

-
- +
+
class="flex-1"> {{ main_config.display.get('vegas_scroll', {}).get('scroll_speed', 50) }}
-

Speed of the scrolling ticker (10-200 px/s)

-
- +
+ min="0" max="128" class="form-control"> -

Gap between plugin content blocks (0-128 px)

-
- +
+ -

Higher FPS = smoother scroll, more CPU usage

-
- +
+ -

How many plugins to pre-load ahead

@@ -485,18 +469,17 @@

-
- +
+ -

Set Leader on one Pi, Follower on the other. Restart required after changing.

-
- +
+ min="1024" max="65535" class="form-control"> -

- Must match on both Pis. If ufw is active: - sudo ufw allow {{ main_config.get('sync', {}).get('port', 5765) }}/udp -

- @@ -795,7 +773,7 @@

function updateSyncUI() { const role = document.getElementById('sync_role').value; const bar = document.getElementById('sync_status_bar'); - const posGroup = document.getElementById('sync_position_group'); + const posGroup = document.getElementById('setting-display-sync_follower_position'); if (role === 'standalone') { bar.classList.add('hidden'); document.getElementById('sync_error_detail').classList.add('hidden'); diff --git a/web_interface/templates/v3/partials/durations.html b/web_interface/templates/v3/partials/durations.html index e0833d99..0a1f05aa 100644 --- a/web_interface/templates/v3/partials/durations.html +++ b/web_interface/templates/v3/partials/durations.html @@ -1,9 +1,12 @@ +{% import 'v3/partials/_macros.html' as ui %}

Display Durations

Configure how long each screen is shown before switching. Values in seconds.

+ {{ ui.settings_filter() }} +
Display Durations

{% for key, value in main_config.display.display_durations.items() %} -
+
Display Durations

min="5" max="600" class="form-control"> -

{{ value }} seconds

{% endfor %}
diff --git a/web_interface/templates/v3/partials/general.html b/web_interface/templates/v3/partials/general.html index 780bf0f6..60cc6f6c 100644 --- a/web_interface/templates/v3/partials/general.html +++ b/web_interface/templates/v3/partials/general.html @@ -1,9 +1,12 @@ +{% import 'v3/partials/_macros.html' as ui %}

General Settings

Configure general system settings and location information.

+ {{ ui.settings_filter() }} + General Settings class="space-y-6"> -
+
-

Start the web interface on boot for easier access.

-
- +
+
-

Select your timezone for time-based features and scheduling.