Skip to content
184 changes: 184 additions & 0 deletions test/test_web_settings_ui.py
Original file line number Diff line number Diff line change
@@ -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"
Comment thread
coderabbitai[bot] marked this conversation as resolved.


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"
161 changes: 160 additions & 1 deletion web_interface/blueprints/pages_v3.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 ``<div class="form-group" id="setting-…">``: the
anchor id, ``data-setting-key``, the field's ``<label>`` text, the
``.help-tip`` tooltip text (``data-tooltip``), and the nearest preceding
``<h3>``/``<h4>`` section heading. Parsing the *rendered* HTML (rather than
the schema) guarantees the anchor ids match the live DOM exactly, so the
search index cannot drift from what users actually see.
"""

def __init__(self, tab, tab_label):
super().__init__(convert_charrefs=True)
self.tab = tab
self.tab_label = tab_label
self.fields = []
self._section = ''
self._field = None
self._depth = 0 # open-div depth within the current field
self._in_label = False
self._label_parts = []
self._in_heading = False
self._heading_parts = []

def handle_starttag(self, tag, attrs):
a = {k: (v or '') for k, v in attrs}
classes = a.get('class', '').split()
# Section headings (only when not already inside a field)
if tag in ('h3', 'h4') and self._field is None:
self._in_heading = True
self._heading_parts = []
if tag == 'div':
fid = a.get('id', '')
if self._field is None and 'form-group' in classes and fid.startswith('setting-'):
self._field = {
'anchorId': fid,
'key': a.get('data-setting-key', '') or fid[len('setting-'):],
'label': '',
'help': '',
'section': self._section,
'tab': self.tab,
'tabLabel': self.tab_label,
}
self._depth = 1
return
if self._field is not None:
self._depth += 1
if self._field is not None:
if tag == 'label' and not self._field['label']:
self._in_label = True
self._label_parts = []
if tag == 'button' and 'help-tip' in classes and not self._field['help']:
self._field['help'] = a.get('data-tooltip', '')

def handle_data(self, data):
if self._in_label:
self._label_parts.append(data)
elif self._in_heading:
self._heading_parts.append(data)

def handle_endtag(self, tag):
if tag in ('h3', 'h4') and self._in_heading:
self._in_heading = False
self._section = ' '.join(''.join(self._heading_parts).split()).strip()
return
if self._field is None:
return
if tag == 'label' and self._in_label:
self._in_label = False
self._field['label'] = ' '.join(''.join(self._label_parts).split()).strip()
elif tag == 'div':
self._depth -= 1
if self._depth <= 0:
if self._field['label']:
self.fields.append(self._field)
self._field = None
self._depth = 0


def _partial_html(loader):
"""Run a partial loader and return its HTML string ('' on error)."""
try:
result = loader()
except Exception:
logger.warning("search-index: partial render failed", exc_info=True)
return ''
if isinstance(result, str):
return result
if isinstance(result, tuple): # loaders return (msg, status) on error
return ''
try:
return result.get_data(as_text=True)
except Exception:
return ''


def _extract_settings_fields(html, tab, tab_label):
parser = _SettingsIndexParser(tab, tab_label)
parser.feed(html)
return parser.fields


# Cache the built index keyed on the installed-plugin set. Core labels/tooltips
# are static template text, so only a change in installed plugins invalidates it.
_SEARCH_INDEX_CACHE = {'sig': None, 'fields': None}


@pages_v3.route('/')
def index():
"""Main v3 interface page"""
Expand Down Expand Up @@ -111,6 +220,56 @@ def load_plugin_config_partial(plugin_id):
return '<div class="text-red-500 p-4">Error loading plugin config; see logs for details</div>', 500


@pages_v3.route('/settings/search-index')
def settings_search_index():
"""Return a flat JSON index of every searchable setting (core + plugin).

Powers the web UI's global settings search. Built by rendering the settings
partials server-side and extracting field metadata, then cached per
installed-plugin set so it is off the display's hot path.
"""
# Core settings tabs: (activeTab value, human label, loader).
core_tabs = [
('general', 'General', _load_general_partial),
('display', 'Display', _load_display_partial),
('durations', 'Durations', _load_durations_partial),
('schedule', 'Schedule', _load_schedule_partial),
('wifi', 'WiFi', _load_wifi_partial),
]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
try:
plugin_ids = []
if pages_v3.plugin_manager:
try:
pages_v3.plugin_manager.discover_plugins()
plugin_ids = sorted(
pi.get('id') for pi in pages_v3.plugin_manager.get_all_plugin_info()
if pi.get('id')
)
except Exception:
logger.warning("search-index: could not enumerate plugins", exc_info=True)

sig = tuple(plugin_ids)
if _SEARCH_INDEX_CACHE['sig'] == sig and _SEARCH_INDEX_CACHE['fields'] is not None:
return jsonify({'fields': _SEARCH_INDEX_CACHE['fields']})

fields = []
for tab, label, loader in core_tabs:
fields.extend(_extract_settings_fields(_partial_html(loader), tab, label))

for pid in plugin_ids:
info = pages_v3.plugin_manager.get_plugin_info(pid) or {}
label = info.get('name', pid)
html = _partial_html(lambda pid=pid: _load_plugin_config_partial(pid))
fields.extend(_extract_settings_fields(html, pid, label))

_SEARCH_INDEX_CACHE['sig'] = sig
_SEARCH_INDEX_CACHE['fields'] = fields
return jsonify({'fields': fields})
except Exception:
logger.error("Error building settings search index", exc_info=True)
return jsonify({'fields': []}), 500


@pages_v3.route('/plugin-ui/<plugin_id>/web-ui/<path:filename>')
def serve_plugin_web_ui(plugin_id, filename):
"""Serve a plugin's web_ui/ HTML fragment as a standalone page.
Expand Down
Loading
Loading