mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-06 11:18:06 +00:00
test: add regression guards for the bug classes fixed in this PR
Three lightweight static checks, all enrolled in CI's unit-test allowlist along with the new web-cache test: - test_template_targets.py: every literal render_template() target must exist (would have caught the weather/stocks partial 500s at commit time). - test_widget_scripts.py: every widget JS file must be script-included in base.html or explicitly allowlisted with a reason (would have caught the unloaded time-picker/file-upload-single/plugin-file-manager widgets), and allowlisted files must NOT be included (prevents the example widget from shadowing the real color-picker). - test_doc_links.py: relative markdown links in active docs must resolve (docs/archive/ exempt). Each guard was verified to fail against the pre-PR tree and pass now. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr
This commit is contained in:
@@ -80,4 +80,8 @@ jobs:
|
|||||||
test/test_version_consistency.py \
|
test/test_version_consistency.py \
|
||||||
test/test_plugin_compatibility_gate.py \
|
test/test_plugin_compatibility_gate.py \
|
||||||
test/test_install_preserves_existing.py \
|
test/test_install_preserves_existing.py \
|
||||||
test/test_core_owned_config_keys.py
|
test/test_core_owned_config_keys.py \
|
||||||
|
test/test_template_targets.py \
|
||||||
|
test/test_widget_scripts.py \
|
||||||
|
test/test_doc_links.py \
|
||||||
|
test/web_interface/test_cache.py
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Guard: relative markdown links in active docs must resolve.
|
||||||
|
|
||||||
|
Scans repo-root *.md and docs/ (excluding docs/archive/, which is allowed
|
||||||
|
to rot). External URLs, mailto links, and pure anchors are skipped, as are
|
||||||
|
links inside fenced code blocks.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
LINK_RE = re.compile(r'\[[^\]]*\]\(([^)\s]+)\)')
|
||||||
|
FENCE_RE = re.compile(r'^(```|~~~)')
|
||||||
|
|
||||||
|
|
||||||
|
def _md_files():
|
||||||
|
yield from PROJECT_ROOT.glob('*.md')
|
||||||
|
for path in PROJECT_ROOT.glob('docs/**/*.md'):
|
||||||
|
if 'archive' not in path.parts:
|
||||||
|
yield path
|
||||||
|
|
||||||
|
|
||||||
|
def test_relative_markdown_links_resolve():
|
||||||
|
broken = []
|
||||||
|
for md in _md_files():
|
||||||
|
in_fence = False
|
||||||
|
for lineno, line in enumerate(md.read_text(encoding='utf-8').splitlines(), 1):
|
||||||
|
if FENCE_RE.match(line.strip()):
|
||||||
|
in_fence = not in_fence
|
||||||
|
continue
|
||||||
|
if in_fence:
|
||||||
|
continue
|
||||||
|
for target in LINK_RE.findall(line):
|
||||||
|
if target.startswith(('http://', 'https://', 'mailto:', '#')):
|
||||||
|
continue
|
||||||
|
resolved = (md.parent / target.split('#')[0]).resolve()
|
||||||
|
if not resolved.exists():
|
||||||
|
broken.append(
|
||||||
|
f'{md.relative_to(PROJECT_ROOT)}:{lineno} -> {target}'
|
||||||
|
)
|
||||||
|
assert not broken, 'Broken relative markdown links:\n' + '\n'.join(broken)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Guard: every literal render_template() target must exist on disk.
|
||||||
|
|
||||||
|
Catches routes that reference templates deleted in a refactor (a real bug
|
||||||
|
class: the weather/stocks partials 500'd for months because their
|
||||||
|
templates were removed when those displays became plugins).
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
TEMPLATE_ROOT = PROJECT_ROOT / 'web_interface' / 'templates'
|
||||||
|
RENDER_RE = re.compile(r"""render_template\(\s*['"]([^'"]+)['"]""")
|
||||||
|
|
||||||
|
|
||||||
|
def _python_sources():
|
||||||
|
yield PROJECT_ROOT / 'web_interface' / 'app.py'
|
||||||
|
yield from (PROJECT_ROOT / 'web_interface' / 'blueprints').glob('*.py')
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_literal_render_template_targets_exist():
|
||||||
|
missing = []
|
||||||
|
for source in _python_sources():
|
||||||
|
text = source.read_text(encoding='utf-8')
|
||||||
|
for lineno, line in enumerate(text.splitlines(), 1):
|
||||||
|
for target in RENDER_RE.findall(line):
|
||||||
|
if not (TEMPLATE_ROOT / target).is_file():
|
||||||
|
missing.append(
|
||||||
|
f'{source.relative_to(PROJECT_ROOT)}:{lineno} -> {target}'
|
||||||
|
)
|
||||||
|
assert not missing, (
|
||||||
|
'render_template() references templates that do not exist under '
|
||||||
|
f'web_interface/templates/:\n' + '\n'.join(missing)
|
||||||
|
)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Guard: every widget JS file must be loaded by base.html or explicitly allowlisted.
|
||||||
|
|
||||||
|
Widget files register themselves with LEDMatrixWidgets at load time; a file
|
||||||
|
that exists but is never <script>-included silently breaks any plugin whose
|
||||||
|
config schema declares that widget (the field renders as an empty container
|
||||||
|
that polls the registry forever). base.html's widget list is maintained by
|
||||||
|
hand, so this test keeps it honest.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
WIDGETS_DIR = PROJECT_ROOT / 'web_interface' / 'static' / 'v3' / 'js' / 'widgets'
|
||||||
|
BASE_HTML = PROJECT_ROOT / 'web_interface' / 'templates' / 'v3' / 'base.html'
|
||||||
|
|
||||||
|
# Files that must NOT be script-included, with the reason.
|
||||||
|
ALLOWLIST = {
|
||||||
|
# Documentation example (docs/widget-guide.md); registers the name
|
||||||
|
# 'color-picker' and would shadow the real color-picker.js if loaded.
|
||||||
|
'example-color-picker.js',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_widget_script_is_included_in_base_html():
|
||||||
|
base_html = BASE_HTML.read_text(encoding='utf-8')
|
||||||
|
missing = []
|
||||||
|
for js_file in sorted(WIDGETS_DIR.glob('*.js')):
|
||||||
|
if js_file.name in ALLOWLIST:
|
||||||
|
continue
|
||||||
|
if f'v3/js/widgets/{js_file.name}' not in base_html:
|
||||||
|
missing.append(js_file.name)
|
||||||
|
assert not missing, (
|
||||||
|
'Widget files exist but are never <script>-included in base.html '
|
||||||
|
'(plugins declaring these widgets get blank config fields): '
|
||||||
|
+ ', '.join(missing)
|
||||||
|
+ '. Add a script tag to base.html or add the file to ALLOWLIST '
|
||||||
|
'with a reason.'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_allowlisted_widgets_are_not_included():
|
||||||
|
base_html = BASE_HTML.read_text(encoding='utf-8')
|
||||||
|
wrongly_included = [
|
||||||
|
name for name in ALLOWLIST if f'v3/js/widgets/{name}' in base_html
|
||||||
|
]
|
||||||
|
assert not wrongly_included, (
|
||||||
|
'Allowlisted (must-not-load) widget files are script-included in '
|
||||||
|
'base.html: ' + ', '.join(wrongly_included)
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user