test(led-driver): add pattern smoke harness
Made-with: Cursor
This commit is contained in:
43
tests/fake_driver.py
Normal file
43
tests/fake_driver.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""Minimal Presets-like stub for pattern smoke tests (no machine / NeoPixel)."""
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeNeo:
|
||||||
|
def __init__(self, count):
|
||||||
|
self._count = count
|
||||||
|
self.pixels = [(0, 0, 0)] * count
|
||||||
|
|
||||||
|
def fill(self, color):
|
||||||
|
self.pixels = [tuple(color) for _ in range(self._count)]
|
||||||
|
|
||||||
|
def __setitem__(self, index, value):
|
||||||
|
self.pixels[index] = tuple(value)
|
||||||
|
|
||||||
|
def __getitem__(self, index):
|
||||||
|
return self.pixels[index]
|
||||||
|
|
||||||
|
def write(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FakePresets:
|
||||||
|
"""Subset of led-driver Presets API used by patterns/*.py."""
|
||||||
|
|
||||||
|
def __init__(self, num_leds=24):
|
||||||
|
self.num_leds = num_leds
|
||||||
|
self.n = _FakeNeo(num_leds)
|
||||||
|
self.b = 255
|
||||||
|
self.step = 0
|
||||||
|
|
||||||
|
def apply_brightness(self, color, brightness_override=None):
|
||||||
|
local = brightness_override if brightness_override is not None else 255
|
||||||
|
effective_brightness = int(local * self.b / 255)
|
||||||
|
return tuple(int(c * effective_brightness / 255) for c in color)
|
||||||
|
|
||||||
|
def fill(self, color=None):
|
||||||
|
fill_color = color if color is not None else (0, 0, 0)
|
||||||
|
for i in range(self.num_leds):
|
||||||
|
self.n[i] = fill_color
|
||||||
|
self.n.write()
|
||||||
|
|
||||||
|
def off(self):
|
||||||
|
self.fill((0, 0, 0))
|
||||||
174
tests/pattern_smoke.py
Normal file
174
tests/pattern_smoke.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"""
|
||||||
|
Smoke-test pattern generators with a fake driver (no hardware).
|
||||||
|
|
||||||
|
Run from repo root (CPython or MicroPython):
|
||||||
|
|
||||||
|
python3 led-driver/tests/pattern_smoke.py
|
||||||
|
|
||||||
|
Requires only the stdlib; loads ``preset.py`` and ``patterns/*.py`` from
|
||||||
|
``led-driver/src`` via import paths (MicroPython: copy ``src`` tree to the
|
||||||
|
device and run the same command if ``importlib.util`` is available, or set
|
||||||
|
``PYTHONPATH`` to ``led-driver/src`` and use ``python3`` on the host).
|
||||||
|
|
||||||
|
Exit code 0 on success, 1 on any failure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Host-only ``utime`` so patterns import on CPython.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
_UTIME_MS = [0]
|
||||||
|
|
||||||
|
|
||||||
|
def utime_advance(ms):
|
||||||
|
_UTIME_MS[0] += int(ms)
|
||||||
|
|
||||||
|
|
||||||
|
def _install_utime_shim():
|
||||||
|
if "utime" in sys.modules:
|
||||||
|
return
|
||||||
|
import types
|
||||||
|
|
||||||
|
m = types.ModuleType("utime")
|
||||||
|
|
||||||
|
def ticks_ms():
|
||||||
|
return _UTIME_MS[0]
|
||||||
|
|
||||||
|
def ticks_diff(a, b):
|
||||||
|
return a - b
|
||||||
|
|
||||||
|
def ticks_add(a, b):
|
||||||
|
return a + b
|
||||||
|
|
||||||
|
m.ticks_ms = ticks_ms
|
||||||
|
m.ticks_diff = ticks_diff
|
||||||
|
m.ticks_add = ticks_add
|
||||||
|
sys.modules["utime"] = m
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Load ``preset`` and pattern modules from ``led-driver/src`` (no sys.path).
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
_SRC = Path(__file__).resolve().parent.parent / "src"
|
||||||
|
_TESTS = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
def _load_module(name, path):
|
||||||
|
spec = importlib.util.spec_from_file_location(name, path)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise RuntimeError("no spec for %s" % path)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
def _pattern_class_from_module(mod):
|
||||||
|
for attr_name in dir(mod):
|
||||||
|
attr = getattr(mod, attr_name)
|
||||||
|
if isinstance(attr, type) and hasattr(attr, "run"):
|
||||||
|
return attr
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_preset_class():
|
||||||
|
preset_mod = _load_module("preset", _SRC / "preset.py")
|
||||||
|
return preset_mod.Preset
|
||||||
|
|
||||||
|
|
||||||
|
def _list_pattern_basenames():
|
||||||
|
pat_dir = _SRC / "patterns"
|
||||||
|
out = []
|
||||||
|
for p in sorted(pat_dir.iterdir()):
|
||||||
|
if p.suffix != ".py":
|
||||||
|
continue
|
||||||
|
if p.name in ("__init__.py", "main.py"):
|
||||||
|
continue
|
||||||
|
out.append(p.stem)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _default_preset_dict(basename):
|
||||||
|
"""Reasonable defaults so each pattern's ``run()`` can start."""
|
||||||
|
base = {
|
||||||
|
"p": basename,
|
||||||
|
"d": 100,
|
||||||
|
"b": 200,
|
||||||
|
"a": True,
|
||||||
|
"n1": 5,
|
||||||
|
"n2": 8,
|
||||||
|
"n3": 5,
|
||||||
|
"n4": 4,
|
||||||
|
}
|
||||||
|
if basename in ("rainbow", "colour_cycle"):
|
||||||
|
base["c"] = []
|
||||||
|
base["n1"] = 2
|
||||||
|
elif basename == "transition":
|
||||||
|
base["c"] = [(255, 0, 0), (0, 255, 0), (0, 0, 255)]
|
||||||
|
base["d"] = 200
|
||||||
|
elif basename in ("chase", "circle"):
|
||||||
|
base["c"] = [(255, 0, 0), (0, 0, 255)]
|
||||||
|
elif basename == "pulse":
|
||||||
|
base["c"] = [(0, 200, 100)]
|
||||||
|
base["n1"] = 50
|
||||||
|
base["n2"] = 40
|
||||||
|
base["n3"] = 50
|
||||||
|
base["d"] = 30
|
||||||
|
elif basename in ("blink", "flicker"):
|
||||||
|
base["c"] = [(255, 100, 0)]
|
||||||
|
base["d"] = 80
|
||||||
|
elif basename == "flame":
|
||||||
|
base["c"] = []
|
||||||
|
base["d"] = 40
|
||||||
|
base["n1"] = 40
|
||||||
|
base["n2"] = 2000
|
||||||
|
base["n3"] = -1
|
||||||
|
base["n4"] = 0
|
||||||
|
else:
|
||||||
|
base["c"] = [(200, 200, 200)]
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _run_pattern_ticks(Preset, driver, basename, steps, ms_per_tick):
|
||||||
|
_install_utime_shim()
|
||||||
|
mod = _load_module("patterns.%s" % basename, _SRC / "patterns" / (basename + ".py"))
|
||||||
|
cls = _pattern_class_from_module(mod)
|
||||||
|
if cls is None:
|
||||||
|
raise RuntimeError("no pattern class in %s" % basename)
|
||||||
|
|
||||||
|
preset = Preset(_default_preset_dict(basename))
|
||||||
|
gen = cls(driver).run(preset)
|
||||||
|
for _ in range(steps):
|
||||||
|
utime_advance(ms_per_tick)
|
||||||
|
next(gen)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
failures = []
|
||||||
|
Preset = _load_preset_class()
|
||||||
|
fake_mod = _load_module("fake_driver", _TESTS / "fake_driver.py")
|
||||||
|
FakePresets = fake_mod.FakePresets
|
||||||
|
|
||||||
|
for basename in _list_pattern_basenames():
|
||||||
|
d = FakePresets(16)
|
||||||
|
try:
|
||||||
|
_run_pattern_ticks(Preset, d, basename, steps=80, ms_per_tick=50)
|
||||||
|
print("ok patterns.%s" % basename)
|
||||||
|
except Exception as exc:
|
||||||
|
print("FAIL patterns.%s: %r" % (basename, exc))
|
||||||
|
failures.append((basename, exc))
|
||||||
|
|
||||||
|
if failures:
|
||||||
|
print("%d pattern(s) failed" % len(failures))
|
||||||
|
return 1
|
||||||
|
print("all %d pattern smoke tests passed" % len(_list_pattern_basenames()))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
sys.exit(main())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
sys.exit(130)
|
||||||
Reference in New Issue
Block a user