mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 23:05:10 +00:00
* feat(web): the Display tab is an ES-module page, with a page-visibility service (stage 4) - core/visibility.js: each page gets ctx.visibility (whileVisible, every, isVisible). Work registered there runs only while the page's tab is active and the browser tab visible, and ends when the page is swapped out. It reads the active tab from window.LEDVisibility, so it agrees with the classic partials. The registry gained a mountContext option for per-mount services. - pages/display.js replaces display.html's two inline scripts. The 5 s sync status poll runs through ctx.visibility.every; the status and scroll-speed hint requests go through ctx.api with ctx.signal, as does the Vegas order widget's plugin-list request. The Advanced toggle is a delegated data-action; window.updateSyncUI is a deprecated alias. - New DOM suites test_visibility_service.js and test_display_page.js; test_display_partial_ids.js imports the module. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): no computed keys in the Display page's readout and destroy Codacy's object-injection rule flagged v[id] and ctx.state[name]. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
222 lines
9.6 KiB
Python
222 lines
9.6 KiB
Python
"""The ES-module layer of the web UI is served the way browsers need it.
|
|
|
|
static/v3/js/core/ and js/pages/ are native ES modules, loaded with
|
|
<script type="module"> and no bundler (docs/WEB_FRONTEND_ARCHITECTURE.md):
|
|
|
|
* A module script runs only when served with a JavaScript MIME type, so the
|
|
app pins .js to text/javascript instead of trusting the host's table.
|
|
* Modules import each other by plain relative URL, without the ?v= content
|
|
version url_for adds. Those requests must revalidate rather than be cached
|
|
as immutable for a year, or an update would keep running old modules.
|
|
* Every import resolves to a file that exists, every registered page has its
|
|
module and a partial whose root names it, and a converted partial carries
|
|
no inline <script> (which htmx-config.js would re-run on every swap).
|
|
"""
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
JS = PROJECT_ROOT / "web_interface" / "static" / "v3" / "js"
|
|
PARTIALS = PROJECT_ROOT / "web_interface" / "templates" / "v3" / "partials"
|
|
MODULE_DIRS = (JS / "core", JS / "pages")
|
|
MODULES = sorted(p for d in MODULE_DIRS for p in d.glob("*.js"))
|
|
JS_TYPES = {"text/javascript", "application/javascript"}
|
|
|
|
_IMPORT = re.compile(r"""(?:\bimport\s*\(\s*|\bfrom\s+|^\s*import\s+)['"]([^'"]+)['"]""", re.M)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def client():
|
|
import web_interface.app as web_app
|
|
web_app.app.config["TESTING"] = True
|
|
with web_app.app.test_client() as c:
|
|
yield c
|
|
|
|
|
|
def _url(path):
|
|
return "/static/" + path.relative_to(PROJECT_ROOT / "web_interface" / "static").as_posix()
|
|
|
|
|
|
def test_the_module_directories_hold_modules():
|
|
assert {p.name for p in MODULES} >= {"boot.js", "registry.js", "api.js", "facade.js", "cache.js",
|
|
"durations.js", "operation-history.js", "raw-json.js",
|
|
"backup-restore.js", "schedule.js", "general.js",
|
|
"visibility.js", "display.js"}
|
|
for directory in MODULE_DIRS:
|
|
# node needs this to import them in the JS tests; browsers ignore it.
|
|
assert '"type": "module"' in (directory / "package.json").read_text(encoding="utf-8")
|
|
|
|
|
|
@pytest.mark.parametrize("module", MODULES, ids=[m.name for m in MODULES])
|
|
def test_each_module_is_served_as_javascript_and_revalidated(client, module):
|
|
resp = client.get(_url(module))
|
|
assert resp.status_code == 200
|
|
assert resp.mimetype in JS_TYPES
|
|
# Requested as a relative import would request it: no ?v=.
|
|
assert resp.headers["Cache-Control"] == "no-cache"
|
|
assert resp.headers.get("ETag") or resp.headers.get("Last-Modified")
|
|
|
|
|
|
def test_a_versioned_script_is_still_cached_for_good(client):
|
|
resp = client.get(_url(JS / "core" / "boot.js") + "?v=123")
|
|
assert "immutable" in resp.headers["Cache-Control"]
|
|
|
|
|
|
def test_an_unchanged_module_revalidates_to_a_304(client):
|
|
first = client.get(_url(JS / "core" / "registry.js"))
|
|
again = client.get(_url(JS / "core" / "registry.js"),
|
|
headers={"If-None-Match": first.headers["ETag"]})
|
|
assert again.status_code == 304
|
|
|
|
|
|
def test_classic_scripts_keep_a_javascript_type(client):
|
|
resp = client.get("/static/v3/js/app-early.js")
|
|
assert resp.mimetype in JS_TYPES
|
|
|
|
|
|
def test_base_html_loads_the_entry_module_last(client):
|
|
page = client.get("/").get_data(as_text=True)
|
|
tag = re.search(r'<script type="module" src="(/static/v3/js/core/boot\.js\?v=\d+)"></script>', page)
|
|
assert tag, "base.html must load js/core/boot.js as a versioned module script"
|
|
# After every classic script, so nothing classic can depend on it at load.
|
|
assert page.index(tag.group(0)) > page.index("plugins_manager.js")
|
|
|
|
|
|
@pytest.mark.parametrize("module", MODULES, ids=[m.name for m in MODULES])
|
|
def test_every_import_resolves_inside_the_module_tree(module):
|
|
source = module.read_text(encoding="utf-8")
|
|
for spec in _IMPORT.findall(source):
|
|
assert spec.startswith("./") or spec.startswith("../"), (
|
|
f"{module.name}: {spec!r} -- no bundler, so only relative imports work")
|
|
target = (module.parent / spec).resolve()
|
|
assert target.is_file(), f"{module.name}: {spec!r} does not exist"
|
|
assert any(target.parent == d.resolve() for d in MODULE_DIRS), (
|
|
f"{module.name}: {spec!r} leaves js/core and js/pages")
|
|
|
|
|
|
# Converted so far: page name -> (its partial, the route that serves it). The
|
|
# migration order is in docs/WEB_FRONTEND_ARCHITECTURE.md.
|
|
CONVERTED = {
|
|
"cache": ("cache.html", "/partials/cache"),
|
|
"durations": ("durations.html", "/partials/durations"),
|
|
"operation-history": ("operation_history.html", "/partials/operation-history"),
|
|
"raw-json": ("raw_json.html", "/partials/raw-json"),
|
|
"backup-restore": ("backup_restore.html", "/partials/backup-restore"),
|
|
"schedule": ("schedule.html", "/partials/schedule"),
|
|
"general": ("general.html", "/partials/general"),
|
|
"display": ("display.html", "/partials/display"),
|
|
}
|
|
|
|
# Old window.* names that moved into a page module. Each stays as a
|
|
# deprecated alias in boot.js that forwards to the module's export.
|
|
ALIASES = {
|
|
"cache": ["deleteCacheFile"],
|
|
"raw-json": ["formatJson", "manualValidateJson", "validateJSON",
|
|
"saveMainConfig", "saveSecretsConfig"],
|
|
"backup-restore": ["exportBackup", "loadBackupList", "validateRestoreFile",
|
|
"clearRestore", "runRestore"],
|
|
"schedule": ["handleScheduleResponse", "handleDimScheduleResponse"],
|
|
"general": ["webLogin"],
|
|
"display": ["updateSyncUI"],
|
|
}
|
|
|
|
|
|
def _boot():
|
|
return (JS / "core" / "boot.js").read_text(encoding="utf-8")
|
|
|
|
|
|
def _registered_pages():
|
|
return re.findall(r"'([\w-]+)':\s*page\(function\(\)\s*\{\s*return import\('\.\./pages/([\w-]+)\.js'\)",
|
|
_boot())
|
|
|
|
|
|
def test_every_converted_page_is_registered():
|
|
assert dict(_registered_pages()) == {name: name for name in CONVERTED}
|
|
|
|
|
|
@pytest.mark.parametrize("name", sorted(CONVERTED))
|
|
def test_a_converted_partial_roots_its_page(client, name):
|
|
partial, route = CONVERTED[name]
|
|
text = (PARTIALS / partial).read_text(encoding="utf-8")
|
|
assert f'data-page="{name}"' in text
|
|
assert "<script" not in text.lower()
|
|
# Its buttons are wired by the module, not by inline handlers naming
|
|
# globals (which would reach the code only through a deprecated alias).
|
|
assert "onclick=" not in text.lower()
|
|
module = (JS / "pages" / f"{name}.js").read_text(encoding="utf-8")
|
|
assert re.search(r"^export function init\(root, ctx\)", module, re.M)
|
|
# Rendered by the real route, the root is there exactly once.
|
|
resp = client.get(route)
|
|
assert resp.status_code == 200, route
|
|
assert resp.get_data(as_text=True).count(f'data-page="{name}"') == 1, route
|
|
|
|
|
|
@pytest.mark.parametrize("name", sorted(ALIASES))
|
|
def test_moved_globals_stay_as_aliases(name):
|
|
boot = _boot()
|
|
module = (JS / "pages" / f"{name}.js").read_text(encoding="utf-8")
|
|
for global_name in ALIASES[name]:
|
|
assert f"'{global_name}'" in boot, f"boot.js does not alias {global_name}"
|
|
assert re.search(rf"^export (?:function|const) {global_name}\b", module, re.M), (
|
|
f"pages/{name}.js does not export {global_name}")
|
|
# No template defines them any more, or calls them (an inline handler
|
|
# would reach the page only through the deprecated alias).
|
|
for partial in PARTIALS.glob("*.html"):
|
|
text = partial.read_text(encoding="utf-8")
|
|
for global_name in ALIASES[name]:
|
|
assert f"window.{global_name} =" not in text, partial.name
|
|
assert f"function {global_name}(" not in text, partial.name
|
|
assert not re.search(rf"\b{global_name}\b", text), (
|
|
f"{partial.name} still names {global_name}")
|
|
|
|
|
|
def test_every_registered_page_has_its_module_and_partial():
|
|
pages = _registered_pages()
|
|
assert ("cache", "cache") in pages
|
|
for name, module in pages:
|
|
assert name == module, "a page is named after its module"
|
|
assert (JS / "pages" / f"{module}.js").is_file()
|
|
partials = [p for p in PARTIALS.glob("*.html")
|
|
if f'data-page="{name}"' in p.read_text(encoding="utf-8")]
|
|
assert len(partials) == 1, f"one partial roots page {name!r}: {partials}"
|
|
|
|
|
|
def test_converted_partials_carry_no_inline_script():
|
|
for partial in PARTIALS.glob("*.html"):
|
|
text = partial.read_text(encoding="utf-8")
|
|
if "data-page=" in text:
|
|
assert "<script" not in text.lower(), (
|
|
f"{partial.name} is a page module now; its code belongs in js/pages/")
|
|
|
|
|
|
def test_the_schedule_page_reads_its_config_back_intact():
|
|
"""schedule.html hands both saved schedules to pages/schedule.js as JSON in
|
|
single-quoted data attributes; a value with quotes or markup must neither
|
|
end the attribute nor change on the way."""
|
|
from html.parser import HTMLParser
|
|
import json
|
|
import web_interface.app as web_app
|
|
from flask import render_template
|
|
|
|
hostile = {"mode": "per-day", "start_time": "07:00", "note": "it's a \"<b>\" & '</div>"}
|
|
|
|
class Root(HTMLParser):
|
|
attrs = None
|
|
|
|
def handle_starttag(self, tag, attrs):
|
|
if dict(attrs).get("data-page") == "schedule":
|
|
self.attrs = dict(attrs)
|
|
|
|
with web_app.app.test_request_context():
|
|
html = render_template("v3/partials/schedule.html", schedule_config=hostile,
|
|
dim_schedule_config=None, normal_brightness=90)
|
|
parser = Root()
|
|
parser.feed(html)
|
|
assert parser.attrs is not None
|
|
assert json.loads(parser.attrs["data-schedule-config"]) == hostile
|
|
# A missing dim schedule arrives as null; the page treats that as {}.
|
|
assert json.loads(parser.attrs["data-dim-schedule-config"]) is None
|