mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
* Add settings tooltips and search to the web UI Help users quickly find settings and understand how each one works. Tooltips: a new delegated controller (static/v3/js/tooltips.js) drives an accessible (i) info tooltip that appears on hover, keyboard focus, and tap. A shared `help_tip` Jinja macro (partials/_macros.html) emits the trigger; the plugin config macro and the core settings partials now surface help text through it. Per the design, the always-visible field help paragraphs are folded into the tooltip to declutter the forms, and the hardware/display settings carry authored detail (default, range, recommendation). Search: a global header search box finds settings across every settings tab — even ones not yet opened — via a lazy client-side index built by scanning the same field markup (static/v3/js/settings-search.js). Selecting a result switches tabs, waits for the field to load, then scrolls to and flashes it. A per-tab filter box hides non-matching fields on the current tab. Plugin settings get tooltips for free by reusing each field's schema `description`; every settings field also gets a stable `setting-<tab>-<key>` anchor id for search navigation. Styling uses the existing --color-* theme vars so light/dark mode both work, and honors prefers-reduced-motion. Adds Flask render smoke tests that assert each settings partial ships tooltips, anchors, and a filter box. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz * Address Codacy static-analysis findings in settings JS Refactor the two new modules to clear the flagged patterns without any behavior change: - Build the search dropdown with DOM nodes + textContent instead of innerHTML string concatenation, removing the XSS sinks and the manual escapeHtml helper it needed. - Replace numeric index access (index[i], terms[j], opts[idx], currentResults[i]) with array iteration methods, NodeList.item(), and Array.prototype.at() to clear detect-object-injection. - Use === via a shared termsMatch() helper, optional-catch binding, and drop a useless initial assignment. Verified with ESLint (eslint:recommended + eslint-plugin-security) at zero findings and re-ran the headless-Chromium behavior test (tooltip, per-tab filter, global search navigation) — all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz * Reduce complexity of revealAncestors in settings search Extract isNodeHidden() and revealNode() helpers so revealAncestors drops below the cyclomatic-complexity threshold. No behavior change; verified with the headless-Chromium test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz * Resolve remaining Codacy findings in settings search - Validate plugin ids against a strict allowlist (mirroring the server's _SAFE_PLUGIN_ID_RE) before they can appear in a fetch path, so the request URL is never built from unvalidated input (Codacy: user-controlled URL). - Document that the fetched HTML is parsed into an inert document (scripts never run, never inserted into the live DOM) purely to read field text for the search index. - Declare block-scoped locals with const instead of var where they were nested inside conditionals (Codacy: var not at function root). No behavior change; re-verified with ESLint and the headless-Chromium test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz * Serve the settings search index from the server as JSON Move index building off the client so there is no client-side HTML fetching or DOM parsing (resolves Codacy's variable-fetch and DOMParser flags on the read-only, same-origin index build). - Add GET /v3/settings/search-index (pages_v3.py): renders the settings partials server-side and extracts each field's anchor id, key, label, tooltip, and section with a small stdlib HTMLParser, then caches the result keyed on the installed-plugin set. Parsing the rendered HTML keeps anchor ids identical to the live DOM, so the index cannot drift. - settings-search.js: buildIndex() now does a single fetch of the literal endpoint + .json(); removed the per-partial fetch loop, DOMParser, scanDoc, CORE_TABS, and the plugin-id allowlist. Search, keyboard nav, navigation, and the per-tab filter are unchanged. Net: fewer requests and no client-side HTML parsing. Verified with a new endpoint test in test_web_settings_ui.py (12 pass) and the headless-Chromium test (tooltip, filter, search navigate + flash all green). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz * Address CodeRabbit review on settings search/tooltips - pages_v3: include Durations tab in the search index so setting-durations-* fields are actually indexed - test_web_settings_ui: assert setting-durations-clock is present in the search-index endpoint response - settings-search.js: on index fetch failure, reset buildPromise instead of caching an empty (truthy) index so search can retry - settings-search.js: filterScope returns null (not document) when no tab container matches, and the caller guards, so the per-tab filter can't hide fields across unrelated tabs - settings-search.js: refresh the stale header comment to describe the server-side JSON index flow - app.css: cap #settings-search-results height with overflow-y so the dropdown scrolls instead of overflowing small screens Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz * Fix stuck search dropdown; add plugin-tab nested-settings filter Global search: - Close the results dropdown on input blur (guarded, with a short delay) so it reliably dismisses when focus leaves — previously it could linger because the only outside-close was a document click that Alpine/HTMX handlers can swallow. - Clear the query text after navigating to a result so refocusing the box doesn't re-open stale results. - Also dismiss on htmx:afterSwap (tab changes / navigation). Per-tab filter (now on plugin tabs too): - Render the shared settings_filter box in the plugin Configuration panel. It auto-wires: the delegated input handler and filterScope already target .plugin-config-tab. - Teach applyTabFilter to reveal matches inside collapsed nested sections (render_nested_section defaults them shut), hide nested-section wrappers with no matches, and restore the original collapsed layout when cleared (only re-collapsing sections the filter itself opened). - Count a visible nested-section as content for its parent heading so the heading isn't hidden while a subsection below still has matches. Adds a plugin-config render test (filter box + nested anchors + tooltips). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz * Close search dropdown via capture-phase outside-click The dropdown could stay open after clicking away because the only outside-click close was a bubble-phase document listener. The v3 UI is one Alpine app() component full of HTMX/Alpine/widget click handlers; when a click lands inside an element that calls stopPropagation(), the event never bubbles to document and the close never runs. - Replace the bubble-phase document 'click' close with a capture-phase 'pointerdown' listener scoped to #settings-search-wrap. Capture runs before any bubbling stopPropagation can swallow the event, so it always fires; pointerdown also covers touch on the Pi screen. Clicking a result stays inside the wrap, so selection is unaffected. - Guard the debounced input handler so a delayed render can't re-open the box after focus has left (type-then-click-away race). Keeps the existing blur / Escape / htmx:afterSwap closes as secondary paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz * Fix settings search dropdown not visually closing .hidden has no effect in this app: app.css is a hand-picked utility subset (no Tailwind build step) and never defines .hidden { display: none }. openResults()/closeResults() only toggled the class, so the dropdown stayed rendered (display: block) even once closeResults() ran - confirmed via computed style in a headless browser. Set style.display directly, matching the fallback already used by revealNode()/collapseNode() elsewhere in this file. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
185 lines
7.5 KiB
Python
185 lines
7.5 KiB
Python
"""
|
|
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"
|