mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-23 11:28:14 +00:00
Two alignment implementations existed and the toolbar used the wrong one. alignElement(dir) set el.x/el.y and stopped there. resolveAnchor turns anchor='right' into `dim - val`, so with xAnchor='right' an "align left" (el.x = 0) resolved to x = MATRIX_W and the element jumped to the far right edge -- the opposite of what was asked. It also never touched el.x0/el.y0, so a line's endpoints were left where they were. _alignElement already did both correctly: it clears the anchor so the stored value is absolute, and moves x0/y0 for lines. Its six wrappers -- alignLeft, alignHCenter, alignRight, alignTop, alignVCenter, alignBottom -- existed and had no callers at all. All six toolbar buttons now call the wrappers, and the legacy method is removed rather than left to drift back into use. Tests: the toolbar calls each wrapper and no longer calls alignElement, the legacy definition is gone, and _alignElement still clears the anchor and moves line endpoints. Two of them fail against the previous markup. Full suite 4062 passed, the one failure being test_install_lowmem (pre-existing, awaiting #492). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
188 lines
7.7 KiB
Python
188 lines
7.7 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"
|