Files
AV-Live/touchosc/tests/test_layout.py
T
L'électron rare 495c5680bf
CI build oscope-of / build-check (push) Has been cancelled
feat: tosc sections page for song build
Add SECTIONS (page 4): 8 slot launch+save buttons, PREV/NEXT nav,
/scene/state receive on the GROUP so sections.lua colors slots
(green=current, blue=filled, dark=empty). Update pager to 4 pages,
add 4 new tests (26 pass), rebuild dist/av-live-control.tosc.
2026-06-28 16:16:14 +02:00

394 lines
16 KiB
Python

"""Layout structure tests — assert pages, addresses, and widget counts."""
from gen import layout, schema
def _addrs(el):
"""Return the set of OSC path address strings found anywhere under ``el``."""
return {
p.find("value").text
for p in el.findall(".//messages/osc/path/partial")
if p.find("value") is not None
}
def _addr_list(el):
return [
p.find("value").text
for p in el.findall(".//messages/osc/path/partial")
if p.find("value") is not None
]
# ---------------------------------------------------------------------------
# Root / pager
# ---------------------------------------------------------------------------
def test_root_has_pager_with_four_pages(tmp_path):
root = layout.build()
out = tmp_path / "full.tosc"
schema.write_tosc(root, str(out))
parsed = schema.read_tosc(str(out))
pager = parsed.find(".//node[@type='PAGER']")
assert pager is not None, "PAGER node not found"
pages = pager.findall("children/node[@type='GROUP']")
assert len(pages) == 4, f"expected 4 pages, got {len(pages)}"
def test_sixteen_patterns():
assert layout.PATTERNS == [
"kick", "hats", "clap", "perc",
"sub", "acid", "arp", "lead",
"stab", "pad", "ride", "rim",
"tom", "reese", "bells", "sweep",
]
# ---------------------------------------------------------------------------
# Page 1 — LIVE
# ---------------------------------------------------------------------------
def test_live_page_has_16_launch_buttons():
page = layout.live_page()
buttons = page.findall(".//node[@type='BUTTON']")
launch = [
b for b in buttons
if b.find("messages/osc/path/partial/value") is not None
and b.find("messages/osc/path/partial/value").text == "/launch"
]
assert len(launch) == 16, f"expected 16 /launch buttons, got {len(launch)}"
def test_live_page_has_16_vol_faders():
page = layout.live_page()
faders = page.findall(".//node[@type='FADER']")
vol = [
f for f in faders
if f.find("messages/osc/path/partial/value") is not None
and f.find("messages/osc/path/partial/value").text == "/launch/vol"
]
assert len(vol) == 16, f"expected 16 /launch/vol faders, got {len(vol)}"
def test_live_page_has_tempo_and_clear():
page = layout.live_page()
addrs = _addrs(page)
assert "/launch/tempo" in addrs
assert "/launch/clear" in addrs
def test_live_page_has_rms_receive():
page = layout.live_page()
addrs = _addrs(page)
assert "/sync/rms" in addrs
assert "/sync/beat" in addrs
# ---------------------------------------------------------------------------
# Page 2 — FX / HARMONIE / CONCERT
# ---------------------------------------------------------------------------
def test_fx_page_addresses():
page = layout.fx_page()
addrs = _addrs(page)
required = [
"/control/fx/filter",
"/control/fx/stutter",
"/control/fx/crash",
"/control/fx/kick",
"/control/fx/swell",
"/control/fx/breakdown",
"/control/harmony/root",
"/control/harmony/scale",
"/control/harmony/octave",
"/control/harmony/phrase",
"/control/concertNext",
"/control/doScene",
]
for a in required:
assert a in addrs, f"missing address {a}"
def test_fx_page_body_play_uses_doScene():
"""body-play must be /control/doScene, NOT /control/bodyPlay."""
page = layout.fx_page()
addrs = _addrs(page)
assert "/control/doScene" in addrs
assert "/control/bodyPlay" not in addrs
def test_fx_page_harmony_scale_sends_string_name():
"""Scale buttons must send string names (minor/dorian/phrygian/penta),
not integer indices."""
page = layout.fx_page()
# Find all argument partials on /control/harmony/scale messages
scale_args = []
for osc_el in page.findall(".//messages/osc"):
path_val = osc_el.find("path/partial/value")
if path_val is not None and path_val.text == "/control/harmony/scale":
for part in osc_el.findall("arguments/partial"):
conv = part.find("conversion")
val = part.find("value")
if conv is not None and val is not None:
scale_args.append((conv.text, val.text))
assert len(scale_args) == 4, f"expected 4 scale arg partials, got {len(scale_args)}"
for conv, val in scale_args:
assert conv == "STRING", f"scale arg must be STRING, got {conv}"
assert val in layout.SCALES, f"scale arg '{val}' not in SCALES"
def test_fx_page_harmony_octave_absolute():
"""Octave buttons must send absolute values 0, 1, 2 (not relative)."""
page = layout.fx_page()
octave_args = []
for osc_el in page.findall(".//messages/osc"):
path_val = osc_el.find("path/partial/value")
if path_val is not None and path_val.text == "/control/harmony/octave":
for part in osc_el.findall("arguments/partial"):
conv = part.find("conversion")
val = part.find("value")
if conv is not None and val is not None:
octave_args.append((conv.text, val.text))
assert len(octave_args) == 3, f"expected 3 octave buttons, got {len(octave_args)}"
vals = {v for _, v in octave_args}
assert vals == {"0", "1", "2"}, f"expected octave values 0,1,2, got {vals}"
def test_fx_page_breakdown_constant_integer():
"""breakdown buttons must send CONSTANT/INTEGER args with values {0, 1}."""
page = layout.fx_page()
breakdown_args = []
for osc_el in page.findall(".//messages/osc"):
path_val = osc_el.find("path/partial/value")
if path_val is not None and path_val.text == "/control/fx/breakdown":
for part in osc_el.findall("arguments/partial"):
typ = part.find("type")
conv = part.find("conversion")
val = part.find("value")
if typ is not None and conv is not None and val is not None:
breakdown_args.append((typ.text, conv.text, val.text))
assert len(breakdown_args) == 2, f"expected 2 breakdown arg partials, got {len(breakdown_args)}"
for typ, conv, _ in breakdown_args:
assert typ == "CONSTANT", f"breakdown arg type must be CONSTANT, got {typ}"
assert conv == "INTEGER", f"breakdown arg conversion must be INTEGER, got {conv}"
vals = {v for _, _, v in breakdown_args}
assert vals == {"0", "1"}, f"expected breakdown values {{0,1}}, got {vals}"
def test_fx_page_harmony_root_constant_integer():
"""root buttons must send CONSTANT/INTEGER args with values {-2, 2}."""
page = layout.fx_page()
root_args = []
for osc_el in page.findall(".//messages/osc"):
path_val = osc_el.find("path/partial/value")
if path_val is not None and path_val.text == "/control/harmony/root":
for part in osc_el.findall("arguments/partial"):
typ = part.find("type")
conv = part.find("conversion")
val = part.find("value")
if typ is not None and conv is not None and val is not None:
root_args.append((typ.text, conv.text, val.text))
assert len(root_args) == 2, f"expected 2 root arg partials, got {len(root_args)}"
for typ, conv, _ in root_args:
assert typ == "CONSTANT", f"root arg type must be CONSTANT, got {typ}"
assert conv == "INTEGER", f"root arg conversion must be INTEGER, got {conv}"
vals = {v for _, _, v in root_args}
assert vals == {"-2", "2"}, f"expected root values {{-2,2}}, got {vals}"
# ---------------------------------------------------------------------------
# Page 3 — SEQ
# ---------------------------------------------------------------------------
def test_seq_page_grid_and_faders():
page = layout.seq_page()
assert page.find(".//node[@type='GRID']") is not None, "GRID node not found"
addrs = _addr_list(page)
assert "/seq/rhythm" in addrs
assert "/seq/melody" in addrs
assert "/seq/rhythm/set" in addrs
assert "/seq/melody/set" in addrs
# 8 melody step faders identified by their nominal /seq/melody/set address
mel = [
f for f in page.findall(".//node[@type='FADER']")
if f.find("messages/osc/path/partial/value") is not None
and f.find("messages/osc/path/partial/value").text == "/seq/melody/set"
]
assert len(mel) == 8, f"expected 8 melody step faders, got {len(mel)}"
def test_seq_page_arm_buttons():
"""ARM MEL and ARM RHY both send to /launch (with melseq/rhythmseq name)."""
page = layout.seq_page()
buttons = page.findall(".//node[@type='BUTTON']")
arm = [
b for b in buttons
if b.find("messages/osc/path/partial/value") is not None
and b.find("messages/osc/path/partial/value").text == "/launch"
]
assert len(arm) == 2, f"expected 2 ARM buttons on seq page, got {len(arm)}"
def test_seq_page_rhythm_presets_use_consti():
"""Rhythm preset buttons must send INTEGER index (not float/string)."""
page = layout.seq_page()
rhythm_indices = []
for osc_el in page.findall(".//messages/osc"):
path_val = osc_el.find("path/partial/value")
if path_val is not None and path_val.text == "/seq/rhythm":
for part in osc_el.findall("arguments/partial"):
conv = part.find("conversion")
if conv is not None and conv.text == "INTEGER":
rhythm_indices.append(part.find("value").text)
assert len(rhythm_indices) == 8, f"expected 8 rhythm preset buttons, got {len(rhythm_indices)}"
def test_seq_page_no_sync_amp():
"""Per-voice VU strip is deliberately omitted; /sync/amp must not appear."""
page = layout.seq_page()
addrs = _addrs(page)
assert "/sync/amp" not in addrs, "/sync/amp found — per-voice VU was supposed to be omitted"
# ---------------------------------------------------------------------------
# Page 1 — LIVE: quantize selector
# ---------------------------------------------------------------------------
def test_live_page_quant_buttons():
"""LIVE page must have 4 /launch/quant buttons with CONSTANT/FLOAT args {0.5,1,4,8}."""
page = layout.live_page()
quant_args = []
for osc_el in page.findall(".//messages/osc"):
path_val = osc_el.find("path/partial/value")
if path_val is not None and path_val.text == "/launch/quant":
for part in osc_el.findall("arguments/partial"):
typ = part.find("type")
conv = part.find("conversion")
val = part.find("value")
if typ is not None and conv is not None and val is not None:
quant_args.append((typ.text, conv.text, val.text))
assert len(quant_args) == 4, f"expected 4 /launch/quant arg partials, got {len(quant_args)}"
for typ, conv, _ in quant_args:
assert typ == "CONSTANT", f"quant arg type must be CONSTANT, got {typ}"
assert conv == "FLOAT", f"quant arg conversion must be FLOAT, got {conv}"
vals = {v for _, _, v in quant_args}
assert vals == {"0.5", "1", "4", "8"}, f"expected quant values {{0.5,1,4,8}}, got {vals}"
# ---------------------------------------------------------------------------
# Pad armed receive on GROUP — FIX 1 regression guard
# ---------------------------------------------------------------------------
def test_live_page_pad_groups_carry_armed_receive():
"""Each pad GROUP (carrying pad.lua) must have the /armed/<name> receive
message on itself — not only on the child arm BUTTON. pad.lua fires via
onReceiveOSC on the node that owns the matching receive message; if the
message lives only on the child BUTTON, the GROUP script never fires and
armed-state color reflection is dead."""
page = layout.live_page()
# Collect GROUP nodes that carry a script property (= pad GROUPs with pad.lua).
# The top-level LIVE GROUP has no script, so this selects exactly the 16 pads.
pad_groups = []
for grp in page.findall(".//node[@type='GROUP']"):
for prop_el in grp.findall("properties/property"):
key_el = prop_el.find("key")
if key_el is not None and key_el.text == "script":
pad_groups.append(grp)
break
assert len(pad_groups) == 16, (
f"expected 16 pad GROUPs with a script property, got {len(pad_groups)}"
)
for grp in pad_groups:
# Resolve the pad name from its name property for a readable failure msg.
grp_name = "?"
for prop_el in grp.findall("properties/property"):
k = prop_el.find("key")
if k is not None and k.text == "name":
v = prop_el.find("value")
grp_name = v.text if v is not None else "?"
break
# The GROUP's direct <messages> block (not descendants) must contain
# exactly one osc element with receive="1" whose path starts /armed/.
armed_msgs = [
osc_el for osc_el in grp.findall("messages/osc")
if osc_el.get("receive") == "1"
and osc_el.find("path/partial/value") is not None
and (osc_el.find("path/partial/value").text or "").startswith("/armed/")
]
assert len(armed_msgs) == 1, (
f"pad GROUP '{grp_name}' must have exactly 1 /armed/* receive msg "
f"on the GROUP itself, got {len(armed_msgs)}"
)
# ---------------------------------------------------------------------------
# Page 4 — SECTIONS
# ---------------------------------------------------------------------------
def test_sections_page_launch_buttons():
"""SECTIONS page: 8 /scene/launch messages with CONSTANT/INTEGER args 0..7."""
page = layout.sections_page()
launch_args = []
for osc_el in page.findall(".//messages/osc"):
path_val = osc_el.find("path/partial/value")
if path_val is not None and path_val.text == "/scene/launch":
for part in osc_el.findall("arguments/partial"):
typ = part.find("type")
conv = part.find("conversion")
val = part.find("value")
if typ is not None and conv is not None and val is not None:
launch_args.append((typ.text, conv.text, val.text))
assert len(launch_args) == 8, f"expected 8 /scene/launch arg partials, got {len(launch_args)}"
for typ, conv, _ in launch_args:
assert typ == "CONSTANT", f"launch arg type must be CONSTANT, got {typ}"
assert conv == "INTEGER", f"launch arg conversion must be INTEGER, got {conv}"
vals = {v for _, _, v in launch_args}
assert vals == {"0", "1", "2", "3", "4", "5", "6", "7"}, (
f"expected launch values 0..7, got {vals}"
)
def test_sections_page_save_buttons():
"""SECTIONS page: 8 /scene/save messages with CONSTANT/INTEGER args 0..7."""
page = layout.sections_page()
save_args = []
for osc_el in page.findall(".//messages/osc"):
path_val = osc_el.find("path/partial/value")
if path_val is not None and path_val.text == "/scene/save":
for part in osc_el.findall("arguments/partial"):
typ = part.find("type")
conv = part.find("conversion")
val = part.find("value")
if typ is not None and conv is not None and val is not None:
save_args.append((typ.text, conv.text, val.text))
assert len(save_args) == 8, f"expected 8 /scene/save arg partials, got {len(save_args)}"
for typ, conv, _ in save_args:
assert typ == "CONSTANT", f"save arg type must be CONSTANT, got {typ}"
assert conv == "INTEGER", f"save arg conversion must be INTEGER, got {conv}"
def test_sections_page_nav_buttons():
"""SECTIONS page: /scene/prev and /scene/next present."""
page = layout.sections_page()
addrs = _addrs(page)
assert "/scene/prev" in addrs, "/scene/prev not found on SECTIONS page"
assert "/scene/next" in addrs, "/scene/next not found on SECTIONS page"
def test_sections_page_state_receive_on_group():
"""SECTIONS GROUP itself must carry /scene/state receive so sections.lua fires."""
page = layout.sections_page()
# Direct messages/osc on the top-level GROUP (not descendants)
state_msgs = [
osc_el for osc_el in page.findall("messages/osc")
if osc_el.get("receive") == "1"
and osc_el.find("path/partial/value") is not None
and osc_el.find("path/partial/value").text == "/scene/state"
]
assert len(state_msgs) == 1, (
f"SECTIONS GROUP must have exactly 1 /scene/state receive msg, got {len(state_msgs)}"
)