Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ on:
- 'src/platform/esp32/platform_esp32_improv.cpp'
- 'test/python/**'
- 'test/js/**'
# A MoonLive script IS the subject of test_scripts_are_cpp.py: editing one is exactly when
# the "this is a subset of C++" claim needs re-checking.
- 'moonlive/**'
- 'src/light/moonlive/MoonLiveBuiltins_light.h'
# The sanitizer jobs below compile the WHOLE C++ suite, so they have to run when any of it
# changes. Without these, a C++ change could break them with no run at all, and the fix could
# not prove itself either: exactly what happened to a missing <string> in a unit test.
- 'src/**'
- 'test/unit/**'
- 'CMakeLists.txt'
- '.github/workflows/test.yml'
push:
branches:
Expand All @@ -50,8 +60,18 @@ jobs:
# against Python-Markdown's real toc slugify. `wled` is the frenck/python-wled
# library HA's WLED integration uses; test_wled_json_shape.py parses the
# device /json vector through its Device.from_dict to pin the wire contract.
# The MoonLive scripts compile as C++ FIRST, and by name: it is the one suite whose subject is
# the shipped content rather than the tooling, so "all 34 scripts are valid C++" should be
# readable in the log instead of folded into a single count. Named here rather than split into
# its own step so the compiler still runs exactly once.
- name: every MoonLive script is valid C++
run: uv run --with pytest pytest test/python/test_scripts_are_cpp.py -v
# The rest, quietly. This directory is excluded because the step above already ran it: a bare
# `pytest test/python` here would compile all 34 scripts a second time.
- name: pytest
run: uv run --with pytest --with pyserial --with markdown --with wled pytest test/python -q
run: >
uv run --with pytest --with pyserial --with markdown --with wled
pytest test/python -q --ignore=test/python/test_scripts_are_cpp.py

js:
runs-on: ubuntu-latest
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ On "run pre-commit": run the checks whose trigger the diff matches, report one l
| platform boundary | `uv run moondeck/check/check_platform_boundary.py` | `src/`, except `src/platform/` |
| hot-path discipline | `uv run moondeck/check/check_nonblocking.py --incremental` | `src/` |
| ESP32 firmware fresh | `uv run moondeck/check/check_esp32_built.py --firmware <fw>` | `src/`, `esp32/`, `CMakeLists.txt`, `library.json`, except `src/platform/desktop/` |
| host tests (Python) | `uv run --with pytest --with pyserial --with markdown --with wled pytest test/python -q` | `moondeck/`, `test/python/` |
| host tests (Python) | `uv run --with pytest --with pyserial --with markdown --with wled pytest test/python -q` | `moondeck/`, `test/python/`, `moonlive/` |
| host tests (JS) | `node --test "test/js/**/*.test.mjs"` | `mooninstaller/`, `test/js/`, `src/ui/` |
| desktop build (zero warnings) 🐢 | `cmake --build build` | `src/`, `test/`, `CMakeLists.txt`, `library.json` |
| unit tests 🐢 | `ctest --test-dir build --output-on-failure --no-tests=error -C Release` | same as the desktop build |
Expand Down
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ add_custom_target(build_info_gen ALL
add_custom_command(
OUTPUT ${CMAKE_SOURCE_DIR}/src/ui/ui_embedded.h
COMMAND ${CMAKE_COMMAND} -DUI_DIR=${CMAKE_SOURCE_DIR}/src/ui -DOUT=${CMAKE_SOURCE_DIR}/src/ui/ui_embedded.h -DUV_EXECUTABLE=${UV_EXECUTABLE} -P ${CMAKE_SOURCE_DIR}/src/ui/embed_ui.cmake
DEPENDS ${CMAKE_SOURCE_DIR}/src/ui/index.html ${CMAKE_SOURCE_DIR}/src/ui/app.js ${CMAKE_SOURCE_DIR}/src/ui/style.css ${CMAKE_SOURCE_DIR}/src/ui/install-picker.js ${CMAKE_SOURCE_DIR}/src/ui/preview3d.js ${CMAKE_SOURCE_DIR}/src/ui/moonlight-logo.png ${CMAKE_SOURCE_DIR}/src/ui/embed_ui.cmake
DEPENDS ${CMAKE_SOURCE_DIR}/src/ui/index.html ${CMAKE_SOURCE_DIR}/src/ui/app.js ${CMAKE_SOURCE_DIR}/src/ui/style.css ${CMAKE_SOURCE_DIR}/src/ui/install-picker.js ${CMAKE_SOURCE_DIR}/src/ui/vendor/prism.js ${CMAKE_SOURCE_DIR}/src/ui/preview3d.js ${CMAKE_SOURCE_DIR}/src/ui/moonlight-logo.png ${CMAKE_SOURCE_DIR}/src/ui/embed_ui.cmake
COMMENT "Embedding UI files"
)
add_custom_target(ui_embed DEPENDS ${CMAKE_SOURCE_DIR}/src/ui/ui_embedded.h)
Expand Down
16 changes: 8 additions & 8 deletions docs/moonmodules/light/MoonLiveLayout.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ class GridLayout {
}

void placeLights() {
for (y = 0; y < rows; y = y + 1) {
for (x = 0; x < cols; x = x + 1) {
for (int y = 0; y < rows; y = y + 1) {
for (int x = 0; x < cols; x = x + 1) {
addLight(x, y, 0);
}
}
Expand All @@ -40,17 +40,17 @@ A few shapes that are one line here and a new class otherwise:

```c
// a strand that runs right to left
for (i = 0; i < cols; i = i + 1) { addLight(cols - 1 - i, 0, 0); }
for (int i = 0; i < cols; i = i + 1) { addLight(cols - 1 - i, 0, 0); }

// a diagonal
for (i = 0; i < cols; i = i + 1) { addLight(i, i, 0); }
for (int i = 0; i < cols; i = i + 1) { addLight(i, i, 0); }

// two rows, stacked
for (i = 0; i < cols; i = i + 1) { addLight(i, 0, 0); addLight(i, 1, 0); }
for (int i = 0; i < cols; i = i + 1) { addLight(i, 0, 0); addLight(i, 1, 0); }

// a circle: lights and grid cells are not the same number
// (`count` and `radius` are members, surfaced by addControl in defineControls)
for (i = 0; i < count; i = i + 1) {
for (int i = 0; i < count; i = i + 1) {
addLight(scale(cos(i * turn(count)), radius * 2 + 1),
scale(sin(i * turn(count)), radius * 2 + 1), 0);
}
Expand Down Expand Up @@ -83,8 +83,8 @@ A serpentine (every other row reversed) is what `if` makes expressible, and it i

```c
byte odd = 0;
for (y = 0; y < rows; y = y + 1) {
for (x = 0; x < cols; x = x + 1) {
for (int y = 0; y < rows; y = y + 1) {
for (int x = 0; x < cols; x = x + 1) {
if (odd == 0) { addLight(x, y, 0); }
else { addLight(cols - 1 - x, y, 0); }
}
Expand Down
16 changes: 16 additions & 0 deletions moondeck/MoonDeck.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,22 @@ uv run moondeck/test/test_desktop.py

Runs `./build/<host>/test/mm_tests -s` (doctest with all test cases shown) — same per-host build dir as the desktop build above.

### test_host

Run the host test suites: the Python ones and the JS ones.

```bash
uv run moondeck/test/test_host.py # both
uv run moondeck/test/test_host.py --python # just Python
uv run moondeck/test/test_host.py --js # just JS
```

The tests the C++ binary cannot reach: the cross-language contracts (the Improv frame's wire format,
WLED's `/json` shape), the MoonDeck scripts themselves, the browser code under `src/ui`, and the
claim that every shipped MoonLive script is valid C++ (`test_scripts_are_cpp.py` hands each one to a
real compiler). The commit gate and CI run the same two commands; this is the card in front of them.
JS reports SKIP rather than failing when node is absent, since a Python-only bench is a normal setup.

### run_desktop

Launch the desktop executable as a detached background process and exit. The app keeps running across other MoonDeck scripts and outlives MoonDeck itself — the same model as flashing an ESP32, where the device runs independently of this console.
Expand Down
1 change: 1 addition & 0 deletions moondeck/check/check_prose.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"docs/tests/", # generated from test comments (fix the test, not the page)
"docs/moonmodules/", # partly generated technical pages
"src/platform/desktop/vendor/", # upstream single-header code (miniaudio): not our prose
"src/ui/vendor/", # upstream browser code (Prism): not our prose either
"moondeck/check/check_prose.py", # the detector: its rule table spells the very patterns
)

Expand Down
9 changes: 9 additions & 0 deletions moondeck/moondeck_config.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@
"script": "test/test_desktop.py",
"needs_module": true
},
{
"id": "test_host",
"tab": "desktop",
"group": "test",
"label": "Host Tests",
"speed": "medium",
"help": "test_host",
"script": "test/test_host.py"
},
{
"id": "scenario_pipeline",
"tab": "desktop",
Expand Down
77 changes: 77 additions & 0 deletions moondeck/test/test_host.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Run the host test suites: the Python ones, the JS ones, or both.

These are the tests that live outside the C++ binary, and they cover what it cannot reach: the
cross-language contracts (the Improv frame's wire format, WLED's /json shape), the MoonDeck scripts
themselves, the browser code in src/ui, and the claim that every shipped MoonLive script is valid
C++. The commit gate and CI both run them; this is the same command with a card in front of it.

uv run moondeck/test/test_host.py # both suites
uv run moondeck/test/test_host.py --python # just Python
uv run moondeck/test/test_host.py --js # just JS

The Python deps ride in a PEP-723 block per test file, so they are named here rather than
installed: `uv run --with` resolves them per run and leaves no environment behind.
"""

import argparse
import shutil
import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[2]

# Named here rather than in a requirements file: each is a test's own dependency (pyserial for the
# flash tests, wled for the /json shim, markdown for the docs checks), and `uv run --with` is how
# every other script in MoonDeck reaches one.
PY_DEPS = ("pytest", "pyserial", "markdown", "wled")


def run(cmd, label):
print(f"\n=== {label} ===", flush=True)
r = subprocess.run(cmd, cwd=ROOT, check=False) # the caller collects the code; a raise would skip the other suite
return r.returncode


def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
# Mutually exclusive: each flag means "ONLY this suite", so passing both asks for two
# contradictory things. Neither flag still means both suites, which is the common case.
only = ap.add_mutually_exclusive_group()
only.add_argument("--python", action="store_true", help="run only the Python suite")
only.add_argument("--js", action="store_true", help="run only the JS suite")
args = ap.parse_args()
both = not (args.python or args.js)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

rc = 0
if both or args.python:
cmd = ["uv", "run"]
for d in PY_DEPS:
cmd += ["--with", d]
cmd += ["pytest", "test/python", "-q"]
rc |= run(cmd, "Python (test/python)")

if both or args.js:
# node, not uv: the JS suite is the browser code's own runner. Reported rather than failed
# when node is absent, because a Python-only bench is a normal setup and a missing runtime
# is "this does not apply here", which is what SKIP means.
if shutil.which("node") is None:
print("\n=== JS (test/js) ===\nSKIP: node is not on PATH", flush=True)
else:
# Expanded HERE, not by node and not by a shell. The call passes a list with no
# shell=True, so a literal `test/js/**/*.test.mjs` would reach node and depend on its
# own glob support, which older runtimes lack. Resolving the paths in Python makes the
# command work on any node, and names the files it ran.
tests = sorted(str(f.relative_to(ROOT)) for f in (ROOT / "test/js").rglob("*.test.mjs"))
if not tests:
print("\n=== JS (test/js) ===\nSKIP: no test files found", flush=True)
else:
rc |= run(["node", "--test", *tests], "JS (test/js)")

print("\nDONE" if rc == 0 else "\nFAILED", flush=True)
return rc


if __name__ == "__main__":
sys.exit(main())
16 changes: 15 additions & 1 deletion moonlive/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class CrosshairEffect {

void defineControls() { addControl("bpm", bpm, 1, 240); }

void column() { for (y = 0; y < height; y = y + 1) { setRGB(y * width + scale(beat(bpm, t), width), 255, 40, 0); } }
void column() { for (int y = 0; y < height; y = y + 1) { setRGB(y * width + scale(beat(bpm, t), width), 255, 40, 0); } }
void tick() { fill(0, 0, 0); column(); }
}
```
Expand All @@ -26,6 +26,16 @@ These are real calls, not pasted-in text: the callee gets its own frame when it
lets one helper call another and lets a function recurse. A function takes no arguments yet, so a
helper is parameterized through the class's members. `effects/crosshair.mle` is the worked example.

**A script is C++, and a compiler checks that.** Every shipped script compiles under
`c++ -std=c++20 -fsyntax-only` (`test/python/test_scripts_are_cpp.py`), so the language cannot drift
into a dialect one feature at a time: an editor highlights a script correctly, a reader brings their
C++ intuition, and a script stands a good chance in any engine that speaks the same subset.

One shape difference is deliberate, and the test bridges exactly that one: a class here needs no
`public:` and no trailing semicolon.

Anything else a compiler rejects is a divergence, and the test is where it surfaces.

**Every function declares what it returns**, the way the compiled module a script stands in for
does: `void tick()` beside `void tick() override`. Three types, which is all the language has values
for:
Expand Down Expand Up @@ -83,6 +93,10 @@ int phase = 900; // a value a byte cannot hold
byte heat[16]; // sixteen elements, all zero to begin with
```

**Every variable is declared, including a loop's counter.** A member states its type, an assignment
to a name that was never declared is refused, and a `for` writes `for (int i = 0; ...)`. One rule
with no exception, and the same line C++ would take.

An index is an arbitrary expression (`heat[i * 2 + 1]`), and an index outside the array is
**clamped to the last element** rather than refused or allowed through: a script computes indices
from live control values, so out of range is a normal run-time state, and the fixture shows a
Expand Down
2 changes: 1 addition & 1 deletion moonlive/effects/aim.mle
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ class AimEffect {

void tick() {
fill(bright, bright, bright);
for (i = 0; i < height; i = i + 1) {
for (int i = 0; i < height; i = i + 1) {
lean = div(spread * i, height);
p = pan + lean - div(spread, 2);
if (p < 0) { p = 0; }
Expand Down
6 changes: 3 additions & 3 deletions moonlive/effects/balls.mle
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ class BallsEffect {
}

void drawBall() {
for (dy = 0; dy < 21; dy = dy + 1) {
for (dx = 0; dx < 21; dx = dx + 1) {
for (int dy = 0; dy < 21; dy = dy + 1) {
for (int dx = 0; dx < 21; dx = dx + 1) {
if ((dx - radius) * (dx - radius) + (dy - radius) * (dy - radius) <= radius * radius) {
setPaletteColor(px + dx, py + dy, b * 64,
255 - scale(((dx - radius) * (dx - radius)
Expand All @@ -41,7 +41,7 @@ class BallsEffect {
if (width < radius * 2 + 2) { radius = 0; }
if (height < radius * 2 + 2) { radius = 0; }

for (i = 0; i < 4; i = i + 1) {
for (int i = 0; i < 4; i = i + 1) {
b = i;
if (b < count) {
px = beatsin(bpm + b * 7, t, width - radius * 2 - 1);
Expand Down
2 changes: 1 addition & 1 deletion moonlive/effects/chase.mle
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ class ChaseEffect {
fill(0, 0, 0);
n = width * height * depth;
head = scale(beat(bpm, t), n);
for (i = 0; i < n; i = i + 1) {
for (int i = 0; i < n; i = i + 1) {
d = head - i;
if (d < 0) { d = d + n; }
if (d < spread) {
Expand Down
6 changes: 3 additions & 3 deletions moonlive/effects/crosshair.mle
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,15 @@ class CrosshairEffect {
}

void column() {
for (y = 0; y < height; y = y + 1) { setRGB(y * width + cx, 200, 30, 0); }
for (int y = 0; y < height; y = y + 1) { setRGB(y * width + cx, 200, 30, 0); }
}

void row() {
for (x = 0; x < width; x = x + 1) { setRGB(cy * width + x, 0, 90, 200); }
for (int x = 0; x < width; x = x + 1) { setRGB(cy * width + x, 0, 90, 200); }
}

void center() {
for (i = 0; i < spread + spread + 1; i = i + 1) {
for (int i = 0; i < spread + spread + 1; i = i + 1) {
d = i - spread;
if (cx + d >= 0) { if (cx + d < width) { setRGB(cy * width + cx + d, 255, 255, 255); } }
if (cy + d >= 0) { if (cy + d < height) { setRGB((cy + d) * width + cx, 255, 255, 255); } }
Expand Down
2 changes: 1 addition & 1 deletion moonlive/effects/dot.mle
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class DotEffect {

void tick() {
fill(0, 0, 0);
for (x = 0; x < width; x = x + 1) {
for (int x = 0; x < width; x = x + 1) {
setRGB(x + scale(beat(bpm, t), height) * width, 0, 255, 0);
}
}
Expand Down
6 changes: 3 additions & 3 deletions moonlive/effects/ember.mle
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,15 @@ class EmberEffect {
}

void tick() {
for (i = 0; i < 16; i = i + 1) {
for (int i = 0; i < 16; i = i + 1) {
if (heat[i] > cool) { heat[i] = heat[i] - cool; } else { heat[i] = 0; }
}

for (j = 0; j < 16; j = j + 1) {
for (int j = 0; j < 16; j = j + 1) {
if (random16(256) < scale(spark, 65535 - beat(cycle, t))) { heat[j] = 255; }
}

for (k = 0; k < 16; k = k + 1) {
for (int k = 0; k < 16; k = k + 1) {
setRGB(k, paletteR(heat[k], heat[k]), paletteG(heat[k], heat[k]),
paletteB(heat[k], heat[k]));
}
Expand Down
4 changes: 2 additions & 2 deletions moonlive/effects/fractal.mle
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ class FractalEffect {
- toFixed(sin(beat(bpm, t) * 2) - 32768) / 5120 * toFixed(seed) / 3277)
* toFixed(870 + noise(t / 4, 0, 0)) / 1000;

for (y = 0; y < height; y = y + 1) {
for (x = 0; x < width; x = x + 1) {
for (int y = 0; y < height; y = y + 1) {
for (int x = 0; x < width; x = x + 1) {
cx = uvX(x, width, height) * toFixed(zoom) / 40;
if (seed == 0) { cx = cx - 0.55; }

Expand Down
2 changes: 1 addition & 1 deletion moonlive/effects/gradient.mle
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class GradientEffect {

void tick() {
n = width * height * depth;
for (i = 0; i < n; i = i + 1) {
for (int i = 0; i < n; i = i + 1) {
setRGB(i, div(i * 255, n), 255 - div(i * 255, n), 60);
}
}
Expand Down
4 changes: 2 additions & 2 deletions moonlive/effects/metal.mle
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ class MetalEffect {
}

void tick() {
for (y = 0; y < height; y = y + 1) {
for (x = 0; x < width; x = x + 1) {
for (int y = 0; y < height; y = y + 1) {
for (int x = 0; x < width; x = x + 1) {
ux = uvX(x, width, height);
uy = uvY(y, width, height);

Expand Down
4 changes: 2 additions & 2 deletions moonlive/effects/noise.mle
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ class NoiseEffect {
}

void tick() {
for (y = 0; y < height; y = y + 1) {
for (x = 0; x < width; x = x + 1) {
for (int y = 0; y < height; y = y + 1) {
for (int x = 0; x < width; x = x + 1) {
setPaletteColor(x, y, noise(x * zoom, y * zoom, scale(t, speed * 280)), 255);
}
}
Expand Down
4 changes: 2 additions & 2 deletions moonlive/effects/octopus.mle
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ class OctopusEffect {
cx = scale(32768, width);
cy = scale(32768, height);

for (y = 0; y < height; y = y + 1) {
for (x = 0; x < width; x = x + 1) {
for (int y = 0; y < height; y = y + 1) {
for (int x = 0; x < width; x = x + 1) {
setPaletteColor(x, y,
polarR(x - cx, y - cy) * 8 + scale(beat(speed, t), 256),
scale(sin(polarA(x - cx, y - cy) * branches
Expand Down
Loading
Loading