feedBack/tests/test_song.py
Byron Gamatos e0270e5c30
Some checks are pending
ship-ci / ci (push) Waiting to run
fix(song): make bass detection instrument-type-aware, not name-only (#1019)
Editor now authors an arrangement's instrument as first-class data (a manifest
'type' field). Core dropped it: the sloppak loader never read 'type', and
'is this a bass?' was defined three different ways across call sites (name-only
in note_pitch_midi and the highway scale-degree path; path_bass+name in bass
selection; name-only in arrangement_string_count). So an authored type=bass
chart not named 'bass' got 6-string lane counts and guitar open-string MIDI.

- Add optional Arrangement.type; sloppak load_song lifts the manifest type onto it
- Add arrangement_is_bass(arr) = type=='bass' OR path_bass OR 'bass' in name
  (None/whitespace safe), and route string count, note_pitch_midi, the highway
  scale-degree base, and bass-player selection through it
- Back-compat: no bass signal -> unchanged 6-string / guitar behavior

Companion to editor #335 (first-class instrument type). Scale degrees are
display-only and never feed a grader.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 13:30:54 +02:00

1427 lines
54 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for lib/song.py wire-format serialization (pure, no fixtures)."""
import json
import pytest
from song import (
Anchor,
Arrangement,
Chord,
ChordTemplate,
HandShape,
Note,
Phrase,
PhraseLevel,
arrangement_from_wire,
arrangement_string_count,
arrangement_to_wire,
chord_from_wire,
chord_template_to_wire,
chord_to_wire,
sanitize_tempos,
compute_smart_names,
base_open_string_midis,
key_to_tonic_pc,
note_from_wire,
note_to_wire,
note_pitch_midi,
phrase_from_wire,
phrase_to_wire,
scale_degree_for_pitch,
)
# ── Note round-trip ──────────────────────────────────────────────────────────
def test_note_minimal_round_trip():
n = Note(time=1.0, string=2, fret=5)
assert note_from_wire(note_to_wire(n)) == n
def test_note_with_every_technique_round_trip():
n = Note(
time=0.5, string=0, fret=3,
sustain=0.25,
slide_to=7,
slide_unpitch_to=9,
bend=1.0,
hammer_on=True, pull_off=True,
harmonic=True, harmonic_pinch=True,
palm_mute=True, mute=True,
vibrato=True,
tremolo=True, accent=True,
tap=True,
)
assert note_from_wire(note_to_wire(n)) == n
def test_note_link_next_round_trips_through_wire():
"""link_next survives the wire under key `ln`.
Originally omitted because the highway derived chord linking from
proximity rather than the linkNext attribute. The editor now needs
round-trip fidelity so an authored linkNext on a sloppak survives
save → reload; `ln` is additive metadata the renderer is free to
ignore.
"""
n = Note(time=0.0, string=0, fret=0, link_next=True)
wire = note_to_wire(n)
assert wire["ln"] is True
assert note_from_wire(wire).link_next is True
def test_note_new_techniques_round_trip():
"""fret_hand_mute, pluck, slap, right_hand, pick_direction, ignore.
Pin the public wire keys (`fhm`, `plk`, `slp`, `rh`, `pkd`, `ig`)
explicitly — a coordinated rename in both encoder and decoder would
still pass a pure round-trip check, but break sloppak readers in
other languages that key off the literal strings.
"""
n = Note(
time=0.0, string=0, fret=0,
fret_hand_mute=True, pluck=True, slap=True,
right_hand=2, pick_direction=1, ignore=True,
link_next=True,
)
wire = note_to_wire(n)
assert wire["ln"] is True
assert wire["fhm"] is True
assert wire["plk"] is True
assert wire["slp"] is True
assert wire["rh"] == 2
assert wire["pkd"] == 1
assert wire["ig"] is True
assert note_from_wire(wire) == n
def test_note_new_techniques_omitted_when_default():
"""New technique keys (ln/fhm/plk/slp/rh/pkd/ig) are default-omitted.
The highway streams notes thousands of times per song; always emitting
seven extra boolean/int keys per note would inflate the WebSocket
payload for the common case where these techniques are unset.
`note_from_wire` decodes missing keys to their dataclass defaults
(False / -1), so the round-trip is lossless.
"""
wire = note_to_wire(Note(time=0.0, string=0, fret=0))
for omitted in ("ln", "fhm", "plk", "slp", "rh", "pkd", "ig"):
assert omitted not in wire, f"{omitted!r} should be default-omitted"
decoded = note_from_wire(wire)
assert decoded.link_next is False
assert decoded.fret_hand_mute is False
assert decoded.pluck is False
assert decoded.slap is False
assert decoded.right_hand == -1
assert decoded.pick_direction == -1
assert decoded.ignore is False
def test_note_from_wire_tolerates_malformed_optional_ints():
"""`rh`/`pkd` survive null / empty / non-numeric wire values."""
for bad in (None, "", " ", "x", "inf"):
n = note_from_wire({"t": 0.0, "s": 0, "f": 0, "rh": bad, "pkd": bad})
assert n.right_hand == -1
assert n.pick_direction == -1
def test_int_optional_falls_back_on_overflow():
"""`inf` / `1e309` raise OverflowError on int(float(v)); fall back too."""
from xml.etree import ElementTree as ET
from song import _int_optional
el = ET.fromstring('<n a="inf" b="1e309" c="-inf"/>')
assert _int_optional(el, "a", default=-1) == -1
assert _int_optional(el, "b", default=-1) == -1
assert _int_optional(el, "c", default=-1) == -1
def test_parse_note_falls_back_to_default_on_malformed_numeric_attrs():
"""Malformed numeric XML attributes degrade gracefully.
Third-party arrangement XML occasionally emits empty / non-numeric
values for fields like `rightHand`. `_int_optional` (used for
optional metadata fields like `rightHand` and `pickDirection`)
falls back to the caller's default instead of raising, so a
malformed `<note>` no longer aborts the surrounding arrangement
parse. Required readers still go through `_int` and fail fast.
"""
from xml.etree import ElementTree as ET
from song import _parse_note
bad = ET.fromstring(
'<note time="0.0" string="0" fret="0" rightHand="" pickDirection="x"/>'
)
n = _parse_note(bad)
assert n.right_hand == -1
assert n.pick_direction == -1
def test_note_time_rounded_to_three_decimals():
n = Note(time=1.23456789, string=0, fret=0)
assert note_to_wire(n)["t"] == 1.235
def test_note_bend_zero_serializes_as_integer_zero():
# note_to_wire uses `round(bend, 1) if bend else 0` — the else branch returns int 0.
# from_wire then float()s it back. Pin this quirk so a refactor doesn't surprise callers.
wire = note_to_wire(Note(time=0.0, string=0, fret=0, bend=0.0))
assert wire["bn"] == 0
assert isinstance(wire["bn"], int)
def test_note_bend_nonzero_rounded_to_one_decimal():
n = Note(time=0.0, string=0, fret=0, bend=1.75)
assert note_to_wire(n)["bn"] == 1.8
# ── Bend shape (bt / bnv, §6.2.1) ────────────────────────────────────────────
def test_note_bend_shape_round_trip():
"""A note with bend intent + a time-stamped curve survives the wire."""
n = Note(
time=0.5, string=0, fret=7, sustain=1.0,
bend=2.0,
bend_intent=4, # round-trip
bend_values=[
{"t": 0.0, "v": 0.0},
{"t": 0.25, "v": 2.0},
{"t": 0.5, "v": 0.0},
],
)
wire = note_to_wire(n)
assert wire["bt"] == 4
assert wire["bnv"] == [
{"t": 0.0, "v": 0.0},
{"t": 0.25, "v": 2.0},
{"t": 0.5, "v": 0.0},
]
assert note_from_wire(wire) == n
def test_note_bend_shape_omitted_when_default():
"""`bt`/`bnv` are default-omitted; absence decodes to 0 / None (not 0-present
/ not [])."""
wire = note_to_wire(Note(time=0.0, string=0, fret=0, bend=1.0))
assert "bt" not in wire
assert "bnv" not in wire
decoded = note_from_wire(wire)
assert decoded.bend_intent == 0
assert decoded.bend_values is None
# ── Teaching marks (§6.2.2) ──────────────────────────────────────────────────
def test_note_teaching_marks_round_trip():
"""fg/ch/sd survive the wire under their literal keys.
Pin the public wire keys explicitly (cross-language sloppak readers key
off the literal strings), like the rh/pkd test above.
"""
n = Note(
time=0.0, string=0, fret=0,
fret_finger=2, strum_group=5, scale_degree=7,
)
wire = note_to_wire(n)
assert wire["fg"] == 2
assert wire["ch"] == 5
assert wire["sd"] == 7
assert note_from_wire(wire) == n
def test_note_teaching_marks_omitted_when_default():
"""fg/ch/sd are default-omitted (-1) and decode back to -1."""
wire = note_to_wire(Note(time=0.0, string=0, fret=0))
for omitted in ("fg", "ch", "sd"):
assert omitted not in wire, f"{omitted!r} should be default-omitted"
decoded = note_from_wire(wire)
assert decoded.fret_finger == -1
assert decoded.strum_group == -1
assert decoded.scale_degree == -1
def test_note_teaching_marks_tolerate_malformed_optional_ints():
"""fg/ch/sd survive null / empty / non-numeric wire values."""
for bad in (None, "", " ", "x", "inf"):
n = note_from_wire({"t": 0.0, "s": 0, "f": 0,
"fg": bad, "ch": bad, "sd": bad})
assert n.fret_finger == -1
assert n.strum_group == -1
assert n.scale_degree == -1
# ── Keys hand assignment ─────────────────────────────────────────────────────
def test_note_hand_round_trips_under_literal_key():
"""The keys hand assignment survives the wire as the literal `hand` key
(spelled out — `rh` is taken by right_hand, the bass plucking finger)."""
for hand in ("lh", "rh"):
n = Note(time=0.0, string=2, fret=12, hand=hand)
wire = note_to_wire(n)
assert wire["hand"] == hand
assert note_from_wire(wire) == n
def test_note_hand_omitted_when_unassigned():
wire = note_to_wire(Note(time=0.0, string=0, fret=0))
assert "hand" not in wire
assert note_from_wire(wire).hand is None
def test_note_hand_junk_never_emitted_and_decodes_to_unassigned():
"""Emit side validates ('LH', True, … stay off the wire); decode side is a
strict enum so a hand-edited pack can't poison hand-split logic."""
for junk in ("LH", "left", "", True, 1, ["lh"]):
assert "hand" not in note_to_wire(
Note(time=0.0, string=0, fret=0, hand=junk))
assert note_from_wire(
{"t": 0.0, "s": 0, "f": 0, "hand": junk}).hand is None
# ── Scale-degree derivation helpers (§6.2.2 / §7.7) ──────────────────────────
@pytest.mark.parametrize("key,pc", [
("C", 0), ("c", 0),
("E", 4), ("Em", 4), ("E minor", 4),
("G", 7), ("G major", 7), ("Gmaj", 7),
("A#m", 10), ("Bb", 10), # enharmonic — same pitch class
("F#", 6), ("F#m", 6),
("Cb", 11), ("B#", 0), # accidentals wrap mod 12
])
def test_key_to_tonic_pc_parses_key_names(key, pc):
assert key_to_tonic_pc(key) == pc
@pytest.mark.parametrize("bad", [None, "", " ", "H", "xyz", "7", 5])
def test_key_to_tonic_pc_rejects_unparseable(bad):
assert key_to_tonic_pc(bad) is None
def test_scale_degree_for_pitch_standard_tuning_key_of_e():
"""Tonic E (pc 4), standard tuning: low-E open -> tonic, A-string fret 2 -> fifth."""
tonic = key_to_tonic_pc("E")
assert tonic == 4
low_e_open = 40 # E2
a_string_fret2 = 45 + 2 # A2 + 2 = B2
assert scale_degree_for_pitch(low_e_open, tonic) == 0 # tonic
assert scale_degree_for_pitch(a_string_fret2, tonic) == 7 # perfect fifth
assert scale_degree_for_pitch(40 + 3, tonic) == 3 # G2 -> minor third
def test_note_pitch_midi_standard_tuning_offsets():
"""`arr.tuning` holds OFFSETS from standard (0 = standard), padded to 6 on
RS-XML; pitch = base + offset + capo + fret. Standard guitar: low-E open ->
40 (E2), A-string fret 2 -> 47 (B2)."""
arr = Arrangement(name="Lead", tuning=[0, 0, 0, 0, 0, 0])
assert note_pitch_midi(arr, Note(time=0, string=0, fret=0)) == 40 # low E open
assert note_pitch_midi(arr, Note(time=0, string=1, fret=2)) == 47 # A + 2 = B
# Drop-D (low string offset -2): low-E string open sounds D2 = 38.
drop_d = Arrangement(name="Lead", tuning=[-2, 0, 0, 0, 0, 0])
assert note_pitch_midi(drop_d, Note(time=0, string=0, fret=0)) == 38
# Capo 2 raises every sounding pitch by 2 semitones.
capo2 = Arrangement(name="Lead", tuning=[0, 0, 0, 0, 0, 0], capo=2)
assert note_pitch_midi(capo2, Note(time=0, string=0, fret=0)) == 42
def test_note_pitch_midi_bass_uses_bass_base():
"""A 4-string arrangement named 'Bass' uses the bass base (low E1 = 28),
not the guitar base (40)."""
bass = Arrangement(name="Bass", tuning=[0, 0, 0, 0])
assert note_pitch_midi(bass, Note(time=0, string=0, fret=0)) == 28
def test_note_pitch_midi_authored_bass_type_uses_bass_base():
"""Editor PR #335: a bass authored via `type` on an arrangement whose NAME
doesn't say "bass". arrangement_string_count now returns 4 for it, so the
open-string base MUST also be the bass base (low E1 = 28), not the guitar
octave (40). Pre-fix note_pitch_midi keyed is_bass off the name only, so
this returned 40 (4 lanes on a guitar octave — the exact inconsistency)."""
bass = Arrangement(
name="Low End", type="bass",
tuning=[0, 0, 0, 0],
notes=[Note(time=float(i), string=i, fret=0) for i in range(4)],
)
assert note_pitch_midi(bass, Note(time=0, string=0, fret=0)) == 28
def test_note_pitch_midi_path_bass_flag_uses_bass_base():
"""Same guarantee via the archive <arrangementProperties> pathBass flag on a
non-"bass"-named arrangement: bass base (28), not guitar (40)."""
bass = Arrangement(
name="Low End", path_bass=True,
tuning=[0, 0, 0, 0],
notes=[Note(time=float(i), string=i, fret=0) for i in range(4)],
)
assert note_pitch_midi(bass, Note(time=0, string=0, fret=0)) == 28
def test_note_pitch_midi_out_of_range_string_is_none():
arr = Arrangement(name="Lead", tuning=[0, 0, 0, 0, 0, 0])
assert note_pitch_midi(arr, Note(time=0, string=9, fret=0)) is None
def test_base_open_string_midis_bass_vs_guitar():
assert base_open_string_midis(6, False)[0] == 40 # guitar low E
assert base_open_string_midis(4, True)[0] == 28 # bass low E
assert base_open_string_midis(4, False)[0] == 40 # 4-string guitar voicing
def test_note_bend_values_rounded_on_wire():
"""`bnv` rounds `t` to 3 and `v` to 1, matching the scalar `bn` precision."""
n = Note(
time=0.0, string=0, fret=0, bend=1.0, bend_intent=1,
bend_values=[{"t": 0.123456, "v": 1.749}],
)
assert note_to_wire(n)["bnv"] == [{"t": 0.123, "v": 1.7}]
def test_note_bend_values_sanitized_from_wire():
"""Malformed `bnv` entries are dropped; bad/empty -> None; result sorted by t."""
# NaN / non-dict / non-numeric entries dropped, remaining sorted by t.
n = note_from_wire({
"t": 0.0, "s": 0, "f": 0, "bn": 2.0,
"bnv": [
{"t": 0.5, "v": 2.0},
{"t": 0.0, "v": 0.0},
{"t": "x", "v": 1.0}, # non-numeric t -> dropped
{"t": 0.25, "v": float("nan")}, # non-finite v -> dropped
"garbage", # non-dict -> dropped
],
})
assert n.bend_values == [{"t": 0.0, "v": 0.0}, {"t": 0.5, "v": 2.0}]
# Empty / non-list / all-invalid collapse to None (never []).
for bad in (None, [], "nope", [{"t": "a", "v": "b"}], [42]):
assert note_from_wire(
{"t": 0.0, "s": 0, "f": 0, "bnv": bad}).bend_values is None
def test_chord_note_carries_bend_shape():
"""Chord member notes inherit bt/bnv through chord_note_to_wire/chord_from_wire."""
c = Chord(
time=2.0, chord_id=0,
notes=[Note(
time=2.0, string=1, fret=5, bend=1.0, bend_intent=2,
bend_values=[{"t": 0.0, "v": 1.0}, {"t": 0.3, "v": 0.0}],
)],
)
decoded = chord_from_wire(chord_to_wire(c))
cn = decoded.notes[0]
assert cn.bend_intent == 2
assert cn.bend_values == [{"t": 0.0, "v": 1.0}, {"t": 0.3, "v": 0.0}]
# ── Chord round-trip ─────────────────────────────────────────────────────────
def test_chord_with_multiple_notes_round_trip():
c = Chord(
time=2.0,
chord_id=5,
high_density=False,
notes=[
Note(time=2.0, string=0, fret=3),
Note(time=2.0, string=1, fret=5),
Note(time=2.0, string=2, fret=5),
],
)
assert chord_from_wire(chord_to_wire(c)) == c
def test_chord_high_density_round_trip():
c = Chord(
time=1.5, chord_id=2, high_density=True,
notes=[Note(time=1.5, string=0, fret=0)],
)
assert chord_from_wire(chord_to_wire(c)) == c
def test_chord_notes_inherit_chord_time_on_deserialization():
"""chord_note_to_wire strips each note's time; chord_from_wire replays the chord time.
So notes constructed with mismatched times are normalized by the round-trip.
"""
c = Chord(
time=3.0, chord_id=0,
notes=[
Note(time=99.0, string=0, fret=0), # will be normalized to 3.0
Note(time=42.5, string=1, fret=1), # will be normalized to 3.0
],
)
result = chord_from_wire(chord_to_wire(c))
assert all(n.time == 3.0 for n in result.notes)
# ── Harmony annotations: chord fn (§6.3.1) + template voicing (§6.6) ──────────
def test_chord_fn_round_trip():
"""A well-formed fn {rn, q, deg} survives the wire under its literal key."""
c = Chord(
time=2.0, chord_id=0,
notes=[Note(time=2.0, string=0, fret=2)],
fn={"rn": "ii7", "q": "m7", "deg": 2},
)
wire = chord_to_wire(c)
assert wire["fn"] == {"rn": "ii7", "q": "m7", "deg": 2}
assert chord_from_wire(wire) == c
def test_chord_fn_omitted_when_none():
"""fn defaults to None and produces no `fn` key on the wire."""
wire = chord_to_wire(Chord(time=0.0, chord_id=0,
notes=[Note(time=0.0, string=0, fret=0)]))
assert "fn" not in wire
assert chord_from_wire(wire).fn is None
@pytest.mark.parametrize("bad", [
None, # absent / null
"ii7", # not an object
{}, # empty
{"rn": "ii7", "q": "m7"}, # missing deg
{"rn": "ii7", "deg": 2}, # missing q
{"q": "m7", "deg": 2}, # missing rn
{"rn": "", "q": "m7", "deg": 2}, # blank rn
{"rn": "ii7", "q": " ", "deg": 2}, # blank q
{"rn": "ii7", "q": "m7", "deg": 15}, # deg out of range (high)
{"rn": "ii7", "q": "m7", "deg": -1}, # deg out of range (low)
{"rn": "ii7", "q": "m7", "deg": "2"}, # deg not an int
{"rn": "ii7", "q": "m7", "deg": True}, # deg is a bool, not a real int
{"rn": 7, "q": "m7", "deg": 2}, # rn not a str
])
def test_chord_fn_malformed_drops_to_none(bad):
"""Any malformed / partial / out-of-range fn decodes to None (never partial)."""
c = chord_from_wire({"t": 1.0, "id": 0, "notes": [], "fn": bad})
assert c.fn is None
@pytest.mark.parametrize("bad_fn", [
{"rn": "ii7"}, # missing q + deg
{"rn": "ii7", "q": "m7", "deg": 15}, # deg out of range
{"rn": "", "q": "m7", "deg": 2}, # blank rn
])
def test_chord_to_wire_drops_invalid_fn_on_emit(bad_fn):
"""A directly-constructed Chord with a partial/out-of-range fn never rides the wire."""
wire = chord_to_wire(Chord(time=1.0, chord_id=0, notes=[], fn=bad_fn))
assert "fn" not in wire
def test_chord_fn_strips_whitespace_on_decode():
c = chord_from_wire({"t": 1.0, "id": 0, "notes": [],
"fn": {"rn": " V7 ", "q": " 7 ", "deg": 7}})
assert c.fn == {"rn": "V7", "q": "7", "deg": 7}
def test_template_voicing_round_trip():
"""A non-empty voicing survives the template wire + arrangement round-trip."""
ct = ChordTemplate(name="Am", display_name="Am", fingers=[-1, 0, 2, 2, 1, 0],
frets=[-1, 0, 2, 2, 1, 0], voicing="open")
assert chord_template_to_wire(ct)["voicing"] == "open"
arr = Arrangement(name="Rhythm", chord_templates=[ct])
assert arrangement_from_wire(arrangement_to_wire(arr)).chord_templates[0] == ct
def test_template_voicing_omitted_when_default():
"""An empty voicing (the default) produces no `voicing` key."""
ct = ChordTemplate(name="Am", fingers=[-1] * 6, frets=[-1] * 6)
assert "voicing" not in chord_template_to_wire(ct)
arr = arrangement_from_wire(arrangement_to_wire(
Arrangement(name="Rhythm", chord_templates=[ct])))
assert arr.chord_templates[0].voicing == ""
@pytest.mark.parametrize("bad", [None, 7, ["open"], {"v": "open"}])
def test_template_voicing_tolerates_malformed(bad):
"""A non-string voicing on the wire falls back to the empty default."""
arr = arrangement_from_wire({
"name": "Rhythm",
"templates": [{"name": "Am", "fingers": [-1] * 6, "frets": [-1] * 6,
"voicing": bad}],
})
assert arr.chord_templates[0].voicing == ""
def test_template_caged_round_trip():
"""A valid CAGED shape survives the template wire + arrangement round-trip."""
ct = ChordTemplate(name="Am", display_name="Am", fingers=[-1, 0, 2, 2, 1, 0],
frets=[-1, 0, 2, 2, 1, 0], caged="E")
assert chord_template_to_wire(ct)["caged"] == "E"
arr = Arrangement(name="Rhythm", chord_templates=[ct])
assert arrangement_from_wire(arrangement_to_wire(arr)).chord_templates[0] == ct
def test_template_caged_omitted_when_default():
"""An empty caged (the default) produces no `caged` key."""
ct = ChordTemplate(name="Am", fingers=[-1] * 6, frets=[-1] * 6)
assert "caged" not in chord_template_to_wire(ct)
arr = arrangement_from_wire(arrangement_to_wire(
Arrangement(name="Rhythm", chord_templates=[ct])))
assert arr.chord_templates[0].caged == ""
@pytest.mark.parametrize("bad", [None, 7, "X", "e", "", ["E"], {"c": "E"}])
def test_template_caged_tolerates_malformed(bad):
"""A non-enum caged on the wire falls back to the empty default."""
arr = arrangement_from_wire({
"name": "Rhythm",
"templates": [{"name": "Am", "fingers": [-1] * 6, "frets": [-1] * 6,
"caged": bad}],
})
assert arr.chord_templates[0].caged == ""
def test_template_caged_guide_tones_sanitized_on_emit():
"""A directly-constructed template can't write an invalid caged / out-of-range
guideTone to the wire — the emitter sanitizes, not just the decoder."""
ct = ChordTemplate(name="Am", fingers=[-1] * 6, frets=[-1] * 6,
caged="X", guide_tones=[3, 99, -1, "x", True])
wire = chord_template_to_wire(ct)
assert "caged" not in wire # non-enum dropped, not emitted
assert wire["guideTones"] == [3] # only the valid in-range int survives
# A wholly-invalid guideTones list omits the key entirely.
ct2 = ChordTemplate(name="Am", fingers=[-1] * 6, frets=[-1] * 6,
guide_tones=[42, "nope"])
assert "guideTones" not in chord_template_to_wire(ct2)
def test_template_guide_tones_round_trip():
"""A non-empty guideTones list survives the template wire + round-trip."""
ct = ChordTemplate(name="G7", display_name="G7", fingers=[3, 2, 0, 0, 0, 1],
frets=[3, 2, 0, 0, 0, 1], guide_tones=[4, 10])
assert chord_template_to_wire(ct)["guideTones"] == [4, 10]
arr = Arrangement(name="Rhythm", chord_templates=[ct])
assert arrangement_from_wire(arrangement_to_wire(arr)).chord_templates[0] == ct
def test_template_guide_tones_omitted_when_default():
"""An empty guide_tones (the default) produces no `guideTones` key."""
ct = ChordTemplate(name="Am", fingers=[-1] * 6, frets=[-1] * 6)
assert "guideTones" not in chord_template_to_wire(ct)
arr = arrangement_from_wire(arrangement_to_wire(
Arrangement(name="Rhythm", chord_templates=[ct])))
assert arr.chord_templates[0].guide_tones == []
@pytest.mark.parametrize("raw,expected", [
(None, []),
(12, []),
("4,10", []),
([12], []),
([-1], []),
([True, 3], [3]), # bool is an int subclass — rejected
([4, "x", 10, 11], [4, 10, 11]),
([0, 11], [0, 11]), # boundary values kept
])
def test_template_guide_tones_tolerates_malformed(raw, expected):
"""Non-int / out-of-range guideTones entries are dropped off the wire."""
arr = arrangement_from_wire({
"name": "Rhythm",
"templates": [{"name": "Am", "fingers": [-1] * 6, "frets": [-1] * 6,
"guideTones": raw}],
})
assert arr.chord_templates[0].guide_tones == expected
# ── Arrangement round-trip ───────────────────────────────────────────────────
def test_arrangement_empty_round_trip():
arr = Arrangement(name="Lead")
assert arrangement_from_wire(arrangement_to_wire(arr)) == arr
def test_arrangement_full_round_trip():
arr = Arrangement(
name="Rhythm",
tuning=[-2, 0, 0, 0, 0, 0],
capo=2,
notes=[
Note(time=1.0, string=0, fret=3, palm_mute=True),
Note(time=1.5, string=1, fret=5, hammer_on=True),
],
chords=[
Chord(
time=2.0, chord_id=1, high_density=True,
notes=[
Note(time=2.0, string=0, fret=0),
Note(time=2.0, string=1, fret=2),
],
),
],
anchors=[
Anchor(time=0.0, fret=1, width=4),
Anchor(time=10.0, fret=7, width=5),
],
hand_shapes=[
HandShape(chord_id=1, start_time=2.0, end_time=2.5),
],
chord_templates=[
# Spec defaults displayName to name on the wire, so the round-trip
# surfaces an explicit display_name="Em" on the deserialised side
# even when none was set on the source dataclass. Make it explicit
# here so the strict-equality assertion captures the contract.
ChordTemplate(
name="Em",
display_name="Em",
fingers=[-1, -1, 2, 3, -1, -1],
frets=[0, 2, 2, 0, 0, 0],
),
],
)
assert arrangement_from_wire(arrangement_to_wire(arr)) == arr
def test_arrangement_default_tuning_is_six_zeros():
arr = Arrangement(name="Bass")
assert arr.tuning == [0, 0, 0, 0, 0, 0]
def test_arrangement_from_wire_missing_fields_use_defaults():
# Minimal wire dict — every list field defaults to empty, capo to 0,
# tuning to six zeros.
arr = arrangement_from_wire({"name": "Lead"})
assert arr.name == "Lead"
assert arr.tuning == [0, 0, 0, 0, 0, 0]
assert arr.capo == 0
assert arr.notes == []
assert arr.chords == []
assert arr.anchors == []
assert arr.hand_shapes == []
assert arr.chord_templates == []
# phrases is the "slider disabled" sentinel — absent key → None, NOT [].
assert arr.phrases is None
# ── Phrase / master-difficulty round-trip (feedBack#48) ─────────────────────
def test_phrase_empty_round_trip():
p = Phrase(start_time=0.0, end_time=10.0, max_difficulty=0, levels=[])
assert phrase_from_wire(phrase_to_wire(p)) == p
def test_phrase_times_rounded_to_three_decimals():
# Pin the rounding behaviour for start_time / end_time so accidental
# precision changes (which would shift frontend event timing or break
# sloppak round-trips) are caught by the suite.
p = Phrase(start_time=1.234567, end_time=9.876543, max_difficulty=0, levels=[])
wire = phrase_to_wire(p)
assert wire["start_time"] == 1.235
assert wire["end_time"] == 9.877
def test_phrase_with_multiple_levels_round_trip():
p = Phrase(
start_time=4.5, end_time=12.25, max_difficulty=2,
levels=[
PhraseLevel(
difficulty=0,
notes=[Note(time=5.0, string=0, fret=3)],
chords=[],
anchors=[Anchor(time=5.0, fret=3, width=4)],
hand_shapes=[],
),
PhraseLevel(
difficulty=1,
notes=[
Note(time=5.0, string=0, fret=3),
Note(time=6.5, string=1, fret=5, palm_mute=True),
],
chords=[],
anchors=[Anchor(time=5.0, fret=3, width=4)],
hand_shapes=[],
),
PhraseLevel(
difficulty=2,
notes=[
Note(time=5.0, string=0, fret=3),
Note(time=6.5, string=1, fret=5, palm_mute=True),
],
chords=[
Chord(
time=8.0, chord_id=1,
notes=[
Note(time=8.0, string=0, fret=0),
Note(time=8.0, string=1, fret=2),
],
),
],
anchors=[Anchor(time=5.0, fret=3, width=4)],
hand_shapes=[HandShape(chord_id=1, start_time=8.0, end_time=8.5)],
),
],
)
assert phrase_from_wire(phrase_to_wire(p)) == p
def test_arrangement_with_phrases_round_trip():
arr = Arrangement(
name="Lead",
phrases=[
Phrase(
start_time=0.0, end_time=8.0, max_difficulty=1,
levels=[
PhraseLevel(difficulty=0, notes=[Note(time=1.0, string=0, fret=0)]),
PhraseLevel(difficulty=1, notes=[
Note(time=1.0, string=0, fret=0),
Note(time=2.0, string=0, fret=2),
]),
],
),
],
)
assert arrangement_from_wire(arrangement_to_wire(arr)) == arr
def test_arrangement_wire_omits_phrases_when_none():
# Slider-disabled sentinel: arrangements without phrase data must NOT
# emit a "phrases" key. Frontends distinguish by presence, not value.
arr = Arrangement(name="Bass")
wire = arrangement_to_wire(arr)
assert "phrases" not in wire
def test_arrangement_wire_emits_phrases_when_set():
arr = Arrangement(
name="Lead",
phrases=[Phrase(start_time=0.0, end_time=4.0, max_difficulty=0, levels=[])],
)
wire = arrangement_to_wire(arr)
assert "phrases" in wire
assert wire["phrases"] == [{
"start_time": 0.0, "end_time": 4.0,
"max_difficulty": 0, "levels": [],
}]
def test_arrangement_wire_omits_phrases_when_empty_list():
# An empty list means "no phrase data" just like None — emitting
# `"phrases": []` would signal slider-enabled-but-no-ladder, which
# is an invalid state for consumers. Normalize at the wire boundary.
arr = Arrangement(name="Rhythm", phrases=[])
wire = arrangement_to_wire(arr)
assert "phrases" not in wire
def test_arrangement_from_wire_empty_phrases_list_becomes_none():
# Symmetric: an explicit `"phrases": []` on the wire must deserialize
# to the None sentinel so the slider-disabled signal is preserved.
arr = arrangement_from_wire({"name": "X", "phrases": []})
assert arr.phrases is None
def test_phrase_wire_is_json_safe():
p = Phrase(
start_time=1.234, end_time=5.678, max_difficulty=1,
levels=[
PhraseLevel(
difficulty=1,
notes=[Note(time=2.0, string=0, fret=5, sustain=0.5, tap=True)],
chords=[
Chord(time=3.0, chord_id=2, high_density=True,
notes=[Note(time=3.0, string=0, fret=0)]),
],
anchors=[Anchor(time=2.0, fret=5, width=4)],
hand_shapes=[HandShape(chord_id=2, start_time=3.0, end_time=3.5)],
),
],
)
wire = phrase_to_wire(p)
# allow_nan=False rejects Infinity/NaN — which JS JSON.parse
# also rejects. Keeps the wire strictly browser-compatible.
assert json.loads(json.dumps(wire, allow_nan=False)) == wire
# ── tones wire round-trip ─────────────────────────────────────────────────────
def test_arrangement_tones_round_trip():
tones = {
"base": "Clean",
"changes": [{"t": 12.5, "name": "Drive"}],
"definitions": [{"Name": "Clean", "Key": "Tone_A", "GearList": {}}],
}
arr = Arrangement(name="Lead", tones=tones)
wire = arrangement_to_wire(arr)
assert wire["tones"] == tones
assert arrangement_from_wire(wire).tones == tones
def test_arrangement_without_tones_omits_wire_key():
wire = arrangement_to_wire(Arrangement(name="Lead"))
assert "tones" not in wire
assert arrangement_from_wire(wire).tones is None
def test_arrangement_from_wire_ignores_non_dict_tones():
# A malformed `tones` value must not crash the loader.
assert arrangement_from_wire({"name": "Lead", "tones": []}).tones is None
def test_arrangement_tones_wire_is_json_safe():
# `definitions` is copied verbatim from the archive manifest — the wire
# output must still be strict JSON (allow_nan=False, as the browser's
# JSON.parse requires).
arr = Arrangement(name="Lead", tones={
"base": "Clean",
"changes": [{"t": 12.5, "name": "Drive"}],
"definitions": [{
"Name": "Clean", "Key": "Tone_A",
"GearList": {"Amp": {"Type": "Amp_Twin",
"KnobValues": {"Gain": 45.5}}},
}],
})
wire = arrangement_to_wire(arr)
assert json.loads(json.dumps(wire, allow_nan=False)) == wire
def test_arrangement_from_wire_empty_tones_dict_becomes_none():
# An empty `{}` normalizes to None, symmetric with arrangement_to_wire
# only emitting the key when arr.tones is truthy.
assert arrangement_from_wire({"name": "Lead", "tones": {}}).tones is None
# ── Dataclass defaults ───────────────────────────────────────────────────────
def test_note_defaults():
n = Note(time=0.0, string=0, fret=0)
assert n.sustain == 0.0
assert n.slide_to == -1
assert n.slide_unpitch_to == -1
assert n.bend == 0.0
assert n.hammer_on is False
assert n.pull_off is False
assert n.harmonic is False
assert n.harmonic_pinch is False
assert n.palm_mute is False
assert n.mute is False
assert n.vibrato is False
assert n.tremolo is False
assert n.accent is False
assert n.link_next is False
assert n.tap is False
def test_anchor_default_width_is_four():
a = Anchor(time=0.0, fret=1)
assert a.width == 4
def test_chord_default_high_density_is_false():
c = Chord(time=0.0, chord_id=0)
assert c.high_density is False
assert c.notes == []
# ── JSON-safety (#41) ────────────────────────────────────────────────────────
# The *_to_wire functions are documented as producing "JSON-ready" dicts that
# the highway WebSocket streams to the client. These tests catch things the
# Python-level round-trip tests above don't: non-JSON-native values (Path,
# Decimal, dataclass, set), and tuples (which JSON coerces to lists, failing
# the round-trip equality check).
def test_note_to_wire_is_json_safe():
n = Note(
time=0.5, string=0, fret=3,
sustain=0.25, slide_to=7, slide_unpitch_to=9, bend=1.0,
hammer_on=True, pull_off=True,
harmonic=True, harmonic_pinch=True,
palm_mute=True, mute=True,
vibrato=True,
tremolo=True, accent=True, tap=True,
)
wire = note_to_wire(n)
# allow_nan=False rejects Infinity/NaN — which JS JSON.parse
# also rejects. Keeps the wire strictly browser-compatible.
assert json.loads(json.dumps(wire, allow_nan=False)) == wire
def test_note_from_wire_accepts_vibrato_flag():
n = note_from_wire({"t": 1.0, "s": 2, "f": 7, "vb": True})
assert n.vibrato is True
legacy = note_from_wire({"t": 1.0, "s": 2, "f": 7, "vibrato": True})
assert legacy.vibrato is True
def test_chord_to_wire_is_json_safe():
c = Chord(
time=2.0, chord_id=5, high_density=True,
notes=[
Note(time=2.0, string=0, fret=3, palm_mute=True),
Note(time=2.0, string=1, fret=5),
Note(time=2.0, string=2, fret=5),
],
)
wire = chord_to_wire(c)
# allow_nan=False rejects Infinity/NaN — which JS JSON.parse
# also rejects. Keeps the wire strictly browser-compatible.
assert json.loads(json.dumps(wire, allow_nan=False)) == wire
def test_arrangement_to_wire_is_json_safe():
# Same shape as test_arrangement_full_round_trip — exercises every nested
# list / dict / int / str / bool path the wire format emits.
arr = Arrangement(
name="Rhythm",
tuning=[-2, 0, 0, 0, 0, 0],
capo=2,
notes=[
Note(time=1.0, string=0, fret=3, palm_mute=True),
Note(time=1.5, string=1, fret=5, hammer_on=True),
],
chords=[
Chord(
time=2.0, chord_id=1, high_density=True,
notes=[
Note(time=2.0, string=0, fret=0),
Note(time=2.0, string=1, fret=2),
],
),
],
anchors=[
Anchor(time=0.0, fret=1, width=4),
Anchor(time=10.0, fret=7, width=5),
],
hand_shapes=[
HandShape(chord_id=1, start_time=2.0, end_time=2.5),
],
chord_templates=[
ChordTemplate(
name="Em",
fingers=[-1, -1, 2, 3, -1, -1],
frets=[0, 2, 2, 0, 0, 0],
),
],
)
wire = arrangement_to_wire(arr)
# allow_nan=False rejects Infinity/NaN — which JS JSON.parse
# also rejects. Keeps the wire strictly browser-compatible.
assert json.loads(json.dumps(wire, allow_nan=False)) == wire
# ── Wire-format default-value fallbacks (#44) ────────────────────────────────
# Pin the fallback values embedded in arrangement_from_wire() so future
# refactors can't silently change what a sparse wire dict deserializes to.
def test_anchor_missing_width_defaults_to_four():
# arrangement_from_wire: `width=int(a.get("width", 4))` at song.py:198
arr = arrangement_from_wire({
"name": "Lead",
"anchors": [{"time": 0.0, "fret": 1}], # no "width" key
})
assert len(arr.anchors) == 1
assert arr.anchors[0].width == 4
def test_chord_template_missing_fingers_frets_defaults_to_negative_ones():
# arrangement_from_wire: fingers/frets default to `[-1] * 6` at song.py:209-210
arr = arrangement_from_wire({
"name": "Rhythm",
"templates": [{"name": "Em"}], # no "fingers" or "frets" keys
})
assert len(arr.chord_templates) == 1
ct = arr.chord_templates[0]
assert ct.name == "Em"
assert ct.fingers == [-1, -1, -1, -1, -1, -1]
assert ct.frets == [-1, -1, -1, -1, -1, -1]
def test_chord_with_empty_notes_list_round_trips():
# A chord with no notes (unusual but valid input) should survive round-trip.
c = Chord(time=1.0, chord_id=3, notes=[])
assert chord_from_wire(chord_to_wire(c)) == c
# ── arrangement_string_count (feedBack-plugin-3dhighway#7) ──────────────────
def test_string_count_4_for_bass_arrangement_with_full_string_usage():
# 4-string bass: notes reference strings 0..3.
arr = Arrangement(
name="Bass",
notes=[
Note(time=0.0, string=0, fret=3),
Note(time=1.0, string=2, fret=5),
Note(time=2.0, string=3, fret=0),
],
)
assert arrangement_string_count(arr) == 4
def test_string_count_4_for_bass_with_sparse_string_usage():
# 4-string bass with notes only on strings 0..2. Notes-derived
# gives 3, but the name-based fallback bumps it to 4. This is
# the case codex flagged as broken under the pure notes-derived
# approach — a real-world bass line that doesn't touch the high
# G string still has 4 strings on the instrument.
arr = Arrangement(
name="Bass",
notes=[
Note(time=0.0, string=0, fret=3),
Note(time=1.0, string=1, fret=5),
Note(time=2.0, string=2, fret=0),
],
)
assert arrangement_string_count(arr) == 4
def test_string_count_6_for_standard_guitar_with_full_string_usage():
# Notes spread across all 6 strings.
arr = Arrangement(
name="Lead",
notes=[Note(time=float(i), string=i, fret=0) for i in range(6)],
)
assert arrangement_string_count(arr) == 6
def test_string_count_6_for_guitar_with_sparse_string_usage():
# 6-string lead chart with notes only on strings 0..4 (never
# touches string 5, the highest-index string in RS indexing).
# Notes-derived gives 5; name-based fallback (anything-not-bass
# = 6) bumps to the correct 6.
arr = Arrangement(
name="Lead",
notes=[Note(time=float(i), string=i, fret=0) for i in range(5)],
)
assert arrangement_string_count(arr) == 6
def test_string_count_uses_chord_notes_when_higher_than_single_notes():
# Single notes only touch strings 02; the chord touches string 5.
arr = Arrangement(
name="Rhythm",
notes=[Note(time=0.0, string=0, fret=0), Note(time=1.0, string=2, fret=3)],
chords=[Chord(time=2.0, chord_id=0, notes=[
Note(time=2.0, string=4, fret=0),
Note(time=2.0, string=5, fret=0),
])],
)
assert arrangement_string_count(arr) == 6
def test_string_count_empty_bass_arrangement_returns_4():
# Empty arrangement named "Bass" — name-based fallback wins.
arr = Arrangement(name="Bass")
assert arrangement_string_count(arr) == 4
def test_string_count_empty_non_bass_arrangement_returns_6():
# Empty non-bass arrangement defaults to the canonical 6.
arr = Arrangement(name="Lead")
assert arrangement_string_count(arr) == 6
def test_string_count_7_for_extended_range_guitar():
# 7-string guitar (GP-imported sources may carry these). Notes
# span 0..6, so the notes-derived count is 7. The name-based
# fallback gives 6, but max() picks the higher value — extended-
# range arrangements are correctly handled WITHOUT having to
# special-case "7-string" in the name.
arr = Arrangement(
name="Lead",
notes=[Note(time=float(i), string=i, fret=0) for i in range(7)],
)
assert arrangement_string_count(arr) == 7
def test_string_count_5_for_extended_range_bass():
# 5-string bass via GP import — notes span 0..4. Notes-derived
# gives 5; name-based gives 4; max picks 5. No special-casing
# for "5-string" in the arrangement name needed.
arr = Arrangement(
name="Bass",
notes=[Note(time=float(i), string=i, fret=0) for i in range(5)],
)
assert arrangement_string_count(arr) == 5
def test_string_count_name_match_is_case_insensitive():
arr_lower = Arrangement(name="bass")
arr_upper = Arrangement(name="BASS")
arr_mixed = Arrangement(name="Combo Bass") # substring match
assert arrangement_string_count(arr_lower) == 4
assert arrangement_string_count(arr_upper) == 4
assert arrangement_string_count(arr_mixed) == 4
def test_string_count_uses_tuning_length_for_sparse_extended_range_bass():
# A sloppak / GP-imported 5-string bass may encode the
# instrument range in tuning even if the chart never touches
# the highest string index. tuning_count (5) wins over
# notes_count (4) AND name_based (4) — extended-range bass
# without name-based hints still resolves correctly.
arr = Arrangement(
name="Bass",
tuning=[0, 0, 0, 0, 0],
notes=[Note(time=float(i), string=i, fret=0) for i in range(4)],
)
assert arrangement_string_count(arr) == 5
def test_string_count_uses_tuning_length_for_sparse_7_string_guitar():
# 7-string GP-imported guitar where the chart only uses
# strings 0..5 (sparse top-string usage). tuning_count (7) is
# the only reliable signal; notes_count gives 6 and name_based
# gives 6.
arr = Arrangement(
name="Lead",
tuning=[0, 0, 0, 0, 0, 0, 0],
notes=[Note(time=float(i), string=i, fret=0) for i in range(6)],
)
assert arrangement_string_count(arr) == 7
def test_string_count_ignores_rs_padded_tuning_for_bass():
# arrangement XML bass: tuning is padded to length 6 with zeros at
# indices 4-5. Even though len(tuning) == 6, we MUST NOT use
# that as a 6-string signal (would mis-classify bass as
# guitar). arrangement_string_count's `tuning_count = 0 if
# tuning_len == 6 else tuning_len` rule takes care of this.
arr = Arrangement(
name="Bass",
tuning=[0, -5, -10, -15, 0, 0], # bass with arrangement XML padding
notes=[Note(time=float(i), string=i, fret=0) for i in range(4)],
)
assert arrangement_string_count(arr) == 4
def test_string_count_4_for_path_bass_flag_without_bass_in_name():
# Archive/DLC bass whose manifest ArrangementName isn't "Bass" but whose
# <arrangementProperties> pathBass flag is set. Notes on 0..3, tuning
# padded to the arrangement-XML length of 6. Pre-fix, name_based forced
# 6 (name has no "bass"), so this returned 6 despite the authoritative
# instrument flag saying bass.
arr = Arrangement(
name="Low End",
path_bass=True,
tuning=[0, 0, 0, 0, 0, 0],
notes=[Note(time=float(i), string=i, fret=0) for i in range(4)],
)
assert arrangement_string_count(arr) == 4
def test_string_count_4_for_authored_bass_type_without_bass_in_name():
# Editor PR #335: an instrument `type` authored as bass on an arrangement
# whose NAME does not contain "bass" (the sloppak loader lifts the manifest
# `type` onto Arrangement.type). Notes on 0..3, tuning padded to 6.
# The editor lays out 4 lanes off the type; core must agree.
arr = Arrangement(
name="Low End",
type="bass",
tuning=[0, 0, 0, 0, 0, 0],
notes=[Note(time=float(i), string=i, fret=0) for i in range(4)],
)
assert arrangement_string_count(arr) == 4
def test_string_count_6_for_authored_guitar_type_no_regression():
# A non-bass authored type on a generic name still resolves to the
# canonical 6 — the type signal only pulls DOWN to 4 for bass.
arr = Arrangement(
name="Track 1",
type="guitar",
notes=[Note(time=float(i), string=i, fret=0) for i in range(5)],
)
assert arrangement_string_count(arr) == 6
def test_arrangement_is_bass_signal_safety():
# The manifest `type` is lifted onto arr.type verbatim; the helper must be
# safe against the messy shapes a hand-edited/loose source can produce.
from song import arrangement_is_bass
assert arrangement_is_bass(Arrangement(name="Low End", type="bass"))
assert arrangement_is_bass(Arrangement(name="Low End", type=" BASS ")) # ws/case
assert arrangement_is_bass(Arrangement(name="Low End", path_bass=True))
assert arrangement_is_bass(Arrangement(name="Slap Bass")) # legacy name
# Non-bass / absent signals stay False (back-compat: no bass signal → 6).
assert not arrangement_is_bass(Arrangement(name="Lead", type=""))
assert not arrangement_is_bass(Arrangement(name="Rhythm", type="guitar"))
assert not arrangement_is_bass(Arrangement(name="", type=""))
# ── compute_smart_names ───────────────────────────────────────────────────────
def _sarr(path_lead=False, path_rhythm=False, path_bass=False,
bonus_arr=False, represent=0, name="Combo") -> Arrangement:
return Arrangement(
name=name,
path_lead=path_lead,
path_rhythm=path_rhythm,
path_bass=path_bass,
bonus_arr=bonus_arr,
represent=represent,
)
def test_smart_names_single_lead():
assert compute_smart_names([_sarr(path_lead=True)]) == ["Lead"]
def test_smart_names_single_rhythm():
assert compute_smart_names([_sarr(path_rhythm=True)]) == ["Rhythm"]
def test_smart_names_single_bass():
assert compute_smart_names([_sarr(path_bass=True, name="Bass")]) == ["Bass"]
def test_smart_names_lead_and_alt_lead():
# represent=1 → standard ("Lead"); represent=0 → alternate ("Alt. Lead")
arrs = [
_sarr(path_lead=True, represent=0), # index 0 → Alt. Lead
_sarr(path_lead=True, represent=1), # index 1 → Lead (standard)
]
assert compute_smart_names(arrs) == ["Alt. Lead", "Lead"]
def test_smart_names_three_leads_main():
# represent=1 → Lead; represent=0 and represent=2 → Alt. Lead 1 / 2
# Alts are sorted by represent ascending: 0 comes before 2.
arrs = [
_sarr(path_lead=True, represent=0), # index 0 → Alt. Lead 1
_sarr(path_lead=True, represent=1), # index 1 → Lead (standard)
_sarr(path_lead=True, represent=2), # index 2 → Alt. Lead 2
]
assert compute_smart_names(arrs) == ["Alt. Lead 1", "Lead", "Alt. Lead 2"]
def test_smart_names_single_bonus_lead():
assert compute_smart_names([_sarr(path_lead=True, bonus_arr=True)]) == ["Bonus Lead"]
def test_smart_names_two_bonus_leads():
arrs = [
_sarr(path_lead=True, bonus_arr=True, represent=0),
_sarr(path_lead=True, bonus_arr=True, represent=1),
]
assert compute_smart_names(arrs) == ["Bonus Lead 1", "Bonus Lead 2"]
def test_smart_names_full_mix():
# 1 lead + 1 alt lead + 1 bonus lead + 1 rhythm + 1 bass
# index 0: represent=0 → Alt. Lead
# index 1: represent=1 → Lead (standard)
# index 2: bonus_arr → Bonus Lead
# index 3: represent=0, single rhythm → Rhythm (fallback: no represent=1)
# index 4: represent=0, single bass → Bass (fallback: no represent=1)
arrs = [
_sarr(path_lead=True, represent=0),
_sarr(path_lead=True, represent=1),
_sarr(path_lead=True, bonus_arr=True, represent=0),
_sarr(path_rhythm=True, represent=0),
_sarr(path_bass=True, represent=0, name="Bass"),
]
assert compute_smart_names(arrs) == [
"Alt. Lead", "Lead", "Bonus Lead", "Rhythm", "Bass"
]
def test_smart_names_unknown_name_returns_none():
# Arrangement without path flags and a name outside the fallback set
# (Lead / Rhythm / Bass / Combo) → None. Distinct from Vocals/ShowLights,
# which have their own explicit-skip coverage below.
assert compute_smart_names([_sarr(name="JustSomethingElse")]) == [None]
def test_smart_names_name_fallback_when_path_flags_zero():
# custom song often leaves path flags at 0; fall back to arrangement name
arrs = [_sarr(name="Lead"), _sarr(name="Rhythm"), _sarr(name="Bass")]
assert compute_smart_names(arrs) == ["Lead", "Rhythm", "Bass"]
def test_smart_names_combo_treated_as_lead():
# "Combo" is a guitar arrangement — treated as Lead type for smart naming
arrs = [_sarr(name="Combo")]
assert compute_smart_names(arrs) == ["Lead"]
def test_smart_names_recognises_display_names_from_load_song():
# load_song() synthesises display names like "Bonus Lead" / "Bass 2"
# when manifest JSON is missing. compute_smart_names must classify them
# via the name fallback (and infer bonus_arr for "Bonus *") so they
# don't fall through to None and break smart-mode filtering.
arrs = [
_sarr(name="Lead"), # standard main
_sarr(name="Bonus Lead"), # bonus → "Bonus Lead"
_sarr(name="Bass 2"), # bass-typed → "Bass" (alone in its group)
]
assert compute_smart_names(arrs) == ["Lead", "Bonus Lead", "Bass"]
def test_smart_names_multiple_combos_get_alt_names():
# 3 Combo tracks with represent=0 → Lead, Alt. Lead 1, Alt. Lead 2
arrs = [_sarr(name="Combo"), _sarr(name="Combo"), _sarr(name="Combo")]
assert compute_smart_names(arrs) == ["Lead", "Alt. Lead 1", "Alt. Lead 2"]
def test_smart_names_combo_and_bass_mixed():
# Real-world custom song: 3 Combo + 1 Bass, all path flags zero
arrs = [
_sarr(name="Combo"),
_sarr(name="Combo"),
_sarr(name="Combo"),
_sarr(name="Bass"),
]
names = compute_smart_names(arrs)
assert names == ["Lead", "Alt. Lead 1", "Alt. Lead 2", "Bass"]
def test_smart_names_path_flags_take_priority_over_name():
# If path_rhythm is set, an arrangement named "Lead" is still Rhythm
arrs = [_sarr(name="Lead", path_rhythm=True)]
assert compute_smart_names(arrs) == ["Rhythm"]
def test_smart_names_represent_ordering():
# Neither arrangement has represent=1, so the fallback applies:
# sort alts by represent ascending and promote the first as standard.
# represent=2 (index 1) < represent=5 (index 0) → index 1 becomes "Lead".
arrs = [
_sarr(path_lead=True, represent=5),
_sarr(path_lead=True, represent=2),
]
names = compute_smart_names(arrs)
assert names[0] == "Alt. Lead"
assert names[1] == "Lead"
def test_smart_names_vocals_returns_none():
# "Vocals" and other non-instrument names return null
assert compute_smart_names([_sarr(name="Vocals")]) == [None]
assert compute_smart_names([_sarr(name="ShowLights")]) == [None]
def test_smart_names_arrangement_properties_defaults():
# Verify new dataclass fields have correct defaults
arr = Arrangement(name="Lead")
assert arr.path_lead is False
assert arr.path_rhythm is False
assert arr.path_bass is False
assert arr.bonus_arr is False
assert arr.represent == 0
# ── tempos (per-chart §6.10 + shared sanitizer) ──────────────────────────────
def test_sanitize_tempos_filters_sorts_and_coerces():
assert sanitize_tempos([
{"time": 2.0, "bpm": 90},
{"time": 0.0, "bpm": 120},
{"time": 1.0, "bpm": 0}, # bpm <= 0 -> dropped
{"time": float("nan"), "bpm": 100}, # non-finite time -> dropped
{"bpm": 100}, # missing time -> dropped
{"time": 3.0, "bpm": float("inf")}, # non-finite bpm -> dropped
"x", # non-dict -> dropped
]) == [{"time": 0.0, "bpm": 120.0}, {"time": 2.0, "bpm": 90.0}]
assert sanitize_tempos(None) == []
assert sanitize_tempos("nope") == []
def test_arrangement_tempos_round_trip_and_omitted_when_absent():
arr = arrangement_from_wire({
"name": "Bass", "tuning": [0, 0, 0, 0, 0, 0], "capo": 0,
"tempos": [{"time": 0.0, "bpm": 60}, {"time": 2.0, "bpm": 120}],
})
assert arr.tempos == [{"time": 0.0, "bpm": 60.0}, {"time": 2.0, "bpm": 120.0}]
assert arrangement_to_wire(arr)["tempos"] == \
[{"time": 0.0, "bpm": 60.0}, {"time": 2.0, "bpm": 120.0}]
# Absent per-chart tempos -> None, and the wire key is OMITTED (not []),
# so the chart follows the song-level tempo (spec §6.10).
arr2 = arrangement_from_wire({"name": "Lead", "tuning": [0] * 6, "capo": 0})
assert arr2.tempos is None
assert "tempos" not in arrangement_to_wire(arr2)