diff --git a/tests/fake_driver.py b/tests/fake_driver.py new file mode 100644 index 0000000..3c94ed8 --- /dev/null +++ b/tests/fake_driver.py @@ -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)) diff --git a/tests/pattern_smoke.py b/tests/pattern_smoke.py new file mode 100644 index 0000000..55280be --- /dev/null +++ b/tests/pattern_smoke.py @@ -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)