Skip to content

Unit Tests

Auto-generated from test/unit/{core,light}/unit_*.cpp by moondeck/docs/generate_test_docs.py. Do not edit by hand — update the source file's @module / @also and per-TEST_CASE // descriptions instead, then regenerate.

Unit tests are the fastest tier in the test strategy: they run the production code in-process with doctest, no platform, no network. Each section below covers one module.

ActiveInstance

test/unit/core/unit_ActiveInstance.cpp

  • A fresh seat is empty until someone claims it.
  • First claim wins; a second claimant does NOT displace the holder (claim-if-empty).
  • vacate() only releases if this instance holds the seat — it never yanks another's.
  • After the holder vacates, a surviving instance reclaims the empty seat with the SAME claim() call (the idempotent-claim = survivor-reclaim contract that AudioService's tick relies on).
  • The destructor vacates a held seat — the dangling-static guard. Without it, active() would point at freed memory after the holder is destroyed.
  • Destroying a NON-holder leaves the holder's seat intact (vacate is guarded on "if mine").
  • The seat is PER-TYPE: two different participant types have independent seats.

AudioService

test/unit/light/unit_AudioBands.cpp Also touches: AudioSpectrumEffect.

  • AudioBands: silence yields all-zero bands and no peak
  • AudioBands: a low tone lands in a low band, a high tone in a high band
  • AudioBands: the reported peak frequency tracks the played tone
  • AudioBands: a single tone concentrates energy, not smears it everywhere
  • AudioBands: noiseFloor gates a low idle spectrum to zero, gain scales it back
  • AudioBands: zero / degenerate input never crashes

test/unit/light/unit_AudioLevel.cpp Also touches: AudioVolumeEffect.

  • DcBlocker: a constant DC offset is filtered out
  • DcBlocker: an audio tone passes through (DC removed, AC kept)
  • DcBlocker: reset clears state, null-safe
  • AudioLevel: silence reads zero
  • AudioLevel: pure DC reads zero (DC offset stripped)
  • AudioLevel: a loud sine reads a higher level than a quiet one
  • AudioLevel: DC bias does not change the level of a sine
  • AudioLevel: a high noiseFloor (dB floor) gates a modest signal to zero
  • AudioLevel: higher gain (narrower dB window) reads a higher level
  • AudioLevel: empty / null input is silence, never a crash
  • AudioLevel: isqrt64 matches floor(sqrt) on a spread of values
  • Regression: the boot wiring in main.cpp does create("AudioService")->markWiredByCode() and create() returns nullptr for an UNREGISTERED type — so a missing registerType made the deref crash and the device boot-looped (found on the S3 bench). These pin that AudioService and the two audio effects are all registered + createable through the factory, and that latestFrame() is never null even with no mic (so a consumer added before the mic can't deref null).
  • AudioService::latestFrame is never null (silent frame with no active mic)

test/unit/light/unit_AudioService.cpp

  • AudioService: a fresh, unconfigured module is idle (pins default unset)
  • AudioService: setup/release is repeatable with no residual state
  • AudioService: release clears the active mic (latestFrame falls back to silence)
  • AudioService: two mics — first wins, survivor re-elects, any order stays coherent
  • AudioService: a DISABLED module does not win the mic election at boot

test/unit/core/unit_AudioService_sync.cpp Also touches: WledAudioSyncPacket.

  • AudioService Local+send: lazy-opens once and reports sending
  • AudioService Local+send: broadcasts are throttled to ~kSyncSendIntervalMs
  • AudioService Receive: a localhost WLED packet drives frame, then holds it and reports listening_
  • AudioService Receive: a failed bind backs off instead of retrying every tick
  • AudioService Local (not sending): no socket, reports off
  • Regression: a persisted send must NOT broadcast once the module switches to Simulate mode — Simulate has no captured frame worth sending, so sync() (and thus the socket) must go quiet. Pins the mode==0 guard on the send leg of sync().

BlendMap

test/unit/light/unit_BlendMap.cpp Also touches: MappingLUT.

  • Identity mapping (logical N → physical N) leaves every byte unchanged.
  • One logical light routed to multiple physical positions copies the colour to each (mirror-style mappings work).
  • A paged LUT (forced via the maxAllocBlock test cap) must produce a byte-identical dst to a single-alloc LUT with the same mapping. Paging is an allocation detail; blendMap output must not depend on it. This is the end-to-end pin for the no-PSRAM-fragmentation fix.
  • An additive (overwrites=false) LUT folding two sources onto one physical light adds and clamps at 255 (no overflow). overwrites=false is the opt-in for the within-layer overlap case; the default copy path would instead overwrite, and a full-opacity Overwrite op still routes through this additive accumulate, so this pins the contract explicitly (the regression after the multi-layer rewrite).
  • The default (overwrites=true) path plain-copies: two sources mapped to the same physical means the LAST writer wins, no addition. Pins the fast path.
  • Sparse overwrite mapping clears untouched physical cells. A sphere-style layout maps only a subset of the physical box to a source; the rest must end up black, not retain stale data from a previous frame. Pre-fills dst dirty and asserts unmapped cells are zeroed — fails if BlendMap's dst.clear() is removed (the regression target).
  • Alpha-over at half opacity: dst = srcα + dst(255-α). With dst=200, src=100, α=128 → 100128 + 200127 = 12800 + 25400 = 38200; /255 ≈ 150.
  • Alpha at full opacity collapses to overwrite (src replaces dst exactly).
  • Alpha at opacity 0 is a no-op (dst unchanged) — the invisible-layer case.
  • Additive with opacity scales the source before adding, then clamps. dst=100, src=200, opacity=128 → add 200*128/255 ≈ 100 → 200.
  • clearFirst=false preserves dst cells the source doesn't touch — the mechanic that lets a top layer blend onto the bottom layer's already-composited frame.
  • No-LUT alpha-over at half opacity: dst = div255(srcα + dst(255-α)). dst=200, src=100, α=128 → div255(100128 + 200127) = div255(38200) = 149.
  • No-LUT alpha at full opacity collapses to a plain copy (overwrite).
  • No-LUT alpha at opacity 0 is a no-op (the invisible top layer).
  • No-LUT additive with opacity scales the source then clamps at 255. dst=100, src=200, opacity=128 → 100 + div255(200*128)=100 → 200.
  • No-LUT additive at full opacity saturates: 200 + 100 = 300 → clamp 255.

BlockModifier

test/unit/light/unit_BlockModifier.cpp

  • The centre light of the box folds to distance 0 (the innermost ring, y=0); a corner folds to the largest distance, and z is always cleared to 0.
  • The distance is the MAXIMUM of |dx| and |dy| (a square ring), not the sum or the Euclidean length: an off-diagonal light sits on the ring of its larger axis delta.
  • modifyLogicalSize collapses the box to one column, its height = the max block distance + 1 (rings from centre to the far corner, inclusive), depth 1.
  • Degenerate grids never crash and stay well-formed: 0x0x0 and 1x1x1 both fold and size without dividing by zero or producing a zero-height box (the Effects hard rule).

BlurzEffect

test/unit/light/unit_BlurzEffect.cpp Also touches: AudioService.

  • With no live audio source the buffer stays black: the dot is audio-gated, so silence renders nothing.
  • With a synthesized audio frame the effect lights the buffer: the coloured dot appears and the blur smears it into a soft blob, so at least some lights become non-zero.
  • geqScanner sweeps the dot steadily across the strip: one pixel per frame, so consecutive frames land the lit dot at different linear positions rather than the same spot.
  • The hard rule: the effect runs at any grid size without crashing, including a 0×0×0 and a 1×1 grid.

BouncingBallsEffect

test/unit/light/unit_BouncingBallsEffect.cpp

  • On the first frame every ball is at rest (zero-init state) and bounces off the floor, so the effect paints the bottom row of every column and leaves the rows above it black.
  • numBalls=0 draws nothing: the effect clamps below its minimum and the buffer stays black.
  • The effect runs at a degenerate grid size without crashing (the "every grid size" hard rule).
  • A ball reaches its apex mid-flight: once time advances, at least one ball has risen off the bottom row, so the lit column occupies a row above the floor.

Buffer

test/unit/core/unit_Buffer.cpp

  • allocate(N,3) reserves count×channels bytes; count/channelsPerLight/bytes/data/span all reflect that.
  • clear() zeroes every byte in the allocated range.
  • Move-constructing transfers the data pointer and resets the source (no double free, no copy).
  • Move-assigning transfers ownership the same way the move constructor does.
  • Calling free() twice is harmless; pointer and count remain zeroed.
  • allocate() refuses zero-count or zero-channels (returns false, no allocation, buffer left empty so a caller that ignores the bool doesn't get a partial state).

CheckerboardModifier

test/unit/light/unit_CheckerboardModifier.cpp

  • A mask leaves the logical box unchanged.
  • size=1: every cell is its own square; parity = (x+y+z)&1. Default (invert false) keeps even-parity cells, drops odd-parity. Passing cells keep their coord.
  • invert flips which parity passes.
  • size>1 groups cells into squares: with size=2, the 2×2 block at the origin is all one square (parity 0), so all four pass; the next block over drops.

CircleModifier

test/unit/light/unit_CircleModifier.cpp

  • The box centre folds to the origin (distance 0), and every coord collapses onto the single x=0/z=0 column — the box becomes a 1D radial run.
  • A light's ring is its integer-truncated Euclidean distance from the centre, so two lights equidistant from the centre map to the SAME radius — the defining circle property. Centre (4,4): (7,4) and (4,7) are both 3 away; (7,7) is sqrt(18)→4.
  • modifyLogicalSize folds the far corner to its distance, then grows every axis by one: the logical box is a (radius+1)-tall column with x=1 and z=1. Corner (8,8,1) off centre (4,4,0) is sqrt(16+16+1)=5.74→5, so the size is (0+1, 5+1, 0+1).
  • Effects-run-at-every-grid-size hard rule: a degenerate box never crashes and still yields a valid single-column size. 0×0×0 folds to (0,0,0)→+1 = (1,1,1); 1×1×1's corner is sqrt(3)→1, so size (1, 2, 1).

Color

test/unit/core/unit_Color.cpp

  • Hue 0 is pure red.
  • Hue 85 (one third round the wheel) is pure green; a sliver of red is tolerated since 85 is approximate, not exact.
  • Hue 170 (two thirds round) is pure blue.
  • Zero saturation produces a grey of the given value, regardless of hue.
  • Zero value is black, regardless of hue or saturation.
  • A hue between the cardinal points blends two channels (here: orange = red + green).
  • hsvToRgb is constexpr — evaluable at compile time.
  • scale8(v, f) multiplies two 8-bit values and returns 8 bits. Factor 255 is identity, factor 0 zeroes, factor 128 halves (within integer rounding).
  • scale8 is also constexpr.

Control

test/unit/core/unit_Control_apply_absent_key.cpp Also touches: FilesystemModule.

  • hasKey distinguishes an absent key from one whose value is 0 — the capability the fix relies on. parseInt alone can't (returns 0 for both).
  • The core regression: a control bound with a non-zero value, overlaid with a JSON that does NOT contain its key, must keep its value — not snap to 0.
  • A present key still applies (the fix must not break the normal load path).
  • A present key whose value IS 0 must apply the 0 (don't confuse "present 0" with "absent"). Guards against an over-eager fix that skipped on value rather than key.
  • a per-control validator accepts a valid value and rejects bad input
  • Length boundary of the deviceModel validator (accepts 1..31). Uses a buffer wider than the validator's limit so the 32-char value reaches the validator intact (parseString truncates to bufSize-1, so the buffer must exceed 32 for the validator's own length check — not parse truncation — to be what rejects it). The scratch buffer in applyControlValue is sized to bufSize, so a long value isn't truncated before validation.
  • a Text control with no validator accepts anything that fits

test/unit/core/unit_Control_list.cpp

  • EditableList: a plain ListSource is not editable
  • EditableList: add returns a fresh stable id each time
  • EditableList: edit a row field by id
  • EditableList: delete by id; a locked row is protected
  • The load-bearing invariant for reference-by-id: an id assigned to a row NEVER changes across add / delete / reorder of OTHER rows. A driver that stored "preset id 2" still resolves it.
  • ControlType::List value serializes as an array of row summaries
  • ControlType::List metadata carries a parallel detail array
  • ControlType::List with an empty source emits []
  • ControlType::List type identity + persistable + restore round-trip

Correction

test/unit/light/unit_Correction.cpp

  • At brightness=255, the LUT maps every input value to itself (no scaling).
  • At brightness=128, every entry is roughly halved using scale8 (255→128, 128→64, 2→1).
  • RGB preset at full brightness passes the source RGB through unchanged (3 output channels, no white).
  • GRB preset swaps R and G in the output (G first, then R, then B) — for WS2812-like drivers.
  • BGR preset reverses the channel order entirely (B, G, R).
  • RGBW preset adds a fourth white channel derived as min(R, G, B) per pixel.
  • GRBW preset combines the GRB reorder with the W derivation (G, R, B, W=min).
  • Brightness scaling runs before white derivation so W = min of the scaled RGB values.
  • rebuild() can switch the output channel count between RGB (3) and RGBW (4) on the fly.
  • whiteMode = Min is the default and derives W = min(scaled R,G,B) leaving RGB intact — this is the byte-identical behavior the earlier RGBW tests already pin. whiteMode = None forces the white channel to 0 each frame (for effects that drive W themselves) — written, not skipped, so a reused buffer can't keep a stale value (see the assertion below).
  • whiteMode = Accurate pulls the common white component OUT of RGB (so the white LED carries it) rather than adding it on top — R,G,B each drop by min(R,G,B).
  • A Custom wiring is described by a channel-role array; rebuild() derives the color offsets from it. Here: white first, then B, G, R — a 4-channel arbitrary order that no curated preset names, proving the role array reaches any wiring.
  • A color role absent from the array stays kAbsent and apply() doesn't write it — a wiring can carry any SUBSET of color roles (e.g. a 2-channel R,B light with no green channel).
  • A non-color role (Pan) occupies a channel but apply()'s RGB path ignores it — the channel is left for the fixture role writer, and outChannels still counts it.
  • WarmWhite / Yellow / UV are synthesized from RGB off the SAME whiteMode as White, so a fixture carrying them lights up (best-effort approximations, not a color model yet): WW ≈ min(RGB), Yellow ≈ min(R,G), UV ≈ the blue-excess max(0, B-max(R,G)). This is the "all channels burn so you can eyeball a fixture" behavior the finding asked for; a real per-emitter model comes later.
  • Under Accurate, White pulls its component OUT of RGB — but the additive stand-ins (WW/Yellow/UV) must approximate from the RGB the effect produced, BEFORE that subtraction, or they collapse. This pins the compute-stand-ins-before-White ordering (a regression would compute them post-subtraction).
  • UV stays dark on a warm color (no blue excess), and every synthesized emitter is forced to 0 under whiteMode=None so none holds a stale value — the same reuse-safety the White channel has.

DemoReelEffect

test/unit/light/unit_DemoReelEffect.cpp

  • The reel enumerates the effect registry, hosts one effect at a time, renders it, and advances through the whole list without crashing — the create/release/delete churn every tick is the robustness path this pins. Registering two real effects + the reel gives it something to cycle.

DevicePlugin

test/unit/core/unit_DeviceIdentify.cpp Also touches: DevicesModule.

  • MmPlugin claims a presence packet carrying the projectMM marker
  • MmPlugin declines a plain WLED packet (no projectMM marker)
  • WledPlugin claims a plain WLED packet as WLED
  • WledPlugin declines a projectMM-marked packet (that's a peer, not a WLED)
  • Plugins decline a short / garbage datagram, never read out of bounds
  • WledPlugin tolerates an empty name (the module supplies the IP fallback)

DevicesModule

test/unit/core/unit_DevicesModule_ageout.cpp

  • A cached (restored-but-never-re-heard) device is on a short probation, NOT the full 24 h — else a long-gone persisted device would survive forever across reboots (its clock resets to "boot" each restore). It drops once past kCachedGraceMs.
  • DevicesModule: a cached device drops once past the probation window
  • A live-confirmed device (a presence packet cleared its cached flag) gets the full 24 h.
  • DevicesModule: a live-confirmed device drops once past kStaleMs (24h)
  • A projectMM peer also answers as a plain WLED (its presence packet without our marker), so a later WLED-classified sighting must NOT relabel a restored projectMM row. This drives the downgrade-prevention in upsertDevice through the public path: restore the row as projectMM, inject a plain WLED packet from the same IP, confirm it stays projectMM.
  • DevicesModule: restore tolerates an empty / malformed cache

test/unit/core/unit_DevicesModule_discovery.cpp Also touches: DevicePlugin.

  • DevicesModule: a plain WLED packet lists a WLED device with its name
  • DevicesModule: a projectMM-marked packet lists a projectMM device
  • DevicesModule: a short / garbage datagram is ignored, never listed
  • The P4-bench bug: two DIFFERENT devices (a WLED and a projectMM peer) must each keep their OWN name + type — no cross-contamination between packets.
  • A peer RENAME must propagate: a later packet from the same IP with a new name updates the row in place — the live-update requirement (the name rides the presence packet).
  • A projectMM device stays projectMM even when a later plain-WLED packet arrives from the same address — the type only RAISES toward projectMM, never downgrades. (A projectMM peer could be seen via an unmarked packet too; that must not relabel it WLED.)
  • DevicesModule: a DISABLED module does not claim the active seat at boot_

test/unit/core/unit_DevicesModule_hue.cpp

  • DevicesModule: a Hue bridge is listed with its color count
  • DevicesModule: upsertHueBridge is idempotent, updates count in place
  • DevicesModule: a persisted Hue bridge restores as a Hue row with its count
  • DevicesModule: a corrupt persisted color clamps to the valid range, row still restores

DistortionWavesEffect

test/unit/light/unit_DistortionWavesEffect.cpp

  • DistortionWavesEffect writes non-zero RGB data
  • DistortionWavesEffect produces spatial variation
  • DistortionWavesEffect speed 0 is frozen (stable across ticks)
  • DistortionWavesEffect survives a 0x0x0 grid

Drivers

test/unit/light/unit_Drivers_container.cpp

  • Regression (the preset-edit LED-blank bug): editing a live light preset blanked the strip for ~½s, even on drivers NOT using that preset. Cause: the list-mutation handler re-ran a whole-tree prepareTree(), and a physical driver's prepare() reinits its output peripheral (an RMT channel teardown → dark for a tick). A preset edit changes correction DATA, not pipeline STRUCTURE, so the fix routes it through rebuildAllCorrections() — the tier-1 correction refresh — which must re-resolve each driver's correction WITHOUT calling its prepare(). This pins that split so the blank can't return: rebuildAllCorrections() bumps the correction path, never prepare().
  • The on control is master power: on=false scales the correction LUT to zero (output black) while PRESERVING the brightness value, so on=true restores the exact level. It rides the same cheap LUT rebuild as brightness (no pipeline realloc). This pins the shared power control IR/MQTT/WLED drive.
  • Regression (the localBrightness bug): a per-driver localBrightness change must RE-SCALE that driver's correction LUT — global × local — just like a global brightness change does. The bug was that localBrightness edits didn't reach the LUT (only global did). Both sliders must reach output.
  • Disabled child drivers don't tick: toggling enabled flips whether that driver's tick() runs.
  • The "+ add" picker under Drivers must offer ONLY drivers, not every generic system module — else the 6 drivers are buried under ~18 generics (Devices, Filesystem, …). acceptsChildRoles drives that picker, so it returns "driver" alone. The one non-driver child (the boot-wired LightPresets library) is added directly at boot, bypassing this check, and is non-deletable — so it needs no "generic" here. Pins the filter the product owner asked for.
  • The boot-wired light-preset library is a permanent singleton: not user-deletable (Drivers accepts only driver, so a deleted library could never be re-added, and every driver resolves its preset through it). Mirrors the boot-wired PreviewDriver's userEditable(false).

test/unit/light/unit_Drivers_firstOutputRgb.cpp

  • Drivers::firstOutputRgb reads pixel 0 of the driven buffer
  • Drivers::firstOutputRgb reports black pixel 0 as-is (caller substitutes the default)
  • Drivers::firstOutputRgb returns false when there is no driven buffer
  • MoonModule::firstOutputRgb defaults to false (no output module)

test/unit/light/unit_Drivers_rendersplit.cpp Also touches: platform.

  • render-split: multicore on → every driver ticks on the worker, never on a torn frame
  • render-split: multicore off → drivers tick inline on the render core (the proven path)
  • render-split: no driver → the split does not engage (nothing to move)
  • render-split: live disengage stops the worker when the last driver leaves
  • THE INVARIANT: core quiesces the worker before any structural mutation of a container's children. MoonModule::removeChild() calls quiesce() — a no-op for a module with no worker, overridden by Drivers to wait out the in-flight encode — so a mutation cannot begin while core 1 is inside a child's tick(). Violate it and the sequence removeChild → release → deleteTree frees the driver (and its DMA buffers) out from under the worker mid-encode: a use-after-free, LoadProhibited on ESP32. The test deletes the driver at the one instant that is unsafe: while the worker is provably inside its tick(). Under ASan a regression is a heap-use-after-free; without ASan, the ordering assert still catches it (removeChild must not return until the worker is out).
  • render-split: toggling multicore live engages and disengages the worker
  • ROBUSTNESS FLOOR: a wedged core-1 worker must not hang the RENDER loop. The frame boundary waits for the encode, normally bounded by one encode (the renderWait KPI measures it). But a worker that never signals done — starved, wedged, a lost notify — would otherwise spin core 0 forever, and a permanent wedge ranks BELOW "degraded": the device must keep running, even poorly. So the boundary times out, DISENGAGES the split, and every driver falls back to ticking inline on core 0 — the same single-core path a memory-tight board already takes. Slower, still lit. This times tick()'s boundary specifically. It does NOT go through quiesce() (the structural-mutation hook), which deliberately JOINS the worker on timeout — a blocking join is right there (the caller is about to free the driver) but would be wrong here, on the render path.

FileManagerModule

test/unit/core/unit_FileManagerModule.cpp

  • FileManager: mkdir creates a dir at the target path; delete removes it
  • FileManager: mkdir nested under an existing dir
  • FileManager: delete of a non-empty folder is rejected, not a crash
  • FileManager: delete removes a file too
  • FileManager: a '..' traversal never escapes root (the seam's confinement)
  • parseFilePath is the single path guard every filesystem HTTP entry (read/write/dir/mkdir/delete) runs on — pure string→string, so it's tested directly here without a socket. It decodes the path= query value (%XX + '+'), roots a relative path at the mount, and rejects a missing/empty path, a .. traversal (raw OR percent-encoded), and an overlong (buffer-filling) value.
  • HttpServer::parseFilePath rejects traversal, empty, missing, and overlong
  • FileManager: fsWriteStream writes a multi-chunk NUL-containing payload in full
  • A source that reports a short read (mid-stream failure) still commits atomically — here a clean end just yields the bytes delivered; there's no partial/torn file (temp → rename).
  • A source that aborts mid-stream (an incomplete/timed-out upload) must NOT commit — fsWriteStream discards the temp and returns false, so a truncated body never lands as a real file.

FilesystemModule

test/unit/core/unit_FilesystemModule_persistence.cpp Also touches: Scheduler, Layer.

  • Persistence round-trip: set deviceName → save → recreate Scheduler+modules → load → assert. Uses fsSetRoot to isolate the test from any real /.config/ on disk. A control change (deviceName) saved with flush() reappears on the next boot once a fresh Scheduler loads the same path.
  • Structural persistence: hand-write a Layer.json describing a different tree shape than the one main.cpp builds, then load and verify the live tree reconciles to match the JSON — type swap at position 0, trim of position 1. On load, a Layer's children are reconciled against the saved JSON: position 0 swaps to the saved type, extras at later positions are trimmed.
  • Pins the wiredByCode-preserves-child contract that lets a new firmware revision add a code-created child (e.g. ImprovProvisioning under NetworkModule) without the child getting trimmed on every boot for users whose saved Network.json predates the addition. Setup: an on-disk file describes Layer with zero children. Live tree has Layer with a RainbowEffect child that main.cpp would have wired and marked. After scheduler.setup() runs the persistence load, the wired child must survive. A code-wired child (markWiredByCode) survives a load from older JSON that doesn't mention it — new firmware additions aren't trimmed for existing users.
  • Companion to the wiredByCode case above: when the JSON describes a different type at the position where a code-wired child lives, the position-replacement must NOT kill the code-wired child. Stop reconciliation at that index instead and let the next save re-write the file with the actual tree shape. When the saved JSON wants a different type at the position where a code-wired child lives, reconciliation stops at that index instead of destroying the wired child.
  • Round-trip persistence with children: write a Layer subtree that contains both controls and child modules with controls of their own, then read the file back as text and verify it parses as valid JSON. Regresses the missing-comma bug between each child's "N.type" field and that child's first control (e.g. "0.type":"X""0.foo":1 instead of "0.type":"X","0.foo":1). Saving a Layer with multiple children produces valid JSON — comma separators between child N.type and the child's first control field are present.
  • No size cap: a config LARGER than the old fixed 2 KB save buffer round-trips in full. The save serializes into a growable JsonSink and the load reads a file-sized heap buffer, so neither side truncates. Built from a LightPresetsModule with many custom presets — its persisted array of role wirings comfortably exceeds 2048 bytes, which the old fixed buffer would have silently dropped (returning false → nothing written → config lost on reboot).
  • Singleton survives probe lifecycle: /api/types factory-creates a probe of every registered type (including FilesystemModule) to capture defaults, then deletes it. The probe's destructor must NOT clear the singleton — otherwise every save path (noteDirty, debounced tick1s, flushPending on reboot) silently no-ops for the rest of the device's life. The fix is to register the singleton in setScheduler(), not in the constructor. This test catches that singleton-clear regression. /api/types factory-creates a temporary FilesystemModule probe; its destruction must NOT clear the static singleton (otherwise every later save silently no-ops).
  • Regression: Int16 controls (GridLayout's width/height/depth, Layer's start/end) round-tripped through the filesystem load path were clamped to c.min/c.max, which default to 0,0 because ControlDescriptor.min/max are uint8_t and can't represent an int16 range. Every Int16 control loaded as 0 — so a 128×128 grid became 0×0×0 after restart and the whole pipeline allocated no buffers. Int16 controls (GridLayout width/height, RegionModifier start/end) preserve their saved value across load — no zero-clamping from uint8 min/max bounds.

FireEffect

test/unit/light/unit_FireEffect.cpp

  • On a 16×16 grid the heat buffer sizes to width × height bytes (one byte of heat per cell).
  • With sparking at max, the buffer contains non-zero pixels within 50 frames (sparks emerge and propagate).
  • Disabling the effect releases its heat buffer back (dynamicBytes drops to 0).

FirmwareUpdateModule

test/unit/core/unit_FirmwareUpdateModule.cpp

  • The firmware control is always present and non-empty (either a real firmware key from build_info.h or the fallback "unknown"). The firmware card owns firmware identity (version/build/firmware) + the partition usage.
  • OTA phase is surfaced through the shared status slot (MoonModule::setStatus()), not a control. publishStatus() runs in setup()/tick1s() and maps the platform OTA status string to a severity: "idle" clears the banner, an "error: " prefix is Severity::Error, anything else is neutral Severity::Status.

FixedRectangleEffect

test/unit/light/unit_FixedRectangleEffect.cpp

  • A small box (2×2 at the origin) lights exactly its cells and leaves every cell outside it black.
  • With defaults (origin 0,0,0 + 15×15×15 extent) the box fills the whole grid — the origin corner lights up.
  • The box is offset away from the origin: only the offset cell lights, the origin stays black.
  • A degenerate 0×0×0 grid must not crash (Effects run at every grid size).

FreqMatrixEffect

test/unit/light/unit_FreqMatrixEffect.cpp Also touches: AudioService.

  • A real tone above the 80 Hz gate paints a lit colour at the source pixel (0,0).
  • The column is a shift register: a lit source pixel scrolls to y=1 on the next tick.
  • Silence (no active mic → the static silent frame) paints black: no tone, no light.
  • The "runs at every grid size" hard rule: degenerate grids never crash.

FreqSawsEffect

test/unit/light/unit_FreqSawsEffect.cpp Also touches: AudioService.

  • With no audio (silence) and keepOn off, every band decays to rest so the effect draws nothing — the whole buffer stays black. (No mic is active here, so latestFrame() is the static silence.)
  • keepOn keeps every band drawing even when its speed has fully decayed, so on a rested (silent) panel the columns are still lit rather than fully dark between hits.
  • Fed a live (simulated) audio frame, the effect reacts: loud bands rise and paint their columns, leaving the buffer non-black even with keepOn off (so the light comes from the audio, not keepOn).
  • The "runs at every grid size" hard rule: a degenerate 0×0×0 grid and a 1×1 grid both render without crashing (the imap zero-span guard and the sizeX/sizeY<=0 early-out cover them).

GEQ3DEffect

test/unit/light/unit_GEQ3DEffect.cpp Also touches: AudioService.

  • Silence (no active mic) leaves the buffer all-black — every band magnitude is 0, so no bar rises.
  • A synthesized sweep frame with only the lowest band lit paints the LEFT of the grid and leaves the far RIGHT dark.
  • The effect runs at a degenerate 0×0×0 grid without crashing (the "every grid size" hard rule).
  • A narrow grid with fewer columns than bands still spreads bars (numBands is clamped to the column count, so no divide-by-zero, no bar pile-up at x=0) and never crashes.

GEQEffect

test/unit/light/unit_GEQEffect.cpp Also touches: AudioService.

  • With no live audio source every band is silent, so no bar rises and the buffer stays black.
  • A bar grows from the floor up: when a column's band is loud, its bottom (floor) pixel is lit while a pixel above the bar's top stays dark — bars fill upward from the bottom row, not top-down or floating.
  • colorBars colours each bar by its column index, so two well-separated lit columns take different hues rather than sharing the row-height gradient — the toggle changes what colour a bar is.
  • The hard rule: the effect runs at any grid size without crashing, including 0×0×0 and 1×1, with a live audio frame feeding it every tick.

GameOfLifeEffect

test/unit/light/unit_GameOfLifeEffect.cpp

  • The B#/S# parser turns a rule string into birth/survive neighbour sets. Conway = B3/S23.
  • A 2×2 block is a Conway still life: every live cell has 3 neighbours (survives), and the surrounding dead cells never have exactly 3 (no births). It must be identical after a step.
  • Regression: a 3D grid gives a cell up to 26 neighbours (3×3×3 minus self), but the B/S rule tables are sized 9 (single-digit Conway notation, 0..8). A dense 3D neighbourhood must not read those tables out of bounds — a count ≥9 is in no single-digit ruleset, so the cell dies / stays dead. This fills a 3×3×3 cube (the centre has all 26 neighbours alive) and just steps: the test passing under ASan/bounds-checking is the OOB-read pin; behaviourally the over-crowded centre dies (26 ∉ S) and the dense interior doesn't survive.
  • A horizontal 3-cell blinker oscillates to vertical after one step (period-2 oscillator). This is the canonical "the rules actually run" check: birth on 3, death of the ends (1 neighbour each).
  • A lone cell (0 neighbours) dies — the dead-by-isolation rule, and a sanity check that an empty grid stays empty (no spontaneous births at count 0 under Conway).

GridBlacksLayout

test/unit/light/unit_GridBlacksLayout.cpp Also touches: Layouts, GridLayout.

  • A dark column run: [blackStart, blackStart+blackCount) is black in every row. The physical index still advances across gaps (they are wire slots), the coordinate is the true (x,y), and lit/black is decided on x — so lit columns beyond the gap keep their true positions (the picture is HOLED, not collapsed).
  • The gap test is on the TRUE x, not the wire order, so a serpentine strip keeps the same physical columns dark whichever way it snakes into a row.
  • No black run → no gaps, and the walk is byte-identical to a plain grid (a GridBlacks with blackCount 0 renders exactly like a Grid). blackCb never fires; hasBlackPixels is false.
  • Robustness: a black run wider than the grid darkens every column (whole grid dark, no crash), and a run starting past the right edge darkens nothing.

GridLayout

test/unit/light/unit_GridLayout.cpp Also touches: Layouts.

  • A 4×4×1 grid yields 16 lights iterated row-major: x sweeps fastest, then y, then z.
  • Serpentine reverses x on odd rows (boustrophedon), so the strip snakes back and forth: driver index advances linearly while the emitted x zigzags. Even rows L→R, odd rows R→L. The COORDINATE is always the true (x,y) — only the index→position order changes, which is what makes the mapping non-identity.
  • A 3D 2×2×2 grid yields 8 lights with z-plane separation (indices 0-3 at z=0, 4-7 at z=1).
  • A single-light grid (1×1×1) is a valid layout: one coordinate at (0,0,0).
  • Layouts with a single child delegates totalLightCount and forEachCoord to that child directly.
  • Two child layouts produce contiguous physical indices: the second layout's coords are offset by the first's lightCount.

HttpServerModule

test/unit/core/unit_HttpServerModule_apply.cpp

  • apply-core: applyAddModule adds a child, idempotent on the id
  • apply-core: applySetControl writes a value, rejects out-of-range / unknown
  • apply-core: applyClearChildren empties a container (replaceChildren)
  • apply-core: applyOp dispatches each op type and tolerates bad input
  • A per-control validator (like SystemModule.deviceModel's printable-ASCII rule) is enforced THROUGH the apply-core — so the APPLY_OP set the installer pushes over serial is guarded exactly like an HTTP write, with no per-transport special-casing. This is the point of moving validation onto the control: one backend check, every path.
  • The WLED shim's {on,bri} apply drives the real on + brightness controls independently: turning off must NOT clobber the brightness value (the whole point of the shared on control, replacing the old bri=0 fudge). Home Assistant + the WLED app both post through this path.
  • Diff-on-the-wire (the 1 Hz-stutter fix): the periodic WS push sends only CHANGED control values, not the whole ~34 KB tree every second. buildStatePatch value-hashes each leaf against a baseline and emits only the ones that differ. These pin the core guarantees: an unchanged tree → EMPTY patch (the whole point — no per-second re-serialise of static config), and a single value change → a one-entry patch addressed by "/".
  • A schema change (rebuildControls — hidden flags / option sets) can't be seen by the value-hash patch, so any module's rebuildControls() flips the WS full-resync flag through the static schema-changed hook. This is what carries a metadata-only change (WiFi addressing hides fields, a preset Select gains an option) to connected clients. Pins the hook wiring + the subtraction of the old per-call-site resyncs.
  • buildStatePatch: a changed control value yields a one-entry patch
  • A module's STATUS must ride the 1 Hz value-diff, not the full state alone. A driver can fault at any moment (a bus that won't init, a loopback verdict, a Hue pairing result) with no schema change and no structural change — so nothing triggers a resync, and a status carried only by the full state would sit stale indefinitely. It is worse with the tabbed UI: a module whose card is behind a collapsed tab would surface no fault at all. This pins @status/@severity as patch leaves.
  • REGRESSION GUARD for the front/back-end sync class of bug that recurred several times: a control change the UI can only learn from the FULL state (never the per-second value patch) MUST request a full resync, or the client's cached state keeps the stale value and reverts the change ~1 s later. The canonical case is the module enabled toggle (it rides the full state, not the patch). The counterpart is equally load-bearing: an ORDINARY value change must NOT request a resync, or every slider drag nukes+rebuilds the whole UI (the "expander collapses / picker closes" symptom). This test pins BOTH directions at the one seam where they broke — Scheduler::setControl, via applySetControl — using the schema-changed hook as the resync signal.

HueDriver

test/unit/light/unit_HueDriver.cpp

  • HueDriver: a coloured pixel becomes an on/bri/hue/sat state body
  • HueDriver: a black pixel becomes on:false
  • HueDriver: RGB→HSV maps the primaries to the right Hue wheel positions
  • HueDriver: unchanged colour is not resent, a changed one is
  • HueDriver: parseLights keeps only colour-capable, reachable lights
  • Room + light selection filters which colour lights the driver actually drives. Both dropdowns default to "All" (index 0): then every colour light is driven (unchanged behaviour). Selecting a room narrows the driven set to that room's colour lights; selecting a light drives just that one.
  • The single status line (folding what were the separate hueStatus / colourLights controls) shows the light count as driven-of-total: "N-M lights" while filtered, the plain "M lights" when not.
  • fetchLights sizes its read buffer by growing while the body looks truncated. The signal is "does the body end in '}'": a too-small buffer cuts the JSON mid-content. (Regression: an earlier check tested strlen==cap-1, which never fires because httpRequest strips headers first, so a >2 KB bridge response was parsed truncated and lights silently disappeared.)

ImprovFrame

test/unit/core/unit_ImprovFrame.cpp

  • improvChecksum returns the sum of all input bytes modulo 256 (zero-length input is 0).
  • buildImprovFrame writes the full wire shape: "IMPROV" magic + version + type + length + payload + 1-byte checksum.
  • A payload larger than kImprovMaxPayload (128) is refused: builder returns 0 bytes written.
  • If the caller's output buffer can't hold the framed bytes, the builder refuses (returns 0).
  • A zero-length payload is valid: length byte is 0, checksum covers magic+version+type+length only.
  • Feeding a well-formed frame byte by byte ends in FrameReady; the parser exposes the type, length, and payload.
  • A zero-length payload frame parses to FrameReady with lastPayloadLen() == 0.
  • A corrupted checksum byte yields BadChecksum at the end of the frame.
  • A length byte greater than kImprovMaxPayload trips OversizePayload at that byte (before any payload data arrives).
  • Garbage bytes before the magic 'I' are silently skipped; a fresh well-formed frame after them parses normally.
  • "I" followed by another "I" treats the second byte as a fresh magic-start (not discarded) — the parser doesn't lose a real frame that begins mid-aborted-magic.
  • When the byte after MagicV isn't the version but happens to be 'I', the parser re-enters magic search at Magic1 — recovers a new frame that arrives right after a corrupted header.
  • Every defined ImprovFrameType (CurrentState, ErrorState, Rpc, RpcResponse) round-trips through builder + parser cleanly.
  • After FrameReady the parser returns to Magic0 and parses the next frame on the same instance without reset.

ImprovOpReassembler

test/unit/core/unit_ImprovOpReassembler.cpp

  • a single-frame op (seq 0, last 1) is Ready with the exact bytes
  • a multi-chunk op reassembles in order and NUL-terminates
  • a duplicate chunk is rejected and resets the buffer
  • an out-of-order chunk (skipped seq) is rejected
  • a non-zero opening seq (no fresh start) is rejected
  • overflow past the buffer (minus the NUL) is rejected, not truncated
  • exactly buffer-minus-one bytes fits (boundary)
  • seq 0 mid-stream abandons a partial op and starts fresh
  • an empty final chunk still completes (last with zero bytes)
  • reset() drops a partial op

IpList

test/unit/core/unit_IpList.cpp

  • parseIpList: a range expands over the last octet
  • parseIpList: a list of bare host numbers continues the same subnet
  • parseIpList: full quads may switch subnet, and ranges/lists mix
  • parseIpList: blank is not an error — it means no destinations (idle)
  • parseIpList: malformed input is rejected, never guessed at
  • parseIpList: the destination cap is enforced, not silently truncated

IrService

test/unit/core/unit_IrService.cpp Also touches: Scheduler.

  • IrService: a learned code adjusts Drivers brightness by the step
  • IrService: a learned on/off code toggles the Drivers on control
  • IrService: firing a learned code reports what it changed via status
  • IrService: pin state drives readiness status
  • IrService: a learned brightness code clamps at 0 and 255
  • IrService: learned palette codes step the Select and clamp at the ends
  • IrService: learn binds a code to an action, then that code drives it
  • IrService: an unlearned code is reported as unassigned, drives nothing
  • IrService: two codes bind to two actions independently
  • IrService: an unassigned code is a no-op, not a crash
  • IrService: a learned code whose target module is gone is a no-op, reported

JsonSink

test/unit/core/unit_JsonSink_detach.cpp

  • JsonSink::detach hands over the heap buffer; sink frees nothing after
  • JsonSink::detach is a no-op in fixed-buffer mode
  • JsonSink::detach on an empty buffer-mode sink returns null, not a dangling block

JsonUtil

test/unit/core/unit_JsonUtil_parse.cpp

  • parse a flat object reads each typed field
  • parse an array of objects (the persisted device list use case)
  • parse a nested object
  • escaped quotes and backslashes round-trip inside a string value
  • negative and fractional numbers
  • malformed inputs fail cleanly without crashing
  • no node cap: a large array parses (heap-grown node pool)
  • overflow safety: nesting deeper than kMaxDepth fails cleanly
  • no length cap: a long input parses (heap-sized text arena)
  • parseString must DECODE the JSON string escapes our own writer emits (JsonSink/writeJsonString) — \" \ \n \r \t \b \f — so reader and writer are symmetric. A multi-line value (a script with \n) must arrive as a real newline, not a literal backslash-n.

Layer

test/unit/light/unit_Layer_extrude.cpp Also touches: RainbowEffect, NoiseEffect, PlasmaEffect, SpiralEffect, FireEffect, ParticlesEffect.

  • A D2 effect (Rainbow) on a 3D layer writes z=0 once; Layer::extrude copies that slice across every z>0 — slices are byte-identical.
  • A D1 effect writes the x=0 column; extrude duplicates it across every x and every z-slice.
  • NoiseEffect declared D3 still produces a valid image on a depth=1 layer (it honours the runtime depth instead of hardcoding z).
  • PlasmaEffect (D3) on a 2D layer same contract: valid 2D image, no buffer overrun.
  • NoiseEffect (D3) on a 1D layer (height=depth=1) writes a valid strip and never overflows.
  • PlasmaEffect (D3) on a 1D layer same contract: valid 1D strip, no overflow.
  • SpiralEffect (D2) on a 3D layer: extrude copies z=0 to every z>0 (stateless D2 contract).
  • FireEffect (D2, stateful — heat buffer sized to w×h) extrudes cleanly across z on a 3D layer.
  • ParticlesEffect (D2, stateful — trail sized to w×h×cpl) extrudes cleanly across z on a 3D layer.

test/unit/light/unit_Layer_live_modifier.cpp Also touches: RotateModifier, ModifierBase.

  • With a Rotate present, the live pass rotates the gradient each frame as the angle advances — so two frames at different times differ. A static GradientEffect alone would produce identical frames, so any difference is the live remap.
  • PAY-FOR-WHAT-YOU-USE: a Layer with no live modifier must NOT run the live pass — the static gradient is byte-identical across frames regardless of the clock.
  • A DISABLED Rotate must not run the live pass either (the gate keys off ENABLED live modifiers). Same static gradient → identical frames.
  • COALESCED REBUILD: two beat-driven modifiers (RandomMap) on one Layer both ask for a rebuild on a beat; Layer::tick() must rebuild ONCE (not re-enter prepare per modifier) and the Layer must stay valid — the composed mapping changes, no crash.

test/unit/light/unit_Layer_modifier_chain.cpp Also touches: ModifierBase.

  • Region (left half) THEN Multiply (2× mirror): the logical box folds twice. On a 16-wide axis: Region 0..50 → 8, then Multiply 2 → 4. Both modifiers apply — the second is no longer dead weight.
  • Order matters: Region-then-Multiply differs from Multiply-then-Region. Region's percentage applies to whatever box it sees, so the composed logical size differs.
  • A DISABLED middle modifier is skipped — the chain folds only the enabled ones.

test/unit/light/unit_Layer_phase_animation.cpp Also touches: MetaballsEffect, SpiralEffect, LavaLampEffect, SpiralEffect.

  • Metaballs visibly changes over 100ms even when per-tick dt is sub-millisecond (no phase-accumulator truncation).
  • SpiralEffect advances at desktop speed (the spiral rotates across 100ms).
  • LavaLamp animates across 100ms (blobs move).
  • Spiral animates across 100ms (rotation visible).
  • Replace path: swap one effect for another mid-flight (same shape as HttpServerModule::handleReplaceModule) and confirm the new effect animates. Replacing one effect with another mid-tick (HttpServerModule's swap path) leaves the new effect animating, not frozen.

test/unit/light/unit_Layer_sparse_mapping.cpp

  • Dense grid: every box cell is a light, so no LUT — the identity/memcpy fast path is preserved exactly (the grid short-circuit).
  • Serpentine grid: dense (every box cell is a light, so the count check alone would pick the identity fast path) but SHUFFLED (driver index i != box cell i). isNaturalOrder() measures that from the coords and routes it through the box->driver LUT instead. This is the lever for exercising the non-identity mapping path without a sparse layout or a modifier.
  • Sparse sphere: a LUT is built; its destinations are driver indices in [0, lightCount), and the render buffer stays the dense bounding box.
  • Sphere + Mirror: the modifier's box-coordinate destinations are translated into driver-index space; no destination escapes [0, lightCount).
  • REGRESSION: a high fan-out Multiply (8×8×4 = 256) on a 128×128 grid must build a NON-EMPTY LUT that covers every physical light. The maxDest estimate (logicalCount × maxMultiplier) is computed in 64-bit; before that fix it overflowed uint16 on no-PSRAM boards (256 × 256 = 65536 wraps to 0), sized the LUT to ~nothing, and blanked the display. Here we assert the LUT actually maps the full light set, in range — the symptom that black-screened the device.
  • Region carving: a RegionModifier shrinks the Layer's LOGICAL box to the region (so the effect renders only there), and the LUT maps each region cell to its box cell at the start offset — every destination in range, none outside the region. The driver buffer still holds all physical lights; cells outside the region simply get no logical source (dark). Default 0/100 = full box (the no-carve fast path) is covered by unit_RegionModifier; here we carve a quarter.
  • Black pixels (mid-strand dark gaps): a GridLayout with a dark column run leaves the identity fast path (an identity map would light the gap) and builds a LUT that maps only the LIT cells. The gap's physical index is a real wire slot (counted in the physical/driver total) but is NO logical cell's destination, so the scatter never writes it and it stays black — a "physical pixel that stays black". This is the GridLayout-native form of the sparse mapping the sphere/region tests above pin.
  • Robustness: an ALL-black grid (every column dark) is a valid degenerate config — every physical slot is a real wire position the driver still clocks, but NO cell maps to a light, so the LUT has zero destinations. Must build and run (buffer stays black), never crash.

test/unit/light/unit_Layer_zero_grid.cpp Also touches: RainbowEffect, NoiseEffect, PlasmaEffect, SpiralEffect, MetaballsEffect, RingsEffect, RipplesEffect, LavaLampEffect, FireEffect, ParticlesEffect, GameOfLifeEffect, GEQ3DEffect, PaintBrushEffect.

  • Rainbow on 0,0,0 grid: no crash.
  • Noise on 0,0,0 grid: no crash.
  • Plasma on 0,0,0 grid: no crash.
  • Spiral on 0,0,0 grid: no crash.
  • Metaballs on 0,0,0 grid: no crash.
  • Rings on 0,0,0 grid: no crash.
  • Ripples on 0,0,0 grid: no crash.
  • LavaLamp on 0,0,0 grid: no crash.
  • Fire on 0,0,0 grid: no heat buffer allocated, no crash.
  • Particles on 0,0,0 grid: no trail buffer allocated, no crash.
  • GameOfLife on 0,0,0 grid: no heap alloc for 0 cells, no crash.
  • GEQ3D / PaintBrush on 0,0,0 grid: audio effects, no crash with no buffer.
  • PaintBrushEffect on 0,0,0 grid

Layers

test/unit/light/unit_Layers_container.cpp Also touches: Layer.

  • A Layers container with one child Layer must produce the same output as that Layer used directly (no-op container).
  • With two child Layers, each one's tick() runs and writes its own buffer (the container iterates all enabled children).
  • Multi-layer composition: Drivers blends ≥2 enabled Layers into its own output buffer and hands THAT to drivers (not a single Layer's buffer). Bottom layer overwrites; top layer blends per its blendMode/opacity. This is the end-to-end pin for the composite loop in Drivers::tick.
  • Disabling the top layer drops cleanly to the single (bottom) layer — no crash, the driver now sees the bottom layer's content. Pins the robustness path.
  • Drivers' composition/output-buffer allocation contract (architecture.md § Adaptive allocation). The driver output buffer exists ONLY when the pipeline must blend into physical space; otherwise the lone layer's buffer is handed to drivers directly (zero-copy). dynamicBytes() reflects outputBuffer_.bytes(), so it's 0 ⇔ no buffer. Pins all three cases in one place: 1. one identity (no-LUT) layer → NO output buffer (zero-copy) — WITH multicore off 2. two enabled layers → output buffer (must composite) 3. one layer WITH a LUT → output buffer (must map logical→physical) The multicore render↔encode split adds a fourth reason to own a buffer: it is the frame core 1 reads while core 0 renders the next one, so with the split ON the identity case DOES allocate one (case 1b). That is the documented cost of multicore; turning it off (or failing to allocate) restores the zero-copy profile exactly.
  • activeLayer() returns the first enabled child, or the only child if all are disabled (so dimensions stay queryable during boot/toggle-off).
  • firstEnabledLayer() is the output-selection counterpart to activeLayer(): it never falls back to a disabled layer, so it returns nullptr exactly when nothing renders.
  • If the container holds only non-Layer children, activeLayer() returns nullptr (the role-guard skips, never miscasts).
  • The disable cascade: disabling a PARENT releases every descendant's resources, because applyState() routes each node by its own effectivelyEnabled() — which is false for a child whose ancestor is disabled. This is the core guarantee of the unified lifecycle: a disabled subtree holds nothing (memory or hardware). FireEffect is the probe — its heat buffer's dynamicBytes() is host-observable, standing in for any per-module resource.

Layouts

test/unit/light/unit_Layouts_container.cpp

  • Disabled layouts contribute nothing; enabled siblings shift down to close the gap (no index holes).
  • Disabling the Layouts container itself zeroes totalLightCount and yields no coordinates.

test/unit/light/unit_Layouts_mutation.cpp

  • Add a single layout: the container reports its light count and iterates it.
  • Add more than one layout (mixed types): counts sum, indices stitch end-to-end.
  • Replace a layout with a different type at the same slot: the other layouts and their order are preserved; only the replaced slot's contribution changes.
  • Remove a layout: it leaves the tree, the remaining layouts shift to close the gap, and the total drops by exactly the removed layout's light count.

test/unit/light/unit_Layouts_toggle_cycle.cpp Also touches: Layer, Drivers.

  • Disabling the only layout child and re-enabling it must not crash Drivers, and rendering resumes cleanly.

LightPresetsModule

test/unit/light/unit_LightPresetsModule.cpp

  • LightPresets seeds the curated built-ins as locked rows
  • Option-array hoist (the 1 Hz-push efficiency fix): the 14 channel-role option strings are emitted ONCE per list in optionSets["channelRole"], and each ch select references it via optionsRef — NOT re-inlined per channel per row. A 32-channel fixture × 13 rows would otherwise repeat that array 400+ times in every state push. Pins that the row detail carries optionsRef, never inline options.
  • LightPresets add / edit / resolve a custom preset
  • setListRowField parses the "ch" channel index with strtol, not atoi — a malformed suffix must be REJECTED, not silently coerced to channel 0 (atoi("ch3x")→0 would misroute the write). Pins that a trailing-garbage or out-of-range channel name returns false and leaves the roles unchanged.
  • Regression (live bug): growing a preset's channel count must PRESERVE the roles already set on the existing channels — only the new channels get defaults. The earlier rebuildPool copied the NEW (larger) count of bytes from the old (smaller) slice, reading past it / skipping the copy, so the existing picks were lost on every channel increase.
  • LightPresets: a built-in is protected; a missing id does not resolve
  • LightPresets: delete + reorder keep ids stable
  • LightPresets: a custom preset round-trips through persistence with its roles
  • A driver's preset Select is populated from the library, and picking one resolves
  • CONSISTENCY (the product owner's requirement): editing a preset's wiring must immediately reach EVERY driver that references it — no reboot. The device wires this via prepareTree() (fired by the list mutation), which re-runs each driver's rebuildCorrection() → re-resolves its preset from the library. This test edits a referenced preset and re-resolves, asserting the driver's Correction now reflects the edit. Two drivers on the SAME preset both update — one shared definition, consistent.
  • A newly-ADDED preset must become selectable on a driver — the driver's preset Select option set is built from the library, so adding a preset has to refresh it. On the device, afterListMutation rebuilds every module's controls after a list mutation for exactly this reason; here we simulate that by rebuilding the driver's controls after the add and checking the Select grew.
  • whiteMode visibility: a driver's whiteMode control is hidden unless the REFERENCED preset carries a white channel — an RGB/GRB strip has nothing to synthesise. Regression: after Inc 2 moved preset selection to a library reference, whiteMode was shown for every preset (the old inline-preset hasWhite check was gone).
  • The migrated colour-order built-ins resolve to the right channel offsets. WRGB (ws2814) puts white at channel 0, so R/G/B shift up one — a distinctive layout that catches a bad migration.
  • RGBCCT carries a cold white (W) AND a warm white (WW). The new WarmWhite role must count as "has white" so a driver referencing it still shows whiteMode — the second white is white too.
  • A moving-head built-in migrates as a wide fixture: the RGB block sits at its real offset within the DMX map (BeeEyes: R@10,G@11,B@12 of 15), the fixture is the right width, and it resolves without crashing at that odd width (Robust-to-any-input). The Pan/Tilt/Zoom/Gobo channels carry their roles in the preset but aren't colour offsets, so they're inert until effect writers land.
  • APPEND-ONLY regression: inserting WarmWhite/Yellow/UV after White must NOT renumber the existing colour roles, or every persisted RGBW preset's bytes would resolve to the wrong colours. A straight RGBW built-in still deriving R@0,G@1,B@2,W@3 proves the low indices are unchanged.
  • MIGRATION SECURITY (the product owner's ask): a driver whose referenced preset no longer exists — a custom preset deleted, or a persisted reference to a preset a firmware no longer ships — must fall back to the default (first) built-in, resolving to a valid RGB output rather than blanking or crashing. rebuildCorrection re-points a dangling id to defaultId(); this pins that path so the fallback can't silently regress (Robust-to-any-input). Two routes are checked: a deleted custom, and a straight-up bogus id.

LissajousEffect

test/unit/light/unit_LissajousEffect.cpp

  • A single frame paints part of the grid: the swept curve lights some pixels (not a black frame).
  • The curve is sparse: on a large grid it lights only some pixels, leaving others black.
  • On a 1×1 grid the whole curve collapses onto the single origin light without indexing out of bounds.
  • Effects must run at every grid size (hard rule): a 0×0×0 grid renders without crashing.

MappingLUT

test/unit/core/unit_MappingLUT.cpp

  • A fresh LUT carries no mapping (hasLUT==false, logicalCount==0); BlendMap takes the fast identity copy path.
  • setIdentity(N) declares a 1:1 mapping for N lights without allocating a LUT; forEachDestination still iterates correctly.
  • Each logical light can map to a different count of physical lights; forEachDestination yields every mapped index in order.
  • When no single contiguous block fits (forced via the test cap) but total heap allows it, build() pages the destinations array. The mapping must read back identically to a single-alloc build — paging is an allocation detail, not a behaviour change. isPaged() confirms the fallback actually engaged.
  • build() returns false on genuine exhaustion — total free heap (minus the reserve) can't hold the destinations — so the caller degrades to 1:1. Forced here via a non-zero freeHeap is desktop-only-unavailable, so this case pins the paged path's success and the boundary; the tier-3 false path is covered by the Layer sparse-mapping degrade test on real heap limits.
  • free() releases memory and resets counts; build() can be called again to install a fresh mapping.

MetaballsEffect

test/unit/light/unit_MetaballsEffect.cpp

  • One tick on a 16×16 grid leaves at least one non-zero byte in the layer buffer (proves the effect rendered).
  • Pixels at opposite corners of a 32×32 grid differ in colour (the effect is not flat-filling the buffer).

MirrorModifier

test/unit/light/unit_MirrorModifier.cpp

  • modifyLogicalSize halves each mirrored axis, rounding up: an even 128 → 64, an odd 65 → 33 (the centre column stays unpaired).
  • A coord and its mirror across the box centre map to the same logical position: on an even 8-wide axis, physical 7 folds to 0, 6 to 1, 5 to 2, 4 to 3, while the near half 0..3 passes through unchanged.
  • An odd extent keeps its centre column unpaired: on a 5-wide axis the half-extent is 3, so 0,1,2 pass through and the far edge 4→1, 3→2, leaving logical column 2 unpaired.
  • A disabled axis is left untouched: with only Y mirrored, X and Z sizes are unchanged and X coordinates pass through while Y folds.
  • Degenerate axes don't crash: a 1-wide axis stays 1 ((1+1)/2 == 1) and no coordinate ever reaches the half-extent to fold; a 0-extent axis stays 0.

ModuleFactory

test/unit/core/unit_ModuleFactory.cpp

  • registerType(name) instantiates a probe of T to read its role(), then stores name+role+constructor for later create() calls.
  • create(name) returns a heap-allocated instance whose role and typeName match what was registered.
  • create() returns nullptr for an unknown name or a nullptr name (no crash on bogus input).
  • typeName/typeRole with an out-of-range index returns nullptr / Generic safely (never UB).
  • The factory grows its registry capacity dynamically — registering 10+ extra types past the initial size still works and every name stays discoverable.

MoonLedDriver

test/unit/light/unit_MoonLedDriver.cpp Also touches: MultiPinLedDriver, ParallelLedDriver.

  • The load-bearing difference from its sibling. MultiPinLedDriver runs on the i80 bus — LCD_CAM on the S3/P4 AND the I2S peripheral on the classic ESP32 (IDF's esp_lcd picks the backend). MoonI80 programs LCD_CAM directly, so it must NOT claim the classic chip: lanesAvailable() reads lcdLanes alone, without the + i2sLanes its sibling adds. Getting this wrong would offer the driver on a chip whose peripheral it cannot drive.
  • The i80 BUS is 8 or 16 bits wide whatever the pin count, so the base rounds it up (kPowerOfTwoBus) and parks the lanes the board does not use. And the loopback cannot build a 1-lane private bus, so its test frame is encoded at the full operational width.
  • The bus control pins are a '595 cost, not an i80 cost — and owning the DMA is what proves it. DC exists so an LCD panel can separate command bytes from data bytes; a WS2812 strand has no such concept, and the peripheral holds DC at a constant level. WR is the pixel clock, which only a shift register consumes (as SRCLK) — WS2812 is self-clocked, so a strand ignores it. esp_lcd mandates a valid GPIO for BOTH regardless (wr_gpio_num >= 0 && dc_gpio_num >= 0), which is why the sibling still spends two pins on them. This backend routes its own GPIO matrix, so it routes neither in direct mode: there is no dcPin at all, and clockPin reaches a pad only under the expander. The observable consequence, and what this case pins: in DIRECT mode clockPin may freely name a GPIO that a strand also uses, because the signal never leaves the peripheral. Rejecting that would forbid a working config to protect a signal nobody reads.
  • Under the expander WR IS routed (it clocks the '595s), so now it can collide — and a data lane sharing it is silent corruption: the matrix drives both signals onto the one pad and that strand emits the shift clock instead of pixel data.
  • The '595 latch rides a DATA lane (the peripheral gives only one clock output, and WR is already the shift clock), so it must not land on WR — the latch would ride the shift clock itself and nothing would ever latch, which looks like a dead strip rather than a config error.
  • The '595's shift clock IS WR, so shift mode needs clockPin on a real GPIO. Unset (-1) would route the peripheral's WR signal to GPIO 65535 — catch it as a config error, not a bad pad write. Direct mode does not care (WR is unrouted there), so the same unset pin is fine without the expander.
  • Sanity: with a valid config the driver is a working CRTP sibling — it slices lanes and reports the lights it drives, exactly like its sibling. (The lane/frame ARITHMETIC itself is the base's, and is covered once, in unit_I80LedDriver and the Mock suites.)

test/unit/light/unit_ParallelLedDriver_ring.cpp Also touches: ParallelLedDriver.

    1. TILING — the ring's slices, concatenated in row order, reproduce a whole-frame encode byte for byte. This is the invariant the prior attempt violated (the 16-stride "domino" repeat). A slice writes to dst+0 for any firstRow, so if the tiling is right the reassembled buffer == the frame.
    1. ROWS-ONLY — no slice appends a latch pad; every buffer is zero past its rows. The buffers are allocated rows-only (the WS2812 reset comes from stopping the peripheral, not a pad in a circulating buffer), so a slice that wrote a latch word past its rows would overrun the allocation on hardware.
    1. RECYCLED == FRESH — a second frame through the SAME (recycled, not zeroed) ring buffers produces byte-identical output to the first. This catches a stale-constant / stale-pad bug that a single-frame test cannot see — the failure mode unique to a recycled ring.
    1. NON-MULTIPLE-OF-16 STRAND LENGTH — the last slice is SHORT and lands in a REUSED buffer, so its pad window still holds that buffer's earlier full slice. The pad-zero must scrub it, or ghost rows clock in place of the ≥300 µs LOW reset and a strand can miss its latch. 200 lights → 13 slices > 8 buffers (reuse) AND lastRows=8 (short) — exactly the case 128/192/256 (all ×16) never hit.
    1. SOURCE SNAPSHOT — the ring encodes off the render thread across the ~6 ms wire, so it must read a frozen per-frame copy, not the live Layer buffer. tickRing calls snapshotSourceForRing() before kicking the frame; after that the render loop is free to overwrite (or free) the source. This pins the invariant: once snapshotted, the encode's bytes track the SNAPSHOT, so mutating the live source mid-frame changes nothing on the wire. (On device this is what stops a grid resize / RGBW switch mid-wire from tearing or reading freed memory.)
    1. WINDOWED SNAPSHOT — the snapshot copies only THIS DRIVER'S WINDOW (winLen_ × srcCh from winStart_), not the whole source, then biases encodeSrc_ by -winStart_ so the encode's index math is unchanged. With a NON-ZERO window start the bias is load-bearing (a plain full-copy would read the wrong pixels), so this drives the same content two ways — once through a windowed snapshot at start=W, once by hand- offsetting a whole-buffer source so the live read lands on the same pixels — and asserts they match.
  • 7a. CLEAN LOW TAIL — with enough buffers that the frame does NOT reuse (nSlices < bufs), every buffer clocked after the last real slice is all-LOW: the ≥300 µs WS2812 reset that latches the '595. This is the no-reuse stopgap's guarantee; it is what renders clean at ≤240 lights/strand (kRingBufs=16).
  • 7b. THE NO-REUSE STOPGAP SIZING — with bufs > nSlices the tail buffer is a distinct physical buffer the priming loop zeroed and the wrap re-clocks as a clean LOW; with bufs == nSlices there is NO spare buffer, so the tail clock wraps onto buffer 0. The BYTE model shows the wrap re-clocks buffer 0 after the ISR has zeroed it on the very first drain's refill, so bytes alone stay LOW — but on HARDWARE the equal case STALLS ("output stalled") because the stop counter (nSlices + kTailBufs drains) needs a buffer the priming never left free, a DMA-timing property the byte model cannot see (256 lights at kRingBufs=16 is exactly this; the fix is kRingBufs >= 17). What this test CAN pin is the correct-sizing guarantee and the drain count, so a future change that breaks the tail or the stop timing at the correct sizing is caught here; the equal-case stall is covered in the backlog.
    1. RAGGED TILING — the same byte-for-byte tiling invariant as test 1, but with strands of DIFFERENT lengths. This is the case the uniform tests structurally cannot see, and it is a real end-user config: ledsPerPin takes one entry per STRAND (pins × 8 with the expander), precisely so two strands on the SAME '595 can differ — a strip on one output, a panel on the next. Why ragged is a distinct risk from uniform: the shift constants (the pulse-start word) depend on the ACTIVE MASK, and a strand that runs out drops from that mask at the row where it ends. So the constants stop being frame-uniform and must be laid per RUN of rows sharing a mask (ParallelLedDriver::prefillShiftRows). Get it wrong — lay row 0's mask over every row — and an exhausted strand keeps its pulse-start asserted and FLASHES WHITE at full brightness. The lengths are chosen so a strand ends INSIDE a slice, not on a buffer boundary: 16 rows/buffer and a strand ending at 100 puts the mask change in the middle of slice 6 (rows 96..111). That is the composition — run-splitting × slice tiling — that neither the encoder-level ragged tests (which never slice) nor the driver-level ring tests (which are never ragged) reach on their own.
  • 9a2. COALESCED EOFs — THE v2 REGRESSION TEST. The GDMA EOF interrupt is a latch, not a queue: under load two drains arrive as ONE firing, and the clock-oracle ISR then batch-refills both slices in that single invocation. The old one-refill-per-firing design shifted every later slice by one position when this happened (the shifted-region / wrong-color wall artifact); the batch contract requires the wire bytes to be IDENTICAL whatever the grouping. Deep-lapping geometry so batches cross the recycle boundary repeatedly.
  • 9b. EMPTY LANES ARE NOT RAGGED. The prefill-skip gate (uniformLaneCounts) requires a frame-constant active mask, and a count-0 lane is in NO row's mask — so a source that fills only 15 of 16 expander strands must count as uniform (skip allowed), not ragged (prefill every refill, ~1/3 of the refill cost). This pins BOTH halves: the gate says uniform, and the ring's recycled-buffer frames — which now skip the prefill — stay byte-identical to the whole-frame encode.
    1. RAGGED — AN EXHAUSTED STRAND GOES DARK. The tiling test above compares the ring against the whole-frame encode, so it catches any DISAGREEMENT between the two paths — but it would not notice both being wrong the same way. This one asserts the actual hardware requirement directly, with no reference frame: once a strand runs out, every byte it clocks is 0. This is the bug's real-world face. The '595 is fed serially and every bus word is an SRCLK edge, so a strand cannot be "left alone" — keeping it dark means clocking ZEROS into its shift position. The pulse-start word carries the active mask, so a stale mask re-asserts an exhausted strand and it FLASHES WHITE at full brightness. Driving the source all-0xFF makes any leak maximally visible. Both frames are checked: the constants must be re-laid correctly on a RECYCLED buffer too (ring buffers are reused, not zeroed), which is where a "lay it once at init" shortcut breaks on frame 2.
    1. THE RING MUST NOT SURVIVE A FAILED BUILD. reinit() builds the ring in two steps — the bus, then the source snapshot the ring's encode reads — and BOTH must hold or the driver falls back to whole-frame. The trap is the middle case: the bus builds (a GDMA channel, its EOF interrupt, and ~150 KB of internal DMA buffers are now live) and the snapshot then fails to allocate. That is not a hypothetical — it is the likeliest OOM in the whole driver, because the snapshot is asked for right after the ring took the scarcest memory on the chip. If the fall-through path tears down conditionally (if (inited_) busDeinit()), it walks straight past that live ring: inited_ is false by construction on this path, and the whole-frame build below then OVERWRITES the bus handle — leaking the channel, the ISR registration and the RAM, with no way back but a reboot. Worse, it repeats on every prepare rebuild, so a board tight enough to hit it once bleeds on every geometry change. This pins the rule that makes it safe: after a failed ring build the bus is torn down UNCONDITIONALLY. The mock's busDeinit clears ringActive_, so a surviving ring is visible here.
  • MoonI80 ring: the PARALLEL snapshot's range-split is byte-identical to the whole-range serial
  • The snapshot window clamp must key on the SOURCE stride (srcCh), not outCh — the buffer is a raw srcCh-strided memcpy. With outCh > srcCh (an RGB source through an RGBW correction: srcCh=3, outCh=4) an outCh-based clamp would compute winLen4 > winLen3 and silently drop ~1/4 of the window, leaving its tail reading stale bytes. This pins the full window survives.
  • ringSnapshot is meaningful ONLY when a ring runs (wantsRing()); the schema must hide it otherwise so the user never sees a control that does nothing on their config. wantsRing() reads plain flags (not the source buffer), so the gate resolves correctly even at defineControls() time before the buffer is wired.

MoonLive

test/unit/core/unit_moonlive_compiler.cpp

  • compileSource: fill(r,g,b) fills every light
  • compileSource: setRGB(index, r,g,b) writes one pixel
  • REMARK #1: every argument is an expression — random16 in ANY slot.
  • REMARK #2: a literal / random16 bound may be a uint16 (0..65535), not capped at 255.
  • compileSource: out-of-range index is bounds-rejected at runtime
  • compileSource rejects malformed programs with a diagnostic, never crashes
  • MoonLive.compile(source) on a bad script leaves the engine !ok with an error
  • a multi-call statement reuses dead vregs and stays within the register budget
  • DOMAIN-NEUTRAL: the core compiler owns no function names. With an EMPTY table it knows nothing — setRGB/fill/random16 are all "unknown function". The LED vocabulary lives only in the host's table; a different host registers different names. (Remark #3.)
  • MoonLive recompiling swaps the program live (fill <-> setRGB)
  • STAGE 1 CONTROLS — parse layer: a uint8_t name = def; // @control min..max declaration surfaces a DeclaredControl, and a declared name used in a statement resolves to it. The DeclaredControl tests also need lowerToBytes to return non-zero — r.ok gates on it.
  • compileSource: malformed control declarations fail with a diagnostic, never crash

test/unit/core/unit_moonlive_fill.cpp

  • MoonLive emitFill produces a non-empty routine
  • MoonLive emitFill rejects a too-small buffer (degrades, no overrun)
  • MoonLive emitFill/emitAnimatedFill reject a null output buffer (no crash)
  • MoonLive compiles and fills a buffer with the chosen colour
  • MoonLive run on zero lights writes nothing (robust to empty)
  • The native routines write channels +0/+1/+2 per light, so a layer with fewer than 3 channels per light can't hold RGB — run() must leave it untouched, not overrun it.
  • MoonLive recompile swaps the colour; free returns to !ok
  • MoonLive animated fill derives colour from the per-frame t
  • platform allocExec returns usable executable memory, freeExec releases it
  • MoonLive controls: declaredControls + controlSlot seeded from the default
  • MoonLive controls: arena address is STABLE across a recompile and the slot value survives
  • MoonLive controls: free() releases the arena (no stale slot after release)

test/unit/core/unit_moonlive_ir.cpp

  • MoonLive compiled fill is BEHAVIORALLY identical to the hand-encoded emitFill (golden)
  • MoonLive compiled fill is robust: zero lights writes nothing
  • MoonLive compileSource degrades on a too-small code buffer
  • MoonLive compiled setRGB writes one pixel; out-of-range is bounds-rejected
  • MoonLive control: a declared control reads the arena live (no recompile on value change)
  • MoonLive control survives a host call (kArg4 live across random16)

MoonModule

test/unit/core/unit_MoonModule.cpp

  • setup() and release() each fire exactly when called and update their respective state flags.
  • name() starts empty; setName() copies the string into the internal 16-byte buffer.
  • typeName (set by the factory) is independent of name; setName doesn't touch typeName so a human-renamed module still serializes under its real type.
  • dirty()/markDirty()/clearDirty() round-trip cleanly (the bit FilesystemModule polls for save scheduling).
  • parent() starts null; setParent() records the upstream container for tree walks.
  • Adding Uint8/Bool controls stores live pointers to the module's fields, so changes propagate either direction (field ↔ control->ptr).
  • controls().clear() empties the list; calling defineControls() again repopulates it (the standard rebuild path).
  • schemaSignature: value change is invisible, schema change is not
  • schemaSignature: recurses into children (a child schema change is caught)
  • addReadOnly binds a char buffer the UI can render; updating the buffer is visible through control.ptr.
  • addSelect binds a uint8 + an options array (stored in aux) — control.max carries the option count.
  • addProgress binds a uint32 plus a "total" value (in aux) — the UI renders value/total as a progress bar.
  • enabled defaults to true; setEnabled flips the universal enable gate (Scheduler and parent containers respect it).
  • addBool binds a bool field — toggling the field updates control.ptr's view.
  • appearsInUi() defaults to true (every ordinary module shows in the UI) and is overridable to false so infrastructure modules (FilesystemModule, HttpServerModule) can hide — the flag the state serializer reads to skip a module. A base MoonModule and a control-bearing one both appear; only a module that overrides to false hides.
  • readBool/readUint8 — the shared generic control reader (reviewer #8): one implementation so the absent-control default can't disagree between callers (HttpServerModule + MqttModule both read Drivers.on through this). Returns the bound value; returns the caller's default when absent/wrong-type.

test/unit/core/unit_MoonModule_control_change_gate.cpp Also touches: GridLayout, MultiplyModifier, NoiseEffect, Drivers.

  • Layout and Modifier modules opt in to rebuild on a control change (their controls reshape the pipeline).
  • Effects and Drivers opt out — their controls are values read directly in the hot path, no rebuild needed (prevents slider stutter).

test/unit/core/unit_MoonModule_lifecycle.cpp

  • A parent's default tick() fans out to every enabled child — no per-container boilerplate needed.
  • Disabled children are skipped during propagation (the universal enable-gate).
  • Modules that override respectsEnabled() to false (NetworkModule, SystemModule, …) tick regardless of their enable bit.
  • tick20ms / tick1s use the same gate-and-propagate rule as tick().
  • A leaf module (no children) ticks safely as a no-op with no accumulated timing.
  • Each child's tickTimeUs() reflects its own accumulated cost (Scheduler reads per-child timing, not the parent's sum).

test/unit/core/unit_MoonModule_movechild.cpp

  • Moving a child to its current index returns false and changes nothing.
  • Moving a child forward shifts intervening children leftward to close the gap.
  • Moving a child backward shifts intervening children rightward to make room.
  • Single-position moves work in either direction (UI's up/down arrow buttons).
  • A target index beyond childCount() is refused (returns false, tree untouched).
  • A module that isn't actually a child of the parent is refused.
  • Middle-to-middle moves preserve the integrity of every sibling's index.

test/unit/core/unit_MoonModule_replacechild.cpp

  • Replacing position 1 swaps that child while leaving siblings and child count untouched.
  • The returned old child has its parent cleared; the fresh child has its parent set to the container.
  • An out-of-range index returns nullptr and the tree (plus the rejected replacement's parent pointer) stays untouched.
  • A nullptr replacement returns nullptr and leaves the tree intact.
  • After replace, the caller follows the lifecycle order: defineControls → setup → prepare on the fresh module, then release on the old.

MqttModule

test/unit/core/unit_MqttModule.cpp Also touches: Scheduler.

  • MqttModule: on/set drives Drivers.on
  • MqttModule: brightness/set rescales 0-100 to 0-255
  • MqttModule: hsv/set maps a hue to the nearest palette + value to brightness
  • MqttModule: ha/set {state} drives Drivers.on
  • MqttModule: ha/set {brightness} maps 0-255 with no rescale
  • MqttModule: ha/set is key-order-independent
  • MqttModule: ha/set with only state leaves brightness untouched
  • The discovery announce: on CONNACK the module publishes a RETAINED config to homeassistant/light/projectMM_/config. Assert via the outbound-capture seam (no live socket).
  • Regression (found live on P4/S31 hardware): turning haDiscovery OFF must free the discovery buffers EVEN when the socket is not currently Connected (mid-reconnect). The original guard bailed on state_ != Connected before reaching the free, so a discovery-off toggle during a reconnect stranded the 768 B until release — breaking "no memory when discovery is off". Freeing local memory needs no socket, so the retract path frees unconditionally; only the empty-retained PUBLISH needs a live link.
  • MqttModule: a PUBLISH on an unrelated topic is ignored, not a crash
  • MqttModule: a PUBLISH split across feeds still routes (fragment reassembly)
  • Regression (reviewer): the topic identity is the STABLE MAC (projectMM/), NOT the device name — so renaming the device must NOT change which topics the module listens on. A command on the MAC-based topic keeps working after a rename; a command on a name-based topic never matched.

MqttPacket

test/unit/core/unit_MqttPacket.cpp

  • --- Remaining-length varint (§2.2.3) — the fiddly bit, boundary-tested ---
  • mqttWriteFixedHeader must validate space for the WHOLE header (type byte + varint) before it writes any of it — a bodyLen needing a 2-byte varint into a 2-byte buffer must return 0 without touching out[2] (the byte past the buffer). Regression guard for a fixed-header overrun.
  • --- CONNECT golden vector (§3.1) ---
  • MqttPacket: CONNECT with username + password sets the flags + payload
  • Regression (reviewer #10 / MQTT-3.1.2-22): a password without a username must NOT set the password flag — a compliant broker rejects that CONNECT. The builder drops the password when username empty.
  • A Last Will (§3.1.2.5-.7 + §3.1.3) makes the broker publish the will payload if the client drops — the availability seam HA greys the entity out on. Will Topic + Will Message go in the payload AFTER the clientId and BEFORE username/password; the flags byte carries the Will + Will-Retain bits.
  • --- PUBLISH golden vector (§3.3), QoS0 ---
  • The retain flag (§3.3.1.3) sets bit 0 of the fixed-header type nibble — used for the friendly name topic so a late-subscribing hub still receives it.
  • --- SUBSCRIBE golden vector (§3.8) ---
  • --- PINGREQ / DISCONNECT ---
  • --- Inbound parser: CONNACK return code read off the completed body (how the module reads it) ---
  • --- Inbound parser: round-trip a PUBLISH built by our own builder ---
  • --- Inbound parser: fragmentation — a packet split across feeds still reassembles ---
  • --- Inbound parser: a multi-byte remaining-length (128+ body) is handled ---

MultiPinLedDriver

test/unit/light/unit_MultiPinLedDriver.cpp Also touches: Drivers, Correction.

  • Explicit counts slice the buffer consecutively; the frame is sized by the LONGEST lane. The bus always has all 8 lanes — unused strands take the 0-light remainder and idle LOW.
  • Empty ledsPerPin splits evenly — same PinList semantics the RMT driver uses.
  • An RGB→RGBW preset toggle grows the frame (32 vs 24 slot bytes per light).
  • A bad pin list idles the driver with the parse literal in the status; fixing it recovers.
  • Pins now default UNSET (the "default only when it cannot do harm" rule — the strand is user-soldered). A fresh, unconfigured driver idles, never grabbing the 8 data GPIOs on its own. (wire() back-fills empty pins for the slicing cases, so this one wires the buffer directly to keep pins empty.)
  • The BUS width is 8 or 16; the PIN COUNT is whatever the board drives. They are different numbers and the driver reconciles them: the peripheral always clocks a full 8- or 16-bit word, but nothing requires every bit to reach a GPIO — busPinList() parks the lanes the board doesn't use on WR, where the peripheral already drives and no strand reads them. So a 5-pin board is 5 data lanes on an 8-bit bus, not a config error, and needs no fake "ghost" pins to pad the list out.
  • A data lane on the same GPIO as the WR (clockPin) or DC pin is a WARNING, not a blocker: that lane carries the clock/DC waveform instead of pixel data, but on a board that wires all 8/16 lanes yet drives fewer strands, parking WR/DC on an unused data pin is a valid choice — so the driver still runs and flags a warning. clockPin/dcPin default to 10/11.
  • A 0×0×0 grid is a clean idle: zero counts, zero frame (no pad for an empty frame), no crash.
  • setup/release cycles leave no residue (status clean, ASAN-checked heap).
  • loopbackRxPin is bound always, visible only while loopbackTest is on.
  • loopbackTxPin (optional lane-0 TX override) is bound always, hidden until the test is on — same conditional-control contract as loopbackRxPin. The override's lane-0 substitution is hardware-only (lcdLanes==0 on desktop); the visibility contract is host-testable here via the shared helper (toggles loopbackTest both ways and asserts the control stays bound while flipping visibility).

test/unit/light/unit_ParallelLedDriver_pinexpander.cpp Also touches: ParallelLedDriver.

  • Each data pin fans out to 8 strands through its '595, so the driver drives pins x 8 lanes — the whole point of the expander (pins are the scarce resource, not strands).
  • ROBUSTNESS INVARIANT: a driver consumes only what IT drives (pins x ledsPerPin) and ignores any EXCESS the layout declares. A layout can legitimately publish more lights than one driver covers (a 2D panel grid the driver only partly maps, or several drivers splitting one big source), so the driver must clamp to its own lane capacity and keep every source read in-bounds — never encode past its share into unmapped source (which shows as frozen/garbage LEDs on the uncovered region). This pins the clamp: source = 1920, but 2 pins x 8 x 60 = 960, so the driver drives EXACTLY 960 and its furthest read is < 1920.
  • Direct mode is unchanged: one strand per pin, no latch. Pins the no-regression half — the expander must not have altered the existing behaviour.
  • THE COUNTER-INTUITIVE RESULT, and the one the memory budget rests on: the x8 fan-out multiplies the frame (a '595 is serial-in — each slot costs 8 shift cycles), but extra STRANDS are free (they ride the bus width). So the PO's two panels — 15x256 = 3,840 lights and 48x256 = 12,288 lights — cost the SAME DMA frame. If this ever regresses, the memory model is wrong and the bigger panel will silently fail to allocate.
  • The x8 IS paid for, in the frame: the same strands at the same length cost 8x the DMA bytes with the expander fitted. (This is what walls the classic ESP32 — its DMA can't reach PSRAM.)
  • The bus width follows the PHYSICAL pins (+ the latch lane), not the strand count. 48 strands on 6 pins is still an 8-bit bus — if this regressed to keying on the strand count, the driver would encode 16-bit slots into an 8-bit bus and emit a frame of pure garbage.
  • The latch is a real bus lane (a bit in every bus word), so it cannot double as a data pin — that lane would carry the latch waveform instead of pixel data. A config error, not a crash.
  • Without a latch the '595s never present a byte, so the strands would stay dark. Refuse the config rather than run a bus that silently outputs nothing.
  • The latch must not land on the peripheral's own WR/DC pins either — bench-found, because WR defaults to GPIO 10 and that is the first free-looking pin a user reaches for. The i80 bus builds fine, so the failure is silent garbage on the strands rather than an init error; that is what makes it worth an explicit guard. (The check itself lives in MultiPinLedDriver::validateBusFatal, which the mock does not have — this pins the base's half: a data-pin collision is caught, so the mechanism is live. The WR/DC half is a compile-time-visible guard in the i80 driver.)
  • The loopback test frame must carry the expander's ×8 factor. The rig transmits the REAL frame through the real DMA path, so a frame sized without the ×8 would clock out a truncated waveform and fail every bit — a false failure that looks like broken hardware. (The mock's busLoopback is a stub, so this pins the SIZE arithmetic, which is the part that was wrong; the captured-bits half is hardware and is proven on the bench.)
  • Robust to any input (CLAUDE.md): flipping the expander on and off on a live driver must reconfigure cleanly each time, never wedge or leave stale lane state behind.
  • A transfer that NEVER completes (the done-callback never fires) must not wedge the driver. On the bench this manifested as a 42 ms driver tick for a 4.6 ms transfer — the driver spent every frame inside a timing-out wait. It must stay responsive, and it must not corrupt the in-flight buffer.
  • The buffer-safety invariant the timeout exists to protect: while a transfer may still be reading a buffer, the driver must NOT encode into it again. A re-encode mid-transfer is what puts half of one frame and half of the next on the wire — the "scattered random pixels" seen on the bench.
  • The robustness rule: a broken bus must not cost the user the DEVICE. Every dead transfer is a wait on the render thread, so a bus that never delivers doesn't just fail to light LEDs — it eats the CPU that WiFi/HTTP need and the device drops off the network entirely, with no way back in except a cable. That is exactly what a misconfigured shift-register frame did to a bench board (2026-07-14): unreachable and unrecoverable remotely, from nothing but an LED setting. So a persistently-dead bus must be GIVEN UP ON: the driver reports the failure and stops paying for it. Output idle, device alive — never the other way round.
  • Giving up must not be permanent: the user fixes the setting that broke the bus, the driver rebuilds, and the LEDs come back — no reboot (the live-reconfiguration rule).
  • THE INVARIANT THE STREAMING RING RESTS ON. The ring never materialises the big encoded frame: the DMA loops a few small INTERNAL buffers, and the CPU encodes each slice straight into the buffer the DMA is about to read. That is only sound if encoding in slices produces EXACTLY the bytes the whole-frame encode would have produced — otherwise the wire sees a different frame depending on how it happened to be chunked, which is the class of bug that is invisible on a sparse effect and catastrophic on a dense one. Note the latch pad: only the LAST slice closes the frame (closeFrame), because the pad is what makes every strand idle LOW into the WS2812 reset. A pad emitted mid-frame would reset the strand halfway.
  • The pin list handed to the peripheral must be exactly as long as the count that goes with it. The BUS is 8 or 16 bits wide whatever the board drives, so a 3-pin board still hands the peripheral an 8-entry list: 3 data lanes, then the spares parked on the clock pin (a real GPIO the peripheral already drives, so the lane is inert). This is what lets a board name only the pins it uses. The bug this pins: busPinList() used to shortcut if (!pinExpanderMode()) return laneList_ — the RAW, unpadded list — while busPinCount() reported the rounded width. With fewer pins than the bus is wide, the platform would then read past the end of laneList_. Direct mode never hit it only because the validation rejected any count but 8 or 16; allowing any count is what exposes it.
  • The shift-mode loopback frame must reserve the trailing latch word it unconditionally writes. encodeLoopbackFrameShift emits lights rows and then ALWAYS calls encodeWs2812ShiftLatchPad to close the frame — one extra Slot past the last row. The buffer was sized for the rows only, so that final word landed one-to-two bytes past the end of the heap block: a real overrun on every shift-mode loopback, silent on a forgiving allocator and a crash on an unlucky one. (Direct mode writes no pad, so it is unaffected — and that asymmetry is why sizing them the same was wrong.) The bug is a heap write, so the assertion that really catches it is the sanitizer, not a CHECK: run this suite under ASan and the pre-fix code trips heap-buffer-overflow here. What the CHECKs below can pin on their own is that the loopback ran to completion and left the driver healthy rather than tripping the out-of-memory path.

test/unit/light/unit_ParallelSlots.cpp Also touches: Correction.

  • One lane, one byte 0xA5: slot0 always the mask, slot1 follows the bits MSB-first, slot2 always zero.
  • Two lanes 0xFF/0x00 in one row: the data slot carries lane 0's bit only — the transpose itself.
  • A lane excluded from the mask contributes to NEITHER slot 0 nor slot 1, even with garbage wire bytes — short strands idle LOW (no white flashes).
  • Mask 0 (a row past every lane's strand) is a fully idle row.
  • Channel order comes from Correction (logical red → GRB wire {0,255,0}); the encoder is order-agnostic.
  • RGBW rows emit 4 channels × 8 bits × 3 slots = 96 bytes.
  • The branch-free SWAR transpose must equal the naive per-bit-per-lane gather it replaced, for EVERY lane pattern and mask — the whole point is a behavior-identical speedup. Pin it directly (not just via a few golden rows) so a future edit to the delta-swap constants can't silently corrupt a plane.
  • The 16-lane transpose (transposeLanes16x8, for the 16-bit bus) must equal the naive 16-lane gather for EVERY pattern and mask — the low byte of each uint16 plane is lanes 0..7, the high byte lanes 8..15. Cycles mask shapes including high-lane-only (lanes 8..15 set, 0..7 clear), the case the byte-split hinges on.
  • 16-lane golden encoder cases: the uint16 slot carries 16 data lines. Prove a high lane (15) lands in the plane's high byte, and a boundary pair (7 + 8). The wire lane stride is channels (here 1), so lane L's byte 0 is wire[L * channels].
  • RGBW 16-lane row: 4 channels × 8 bits × 3 slots = 96 uint16 slots, all written.
  • The latch is pulsed on the LAST shift cycle of every slot and nowhere else — that is what presents the shifted byte on the '595 outputs. A latch that fired early would present a half-shifted byte; one that never fired would leave the strands dark. The latch fires on the FIRST bus word of each slot, never the last — and this is THE bug that broke the first bench run, so it is pinned hard. The i80 WR (pixel clock) is the '595's shift clock, so every bus word clocks one bit in, INCLUDING the word carrying the latch. RCLK is rising-edge triggered, so it must fire when the slot's 8 bits are already all in — which is one word AFTER the last shift word, i.e. word 0 of the NEXT slot. Latching on the LAST word (the intuitive choice, and what this encoder shipped first) asserts RCLK while the 8th bit is still being clocked, so the '595 presents a byte shifted one short: on real hardware only the first LED or two of every strand lit. The old version of this test asserted latch iff LAST cycle — it passed while the panel was broken, which is exactly why the assertion is now written the other way round.
  • A '595 shifts MSB-of-the-register-first: the bit clocked in FIRST ends up on the LAST output (QH). So strand V (output V) must be carried on shift cycle (kPinExpanderOutputs-1-V). Get this backwards and every strand lights its neighbour's data — the failure mode that is nearly impossible to debug on a wired panel, so it is pinned here.
  • The whole point of the fan-out: strands on DIFFERENT physical pins ride the same shift cycle in parallel (different bus bits), while strands on the SAME pin are serialised across cycles. This is why extra strands are free but the x8 is not.
  • A strand whose strip is shorter than the longest must idle LOW for the rest of the frame (the activeMask rule) — it must not flash white. Same contract as the direct encoder, but it has to survive the fan-out: an inactive strand contributes no set bit on ANY cycle.
  • The real-world shape of the rule above, and the one that bites on hardware: two strands on the SAME '595, one longer than the other. Once the short strand is exhausted its activeMask bit clears while its neighbour keeps rendering — so the pin stays busy, and only a PER-CYCLE activity test can keep the exhausted strand dark. (A per-pin "has any live lane" mask drives the pulse-start HIGH on every cycle of that pin, and the short strand flashes white at full brightness for the rest of the frame.)
  • The pulse-start slot clocks in a 1 for every ACTIVE STRAND (so the '595 presents the start of the WS2812 pulse on that output), and the tail slot clocks in zeros. Note this is a per-CYCLE property, not a per-pin one: cycle c carries shift position kSh-1-c, so the bit belongs to exactly one strand of that pin. With all 8 strands of a pin active, the pin is HIGH on all 8 start-slot cycles — clocking in 0xFF, which the '595 presents as all-outputs-HIGH.
  • =========================================================================== THE TEST THAT MATTERS: replay the emitted words through a '595 simulator and check the strand receives a real WS2812 waveform. Every earlier shift test asserted bus-word indices — the encoder's internal layout — and they all passed while the panel showed garbage. This one asserts what the LED actually sees, so it fails when the hardware would. The WS2812 wire contract (ParallelSlots.h): each data bit is 3 slots — all-HIGH pulse start, the data bit, all-LOW tail. So a "1" is HIGH for 2 slots and a "0" for 1 slot.
  • THE RESET. After the last data bit the frame ends with a zeroed latch pad — >=300 us of idle that tells every WS2812 "frame over, latch what you have". The strand MUST be LOW for that whole pad. With a '595 that is not automatic, and this is the bug the first waveform test walked straight past (it breaks before the final bit, with a comment noting the tail "runs off the end of the frame"). The pipeline is one slot deep: the byte clocked during slot N is presented during slot N+1. The LAST clocked slot therefore needs a latch edge AFTER it — and a pad of pure zeros contains no latch bit at all. So the '595 keeps presenting the final DATA byte for the entire pad: final wire byte even (last bit 0) -> strand idles LOW -> resets correctly final wire byte ODD (last bit 1) -> strand idles HIGH -> NEVER resets A strand that never sees the reset appends the next frame's bits to an unlatched stream and garbles — content-dependently, which is the worst kind of bug to chase. Hence: the pad must open with one latch-only word.
  • The prefill/data split must be byte-identical to the whole-slot encoder. Two thirds of the shift encoder's stores write frame-CONSTANTS: the pulse-start word (which strands are active) and the pulse-tail word (all-LOW) are the same for every light in the frame. Only the middle word carries pixel data. So the constants are pre-filled once (cold path) and the per-light encoder writes only the data word — which is what takes the encode from ~9.7 µs/light to ~3 µs on an S3, and it is the difference between 8 fps and 25 fps on a 48-strand panel. That is only sound if prefill + data == the whole-slot encode, byte for byte. A single wrong word here is a corrupted waveform on every strand, so it is pinned exactly.
  • The same, with a SHORT strand — the case the per-cycle active mask exists for. An exhausted strand sharing a '595 with a longer one must stay dark, and the prefill is what encodes that (its pulse-start word omits the dead strand's pin bit on that cycle). If the prefill and the encoder disagreed about which strands are live, the short one would flash white at full brightness.
  • The hot-path sweep — the safety net for the packed-transpose rewrite. encodeWs2812ShiftData keeps the whole transpose in registers: it packs the lane bytes straight into the SWAR word and shifts each bit-plane byte back out, with no staging arrays. The packing depends on the PIN COUNT, and the 16-bit bus adds a second packed word (pins 8-15) — so there are several distinct paths, and a wrong shift or mask in any of them silently corrupts a strand. So sweep every pin count against the whole-slot encoder, on BOTH bus widths, with a dense pattern and exhausted strands mixed in. encodeWs2812ShiftSlots is the reference: the simple, unoptimized form that the '595 simulator already validates end-to-end. (This sweep earned its keep immediately: it caught a real bug in a batched-transpose variant that the rest of the suite passed clean.)

MultiplyModifier

test/unit/light/unit_MultiplyModifier.cpp

  • MultiplyModifier advertises D3 dimensions
  • Defaults (multiply 2/2/1) → a 128×128 physical grid folds to a 64×64 logical box.
  • MultiplyModifier logical size on Z
  • FAN-OUT (fold direction): with the defaults (mult 2, mirror XY), all four physical CORNERS fold onto the single logical pixel (0,0) — the inverse of the old "logical (0,0) → 4 physical corners". This is the kaleidoscope fold made concrete.
  • mirrorX only: two physical columns fold to the same logical column (original + its horizontal reflection). The logical box is 64 wide.
  • All multipliers 1 → identity: the box is unchanged and every coord folds to itself.
  • Tiling WITHOUT mirror repeats (does not reflect): physical x=64 (tile 1) folds to logical x=0, same as physical x=0 — both tiles map identically, no reflection.
  • multiplyZ on a 2D (depth-1) layout is a no-op: the effective multiplier clamps to the axis extent (1), so depth stays 1 and the layer isn't blanked.
  • A multiplier larger than the axis extent clamps to the extent.
  • REGRESSION (🐇): a non-divisible extent leaves a leftover edge strip that the tiles don't cover — those pixels must be DROPPED, not wrapped back into a tile (which would duplicate the edge). 5-wide, multiply 2 → tile width 2, covers pixels 0..3; pixel 4 is the leftover and has no tile.

NetworkModule

test/unit/core/unit_NetworkModule.cpp

  • setWifiCredentials copies SSID + password into internal buffers and raises the dirty flag so the next tick1s() applies them.
  • A nullptr SSID is silently ignored (no copy, no dirty flag) — guards against a bogus caller.
  • A nullptr password is treated as empty (open networks), still copies SSID and marks dirty.
  • An over-length SSID (100 chars) is truncated cleanly into the 33-byte buffer; ASAN catches any overflow.
  • After setup(), NetworkModule exposes a mode read-only control whose value reflects the current state-machine state. On the desktop platform every network init stub returns false, so the cascade lands on Idle.
  • parseDottedQuad (in Control.h) is the validator on every IPv4 write, over both the HTTP API and persistence. Pin the contract.
  • The static-IP fields (ip / gateway / subnet / dns) are bound as IPv4 controls — 4 bytes of storage each, not 16-char dotted-quad strings. They start hidden because addressing defaults to DHCP.
  • In WiFi-capable builds (anything other than --firmware esp32-eth), the rssi and txPower controls are present and start hidden — Idle/Ethernet don't expose live WiFi metrics. The Ethernet-only build compiles them out entirely so the iteration finds nothing, which is still a valid pass shape.
  • Conditional controls: the static-IP fields (ip/gateway/subnet/dns) are visible only when addressing == Static (1), hidden under DHCP (0) — but ALWAYS bound so persistence can load a saved static config regardless of the live mode. This is the documented add-then-setHidden pattern (architecture.md § Conditional controls); the test pins it both ways so a regression (e.g. dropping setHidden, or conditionally NOT adding the field) fails here, not on hardware.

test/unit/core/unit_NetworkModule_ethernet.cpp

  • The enum values are a wire contract: the Select index, the ethInit() switch, and every deviceModels.json ethType all agree on these. Pin them so a reorder fails here.
  • Desktop has no Ethernet: the default PHY type is ethNone, so a board that never pushes an eth config still reports "no Ethernet" and the cascade falls through.
  • Ethernet is OPT-IN: NetworkModule's ethType control defaults to ethNone(0), so a board whose deviceModels.json entry has no ethType brings up NO PHY (a WiFi-only board like Shelly, or the QuinLED Dig-Uno/Quad with optional-only eth, must not waste an RMII/SPI init on a PHY it lacks). A board with real Ethernet sets ethType explicitly in its catalog eth block. On ESP32 the control default seeds ethType_ = 0; the platform's ethConfigDefault (whose phyType is the chip's historical PHY) still seeds the PINS so an opt-in board gets them without re-listing — but the PHY selection is off until the catalog turns it on. ethNone==0 is what makes "unset → off" work.
  • The platform seam must accept any runtime config and never bring Ethernet up on desktop — ethInit() returns false so NetworkModule cascades to WiFi/AP. Pushing a fully-populated W5500 config and an RMII config both leave ethInit() false and ethConnected() false; ethStop() is safe to call when nothing is running.

NetworkReceiveEffect

test/unit/light/unit_NetworkReceiveEffect.cpp Also touches: NetworkSendDriver.

  • A packet built by the sender's builder parses back to the same universe and payload — the two sides can't drift.
  • Bad magic, non-OpDmx opcodes, truncated headers, and lying length fields are all rejected — the receiver drops them.
  • Universe universe_start lands at byte 0; the next universe lands at byte 510 — the same split the sender uses.
  • The layer clears its buffer every tick; staging holds the last frame, so the lights don't strobe black between packets.
  • Universes below universe_start are ignored; universes relative to a non-zero start land at offset 0.
  • A payload overrunning the buffer end is clamped; a universe entirely beyond the buffer is ignored.
  • A 0×0×0 grid accepts packets as a clean no-op — degraded, not crashed.
  • Staging is sized in prepare (off the hot path), tick() never reallocates it, release frees it.
  • A real packet sent over localhost UDP lands in the layer buffer — the end-to-end proof of the platform receive path.

test/unit/light/unit_NetworkReceiveEffect_protocols.cpp Also touches: NetworkSendDriver.

  • A packet built by the sender's builder parses back to the same universe and payload — the two sides can't drift.
  • Truncated headers, a bad ACN identifier, wrong layer vectors, a non-zero start code, and a lying property count are all rejected.
  • A packet built by the sender's builder parses back to the same byte offset and payload.
  • Truncated headers, wrong version bits, and a lying length field are rejected.
  • Each universe-protocol parser refuses the other protocols' datagrams — port mix-ups degrade to silence, not garbage.
  • An ArtPoll datagram is recognised (the discovery hook Resolume/Madrix use); OpDmx and non-ArtNet packets are not polls.
  • The ArtPollReply carries the fields controllers read: opcode, IP, port, names, universe switches, MAC.
  • DDP's byte addressing lands payloads at the exact offset; out-of-range and overflowing offsets are clamped or dropped.
  • channels_per_universe = 512 maps universes at 512-byte strides and clamps a 512-channel payload to its slot.
  • Three senders — one per protocol — hit the same effect on its three ports; each payload lands. The autodetect proof.

NetworkSendDriver

test/unit/light/unit_NetworkSendDriver_no_alloc_in_loop.cpp Also touches: Drivers, Correction.

  • prepare sizes the correction-applied buffer to source-count × out-channels. The size matches what tick() needs on its first send. Calling tick() after prepare must not reallocate — pin the data pointer + shape.
  • A preset toggle from RGB to RGBW grows outChannels from 3 to 4. The grow runs in onCorrectionChanged, off the hot path.
  • A brightness-only change keeps outChannels at 3 — onCorrectionChanged is still called, but the resize short-circuits (existing buffer already fits).

test/unit/light/unit_NetworkSendDriver_packet.cpp

  • The built packet contains the exact header layout the Art-Net spec mandates: ID, OpCode, version, sequence, physical, universe, length, data.
  • Universe 259 (0x0103) is encoded little-endian (low byte first), matching the Art-Net wire format.
  • 256 RGB lights (768 bytes) split across exactly 2 universes (510 + 258), matching the 510-channel-per-universe cap.
  • The data-length field is encoded big-endian (high byte first), unlike the universe field — matching the Art-Net spec.
  • The built E1.31 packet carries the exact ACN layout strict sACN receivers (and tools like xLights) validate: identifier, the three flags+length fields, CID, source name, priority, universe, property count, start code.
  • The built DDP packet carries version+push bits, RGB data type, default destination, and big-endian offset/length.
  • The destination list is EMPTY by default, so an unconfigured driver idles instead of falling back to the 255.255.255.255 broadcast. Broadcast does not scale and its failure lands on the NETWORK, not the device: an ArtNet universe id sits in the payload, so every host on the segment must receive and parse every packet before it can discard it (~4,850 pkt/s on a 128x128 grid — measured starving an ESP32 until its HTTP stopped answering). Art-Net 4 forbids broadcast ArtDmx outright.
  • The multi-destination fan-out: ONE driver feeds N tubes, each with its own IP and its own contiguous run of the window. This is the Art-Net-conformant shape (ArtDmx must be unicast to the node owning each universe), and it costs the same total packets as a single broadcast stream while keeping every packet off the other nodes' NICs.
  • NetworkSendDriver: lightsPerIp follows the ledsPerPin idiom
  • A malformed entry must leave the driver IDLE, not half-configured. parseIpList fills its output as it goes, so a bad address AFTER good ones (a typo in tube 3 of 5) yields a partial list; publishing that would send real packets to a real subset of hosts while the card shows an error. Wrong output is worse than no output — so prepare() parses into locals and publishes only when everything validates.
  • A hand-typed lightsPerIp list will sometimes not match the destination count — the likeliest real typo. It must not silently mis-slice: a SHORT list even-splits the remainder across the rest (the documented ledsPerPin broadcasting rule), and a LONG list simply ignores the extras.

Noise2DEffect

test/unit/light/unit_Noise2DEffect.cpp

  • A single frame on an 8×8 grid fills the buffer with a palette-mapped noise field (non-zero).
  • The field is spatial: distant pixels read different noise samples, so their colours differ.
  • Effects must run at every grid size: a 0×0×0 layer renders without crashing (the cols/rows guard).

NoiseEffect

test/unit/light/unit_NoiseEffect.cpp Also touches: PlasmaEffect, RainbowEffect.

  • One tick on an 8×8 grid leaves at least one non-zero byte (noise paints somewhere).
  • Opposite corners of a 16×16 grid carry different colours (noise is not flat).
  • Noise and Rainbow produce visibly different frames on the same grid (sanity check that they're distinct algorithms).
  • With depth > 1, adjacent and distant z-slices each render differently (3D noise, not a stack of identical 2D slices).
  • Same z-slice variation requirement holds for Plasma — each depth plane renders differently.

NoiseMeterEffect

test/unit/light/unit_NoiseMeterEffect.cpp Also touches: AudioService.

  • With no live audio source the level is 0, so no row lights and the fading buffer settles to black.
  • Fed a loud audio frame the meter fills from the floor upward: a lit row above the floor implies every row below it (down to the floor) is also lit — the column never floats. Extrude also means a lit row is complete across x, so column 0 and the last column of a lit row agree.
  • The width gain control scales level→length: width=0 zeroes the length (tmpSound2 = level20/255), so even a loud audio frame lights no row and the buffer stays dark.
  • The "runs at every grid size" hard rule: 0×0×0 and 1×1 both render with a live audio frame every tick without crashing (the sizeX/sizeY<=0 early-out and the maxLen constrain cover them).

PaintBrushEffect

test/unit/light/unit_PaintBrushEffect.cpp Also touches: AudioService.

  • A live broadband signal draws strokes: at least some lights are lit after a frame.
  • Silence draws nothing: with no active mic the frame is all-zero bands, so every line's length maps to 0 (below the minLength gate) and the buffer stays fully black.
  • The minLength gate suppresses strokes: raised to its maximum, no line is ever long enough to draw, so even a loud broadband frame leaves the buffer black — the gate, not the audio, decides.
  • The "runs at every grid size" hard rule: degenerate grids never crash, with a live frame each tick.

Palette

test/unit/light/unit_Palette.cpp

  • Palette: gradient endpoints land on the first/last stop colours
  • Palette: a mid-gradient sample interpolates between stops
  • Palette: colorFromPalette index 0 reads entry 0; brightness scales
  • Palette: the index wraps at 255→0 (no out-of-range read)
  • Palette: a degenerate (empty) gradient is all black, never out-of-bounds
  • Palettes::active swaps the global palette on setActive
  • The HomeKit-colour-wheel → palette mapping (MQTT/Homebridge). Each palette's representative (hue, sat) is computed from its expanded entries; nearestForHue picks the closest. A vivid hue snaps to that hue's palette family; a low-saturation target snaps to the desaturated Rainbow.
  • Regression (reviewer #4): a hue >= 360 (a broker client can send "400,…" on hsv/set) must not overflow the int32 squared-distance math — nearestForHue folds any input into 0..359 up front. 400 % 360 == 40 (orange), 720 % 360 == 0 (red), so both resolve to a valid, sensible index.

ParallelLedDriver

test/unit/light/unit_ParallelLedDriver_doublebuffer.cpp Also touches: MultiPinLedDriver, ParlioLedDriver.

  • Double-buffer mode: the encode target alternates 0,1,0,1,… and a buffer's wait fires only right BEFORE that buffer is reused — never after every transmit. So the first two ticks transmit without a preceding wait (both buffers start idle), and from tick 3 on each tick waits on the buffer it's about to reuse.
  • Single-buffer mode (no second buffer): the driver stays on buffer 0 and waits on it EVERY frame before re-encoding — the old synchronous wait-after-transmit path, so a memory-tight board keeps its old fps rather than failing to init.
  • doubleBuffer is the on/off knob AND drives allocation: OFF (default) allocates ONE buffer and runs the synchronous path; ON requests a second buffer and alternates. Flipping it rebuilds the bus (affectsPrepare) so the second buffer is freed (→off) or allocated (→on) — a board that leaves it off never holds the second buffer. This mirrors the live toggle (the A/B knob), which routes through applyState()/prepare() the same way.
  • A board that WANTS async but can't fit the second buffer (memory-tight) degrades to single-buffer synchronous — never fails to init. doubleBuffer is on, but the mock refuses the second buffer.
  • The frameTime KPI: tick1s() pulls the platform's measured wire time via busLastTransmitUs(). The string formatting + the actual DMA timing are verified on hardware (the metric's whole point is a real wire measurement); here we just pin that tick1s reads the seam without crashing pre-first-frame.
  • Robustness + no-caps (regression for a live bootloop): a correction can carry ANY channel count (RGB=3, RGBW=4, RGBCCT=5, an N-channel fixture). The per-row encode scratch (wire_) is heap-sized to kMaxLanes × outChannels off the hot path, so a >4-channel correction lays out without overrun — the old fixed-4-byte-stride array overflowed and corrupted memory (the SE16 bootloop, 2026-07-13). The driver must DRIVE it (size the frame + encode), not idle and not crash.
  • A reinit (grid resize / pin edit) must drain BOTH buffers' in-flight transfers before freeing them — a live DMA reading a buffer about to be freed is a use-after-free. After two ticks both buffers are in flight; the resize's reinit waits on both before rebuilding. (async on → two buffers.)
  • A wait that TIMES OUT means the DMA may still be reading that buffer. Encoding into it anyway would hand a live transfer a half-rewritten frame — the exact corruption the timeout exists to prevent. (The seam used to return void, so the driver couldn't tell a completion from a timeout and cleared inFlight_ either way; 🐇 CodeRabbit caught it.) The contract now: on timeout the buffer STAYS in-flight, the frame is skipped, and the driver re-waits next tick — self-healing, never corrupting.
  • After ENOUGH consecutive dead frames the driver GIVES UP (stops spending the render thread on a bus that won't deliver — a misconfigured bus must not starve the network). But give-up is not permanent: a TRANSIENT stall (the streaming ring's refill missing one deadline under a burst of HTTP load) must self-recover WITHOUT a reinit, or a momentary hiccup leaves the LEDs dark until the user touches a control. So once given up, the driver periodically lets one frame through; if the bus is alive again, output resumes on its own. This pins that retry-recovery.

ParlioLedDriver

test/unit/light/unit_ParlioLedDriver.cpp Also touches: Drivers, Correction.

  • Three lanes (Parlio accepts any 1..8 count) slice the buffer consecutively; the frame is sized by the LONGEST lane.
  • Empty ledsPerPin (the default) splits evenly over the 8 lanes — shared PinList semantics, same as the RMT/LCD drivers.
  • The Parlio-vs-LCD difference: 1..8 pins are ALL valid (no exactly-8 rule).
  • 9..16 pins are accepted (Parlio drives 1..16, the 16-bit bus); more than 16 is rejected.
  • A 16-lane (>8) config uses the 16-bit bus, so each slot is 2 bytes: the frame is DOUBLE the byte size of the same per-lane lights at ≤8 lanes. Pins the slotBytes threading through frameBytesFor (including the doubled latch pad).
  • An RGB→RGBW preset toggle grows the frame (32 vs 24 slot bytes per light).
  • Parlio single-transfer hardware ceiling (PARLIO_LL_TX_MAX_BITS_PER_FRAME = 0x7FFFF bits = 65535 bytes on P4/S3/most targets): the peripheral clocks the WHOLE frame out in one transaction, so a per-lane strand whose frameBytes exceeds this is rejected by parlioWs2812Init (fixed — was a silent tx failure). The ceiling is a byte limit (65535 bytes/lane), so the equivalent LIGHT count depends on channels-per-light: WS2812 encodes 24 slot-bytes per channel, plus a ~864-byte per-lane latch pad. So the max lights/lane is ~ (65535 − 864) / (channels × 24): 897 for RGB (3ch), ~673 for RGBW (4ch), ~538 for RGBCCT (5ch) — wider fixtures fit fewer lights per one-shot transfer. This pins the boundary in host-visible frameBytes terms for the RGB and RGBW cases. The reject itself is hardware-only (busInit is a desktop no-op), verified on the P4 (LEDs burn at 8×896 RGB/lane; the driver reports a status error above the ceiling). Catches the ceiling shifting if the encoding changes. Mirrors the platform constant.
  • A bad pin list idles the driver with the parse literal in the status; fixing it recovers.
  • Pins now default UNSET (the "default only when it cannot do harm" rule — the strand is user-soldered). A fresh, unconfigured driver idles, never grabbing a GPIO. (wire() back-fills empty pins for the slicing cases, so this one wires the buffer directly to keep pins empty.)
  • A 0×0×0 grid is a clean idle: zero counts, zero frame, no crash.
  • tick() is crash-safe across single-pin / multi-pin / pre-init configs (the transmit path is gated out on the host; this pins the reachable contract).
  • setup/release cycles leave no residue (status clean, ASAN-checked heap).
  • loopbackRxPin is bound always, visible only while loopbackTest is on.
  • loopbackTxPin (optional lane-0 TX override) is bound always, hidden until the test is on — same conditional-control contract as loopbackRxPin. The override's lane-0 substitution is hardware-only (parlioLanes==0 on desktop); the visibility contract is host-testable here via the shared helper (toggles loopbackTest both ways and asserts the control stays bound while flipping visibility).

ParticlesEffect

test/unit/light/unit_ParticlesEffect.cpp

  • The trail buffer sizes to width × height × 3 bytes (one RGB per cell, used to fade existing pixels).
  • A single tick is enough to paint particles into the buffer.
  • Disabling the effect releases the trail buffer (dynamicBytes returns to 0).

PinsModule

test/unit/core/unit_PinsModule.cpp

  • PinsModule: exposes a single read-only pins list, fixed System module (Generic role)
  • PinsModule: collects set Pin controls with name-derived roles, skips -1
  • PinsModule: parses the LED-driver pins CSV into per-lane claims
  • PinsModule: rows are GPIO-ordered and a double-claim stays visible in the detail
  • PinsModule: a conflict promotes a strap warn to error (severity is the max)
  • PinsModule: a disabled module's pins are released from the map, re-claimed on enable
  • PinsModule: a child module's pins are walked (depth-first), not just the roots
  • PinsModule: disabling a PARENT frees its children's pins (effectivelyEnabled cascade)
  • PinsModule: a claim survives its owning module being destroyed (no use-after-free)
  • PinsModule: a CSV with an out-of-range pin claims nothing, and never a false GPIO
  • PinsModule: a claim on a reserved pin is flagged severity error
  • PinsModule: a driven role on a strap pin is flagged severity warn
  • PinsModule: an input role on an input-only pin is NOT flagged
  • PinsModule: a driven role on an input-only pin IS flagged warn
  • PinsModule: a safe pin carries no severity field
  • PinsModule: a claimed pin with live state emits level + drive columns
  • PinsModule: a pin with no live state (valid=false) omits the live columns
  • PinsModule: dir column reflects the live pad direction (out/in/both/off)
  • PinsModule: dir is shown as info, NOT a warning — a driven role with output off is unflagged

PinwheelModifier

test/unit/light/unit_PinwheelModifier.cpp

  • PinwheelModifier 2D reshape puts petals on X, radius on Y
  • PinwheelModifier 1D reshape puts petals on Y (1 x petals x 1)
  • The regression: on a 1D layer, sweeping the source x must map to MORE THAN ONE petal cell, and each mapped coordinate must land within the reshaped {1, petals, 1} box (x == 0, 0 <= y < petals). Before the fix the petal index went to pos.x, so every cell mapped to x == value >= 1 (out of the 1-wide logical box) except petal 0 — the pinwheel collapsed to a single petal.

PlasmaEffect

test/unit/light/unit_PlasmaEffect.cpp Also touches: NoiseEffect.

  • One tick on an 8×8 grid produces at least one non-zero byte.
  • Opposite corners of a 16×16 grid differ in colour (the plasma is not flat-filling).
  • Plasma and Noise produce visibly different frames on the same grid (sanity check that they're distinct algorithms).

PraxisEffect

test/unit/light/unit_PraxisEffect.cpp

  • Praxis overwrites EVERY pixel each frame (a full-grid palette field, no black background) — with a non-black palette active, no light is left at (0,0,0).
  • The hue is a function of (x, y): pixels far apart in the grid carry different colours, so the field is spatial, not a uniform fill.
  • Hard rule: the effect runs at a degenerate grid without crashing. width/height <= 0 is guarded, and a 1×1 grid exercises the render loop at its smallest.

PreviewDriver

test/unit/light/unit_PreviewDriver.cpp

  • A sphere sends its SHELL lights (210), not the dense 9x9x9 box (729).
  • Per-frame 0x02 RGB count matches the coordinate-table count.
  • A small grid sends every light at its grid position (stride 1, exact).
  • A large layout is SPATIALLY downsampled (a regular per-axis lattice, not every-Nth-flat- index) so the payload fits the send-buffer cap without the diagonal moiré that linear stride produced on a grid whose width didn't divide the stride. The wire "stride" field carries the per-axis lattice/downscale factor (colour k still maps 1:1 to coord k).
  • A SPARSE layout under the cap must NOT be downsampled for its big BOUNDING BOX alone: the lattice bound is the layout's LIGHT count, not its box cell count, so a sphere whose shell fits the cap sends every light at stride 1 (a radius-8 sphere → ~812 shell lights, well under the 4096 display cap, in a 17³≈4913-cell box). (A genuinely huge sparse layout above the cap downsamples like any other — the cap is about points streamed, not box size.)
  • Default fps is the rate-limited preview stream rate.
  • Regression: a coordinate table dropped under backpressure must be RETRIED, and colour frames withheld until it lands — otherwise the device sends 0x02 frames the browser skips (count mismatch) and the preview freezes for the whole session. Drives tick() (where the coord-pending logic lives) with a broadcaster that drops every 0x03, then lets it through.
  • Regression: deleting the active Layer must not leave a driver holding a dangling layer_ pointer. Previously Drivers::passBufferToDrivers early-returned when the active Layer was null, leaving PreviewDriver's layer_ pointing at the freed Layer; the next prepare read layer_->layouts() on freed memory and crashed the device (LoadProhibited → boot loop, since the broken tree persists). Now passBufferToDrivers clears the drivers' layer_/sourceBuffer_ to null, a safe idle state. This drives the real path: Drivers bound to a Layers CONTAINER (self-healing), the Layer removed, then prepareTree re-resolves activeLayer()=null.
  • Coordinates are sent ONLY when the geometry changes or a new client connects — never per-frame and never on a timer (a periodic full-table rebuild would starve the tick). A new client (clientGeneration bump) re-sends immediately so a page refresh shows the preview at once. Driven through tick() with a frozen clock for determinism.
  • A full-res RGB frame is sent through the RESUMABLE buffered path (sendBufferedFrame), whose body is the DRIVER (consumer) buffer itself — no copy. For a dense identity grid that's the Layer's dense box buffer; for a sparse/mapped layout it's the LUT-mapped output buffer (the real lights), the same buffer the LED drivers consume — NOT the dense box.
  • Sparse layout: the buffered send streams the LUT-mapped DRIVER buffer (only the real lights, in driver order), exactly like the LED drivers — NOT the dense bounding box. So coordCount == the shell count and the frame is sent whole at full res through the resumable path.
  • The per-module memory readout (dynamicBytes) must ACCOUNT the resumable-path buffers — the staging buffer and the kept-index cache — not read 0 while ~24 KB is allocated (the bug: raw platform::alloc buffers bypass ScratchBuffer's auto-accounting, so they were invisible). With resumableFrames ON and a downsampled layout, dynamicBytes is non-zero and covers both buffers; OFF frees them and it drops to 0. SKIPPED (doctest::skip): this case was written when resumableFrames defaulted ON — its ON assertions relied on the rig constructor's applyState() allocating the staging buffer via that default. resumableFrames now defaults OFF (the synchronous transport is the shipped default; the resumable path tears the preview), and toggling the flag ON post-construction + prepare() does NOT re-allocate the buffers in this rig the way the constructor path did, so the ON reads drop to 0. The dynamicBytes accounting itself (driverHeapBytes sums stageCap_ + keptIdxCap_) is unchanged and correct — only this test's default assumption broke. The un-skip is backlogged (docs/backlog/backlog-light.md § "PreviewDriver resumableFrames default OFF"): wire the flag ON into the rig BEFORE its first applyState so the acquire path runs. Original body is in git history.
  • Dense-grid CLOSED-FORM downsample, exact colour placement: a 200×1 strip pinned over a small cap strides in x only, so the kept lights are columns 0,s,2s,… The colour pass must read each from its dense buffer index (closed-form x for a 1-row grid) and pack them in the SAME order as the coord table — no forEachCoord. Painting a known colour at a kept column and finding it at the matching frame position pins the index math + the lattice order.
  • ADAPTIVE FRAME RATE: while a buffered send is still draining (a slow link), tick() must NOT start a new frame — it waits for bufferedSendIdle(). So the effective rate self-limits to the link.
  • USE-AFTER-FREE GUARD: a geometry rebuild (resize) frees+reallocs the producer buffer, so any in-flight buffered send (which holds a pointer into it) MUST be cancelled in prepare before the buffer goes away — else drainPreviewSend would read freed memory.

RainbowEffect

test/unit/light/unit_RainbowEffect.cpp

  • A single frame on a 4×4 grid leaves the buffer non-zero (rainbow always paints somewhere).
  • Pixel (0,0) carries a lit palette colour — confirms the effect writes a real RGB there.
  • Distant pixels carry different hues (the rainbow gradient is spatial, not uniform).

RandomEffect

test/unit/light/unit_RandomEffect.cpp

  • A single frame on a fresh black buffer lights exactly ONE light (one setRGB per frame).
  • Over many frames with light fade the sparkle field fills — more than one light ends up lit.
  • The effect runs at degenerate grid sizes without crashing (Effects-must-run-at-every-grid-size).

RandomMapModifier

test/unit/light/unit_RandomMapModifier.cpp Also touches: Layer.

  • A remap leaves the logical box unchanged.
  • The core property: a true bijection over [0, whd) — every destination index appears exactly once (no gaps, no duplicates).
  • Deterministic seed → reproducible permutation (what makes it testable).
  • Reshuffling (a beat) changes the mapping, still a bijection.
  • Robustness: an empty (0×0×0) box must not crash — it folds to a no-op.
  • A resize (different box count) rebuilds the permutation to the new size.
  • RandomMapModifier tick() reshuffles on a beat (bpm 60 ≈ 1/s)
  • RandomMapModifier tick() with bpm 0 never reshuffles (frozen)

RegionModifier

test/unit/light/unit_RegionModifier.cpp

  • Default region (0/100 on every axis) is the full box: identity size, no rejection.
  • Half of an axis, half-open: end=50 on 128 → region width 64, not 65.
  • Two abutting regions tile a 128-wide axis with no overlap and no gap.
  • A physical coord inside the region folds to region-local (subtract the start pixel); a coord outside is rejected.
  • Rounding rule on a small panel: start floors, end ceils to an exclusive pixel. start 33 / end 66 on a 4-wide axis → floor(1.32)=1 .. ceil(2.64)=3 → pixels 1,2.
  • A region that rounds to nothing still gets a 1-pixel floor.
  • OFF-SCREEN: a window slid half off the left edge keeps its FULL size (the effect renders at a fixed scale); only the visible half maps to physical lights. startX=-50 on 64 → window [−32, 32), span 64. Physical x 0..31 land at window-local 32..63 (the right half of the window — the visible part); the left half of the window (0..31) has no physical light, so it's dark. The effect isn't rescaled.
  • A window entirely off the box maps NO lights — the layer goes dark on that axis, which is how an effect is moved completely out of view. The box still has a valid size (the effect renders), nothing just reaches the screen.
  • A window stretched WIDER than the box (start<0 and end>100) renders the full span; the box shows the middle slice. startX=-50,endX=150 on 64 → window [−32, 96), span 128.
  • Degenerate axes don't crash: a 1-wide axis stays 1, a 0-extent axis yields 0.

RippleXZModifier

test/unit/light/unit_RippleXZModifier.cpp

  • Default collapses X only: size.x becomes 1, Y and Z keep their extent.
  • towardsZ collapses Z instead; both flags collapse X and Z, leaving Y as the only axis.
  • shrink=false is the identity: no axis collapses, no coordinate folds.
  • Degenerate boxes don't crash: a 0x0x0 box collapses its X to 1, and folding at the origin still accepts and folds x to 0.

RmtLedDriver

test/unit/light/unit_RmtLedDriver_lifecycle.cpp Also touches: Drivers, Correction.

  • RmtLedDriver sizes the symbol buffer in prepare
  • RmtLedDriver keeps the symbol buffer across a rebuild (reinit must not free it)
  • RmtLedDriver keeps the symbol buffer across a pins change
  • RmtLedDriver grows the symbol buffer when the grid grows
  • RmtLedDriver releases the symbol buffer on release
  • RmtLedDriver: disabling releases the resource, re-enabling re-acquires (applyState)
  • RmtLedDriver: a DISABLED driver does not acquire through the boot sweep
  • MoonModule contract: release reverses setup, so setup→release→setup→release cycles leave no residue — no leaked heap (ASAN in the test runner catches that), no stuck state. After each release the driver must look untouched: no symbol buffer, no status. Run several cycles to surface any accumulation.
  • Conditional control: loopbackRxPin is visible only while loopbackTest is on, hidden otherwise — but always bound (so a saved rxPin loads regardless). Same add-then-setHidden pattern as NetworkModule (architecture.md § Conditional controls). This pins the exact behavior that, with the old UI, showed the pin at the wrong times; a regression in the C++ flag now fails here.
  • loopbackTxPin is the optional TX override (transmit on it instead of pins[0] during the self-test). Like loopbackRxPin it's a conditional control: always bound (so a saved override loads), shown only while loopbackTest is on. The override's effect on the transmitted pin is hardware-only (rmtTxChannels==0 on desktop), but the conditional-visibility contract is host-testable here.
  • Editing pins while the loopback test is ON must refresh the parsed config before the self-test runs — onControlChanged fires before the prepareTree sweep re-parses, so without the in-branch parseConfig() the test would transmit on the OLD pin and show a verdict for it. Mirrors the fix in ParallelLedDriver; this pins the RMT sibling that the dedup left behind. Host-observable via pinCount(): the refresh re-parses to the new pin set even though the platform loopback itself is inert.

test/unit/light/unit_RmtLedDriver_pins.cpp Also touches: Drivers, Correction.

  • "18,17,16" parses to three pins in list order — the order defines the buffer slices.
  • A single pin (the default "18") and spaces around tokens are both fine.
  • A "lo-hi" token expands to the inclusive range — the same idiom as the IP-destination list, so a human types consecutive pins once. Single pins and ranges mix freely in one CSV.
  • A range that runs backwards, or overlaps an existing pin, or overruns the cap, is rejected — the same guards a flat list gets, applied to each expanded pin.
  • parsePinList rejects bad input with a static error message
  • maxPins is the chip's RMT TX-channel cap: 5 pins fail an S3-sized 4, fit a classic 8.
  • The same GPIO twice would double-drive one strand — rejected at parse time.
  • The crash guard (WROVER bench 2026-07-13): a value like 999 parses as a valid integer but is not a GPIO — handing it to IDF's gpio_func_sel() faults ("GPIO number error" → reset). parsePinList rejects the WHOLE list when any entry exceeds the chip's MM_MAX_GPIO ceiling, so a garbage pin never reaches hardware; the driver idles with "pin out of range for this chip" in its status. On the host, MM_MAX_GPIO defaults to 63, so 999 (and 64) are out of range, 63 is the last accepted pin.
  • Explicit "100,100,50" maps one count to each pin by position.
  • A SINGLE number broadcasts: that many on EVERY pin (the NumPy/CSS scalar idiom — "one number = that many each"), NOT "on the first pin only, split the rest".
  • Broadcast is clamped so the running sum never reads past the buffer: with 250 lights and 100/pin, pins 0+1 take 100 each, pin 2 gets the last 50.
  • A LIST shorter than the pin count still maps what it names, then even-splits the rest over the unlisted pins (distinguishes list-of-one-plus-comma from broadcast).
  • assignCounts with an empty list splits evenly, last pin takes the rounding remainder
  • assignCounts clamps so the sum never exceeds the buffer
  • assignCounts clamps a pin to the WS2812 ceiling and warns (drives 2048, not zero)
  • assignCounts handles a zero-light buffer (0×0×0 grid) as all-zero
  • assignCounts rejects a bad token
  • assignCounts ignores extra counts beyond the pin list
  • RmtLedDriver slices the buffer across pins (even split)
  • RmtLedDriver slices the buffer per ledsPerPin
  • RmtLedDriver idles with a status error on a bad pin list
  • RmtLedDriver with the empty default pins idles cleanly (no pin assumed)
  • RmtLedDriver re-slices when the source buffer changes
  • RmtLedDriver window: ledsPerPin distributes over the window, not the whole buffer
  • RmtLedDriver window: count 0 means the rest of the buffer from start
  • The DEFAULT window (count_ = kWindowAll = 65535) must mean "all lights", even on a buffer LARGER than 65535. nrOfLightsType is uint32 on a PSRAM board (the desktop test target), so a big grid can exceed 65535 — treating the default as a literal 65535 count would silently cap output there. Tested on the window slice directly (a driver's per-pin WS2812 cap would otherwise mask it).
  • RmtLedDriver window: a size-1 window at 0 is the onboard-LED case
  • RmtLedDriver window: a start past the buffer end yields an empty slice
  • tick() is a safe no-op across single-pin, multi-pin and zero-grid configs.

test/unit/light/unit_RmtLedEncoder.cpp Also touches: Correction.

  • encoder: one byte, MSB-first, 0 and 1 bits get the right pulse widths
  • encoder: one light's channels emit channels*8 symbols in byte order
  • encoder: GRB ordering comes from Correction, encoder is order-agnostic
  • encoder: RGBW preset yields 32 symbols per light

RotateModifier

test/unit/light/unit_RotateModifier.cpp

  • RotateModifier advertises a live (per-frame) modifier
  • At the initial angle (0) the rotation matrix is the identity — every cell samples itself.
  • z passes through (2D rotation) — a 3D coord's z is untouched.
  • An empty box doesn't divide-by-zero or wrap: the remap is a no-op-ish transform that the Layer's live pass then treats as out-of-box (dark), never a crash.

RubiksCubeEffect

test/unit/light/unit_RubiksCubeEffect.cpp

  • The first frame scrambles a fresh cube and projects it onto the volume: with millis() past t=0 the init() path fires (doInit_ is set at construction), so the buffer holds a drawn cube, not black.
  • Every lit voxel carries exactly one of the six Rubik's face colours — the projection only ever writes COLOR_MAP entries, never a blended or arbitrary RGB.
  • turnsPerSecond=0 disables the turn pacing (tick() returns before rotating), but the cube is still drawn on the first frame — init() runs and paints before the turn gate is reached.
  • The effect runs at a degenerate grid size without crashing (the "every grid size" hard rule): tick() bails on a zero extent and the buffer stays empty.

Scheduler

test/unit/core/unit_Scheduler_unique_names.cpp

  • A name with no collision is returned unchanged.
  • The second module with a duplicate name gets " 2" suffixed; the first keeps its original name.
  • Suffix counting increments past existing "-2" / "-3" suffixes ("Layer", "Layer-2", "Layer" → "Layer-3").
  • deduplicateNamesInTree() walks the entire module tree in one pass and disambiguates every duplicate (used after persistence load).
  • firstByName(name) returns the first match in DFS order, or nullptr if no module carries that name.
  • If the disambiguating suffix would overflow the 16-byte name buffer, ensureUniqueName refuses to truncate and keeps the colliding name (sharp edge, documented).

ScratchBuffer

test/unit/core/unit_ScratchBuffer.cpp

  • resize(N) allocates N elements, count()/bytes() reflect it, data() is non-null, and the owner's dynamicBytes tracks the buffer's byte count.
  • (re)alloc zero-fills the buffer.
  • resize(0) frees the buffer and returns the owner's dynamicBytes to zero.
  • A shrink adjusts the owner's total by the signed delta, not by an absolute set.
  • sizeof(T) drives the byte math for non-uint8 element types.
  • release() on the owner frees every registered buffer (the disable-without-destroy path applyState() takes) — the buffer is emptied and the owner's total returns to zero.
  • Multiple buffers on one module each report independently; the owner's total is their sum, and release() frees them all (the StarSky/GameOfLife multi-buffer case).
  • A buffer's destructor frees its heap and deregisters from the still-alive owner — no leak, no dangling list node. (ASAN in the test build is the real guard; this pins the accounting.)

Services

test/unit/core/unit_Services.cpp

  • Services accepts service-role children; System accepts none
  • Services is a thin grouping node — a service child attaches and ticks

SineEffect

test/unit/light/unit_SineEffect.cpp

  • SineEffect writes non-zero RGB data
  • SineEffect amplitude 0 yields a black buffer
  • SineEffect varies across the x axis (R channel follows x)
  • SineEffect survives a 0x0x0 grid

SolidEffect

test/unit/light/unit_SolidEffect.cpp

  • Mode 0 (RGB(W)) fills the whole buffer with one uniform colour: every light equals red/green/blue.
  • Brightness scales the flat colour down per channel (channel * brightness / 255).
  • On an RGBW layer mode 0 writes the white channel too (white scaled by brightness).
  • The effect runs at a degenerate 0×0×0 grid and at every colour mode without crashing.

Sort

test/unit/core/unit_Sort.cpp

  • insertionSort orders ints ascending
  • insertionSort with a custom (descending) comparator
  • insertionSort orders C-strings (the device-name use case)
  • insertionSort is stable — equal keys keep input order
  • insertionSort handles empty and single-element arrays

SphereLayout

test/unit/light/unit_SphereLayout.cpp

  • lightCount() must equal the number of points forEachCoord emits — they share one shell predicate, so allocation and fill can never disagree.
  • The sphere is HOLLOW: the centre lattice point (r,r,r) is never emitted, and neither is any interior point (distance < radius-0.5 from centre).
  • radius = 1 is the smallest hollow sphere: the 6 axis neighbours (d^2=1) plus the 12 edge points (d^2=2) of the centre — 18 lights, no centre.
  • The shell is symmetric about the centre: for every emitted point its mirror through the centre is also emitted (a sphere has no preferred direction).
  • Physical indices are sequential 0..N-1 over the emitted shell points (no gaps from the unindexed lattice voids), so the buffer maps 1:1 to emitted lights.
  • Default radius is a sensible small sphere (not 0, not huge).

SphereMoveEffect

test/unit/light/unit_SphereMoveEffect.cpp

  • The effect fully clears the buffer each frame, so a thin shell leaves the vast majority of a large volume black (it is a hollow surface, not a solid fill).
  • Every voxel the effect lights is a real palette colour (non-black) — the shell is drawn, not left as leftover noise.
  • The effect is 3D-native: it declares D3 dimensions.
  • Hard rule: the effect must run at any grid size without crashing, including a 0×0×0 volume and a 1×1×1 volume (the loop guards w/h/d <= 0 and clamps speed so 100-speed is never zero).

SpiralEffect

test/unit/light/unit_effects_render.cpp Also touches: RingsEffect, RipplesEffect, LavaLampEffect.

  • LavaLampEffect has localised blob features that can land on identical corner palette indices at some t values (corner-pair check is too strict). Scan the whole buffer for any two distinct pixels instead — same approach as RingsEffect below. LavaLamp paints at least one non-zero byte (effect actually renders).
  • Across 10 frames at bpm=60, at least one frame shows two distinct colours somewhere in the buffer (blobs move and the field varies).
  • RingsEffect has localised features (thin rings); corner-pair check is too strict, so we scan for any two distinct pixels instead. Rings paints at least one non-zero byte (effect actually renders).
  • At least two distinct pixels exist somewhere in the buffer (rings are localised, so corner-pair would be too strict).
  • RipplesEffect (MoonLight sine-wave water surface) lights one pixel per column at a sine-driven height. On a flat 2D layer it still paints a visible wavefront — assert it renders something and varies across the surface.
  • Ripples lights one pixel per column at a sine-driven height, so the surface holds at least two distinct colours (wavefront vs background) — scan the whole buffer, corner-pair would be too strict.

StarFieldEffect

test/unit/light/unit_StarFieldEffect.cpp

  • A frame past the speed throttle interval lights at least one star (greyscale, so every lit pixel is a pure grey R==G==B) — the field advances and re-projects stars onto the panel.
  • speed=0 pauses the field: the buffer stays fully black no matter how much virtual time passes.
  • The palette variant lights on-panel stars in colour (not forced grey) — usePalette drives hue.
  • Hard rule: the effect runs at a degenerate 0×0×0 grid without crashing (it allocates nothing and the loop bails on the zero dimensions).

StarSkyEffect

test/unit/light/unit_StarSkyEffect.cpp

  • A field of stars lights at least some pixels on a populated 3D grid after a frame.
  • White stars (usePalette=false) paint only greyscale: every lit pixel has R==G==B.
  • A zero fill ratio still seeds a pool (nb_stars = ratio*count/10000 + 1) so a lit pixel appears.
  • The effect survives degenerate grids (0x0x0 and 1x1x1) without crashing — the every-grid-size rule.

SystemModule

test/unit/core/unit_SystemModule.cpp

  • On the desktop platform (MAC DE:AD:BE:EF:CA:FE), the auto-generated device name is "MM-CAFE" (last two MAC bytes).
  • deviceName is bound as a Text control to the MAC-derived default ("MM-CAFE" on the desktop platform).
  • deviceName is the single network identity, so SystemModule keeps it a valid hostname. A live edit to an invalid value ("My Room!") is coerced on the next tick1s tick (mm::sanitizeHostname), the same path mDNS/AP/DHCP read — so they never see spaces.
  • An all-invalid name collapses to empty after sanitising; the MAC fallback then fills it, so deviceName is never empty (mDNS/AP/DHCP always have a name to register).
  • An already-valid name is left untouched (idempotent) — a normal user name survives.
  • The bootReason control is populated from platform::resetReason; on desktop it reports "OK".
  • System is fixed infrastructure — it accepts no user-added children (they live under the Services container). Its own children (Tasks, I2cScan) are wired by code.
  • Regression: SystemModule overrides setup() and tick1s(); both must chain to MoonModule's base so a wired-by-code child's setup()/tick1s() actually fire. Without the chain a fixed child (Tasks/I2cScan) would never init or poll (the "children miss callbacks" trap from history/decisions.md). tick20ms() isn't overridden, so the base default already propagates it.
  • roleName maps the Service enum to its lowercase API string.

test/unit/core/unit_sanitizeHostname.cpp Also touches: NetworkModule.

  • sanitizeHostname leaves a valid hostname unchanged (idempotent)
  • sanitizeHostname replaces spaces with a single dash
  • sanitizeHostname strips punctuation and other invalid chars
  • sanitizeHostname trims leading and trailing dashes / invalid runs
  • sanitizeHostname yields empty for all-invalid input (caller falls back)

TasksModule

test/unit/core/unit_TasksModule.cpp

  • TasksModule: exposes a tasks list + core0/core1, no separate modules list
  • TasksModule: a fixed System module (Generic role, no delete affordance)
  • TasksModule: the empty desktop snapshot is safe (no RTOS on host)
  • TasksModule: the tasks list renders the injected RTOS tasks with their fields
  • TasksModule: the render task's detail nests the modules + the ∑/tick cross-check

TetrixEffect

test/unit/light/unit_TetrixEffect.cpp

  • During the initial 2 s start delay every column is idle-waiting, so the very first frame renders nothing: the buffer is entirely black even though the effect is enabled and built.
  • Once virtual time advances past the start delay, columns spawn bricks that fall and render: after a span of frames at least one light is lit, and every lit light carries a real (non-black) RGB colour pulled from the palette rather than partial/garbage channels.
  • Effects must run at every grid size: a degenerate 0×0×0 grid and a 1×1 grid both survive a build + several frames across advancing time without crashing (no allocation, no out-of-range write).

TextEffect

test/unit/light/unit_TextEffect.cpp

  • Static text renders glyph pixels top-left. On a grid tall/wide enough for one line of the 6x8 font, a non-empty string lights some pixels; an empty string lights none.
  • The hue default is 128 (mid-palette), not 0: palette index 0 is BLACK in several palettes, so a hue-0 default would render invisible text on those. Pinning the default guards against a silent regression back to 0.
  • A multi-line string wraps: the second line renders on a lower row (font-height down), so a two-line string lights pixels below the first font's height. Uses the 4x6 font (height 6).
  • Scroll mode advances the text over time and never crashes; on a degenerate grid it's a safe no-op.

TransposeModifier

test/unit/light/unit_TransposeModifier.cpp

  • Default (XY on): x and y swap on both the box and the coordinate; z is untouched.
  • XZ swaps x and z; YZ swaps y and z. Only the selected pair moves.
  • inverse flips an axis back-to-front within the TRANSPOSED box: x -> size.x-1-x. With the default XY swap, inverse X flips the (post-swap) x axis, whose span is the original box height. On a {128,64,z} box the transposed x span is 64.
  • Degenerate boxes don't crash and the swap still applies to whatever extent exists.

Uncategorized

test/unit/light/unit_Layer_persistence.cpp

  • Layer: buffer persists across frames (no per-frame clear)
  • Layer: fadeToBlackBy decays the persisted buffer once per frame
  • Layer: multiple fade requests combine with MIN (gentlest wins, longest trail)
  • Layer: collected fade resets after it is consumed
  • Layer: prepare clears the buffer (a rebuild wipes stale pixels)

WaveEffect

test/unit/light/unit_WaveEffect.cpp

  • WaveEffect: sawtooth ramps 0→top across the phase
  • WaveEffect: triangle peaks in the middle and returns
  • WaveEffect: sine sits mid at the zero crossings
  • WaveEffect: square is low then high
  • WaveEffect: every type stays within the grid bounds
  • WaveEffect: a zero-height grid never reads out of bounds

WheelLayout

test/unit/light/unit_WheelLayout.cpp

  • WheelLayout lightCount = spokes * ledsPerSpoke and matches the iterator
  • WheelLayout indices are dense [0, count)
  • WheelLayout coordinates are non-negative (centre-shifted into address space)
  • WheelLayout different spoke counts give different layouts

WledAudioSyncPacket

test/unit/light/unit_WledAudioSyncPacket.cpp

  • build produces a 44-byte v2 packet with the exact WLED layout
  • build -> parse round-trips every AudioFrame field
  • parse rejects wrong length, wrong header, v1, and null
  • parse clamps NaN / out-of-range floats instead of undefined casts
  • golden vector — the exact bytes on the wire (the compatibility contract)

WledPacket

test/unit/core/unit_WledPacket.cpp

  • WledPacket::build produces a valid WLED header (token/id/size)
  • WledPacket::readName round-trips the device name
  • WledPacket marker is set only when stamped, and stays WLED-valid
  • WledPacket::isValid rejects short / wrong-magic / null input
  • WledPacket::readName truncates a long name to the buffer, never overruns

crc

test/unit/core/unit_crc.cpp

  • CRC-16/CCITT-FALSE has a well-known check value: "123456789" → 0x29B1. Pinning it proves the polynomial/init/reflection match the standard variant (so a fingerprint computed here matches any other CCITT-FALSE implementation).
  • A change-detector: different content → (almost always) different CRC; identical content → same.
  • Empty span returns the init value (no bytes processed).

draw

test/unit/light/unit_draw.cpp

  • mm::draw::pixel() writes inside the grid and silently clips outside it (no out-of-bounds write).
  • A 1D line (a row): every pixel from a.x to b.x inclusive is lit.
  • A 2D diagonal: endpoints are lit and the line is contiguous (one pixel per step on the main diagonal of a square).
  • A 3D line: drives all three axes, endpoints lit, no out-of-bounds on a small cube.
  • A line running off the grid clips: it draws the on-grid part and stops, no crash.
  • The shorten parameter pulls the far endpoint back toward a by shorten/255 (with WLEDMM 2 rounding), so an effect can sweep a partial segment. For a→b = (0,0)→(8,0): shorten 255 draws the whole line (tip at 8), 128 ≈ half (tip at (16128/255+1)/2 = 4), 1 = just the start pixel (tip 0), 0 = nothing. This pins the rounding of the shorten branch.
  • draw::blur on a 1D row matches the canonical carryover-seep reference byte-for-byte (same behaviour as FastLED blur1d / MoonLight blurRows), and is symmetric around a centred bright pixel.
  • blur runs separably on every axis with extent>1: a 2D blur spreads a centre pixel to all four orthogonal neighbours; a 3D blur reaches the z neighbours too. And it never writes out of bounds.
  • A glyph blits in the correct orientation — neither X-mirrored (a 'b' as a 'd') nor Y-flipped. 'L' is the ideal probe: its vertical bar must be on the LEFT and its foot on the BOTTOM row. This guards the column-bit and row-direction reads, so the DemoReel name overlay renders each letter upright and un-mirrored.

light_types

test/unit/light/unit_Coord3D.cpp

  • Coord3D arithmetic is per-axis
  • Coord3D modulo and divide fold per axis
  • Coord3D % and / guard a zero or degenerate axis
  • Coord3D equality

math8

test/unit/core/unit_math8.cpp

  • sin8: a 256-entry sine LUT centred on 128, peaking near 255 and 0 a quarter and three-quarters of the way round. cos8 is sin8 shifted a quarter turn.
  • triwave8: linear up 0→255 then down 255→0, peaking at the midpoint.
  • qadd8/qsub8 clamp at the 0..255 ends instead of wrapping.
  • nscale8 is the recognisable spelling of scale8 (n/256 channel scale), so nscale8(x,255)==x.
  • beat8: a sawtooth completing bpm cycles per minute. At t=0 it's 0; halfway through a beat ~128.
  • beatsin8: a sine oscillating in [low,high] at bpm. Stays in range across the cycle and actually moves (not stuck at one value).
  • Random8: a seeded PRNG — same seed gives the same sequence (determinism), and below(n) stays under n. Two different seeds diverge.
  • atan2_8 / dist8: the geometry helpers moved here from color.h still behave.
  • map8 rescales 0..255 onto [lo,hi] inclusively — the top of the input must REACH hi (FastLED's map8 == map(in,0,255,lo,hi)). Regression: an earlier scale8-based form left hi unreachable, so a one-step span (a bar height of 1) collapsed to 0 — the bug GEQ3D's height mapping hit.

noise

test/unit/core/unit_noise.cpp

  • Determinism: the same coordinate always gives the same value (a pure function of position), so a field is reproducible frame to frame and across the 1D/2D/3D entry points at z/y = 0.
  • Smoothness: neighbouring positions WITHIN a cell (sub-256 steps) differ only a little — that's what makes it value noise rather than a raw hash (which would jump randomly every step).
  • Range: output is a full byte; over a swept field it uses a wide span (not stuck near one value).

platform

test/unit/core/unit_TcpConnect.cpp

  • connectStart/connectPoll — the NON-BLOCKING outbound connect MqttModule uses so it never stalls Scheduler::tick (reviewer #2). connectStart returns immediately; connectPoll reports Pending/Connected/Failed without blocking. Over loopback the connect completes within a few polls.
  • connectStart on a bad/empty host fails immediately (no hang); connectPoll on an unstarted connection reports Failed rather than blocking.

test/unit/core/unit_platform_clock.cpp

  • setTestNowMs freezes platform::millis() to the given value; passing 0 restores the real clock so subsequent test cases see fresh time.

test/unit/core/unit_platform_worker.cpp

  • worker seam: each notify wakes the task exactly once
  • worker seam: waitNotify times out and returns false when not notified
  • worker seam: stopPinnedTask joins — the fn has returned before it returns
  • TryLock: acquiring excludes a second acquirer, release re-opens it
  • TryLock: a second THREAD is refused while held, and never blocks
  • TryLock: LockGuard releases on scope exit, and no-ops when busy