Commit 8dd2b710 by PLN (Algolia)

fix(leds): five tests asserted a spec the board stopped using in August

Master was red and nobody had noticed, because these five never fail alone —
they fail in a full-suite run, and the habit had become running subsets.

Four of them still described PLN's original six-step LED ramp, three of whose
steps were dim. value_ramp moved to the DAYLIGHT ramp — five steps, every one
full brightness — on his own ruling of 2026-08-21: "top brightness always would
make more readable signals even in day perfs". The code carried that reasoning
in its docstring; the tests were never brought along.

So the tests now assert the shipped ramp, and the six-step spec keeps a test of
its own under LCXL_DIM_RAMP=1, because it is still live behaviour for a dark
stage — superseded as the default, not deleted.

Two of them needed a different LENS, not a different number:

  * Monotonicity was measured as (green, red) ascending, which held only
    because the dim ramp never took red away. The daylight ramp walks amber ->
    yellow -> green by REMOVING red at full green, so red has to count
    downwards. Same property; the lens has to match the control.

  * The DJ filters' exemption from the ramp was tested by comparing the two
    functions at the centre value only — and the daylight ramp's midpoint is
    also 63, so the two coincided there and the test failed while the exemption
    was perfectly intact. A one-point comparison cannot tell a bipolar mapping
    from a unipolar one. It now asserts the exemption exactly (a DJ filter is
    painted by filter_colour at every one of the 128 values), that the two
    mappings disagree on 69 of 128, and that the bypass detent is narrow where
    the ramp's amber is wide.

And one thing worth keeping: the old monotonicity test read

    assert ranks == sorted(ranks) or len(set(ranks)) == 6

The six-step ramp has exactly 6 distinct ranks, so the second clause was ALWAYS
true and the assertion could not fail. It was hiding a real fact — the dim ramp
genuinely doubles back, dim red -> bright red then bright amber -> dim green —
which is the measured reason the daylight ramp is the better default, not just
a brightness preference. That is now asserted as the truth it is.

The fifth was tools/at/tests/test_lens.py pinning the OPAL setlist at exactly
13 tracks. That file is GENERATED from backlog.md, PLN's SSOT for set
membership and order, so its length changes whenever he changes the set — it
went 13 -> 16 when OPAL was recorded as-performed, and the test failed for the
set doing exactly what it is supposed to do. A hardcoded count on a
human-edited artifact is a stale binding. What the assertion is really for is
non-vacuity (an unparsed setlist would make the loop below run zero times and
pass), so it is a floor now, plus every path in the setlist must resolve on
disk.

  before   885 passed, 5 failed
  after    892 passed, 0 failed
parent 70209824
...@@ -25,24 +25,79 @@ def test_colour_byte_decodes_to_two_bits_each(): ...@@ -25,24 +25,79 @@ def test_colour_byte_decodes_to_two_bits_each():
assert mock.decode_colour(63) == (3, 3) # bright amber assert mock.decode_colour(63) == (3, 3) # bright amber
def test_value_ramp_is_pln_six_steps_in_order(): DAYLIGHT_STEPS = [15, 31, 63, 62, 60] # red, orange, amber, yellow, green
"""dim red -> bright red -> dim amber -> bright amber -> dim green -> bright green.""" DIM_STEPS = [13, 15, 29, 31, 28, 60] # PLN's original six, half of them dim
def _steps(fn):
"""The ramp's distinct steps, in the order the knob walks through them."""
seq = [] seq = []
for v in range(128): for v in range(128):
c = leds.value_ramp(v) c = fn(v)
if not seq or seq[-1] != c: if not seq or seq[-1] != c:
seq.append(c) seq.append(c)
assert seq == [13, 15, 29, 31, 28, 60] return seq
def test_value_ramp_is_monotonic_in_perceived_progress(): def test_value_ramp_is_the_daylight_five_steps_in_order():
"""Never goes backwards: each step is >= the previous in (green, red) order.""" """red -> orange -> amber -> yellow -> green, every step full brightness.
def rank(c):
This supersedes PLN's original six-step spec, on his own ruling of
2026-08-21: *"top brightness always would make more readable signals even
in day perfs"*. The old ramp spent three of its six steps on dim shades —
exactly the ones he could not read outdoors. Trading one step of resolution
for five legible ones is the same call he already made for the DJ filters
in July (*"i agree on clarity > resolution"*).
"""
assert _steps(leds.value_ramp) == DAYLIGHT_STEPS
def test_dim_ramp_restores_plns_original_six_steps(monkeypatch):
"""LCXL_DIM_RAMP=1 is the dark-stage escape hatch, so the six-step spec is
still live behaviour and still gets asserted — it was superseded as the
default, not deleted."""
monkeypatch.setattr(leds, "DIM_RAMP", True)
assert _steps(leds.value_ramp) == DIM_STEPS
def _progress(c):
"""How far along the ramp a colour reads: greener is further, and at equal
green, less red is further.
The old measure was (green, red) ascending, which worked only because the
dim ramp never removed red. The daylight ramp walks amber -> yellow ->
green by TAKING RED AWAY at full green, so red must count downwards. Same
property, and the lens has to match the control it measures.
"""
r, g = mock.decode_colour(c) r, g = mock.decode_colour(c)
return (g, r) return (g, -r)
vals = [leds.value_ramp(v) for v in range(128)]
ranks = [rank(c) for c in vals]
assert ranks == sorted(ranks) or len(set(ranks)) == 6 def test_value_ramp_is_monotonic_in_perceived_progress():
"""Never goes backwards — a ramp that doubles back cannot be read as a value."""
ranks = [_progress(leds.value_ramp(v)) for v in range(128)]
assert ranks == sorted(ranks), _steps(leds.value_ramp)
def test_the_dim_ramp_is_NOT_monotonic_and_that_is_why_daylight_won(monkeypatch):
"""The retired six-step ramp doubles back, and this is the measured reason
the daylight ramp is the better default — not just a brightness preference.
It goes dim red -> BRIGHT red (red rises), then later bright amber -> DIM
green (red falls again at the same green). No single ordering of the two
colour components runs monotonically through it, so a knob's position could
not be read off the hue alone; you had to compare brightness against a
neighbour.
The original test knew this and hid it. It asserted
ranks == sorted(ranks) or len(set(ranks)) == 6
and the six-step ramp has exactly 6 distinct ranks, so the second clause
was ALWAYS true. The test could not fail. Asserting the truth instead.
"""
monkeypatch.setattr(leds, "DIM_RAMP", True)
ranks = [_progress(leds.value_ramp(v)) for v in range(128)]
assert ranks != sorted(ranks), "dim ramp became monotonic — retire the escape hatch"
assert len(_steps(leds.value_ramp)) == 6
def test_value_ramp_bottom_is_dim_not_off(): def test_value_ramp_bottom_is_dim_not_off():
...@@ -109,14 +164,19 @@ def test_the_filter_ramp_is_symmetric_about_the_centre_band(): ...@@ -109,14 +164,19 @@ def test_the_filter_ramp_is_symmetric_about_the_centre_band():
# #11b — value_ramp is actually WIRED IN now # #11b — value_ramp is actually WIRED IN now
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def test_knobs_use_the_six_step_ramp_not_three_states(): def test_knobs_use_the_whole_ramp_not_three_states():
"""The bug PLN caught by eye: "I See only two states, dim and not dim, at 0 and """The bug PLN caught by eye: "I See only two states, dim and not dim, at 0 and
not 0 atm on the knobs". value_ramp was written, unit-tested, and never called — not 0 atm on the knobs". value_ramp was written, unit-tested, and never called —
control_colour had exactly three outcomes, so 30% and 90% were the same colour. control_colour had exactly three outcomes, so 30% and 90% were the same colour.
Asserted against the ramp itself rather than a copied list, so changing the
ramp cannot leave the wiring test asserting a colour set nothing produces.
That is what went stale here: the ramp moved to daylight and these four
tests kept checking the retired six.
""" """
seen = {leds.control_colour(13, "bass", v, True) for v in range(128)} seen = {leds.control_colour(13, "bass", v, True) for v in range(128)}
assert len(seen) == 6 assert seen == set(_steps(leds.value_ramp))
assert seen == {13, 15, 29, 31, 28, 60} assert len(seen) >= 5, "fewer than five states is unreadable as a value"
def test_a_knob_at_30_percent_and_at_90_percent_are_different_colours(): def test_a_knob_at_30_percent_and_at_90_percent_are_different_colours():
...@@ -142,10 +202,33 @@ def test_the_ramp_overrides_role_hue_on_knobs_deliberately(): ...@@ -142,10 +202,33 @@ def test_the_ramp_overrides_role_hue_on_knobs_deliberately():
def test_the_dj_filters_are_exempt_from_the_unipolar_ramp(): def test_the_dj_filters_are_exempt_from_the_unipolar_ramp():
"""They are bipolar: centre is bypass, so a monotonic ramp would paint bypass as """They are bipolar: centre is bypass, so a monotonic ramp would paint bypass as
mid-amber and the two opposite musical extremes as the same colour.""" mid-amber and the two opposite musical extremes as the same colour.
Tested by SHAPE, not by one value. The old test compared the filter colour
against value_ramp at centre only — and the daylight ramp's midpoint is
also 63, so the two coincided there and the test failed while the exemption
was perfectly intact. A one-point comparison cannot tell a bipolar ramp
from a unipolar one; symmetry can.
"""
for cc in leds.DJ_FILTERS: for cc in leds.DJ_FILTERS:
assert leds.control_colour(cc, "fx", 64, True) == 63 # The exemption, stated exactly: a DJ filter is painted by filter_colour
assert leds.control_colour(cc, "fx", 64, True) != leds.value_ramp(64) # at EVERY value, never by the unipolar ramp.
assert all(leds.control_colour(cc, "fx", v, True) == leds.filter_colour(v)
for v in range(128)), f"cc{cc} fell through to the ramp"
assert leds.control_colour(cc, "fx", 64, True) == 63 # centre = bypass
# And the two mappings really are different mappings — they disagree on more
# than half the range, so the exemption is doing visible work. The centre is
# the one place they happen to agree, which is exactly why testing there
# proved nothing.
disagree = sum(1 for v in range(128)
if leds.filter_colour(v) != leds.value_ramp(v))
assert disagree > 64, f"only {disagree}/128 values differ"
# The tell is the bypass detent: narrow amber for a filter you must find by
# feel, wide amber for a ramp that just passes through the middle.
band = lambda fn: [v for v in range(128) if fn(v) == 63]
assert len(band(leds.filter_colour)) <= 12 < len(band(leds.value_ramp))
def test_an_unbound_control_is_still_dark_after_the_ramp_change(): def test_an_unbound_control_is_still_dark_after_the_ramp_change():
......
...@@ -340,7 +340,19 @@ def test_every_opal_track_resolves_all_three_dj_filters(): ...@@ -340,7 +340,19 @@ def test_every_opal_track_resolves_all_three_dj_filters():
setlist = TOOLS.parent / "armada" / "setlist_opal2026.txt" setlist = TOOLS.parent / "armada" / "setlist_opal2026.txt"
rows = [ln.split("#")[0].strip() for ln in setlist.read_text().splitlines()] rows = [ln.split("#")[0].strip() for ln in setlist.read_text().splitlines()]
tracks = [r for r in rows if r] tracks = [r for r in rows if r]
assert len(tracks) == 13 # NOT a fixed count. This file is generated from backlog.md, which is PLN's
# SSOT for set membership and order, so the number changes whenever he
# changes the set — it went 13 -> 16 when OPAL was recorded as-performed
# (15 tracks plus the SOUNDCHECK entry) and failed this test for doing
# exactly what it is supposed to do.
#
# What the assertion is actually for is non-vacuity: if the setlist failed
# to parse, `tracks` would be empty, the loop below would run zero times
# and this test would pass while checking nothing. So: a floor, and every
# path must resolve.
assert len(tracks) >= 13, f"setlist parsed to {len(tracks)} rows — did the format change?"
missing = [r for r in tracks if not (TOOLS.parent / r).exists()]
assert not missing, f"setlist names files that do not exist: {missing}"
for rel in tracks: for rel in tracks:
t = load(str(TOOLS.parent / rel)) t = load(str(TOOLS.parent / rel))
by_cc = {c.cc: c for c in lens.controls(t)} by_cc = {c.cc: c for c in lens.controls(t)}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment