mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
Three findings from CodeRabbit's review of 6c23994b, all verified against
current code before fixing:
- manager.py.j2 interpolated binding.source unescaped into a Python comment
(`pass # dynamic_text binding_source "{{ el.binding_source }}" draws
nothing`). A source string with a newline broke out of the comment; a
crafted payload produces a clean, ast.parse-valid `import os` in the
generated plugin (confirmed against the pre-fix template). This is now a
fixed literal comment that never interpolates the value. Live now that
composer_bp is registered. CWE-94.
- _alignElement moved a line's x0 (or y0) to the new position but left x1
(or y1) behind, so aligning a line changed its shape instead of moving
it. Both endpoints now translate by the same delta.
- web_interface/app.py only assigned project_root inside the relative-path
branch of the plugins_dir resolution. An absolute plugin_system.plugins_
directory (a supported config value) hit UnboundLocalError importing the
module at all, since SchemaManager/composer_bp use project_root further
down. Now assigned unconditionally before the branch.
Also extends BOUND_TYPES coverage in composer-app.js (_isBound,
removeConfigVar, _validateBeforeExport) from dynamic_text/progress_bar to
all six element types that carry a binding object (countdown, pips,
sparkline, gauge too) -- found by direct code reading against
ELEMENT_DEFAULTS in composer-canvas.js, not from a review comment. Without
it, those four types could export with an unbound config key with no
validation error, and deleting a config var they used gave no warning.
All four fixes have mutation-checked regression tests (fail against the
reverted code, pass with the fix): test_binding_source_cannot_break_out_of_the_comment_it_lands_in,
test_align_translates_both_line_endpoints_not_just_the_start,
test_app_plugins_dir_resolution.py, test_binding_checks_cover_every_bound_element_type.
Full suite: 4365 passed, 58 skipped, 2 failed -- both the pre-existing
Europe/Kiev/Asia/Calcutta tzdata-alias gap on this sandbox, identical on
origin/main, unrelated to this change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
264 lines
12 KiB
Python
264 lines
12 KiB
Python
"""Structural checks on the composer's JavaScript.
|
|
|
|
There is no JS test runner in this repo, but three defects here are structural
|
|
enough to assert on the parse tree, and each was a real bug:
|
|
|
|
- Stroke widths inside _drawElement were left in canvas pixels while the
|
|
geometry around them scaled by SCALE, so at SCALE>1 every outline rendered
|
|
thinner than one LED pixel and the preview stopped matching the panel.
|
|
- The `line` branch drew raw el.x0/el.y0/el.x1/el.y1, ignoring the anchor that
|
|
every other element type honours, so setting xAnchor moved everything except
|
|
lines -- and getBoundingBox had the same omission, leaving the hit box behind.
|
|
- Four methods mutated exactly what _snapshot() serialises (metadata,
|
|
currentPreset) without calling it. _snapshot is the only caller of
|
|
_debouncedAutosave, so those changes were lost on reload and could not be
|
|
undone.
|
|
"""
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
tree_sitter = pytest.importorskip("tree_sitter")
|
|
tree_sitter_javascript = pytest.importorskip("tree_sitter_javascript")
|
|
|
|
JS_DIR = Path(__file__).resolve().parent.parent / "web_interface/static/v3/js/composer"
|
|
CANVAS = JS_DIR / "composer-canvas.js"
|
|
APP = JS_DIR / "composer-app.js"
|
|
|
|
|
|
def _function_source(path: Path, name: str) -> str:
|
|
"""Return the source of a top-level function declaration by name."""
|
|
src = path.read_bytes()
|
|
lang = tree_sitter.Language(tree_sitter_javascript.language())
|
|
tree = tree_sitter.Parser(lang).parse(src)
|
|
found = []
|
|
|
|
def walk(node):
|
|
if node.type == "function_declaration":
|
|
ident = node.child_by_field_name("name")
|
|
if ident is not None and src[ident.start_byte:ident.end_byte].decode() == name:
|
|
found.append(src[node.start_byte:node.end_byte].decode())
|
|
for c in node.children:
|
|
walk(c)
|
|
|
|
walk(tree.root_node)
|
|
assert found, f"{name} not found in {path.name}"
|
|
return found[0]
|
|
|
|
|
|
def _method_source(path: Path, name: str) -> str:
|
|
"""Return the source of a top-level object method by name."""
|
|
src = path.read_bytes()
|
|
lang = tree_sitter.Language(tree_sitter_javascript.language())
|
|
tree = tree_sitter.Parser(lang).parse(src)
|
|
found = []
|
|
|
|
def walk(node):
|
|
if node.type == "method_definition":
|
|
ident = node.child_by_field_name("name")
|
|
if ident is not None and src[ident.start_byte:ident.end_byte].decode() == name:
|
|
found.append(src[node.start_byte:node.end_byte].decode())
|
|
for c in node.children:
|
|
walk(c)
|
|
|
|
walk(tree.root_node)
|
|
assert found, f"{name} not found in {path.name}"
|
|
return found[0]
|
|
|
|
|
|
def test_both_files_parse():
|
|
lang = tree_sitter.Language(tree_sitter_javascript.language())
|
|
parser = tree_sitter.Parser(lang)
|
|
for path in (CANVAS, APP):
|
|
tree = parser.parse(path.read_bytes())
|
|
errors = []
|
|
|
|
def walk(node):
|
|
if node.type == "ERROR" or node.is_missing:
|
|
errors.append(node.start_point[0] + 1)
|
|
for c in node.children:
|
|
walk(c)
|
|
|
|
walk(tree.root_node)
|
|
assert not errors, f"{path.name} has parse errors at lines {errors}"
|
|
|
|
|
|
def test_element_strokes_scale_with_scale():
|
|
"""No bare `ctx.lineWidth = 1` inside _drawElement.
|
|
|
|
Selection handles and the grid are drawn in canvas pixels deliberately and
|
|
live in other functions, so this is scoped to the element drawing routine.
|
|
"""
|
|
body = _function_source(CANVAS, "_drawElement")
|
|
offenders = re.findall(r"ctx\.lineWidth\s*=\s*1\s*;", body)
|
|
assert not offenders, f"{len(offenders)} unscaled stroke width(s) in _drawElement"
|
|
|
|
|
|
def test_line_branch_applies_the_anchor_offset():
|
|
"""Scoped to _drawElement.
|
|
|
|
getBoundingBox has its own `case 'line': {` and appears first in the file,
|
|
so searching the whole text found *that* branch -- this assertion passed
|
|
with the draw branch's anchor offset removed. Verified: stripping it and
|
|
re-running gave 11/11 green.
|
|
"""
|
|
body = _function_source(CANVAS, "_drawElement")
|
|
line_branch = body[body.index("case 'line': {"):]
|
|
line_branch = line_branch[:line_branch.index("case 'divider'")]
|
|
assert "ax - el.x0" in line_branch and "ay - el.y0" in line_branch, \
|
|
"line drawing ignores xAnchor/yAnchor"
|
|
assert "moveTo(el.x0 * s" not in line_branch, \
|
|
"line still drawn from unanchored endpoints"
|
|
|
|
|
|
def test_line_bounding_box_applies_the_anchor_offset():
|
|
"""The companion to the above: scoped to getBoundingBox specifically, so
|
|
the two tests cannot both be satisfied by the same branch."""
|
|
body = _function_source(CANVAS, "getBoundingBox")
|
|
box = body[body.index("case 'line'"):]
|
|
box = box[:box.index("case 'divider'")]
|
|
assert "ax - el.x0" in box, "line bounding box ignores the anchor"
|
|
|
|
|
|
@pytest.mark.parametrize("method", [
|
|
"onBgColorChange", # mutates metadata.bgColor
|
|
"setCustomSize", # mutates currentPreset / MATRIX_W / MATRIX_H
|
|
"changePreset", # mutates currentPreset / MATRIX_W / MATRIX_H
|
|
"applyPresetLabel", # same, for sizes not in DISPLAY_PRESETS
|
|
"onColorChange", # the one that was already fixed — keeps it fixed
|
|
])
|
|
def test_state_mutations_take_a_snapshot(method):
|
|
body = _method_source(APP, method)
|
|
assert "_snapshot()" in body, \
|
|
f"{method} changes snapshotted state without calling _snapshot()"
|
|
assert "isDirty = true" in body, f"{method} does not mark the design dirty"
|
|
|
|
|
|
@pytest.mark.parametrize("method", ["changePreset", "applyPresetLabel"])
|
|
def test_restore_path_stays_snapshot_free(method):
|
|
"""_applyState and loadTemplate call these with {silent: true} while
|
|
restoring; snapshotting there would push restore steps onto the undo stack
|
|
and re-autosave the state just loaded."""
|
|
body = _method_source(APP, method)
|
|
assert "opts.silent" in body, f"{method} lost its silent guard"
|
|
snap = body.index("_snapshot()")
|
|
guard = body.index("!opts.silent")
|
|
assert guard < snap, f"{method} snapshots outside the !opts.silent guard"
|
|
|
|
|
|
TEMPLATE_HTML = (Path(__file__).resolve().parent.parent
|
|
/ "web_interface/templates/v3/composer.html")
|
|
|
|
#: The six toolbar buttons and the wrapper each must call.
|
|
ALIGN_BUTTONS = ["alignLeft", "alignHCenter", "alignRight",
|
|
"alignTop", "alignVCenter", "alignBottom"]
|
|
|
|
|
|
def test_alignment_buttons_use_the_anchor_clearing_path():
|
|
"""Two alignment implementations existed and the toolbar used the wrong one.
|
|
|
|
The legacy alignElement(dir) set el.x/el.y but left xAnchor/yAnchor in
|
|
place. resolveAnchor turns anchor='right' into `dim - val`, so "align left"
|
|
(el.x = 0) resolved to x = MATRIX_W -- the element jumped to the far right
|
|
edge instead. _alignElement clears the anchor first, so the stored value is
|
|
absolute, and it also updates el.x0/el.y0 so lines actually move.
|
|
"""
|
|
html = TEMPLATE_HTML.read_text()
|
|
for wrapper in ALIGN_BUTTONS:
|
|
assert f"{wrapper}()" in html, f"toolbar does not call {wrapper}()"
|
|
assert not re.search(r"[^_]alignElement\(", html), \
|
|
"toolbar still calls the legacy alignElement()"
|
|
|
|
|
|
def test_the_legacy_alignelement_is_gone():
|
|
"""Leaving it in place invites the toolbar drifting back to it."""
|
|
src = APP.read_text()
|
|
assert not re.search(r"^\s{4}alignElement\(dir\)", src, re.M), \
|
|
"legacy alignElement(dir) still defined"
|
|
|
|
|
|
def test_align_clears_the_anchor_and_moves_line_endpoints():
|
|
body = _method_source(APP, "_alignElement")
|
|
assert "xAnchor = null" in body and "yAnchor = null" in body, \
|
|
"_alignElement no longer clears the anchor, so aligning an anchored " \
|
|
"element resolves to the wrong edge"
|
|
assert "el.x0" in body and "el.y0" in body, \
|
|
"_alignElement no longer moves line endpoints"
|
|
|
|
|
|
def test_align_translates_both_line_endpoints_not_just_the_start():
|
|
"""Setting only x0 (or y0) left x1/y1 behind, so aligning a line changed
|
|
its shape instead of moving it -- e.g. a line from x0=20 to x1=50 aligned
|
|
right became x0=98, x1=50, stretching rather than translating it. Both
|
|
endpoints must move by the same delta."""
|
|
body = _method_source(APP, "_alignElement")
|
|
x_branch = body[body.index("if (axis === 'x')"):body.index("} else {")]
|
|
y_branch = body[body.index("} else {"):]
|
|
assert "el.x1" in x_branch, \
|
|
"_alignElement moves x0 but not x1 -- a line's shape changes, not its position"
|
|
assert "el.y1" in y_branch, \
|
|
"_alignElement moves y0 but not y1 -- a line's shape changes, not its position"
|
|
|
|
|
|
def test_divider_stroke_is_centered_in_led_pixels():
|
|
"""A 0.5 canvas-pixel offset (correct only at SCALE=1) was applied after
|
|
scaling instead of before it, so at SCALE>1 the stroke bled into the
|
|
preceding LED row/column instead of straddling its own."""
|
|
body = _function_source(CANVAS, "_drawElement")
|
|
divider_branch = body[body.index("case 'divider'"):]
|
|
divider_branch = divider_branch[:divider_branch.index("case 'pips'")]
|
|
assert "ay * s + 0.5" not in divider_branch, \
|
|
"divider still offsets by 0.5 canvas pixels after scaling"
|
|
assert "ax * s + 0.5" not in divider_branch, \
|
|
"divider still offsets by 0.5 canvas pixels after scaling"
|
|
assert "(ay + 0.5) * s" in divider_branch and "(ax + 0.5) * s" in divider_branch, \
|
|
"divider stroke is not centered within its LED pixel"
|
|
|
|
|
|
def test_gauge_radii_are_clamped_to_zero():
|
|
"""An imported design can carry a small gauge with a wide lineWidth --
|
|
width=1, height=1, lineWidth=3 sends a negative radius into
|
|
ctx.ellipse(), which throws IndexSizeError and aborts render() for every
|
|
element still to be drawn, not just the gauge."""
|
|
body = _function_source(CANVAS, "_drawElement")
|
|
gauge_branch = body[body.index("case 'gauge'"):]
|
|
assert re.search(r"Math\.max\(0,\s*rx\s*-\s*lwPx\s*/\s*2\)", gauge_branch), \
|
|
"gauge x-radius is not clamped to zero"
|
|
assert re.search(r"Math\.max\(0,\s*ry\s*-\s*lwPx\s*/\s*2\)", gauge_branch), \
|
|
"gauge y-radius is not clamped to zero"
|
|
assert "rx - lwPx / 2" not in re.sub(r"Math\.max\(0,\s*rx\s*-\s*lwPx\s*/\s*2\)", "", gauge_branch), \
|
|
"an unclamped gauge radius is still passed to ctx.ellipse()"
|
|
|
|
|
|
#: ELEMENT_DEFAULTS in composer-canvas.js gives exactly these types a
|
|
#: `binding` object; verified against that file rather than assumed.
|
|
BOUND_TYPES = ["dynamic_text", "progress_bar", "countdown", "pips", "sparkline", "gauge"]
|
|
|
|
|
|
def test_bound_types_constant_lists_every_bound_element_type():
|
|
"""ELEMENT_DEFAULTS in composer-canvas.js gives dynamic_text, progress_bar,
|
|
countdown, pips, sparkline and gauge a `binding` object. If a type is added
|
|
to (or removed from) that list without updating APP's BOUND_TYPES, the
|
|
checks below drift out of sync silently -- this pins the two together."""
|
|
src = APP.read_text()
|
|
match = re.search(r"const BOUND_TYPES = \[([^\]]*)\];", src)
|
|
assert match, "BOUND_TYPES constant not found in composer-app.js"
|
|
declared = {t.strip().strip("'\"") for t in match.group(1).split(",") if t.strip()}
|
|
assert declared == set(BOUND_TYPES), \
|
|
f"BOUND_TYPES {declared} does not match the bound element types {set(BOUND_TYPES)}"
|
|
|
|
|
|
@pytest.mark.parametrize("method", ["_isBound", "removeConfigVar", "_validateBeforeExport"])
|
|
def test_binding_checks_cover_every_bound_element_type(method):
|
|
"""Only dynamic_text and progress_bar were checked, so a countdown, pips,
|
|
sparkline or gauge element with an empty binding passed export validation
|
|
silently, and deleting a config var still used by one of them gave no
|
|
warning -- the plugin would read a now-missing key at runtime with no
|
|
indication why."""
|
|
body = _method_source(APP, method)
|
|
assert "BOUND_TYPES.includes(e.type)" in body, \
|
|
f"{method} does not check every bound element type via BOUND_TYPES"
|
|
assert "e.type === 'dynamic_text'" not in body, \
|
|
f"{method} still hardcodes only dynamic_text instead of BOUND_TYPES"
|