Move the app under src/, add ring-spanning patterns with shared fonts and beat-driven motion, and wire global audio settings into the web UI. Co-authored-by: Cursor <cursoragent@cursor.com>
251 lines
7.6 KiB
Python
251 lines
7.6 KiB
Python
"""Pattern plugin smoke tests."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
SRC = Path(__file__).resolve().parent.parent / "src"
|
|
if str(SRC) not in sys.path:
|
|
sys.path.insert(0, str(SRC))
|
|
|
|
from leds.panel_udp import HeadlessMatrix
|
|
from leds.pattern import get_registry, merge_params
|
|
|
|
|
|
NEW_PATTERN_IDS = (
|
|
"aurora",
|
|
"meteor",
|
|
"embers",
|
|
"tide",
|
|
"vortex",
|
|
"serpent",
|
|
"lattice",
|
|
"eclipse",
|
|
)
|
|
|
|
|
|
def test_registry_loads_bundled_patterns() -> None:
|
|
ids = get_registry().ids()
|
|
assert "rainbow" in ids
|
|
assert "scanner" in ids
|
|
for pattern_id in NEW_PATTERN_IDS:
|
|
assert pattern_id in ids, pattern_id
|
|
assert len(ids) >= 18
|
|
|
|
|
|
def test_scanner_draws_pixels() -> None:
|
|
cls = get_registry()["scanner"]
|
|
inst = cls()
|
|
params = merge_params(cls, {"colors": {"beam": "#00ff00"}})
|
|
matrix = HeadlessMatrix(20, 9, brightness=1.0)
|
|
inst.draw(matrix, 0.5, params)
|
|
assert any(matrix.rgb_bytes())
|
|
|
|
|
|
def test_ancient_text_patterns_draw() -> None:
|
|
registry = get_registry()
|
|
for pattern_id in (
|
|
"inscription",
|
|
"runes",
|
|
"glyphs",
|
|
"oracle",
|
|
"procession",
|
|
"rite",
|
|
"braziers",
|
|
"constellation",
|
|
"veil",
|
|
*NEW_PATTERN_IDS,
|
|
):
|
|
assert pattern_id in registry
|
|
cls = registry[pattern_id]
|
|
matrix = HeadlessMatrix(45, 9, brightness=1.0)
|
|
cls().draw(matrix, 1.25, merge_params(cls))
|
|
assert max(matrix.rgb_bytes()) > 40, pattern_id
|
|
|
|
|
|
def test_portal_ring_spans_panels() -> None:
|
|
from leds.array_config import PANEL_COUNT, panel_layout
|
|
from leds.portal_span import inject_portal, portal_ring_width
|
|
|
|
cls = get_registry()["procession"]
|
|
inst = cls()
|
|
params = merge_params(cls)
|
|
lit_global = set()
|
|
for index in range(PANEL_COUNT):
|
|
width, height = panel_layout(index)
|
|
matrix = HeadlessMatrix(width, height, brightness=1.0)
|
|
inject_portal(params, index)
|
|
inst.draw(matrix, 2.5, params)
|
|
rgb = matrix.rgb_bytes()
|
|
offset = params["portal"]["x_offset"]
|
|
for x in range(width):
|
|
# Middle row brightness sample.
|
|
i = ((height // 2) * width + x) * 3
|
|
if rgb[i] + rgb[i + 1] + rgb[i + 2] > 30:
|
|
lit_global.add(offset + x)
|
|
# Spanning pattern should light pixels across more than one panel's width.
|
|
assert len(lit_global) > 50
|
|
assert params["portal"]["ring_width"] == portal_ring_width()
|
|
|
|
|
|
def test_ancient_patterns_have_no_preset_bpm() -> None:
|
|
for pattern_id in (
|
|
"inscription",
|
|
"runes",
|
|
"glyphs",
|
|
"oracle",
|
|
"rite",
|
|
"veil",
|
|
"procession",
|
|
"braziers",
|
|
"constellation",
|
|
"aurora",
|
|
"vortex",
|
|
"serpent",
|
|
"lattice",
|
|
"eclipse",
|
|
"tide",
|
|
"embers",
|
|
):
|
|
names = {p["name"] for p in get_registry()[pattern_id].schema()["params"]}
|
|
assert "bpm" not in names, pattern_id
|
|
|
|
|
|
def test_resolve_bpm_uses_settings_fallback() -> None:
|
|
from leds.bpm_limits import resolve_bpm
|
|
|
|
params = merge_params(get_registry()["rite"])
|
|
params["beat"] = {"active": False, "fallback_bpm": 140}
|
|
assert resolve_bpm(params, fallback=120) == 140
|
|
params["extras"]["beat_detect"] = True
|
|
params["beat"] = {"active": True, "bpm": 128, "fallback_bpm": 140}
|
|
assert resolve_bpm(params, fallback=120) == 128
|
|
params["extras"]["beat_detect"] = False
|
|
assert resolve_bpm(params, fallback=120) == 140
|
|
|
|
|
|
def test_no_synthetic_pulse_when_beat_detect_off() -> None:
|
|
from leds.bpm_limits import resolve_beat_wave, resolve_pulse
|
|
|
|
params = merge_params(get_registry()["rite"])
|
|
assert params["extras"]["beat_detect"] is False
|
|
params["beat"] = {"active": True, "bpm": 128, "pulse": 0.9, "fallback_bpm": 120}
|
|
assert resolve_pulse(params, 0.25, fallback_bpm=120) == 0.0
|
|
assert resolve_beat_wave(params, 0.25, fallback_bpm=120) == 0.5
|
|
|
|
|
|
def test_patterns_prefer_live_beat_pulse() -> None:
|
|
from leds.bpm_limits import resolve_pulse
|
|
|
|
cls = get_registry()["rite"]
|
|
params = merge_params(cls)
|
|
params["extras"]["beat_detect"] = True
|
|
params["beat"] = {
|
|
"active": True,
|
|
"bpm": 128,
|
|
"pulse": 0.9,
|
|
"beat_seq": 12,
|
|
"kick": 0.8,
|
|
"bass": 0.6,
|
|
"energy": 0.7,
|
|
"flux": 0.2,
|
|
"hat": 0.1,
|
|
"snare": 0.3,
|
|
"level": 0.4,
|
|
}
|
|
assert resolve_pulse(params, 0.0, fallback_bpm=120) == 0.9
|
|
matrix = HeadlessMatrix(45, 9, brightness=1.0)
|
|
cls().draw(matrix, 1.0, params)
|
|
assert max(matrix.rgb_bytes()) > 40
|
|
|
|
|
|
|
|
def test_inscription_accepts_custom_text() -> None:
|
|
cls = get_registry()["inscription"]
|
|
schema = cls.schema()
|
|
text_param = next(p for p in schema["params"] if p["name"] == "text")
|
|
assert text_param["type"] == "text"
|
|
params = merge_params(cls, {"extras": {"text": "ave"}})
|
|
assert params["extras"]["text"] == "ave"
|
|
matrix = HeadlessMatrix(45, 9, brightness=1.0)
|
|
cls().draw(matrix, 0.0, params)
|
|
assert max(matrix.rgb_bytes()) > 40
|
|
|
|
|
|
def test_oracle_splits_custom_words() -> None:
|
|
from patterns.oracle import _words
|
|
|
|
assert _words("lux / nox") == ("LUX", "NOX")
|
|
cls = get_registry()["oracle"]
|
|
params = merge_params(cls, {"extras": {"text": "LUX", "beats_per_word": 3.0}})
|
|
assert params["extras"]["text"] == "LUX"
|
|
assert params["extras"]["beats_per_word"] == 3.0
|
|
matrix = HeadlessMatrix(45, 9, brightness=1.0)
|
|
cls().draw(matrix, 0.5, params)
|
|
assert max(matrix.rgb_bytes()) > 40
|
|
|
|
|
|
def test_preset_normalize_keeps_text() -> None:
|
|
from leds.presets import _normalize
|
|
|
|
preset = _normalize(
|
|
{
|
|
"name": "Hello",
|
|
"pattern": "inscription",
|
|
"brightness": 0.4,
|
|
"speed": 1.0,
|
|
"colors": {"ink": "#e0b44a", "stone": "#2a1c10"},
|
|
"extras": {"text": "HELLO"},
|
|
}
|
|
)
|
|
assert preset["extras"]["text"] == "HELLO"
|
|
|
|
|
|
def test_new_pattern_presets_normalize() -> None:
|
|
from leds.presets import _normalize
|
|
|
|
preset = _normalize(
|
|
{
|
|
"name": "Vortex",
|
|
"pattern": "vortex",
|
|
"brightness": 0.42,
|
|
"speed": 1.0,
|
|
"colors": {"core": "#6a20ff", "rim": "#f0c060", "void": "#05030a"},
|
|
"extras": {"twist": 0.75, "bg_brightness": 0.4},
|
|
}
|
|
)
|
|
assert preset["pattern"] == "vortex"
|
|
assert preset["extras"]["twist"] == 0.75
|
|
|
|
|
|
def test_text_and_background_brightness() -> None:
|
|
cls = get_registry()["inscription"]
|
|
names = {p["name"] for p in cls.schema()["params"]}
|
|
assert "text_brightness" in names
|
|
assert "bg_brightness" in names
|
|
bright = merge_params(cls, {"extras": {"text": "AVE", "text_brightness": 1.0, "bg_brightness": 0.0}})
|
|
dimmed = merge_params(cls, {"extras": {"text": "AVE", "text_brightness": 0.0, "bg_brightness": 0.0}})
|
|
a = HeadlessMatrix(45, 9, brightness=1.0)
|
|
b = HeadlessMatrix(45, 9, brightness=1.0)
|
|
cls().draw(a, 0.0, bright)
|
|
cls().draw(b, 0.0, dimmed)
|
|
assert sum(a.rgb_bytes()) > sum(b.rgb_bytes())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
test_registry_loads_bundled_patterns()
|
|
test_scanner_draws_pixels()
|
|
test_ancient_text_patterns_draw()
|
|
test_portal_ring_spans_panels()
|
|
test_ancient_patterns_have_no_preset_bpm()
|
|
test_resolve_bpm_uses_settings_fallback()
|
|
test_no_synthetic_pulse_when_beat_detect_off()
|
|
test_patterns_prefer_live_beat_pulse()
|
|
test_inscription_accepts_custom_text()
|
|
test_oracle_splits_custom_words()
|
|
test_preset_normalize_keeps_text()
|
|
test_text_and_background_brightness()
|
|
test_new_pattern_presets_normalize()
|
|
print("ok")
|