feedBack/tests/test_parse_arrangement.py
Bret Mogilefsky af2949677a
rename: slopsmith → feedBack, byron → got-feedBack (#537)
* Update GitHub repo references from feedback* to feedBack*

* rename: slopsmith -> feedBack, byron -> got-feedBack

Renames across the entire codebase:
- slopsmith/Slopsmith/SLOPSMITH/SlopSmith -> feedBack/FeedBack/FEEDBACK/FeedBack
- byron/Byron/Byrongamatos -> got-feedBack/got-feedBack/got-feedBack
- /home/byron/ -> /opt/got-feedBack/
- byron@ougsoft.com -> hi@got-feedBack.org
- github.com/byrongamatos/ -> github.com/got-feedback/
- com.byron. -> com.got-feedback.
- SLOPSMITH_ env vars -> FEEDBACK_ with backward-compat fallback
- Protocol/storage strings migrated with read-old/write-new pattern
- window.slopsmith JS API -> window.feedBack (canonical) + backward-compat alias

Refs: #rename-slopsmith

* rename: complete regen against current main + fix backward-compat alias

Regenerated the slopsmith->feedBack / byron->got-feedBack rename on top of
current main (3 commits had landed since the branch: #572/#554/#574),
resolving the four content conflicts in favour of main's newer content
(autoplay/auto-exit, accuracy-badge, Virtuoso re-home, feedpak badge).

Completion fixes on top of the mechanical rename:
- Re-apply rename to post-branch content the original rename never saw:
  window.slopsmith(.Tour) consumers in lessons.js / notifications.js /
  onboarding-tour.js, and the matching JS + python tests (autoplay_exit,
  progression_*, test_feedpak_extension FEEDBACK_* env vars). The test env
  vars now match server.py (which reads FEEDBACK_SYNC_STARTUP /
  FEEDBACK_SKIP_STARTUP_TASKS), so the sync-startup test exercises the real
  path again.
- Restore the window.slopsmith backward-compat alias dropped during conflict
  resolution, and move the bus aliases to AFTER the _feedBackExisting merge
  block so they reference the fully-assembled object (also fixes the
  loop_api.test.js API-surface regex, which the original PR latently broke).
- Drop the stray empty data/web_library.db (runtime DB lives in CONFIG_DIR)
  and gitignore it.
- Fix stale tone-source test: feed[dB]ack -> fee[dB]ack to match shipped
  source labels.

Verified locally (org CI billing-blocked): JS 819/819 pass; pytest 1669
passed / 1683 collected with 0 import errors; zero residual slopsmith/byron
except the two intentional window.slopsmith aliases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* rename: implement advertised backward-compat + prune dead community plugins

Address gaps where PR #537's "Backward compatibility" section was advertised
but not implemented, and clean up the community plugin list.

Env vars (FEEDBACK_* canonical, legacy SLOPSMITH_* honoured):
- New lib/env_compat.py (getenv_compat / env_flag_compat) + tests. server.py
  (_env_flag + all FEEDBACK_* reads), diagnostics_hardware, gp2midi and
  tailwind_rebuild now resolve the legacy alias, so existing SLOPSMITH_UI /
  SLOPSMITH_PLUGINS_DIR / etc. deployments keep working.
- Fix the rename collapsing plugins/__init__.py and minigames/routes.py from
  `FEEDBACK_PLUGINS_DIR or SLOPSMITH_PLUGINS_DIR` into a redundant
  `FEEDBACK_ or FEEDBACK_` (the fallback was silently lost).

Storage (app.js update-channel):
- Read feedBack-update-channel, fall back to legacy slopsmith-update-channel,
  and clear the legacy key on write — so a user's update-channel preference
  survives the rename instead of resetting to "stable".

Community plugin list (README): the rename rewrote third-party repo URLs we
don't own. Probed every one; their owners never renamed, so:
- Restore the 13 live community plugins to their real slopsmith-* names.
- Prune 6 that are 404 to the public (topkoa splitscreen/stems, OmikronApex
  tuner, Jafz2001 nam-rig-builder, DeathlySin song-preview, Erikcb91 shuffle).
- Fix a pre-existing Guitar Theory clone-command typo (nam-tone -> guitar-theory).

Verified: env_compat 7/7, JS 819/819, pytest 1690 collected / 0 import errors,
rename-sensitive + startup suites green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:03:01 +02:00

706 lines
25 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 parse_arrangement — XML → Arrangement.
Covers the per-phrase difficulty ladder logic added in feedBack#48
(Phrase / PhraseLevel extraction, None-sentinel semantics, the
fallback paths for missing or unusable phrase metadata) and the
<arrangementProperties> flag parsing for smart naming.
"""
import math
from song import parse_arrangement
def _write_xml(tmp_path, xml: str) -> str:
"""Write an XML snippet to a temp file and return its path."""
p = tmp_path / "arr.xml"
p.write_text(xml, encoding="utf-8")
return str(p)
_TUNING_AND_TEMPLATES = (
'<tuning string0="0" string1="0" string2="0" string3="0" string4="0" string5="0"/>'
"<chordTemplates/>"
)
def test_hand_shape_parses_arpeggio_attribute(tmp_path):
xml = (
"<song>"
+ _TUNING_AND_TEMPLATES
+ '<levels count="1">'
+ '<level difficulty="0">'
+ '<notes count="0"/>'
+ '<chords count="0"/>'
+ '<anchors count="0"/>'
+ '<handShapes count="1">'
+ '<handShape chordId="2" startTime="1.0" endTime="2.0" arpeggio="1"/>'
+ "</handShapes>"
+ "</level>"
+ "</levels>"
+ "</song>"
)
arr = parse_arrangement(_write_xml(tmp_path, xml))
assert len(arr.hand_shapes) == 1
assert arr.hand_shapes[0].chord_id == 2
assert arr.hand_shapes[0].arpeggio is True
def test_chord_template_parses_display_name_arpeggio_marker(tmp_path):
xml = (
"<song>"
+ '<tuning string0="0" string1="0" string2="0" string3="0" string4="0" string5="0"/>'
+ '<chordTemplates count="1">'
+ '<chordTemplate chordName="Amin" displayName="Amin-arp" '
+ 'finger1="1" finger2="2" fret1="5" fret2="7"/>'
+ "</chordTemplates>"
+ '<levels count="1">'
+ '<level difficulty="0">'
+ '<notes count="0"/>'
+ '<chords count="0"/>'
+ '<anchors count="0"/>'
+ '<handShapes count="0"/>'
+ "</level>"
+ "</levels>"
+ "</song>"
)
arr = parse_arrangement(_write_xml(tmp_path, xml))
assert len(arr.chord_templates) == 1
assert arr.chord_templates[0].name == "Amin"
assert arr.chord_templates[0].display_name == "Amin-arp"
assert arr.chord_templates[0].arpeggio is True
def test_chord_template_parses_explicit_arpeggio_attribute(tmp_path):
xml = (
"<song>"
+ '<tuning string0="0" string1="0" string2="0" string3="0" string4="0" string5="0"/>'
+ '<chordTemplates count="1">'
+ '<chordTemplate chordName="Amin" displayName="Amin" arpeggio="true" '
+ 'finger1="1" finger2="2" fret1="5" fret2="7"/>'
+ "</chordTemplates>"
+ '<levels count="1">'
+ '<level difficulty="0">'
+ '<notes count="0"/>'
+ '<chords count="0"/>'
+ '<anchors count="0"/>'
+ '<handShapes count="0"/>'
+ "</level>"
+ "</levels>"
+ "</song>"
)
arr = parse_arrangement(_write_xml(tmp_path, xml))
assert len(arr.chord_templates) == 1
assert arr.chord_templates[0].name == "Amin"
assert arr.chord_templates[0].display_name == "Amin"
assert arr.chord_templates[0].arpeggio is True
def test_parse_note_parses_vibrato_attribute(tmp_path):
xml = (
"<song>"
+ _TUNING_AND_TEMPLATES
+ '<levels count="1">'
+ '<level difficulty="0">'
+ '<notes count="1">'
+ '<note time="1.0" string="0" fret="5" sustain="0.5" vibrato="1"/>'
+ "</notes>"
+ '<chords count="0"/>'
+ '<anchors count="0"/>'
+ '<handShapes count="0"/>'
+ "</level>"
+ "</levels>"
+ "</song>"
)
arr = parse_arrangement(_write_xml(tmp_path, xml))
assert len(arr.notes) == 1
assert arr.notes[0].vibrato is True
def _song(levels_xml: str, phrases_xml: str = "", iters_xml: str = "") -> str:
return (
"<song>"
+ _TUNING_AND_TEMPLATES
+ levels_xml
+ phrases_xml
+ iters_xml
+ "</song>"
)
def _level(diff: int, notes: list[tuple[float, int, int]]) -> str:
note_elems = "".join(
f'<note time="{t}" string="{s}" fret="{f}" sustain="0"/>'
for (t, s, f) in notes
)
return (
f'<level difficulty="{diff}">'
f'<notes count="{len(notes)}">{note_elems}</notes>'
'<chords count="0"/>'
'<anchors count="0"/>'
'<handShapes count="0"/>'
"</level>"
)
# ── Happy path: multi-level XML with phrases ─────────────────────────────────
def test_parse_multi_level_populates_phrase_ladder(tmp_path):
# Two phrases, three difficulty tiers. Phrase 0 has max_diff=1 so
# only tiers 0 and 1 are in its ladder; phrase 1 has max_diff=2 so
# all three tiers are in its ladder. Flat merge = max-mastery per
# phrase window.
xml = _song(
'<levels count="3">'
+ _level(0, [(1.0, 0, 5)])
+ _level(1, [(1.0, 0, 5), (2.0, 1, 3)])
+ _level(2, [(1.0, 0, 5), (2.0, 1, 3), (3.0, 2, 7)])
+ "</levels>",
'<phrases>'
'<phrase maxDifficulty="1" name="a"/>'
'<phrase maxDifficulty="2" name="b"/>'
"</phrases>",
'<phraseIterations>'
'<phraseIteration time="0" phraseId="0"/>'
'<phraseIteration time="1.5" phraseId="1"/>'
"</phraseIterations>",
)
arr = parse_arrangement(_write_xml(tmp_path, xml))
assert arr.phrases is not None
assert len(arr.phrases) == 2
p0, p1 = arr.phrases
assert p0.max_difficulty == 1
assert [lv.difficulty for lv in p0.levels] == [0, 1]
assert p1.max_difficulty == 2
assert [lv.difficulty for lv in p1.levels] == [0, 1, 2]
# Per-level clipping works: phrase 0 window is [0.0, 1.5),
# so tier 1 has only the t=1.0 note (t=2.0 is outside).
p0_lv1 = next(lv for lv in p0.levels if lv.difficulty == 1)
assert [n.time for n in p0_lv1.notes] == [1.0]
# Phrase 1 tier 2 window [1.5, ∞) picks up t=2.0 and t=3.0.
p1_lv2 = next(lv for lv in p1.levels if lv.difficulty == 2)
assert [n.time for n in p1_lv2.notes] == [2.0, 3.0]
# Flat max-mastery merge still produces the full chart for
# existing consumers (phrase 0 @ tier 1 = [1.0], phrase 1 @ tier 2
# = [2.0, 3.0]).
assert [(n.time, n.fret) for n in arr.notes] == [(1.0, 5), (2.0, 3), (3.0, 7)]
# ── Single-level XML: no phrase metadata needed ──────────────────────────────
def test_parse_single_level_disables_slider(tmp_path):
# Single-level charts (e.g. GP-converted) skip the phrase branch
# entirely — phrases stays None so the frontend knows to disable
# the slider, and the flat lists get the one level directly.
xml = _song(
'<levels count="1">' + _level(0, [(1.0, 0, 5), (2.0, 1, 3)]) + "</levels>",
)
arr = parse_arrangement(_write_xml(tmp_path, xml))
assert arr.phrases is None
assert [(n.time, n.fret) for n in arr.notes] == [(1.0, 5), (2.0, 3)]
# ── No phrase metadata at all → best-level fallback ──────────────────────────
def test_parse_multi_level_without_phrase_metadata_falls_back(tmp_path):
# Multiple levels but neither <phrases> nor <phraseIterations>.
# The best-level fallback picks the highest-count level.
xml = _song(
'<levels count="2">'
+ _level(0, [(1.0, 0, 5)])
+ _level(1, [(1.0, 0, 5), (2.0, 1, 3), (3.0, 2, 7)])
+ "</levels>",
)
arr = parse_arrangement(_write_xml(tmp_path, xml))
assert arr.phrases is None
assert [(n.time, n.fret) for n in arr.notes] == [(1.0, 5), (2.0, 3), (3.0, 7)]
# ── Empty phraseIterations → revert sentinel, run best-level fallback ────────
def test_parse_empty_phrase_iterations_reverts_to_best_level(tmp_path):
# <phraseIterations> present but empty. The phrase branch enters,
# produces no phrases, then the revert runs the best-level fallback
# inline so we don't ship an empty arrangement with the slider
# enabled against no ladder.
xml = _song(
'<levels count="2">'
+ _level(0, [(1.0, 0, 5)])
+ _level(1, [(1.0, 0, 5), (2.0, 1, 3)])
+ "</levels>",
'<phrases><phrase maxDifficulty="1" name="a"/></phrases>',
"<phraseIterations/>",
)
arr = parse_arrangement(_write_xml(tmp_path, xml))
assert arr.phrases is None
assert [(n.time, n.fret) for n in arr.notes] == [(1.0, 5), (2.0, 3)]
# ── max_diff below every authored level → skip iteration, still ship valid ──
def test_parse_skips_phrase_iteration_when_no_level_reaches_max_diff(tmp_path):
# Phrase 0 declares max_diff=0, but only levels 1 and 2 are authored.
# That iteration has nothing valid to contribute — skip it so the
# slider doesn't enable against an empty ladder. Phrase 1 still
# produces a valid ladder, so phrases is non-None and the flat
# merge comes from phrase 1 only.
xml = _song(
'<levels count="2">'
+ _level(1, [(2.0, 0, 5)])
+ _level(2, [(2.0, 0, 5), (3.0, 1, 3)])
+ "</levels>",
'<phrases>'
'<phrase maxDifficulty="0" name="unreachable"/>'
'<phrase maxDifficulty="2" name="ok"/>'
"</phrases>",
'<phraseIterations>'
'<phraseIteration time="0" phraseId="0"/>'
'<phraseIteration time="1.5" phraseId="1"/>'
"</phraseIterations>",
)
arr = parse_arrangement(_write_xml(tmp_path, xml))
assert arr.phrases is not None
assert len(arr.phrases) == 1
assert arr.phrases[0].max_difficulty == 2
assert arr.phrases[0].start_time == 1.5
# Flat merge only picks up phrase 1's contribution — phrase 0 was
# skipped, so the t=01.5 window produces nothing.
assert [(n.time, n.fret) for n in arr.notes] == [(2.0, 5), (3.0, 3)]
# ── Last phrase has finite end_time (not Infinity) ──────────────────────────
def test_parse_last_phrase_end_time_is_finite(tmp_path):
# The last phrase iteration has no "next" start time to use as its
# end, so parse_arrangement derives one from the last real event
# across all levels. Must be finite: this value ends up in
# Phrase.end_time on the WebSocket wire, and JSON has no Infinity
# literal — JS JSON.parse would reject it.
xml = _song(
'<levels count="2">'
+ _level(0, [(1.0, 0, 5)])
+ _level(1, [(1.0, 0, 5), (2.0, 1, 3)])
+ "</levels>",
'<phrases>'
'<phrase maxDifficulty="1" name="a"/>'
'<phrase maxDifficulty="1" name="b"/>'
"</phrases>",
'<phraseIterations>'
'<phraseIteration time="0" phraseId="0"/>'
'<phraseIteration time="1.5" phraseId="1"/>'
"</phraseIterations>",
)
arr = parse_arrangement(_write_xml(tmp_path, xml))
assert arr.phrases is not None
last = arr.phrases[-1]
assert math.isfinite(last.end_time)
# And it should be past the last real event (t=2.0) so the slice
# window includes it.
assert last.end_time > 2.0
# ── song_end covers anchors / hand shapes / sustains past the last note ────
def test_parse_last_phrase_end_time_covers_non_note_events(tmp_path):
# The last event in the chart isn't always a note or chord — an
# anchor, a hand shape's end_time, or a note's sustain extending
# past its start can all push the real song end past the last
# note/chord start. song_end must cover every event type so the
# final phrase window doesn't slice them out.
xml = (
"<song>"
+ _TUNING_AND_TEMPLATES
+ '<levels count="1">'
+ '<level difficulty="0">'
# Notes stop at t=2.0 (but with a long sustain out to t=10.0)
+ '<notes count="1"><note time="2.0" string="0" fret="5" sustain="8.0"/></notes>'
+ '<chords count="0"/>'
# Anchor way past the last note start
+ '<anchors count="1"><anchor time="20.0" fret="3" width="4"/></anchors>'
# Hand shape ending even later
+ '<handShapes count="1"><handShape chordId="0" startTime="5.0" endTime="25.0"/></handShapes>'
+ "</level>"
+ "</levels>"
+ '<phrases><phrase maxDifficulty="0" name="a"/></phrases>'
'<phraseIterations><phraseIteration time="0" phraseId="0"/></phraseIterations>'
+ "</song>"
)
# Need ≥2 levels to enter the phrase branch, so prepend a second
# level. Its contents don't matter for what this test checks —
# song_end is computed across all levels, and the anchor / hand
# shape on the original level 0 are what push the bound past the
# note starts.
xml = xml.replace(
'<levels count="1">',
'<levels count="2">' + _level(1, [(2.0, 0, 5)]),
)
arr = parse_arrangement(_write_xml(tmp_path, xml))
assert arr.phrases is not None
last = arr.phrases[-1]
# Must cover the hand-shape end (t=25.0), which is past every note
# and chord start time.
assert last.end_time > 25.0
# ── Trailing-silence phrase iteration past the last event ───────────────────
def test_parse_trailing_phrase_iteration_past_last_event(tmp_path):
# Some charts place a silent phrase marker well past the last
# authored note/chord. If song_end were derived from events only,
# the last phrase would get end_time < start_time — invalid window,
# empty slice. Bound song_end by iteration start times too.
xml = _song(
'<levels count="2">'
+ _level(0, [(1.0, 0, 5)])
+ _level(1, [(1.0, 0, 5)])
+ "</levels>",
'<phrases>'
'<phrase maxDifficulty="1" name="a"/>'
'<phrase maxDifficulty="1" name="silence"/>'
"</phrases>",
'<phraseIterations>'
'<phraseIteration time="0" phraseId="0"/>'
'<phraseIteration time="100.0" phraseId="1"/>'
"</phraseIterations>",
)
arr = parse_arrangement(_write_xml(tmp_path, xml))
assert arr.phrases is not None
last = arr.phrases[-1]
assert last.start_time == 100.0
assert last.end_time >= last.start_time
assert math.isfinite(last.end_time)
# ── All phrase iterations reference out-of-range phraseIds → fallback ───────
def test_parse_all_phrase_iterations_out_of_range_falls_back(tmp_path):
# phrase_list has only 1 entry but an iteration references phraseId=5.
# All iterations get skipped via `continue`, phrases stays empty,
# revert + best-level fallback kicks in.
xml = _song(
'<levels count="2">'
+ _level(0, [(1.0, 0, 5)])
+ _level(1, [(1.0, 0, 5), (2.0, 1, 3)])
+ "</levels>",
'<phrases><phrase maxDifficulty="1" name="a"/></phrases>',
'<phraseIterations>'
'<phraseIteration time="0" phraseId="5"/>'
"</phraseIterations>",
)
arr = parse_arrangement(_write_xml(tmp_path, xml))
assert arr.phrases is None
assert [(n.time, n.fret) for n in arr.notes] == [(1.0, 5), (2.0, 3)]
# ── Extended-range tuning + chord templates (7/8-string, 5/6-string bass) ───
def test_parse_arrangement_preserves_7_string_tuning(tmp_path):
"""`string6` on <tuning> must be appended so 7/8-string arrangements
round-trip through GP → XML → parse correctly."""
xml = (
"<song>"
'<tuning string0="0" string1="0" string2="0" string3="0" '
'string4="0" string5="0" string6="0"/>'
"<chordTemplates/>"
'<levels count="1">'
+ _level(0, [(1.0, 0, 5)])
+ "</levels>"
"</song>"
)
arr = parse_arrangement(_write_xml(tmp_path, xml))
assert len(arr.tuning) == 7
assert arr.tuning == [0, 0, 0, 0, 0, 0, 0]
def test_parse_arrangement_preserves_8_string_drop_tuning(tmp_path):
"""8-string with low F# (`string7`) and Drop D on the lowest string."""
xml = (
"<song>"
'<tuning string0="-2" string1="0" string2="0" string3="0" '
'string4="0" string5="0" string6="0" string7="0"/>'
"<chordTemplates/>"
'<levels count="1">'
+ _level(0, [(1.0, 0, 5)])
+ "</levels>"
"</song>"
)
arr = parse_arrangement(_write_xml(tmp_path, xml))
assert arr.tuning == [-2, 0, 0, 0, 0, 0, 0, 0]
def test_parse_arrangement_extended_chord_template(tmp_path):
"""fret6/finger6 attrs round-trip into the parsed ChordTemplate."""
xml = (
"<song>"
'<tuning string0="0" string1="0" string2="0" string3="0" '
'string4="0" string5="0" string6="0"/>'
"<chordTemplates>"
'<chordTemplate chordName="Em7-ext" '
'fret0="0" fret1="0" fret2="0" fret3="2" '
'fret4="2" fret5="0" fret6="0" '
'finger0="-1" finger1="-1" finger2="-1" finger3="2" '
'finger4="3" finger5="-1" finger6="-1"/>'
"</chordTemplates>"
'<levels count="1">'
+ _level(0, [(1.0, 0, 0)])
+ "</levels>"
"</song>"
)
arr = parse_arrangement(_write_xml(tmp_path, xml))
assert len(arr.chord_templates) == 1
ct = arr.chord_templates[0]
assert ct.name == "Em7-ext"
assert ct.frets == [0, 0, 0, 2, 2, 0, 0]
assert ct.fingers == [-1, -1, -1, 2, 3, -1, -1]
def test_parse_arrangement_6_string_template_no_width_inflation(tmp_path):
"""A plain 6-string chord template parses with length-6 frets/fingers
when no `fret6`/`finger6` is present — no spurious slot expansion."""
xml = (
"<song>"
'<tuning string0="0" string1="0" string2="0" string3="0" '
'string4="0" string5="0"/>'
"<chordTemplates>"
'<chordTemplate chordName="C" '
'fret0="-1" fret1="3" fret2="2" fret3="0" fret4="1" fret5="0" '
'finger0="-1" finger1="3" finger2="2" finger3="-1" '
'finger4="1" finger5="-1"/>'
"</chordTemplates>"
'<levels count="1">'
+ _level(0, [(1.0, 0, 0)])
+ "</levels>"
"</song>"
)
arr = parse_arrangement(_write_xml(tmp_path, xml))
ct = arr.chord_templates[0]
assert len(ct.frets) == 6
assert len(ct.fingers) == 6
# ── <arrangementProperties> parsing for smart naming ─────────────────────────
_EMPTY_LEVELS = (
'<levels count="1">'
'<level difficulty="0">'
'<notes count="0"/><chords count="0"/>'
'<anchors count="0"/><handShapes count="0"/>'
"</level></levels>"
)
def test_arrangement_properties_lead_parsed(tmp_path):
xml = (
"<song>"
+ _TUNING_AND_TEMPLATES
+ '<arrangementProperties pathLead="1" pathRhythm="0" pathBass="0"'
+ ' bonusArr="0" represent="3"/>'
+ _EMPTY_LEVELS
+ "</song>"
)
arr = parse_arrangement(_write_xml(tmp_path, xml))
assert arr.path_lead is True
assert arr.path_rhythm is False
assert arr.path_bass is False
assert arr.bonus_arr is False
assert arr.represent == 3
def test_arrangement_properties_bass_bonus_parsed(tmp_path):
xml = (
"<song>"
+ _TUNING_AND_TEMPLATES
+ '<arrangementProperties pathLead="0" pathRhythm="0" pathBass="1"'
+ ' bonusArr="1" represent="7"/>'
+ _EMPTY_LEVELS
+ "</song>"
)
arr = parse_arrangement(_write_xml(tmp_path, xml))
assert arr.path_lead is False
assert arr.path_bass is True
assert arr.bonus_arr is True
assert arr.represent == 7
def test_arrangement_properties_defaults_when_element_missing(tmp_path):
xml = (
"<song>"
+ _TUNING_AND_TEMPLATES
+ _EMPTY_LEVELS
+ "</song>"
)
arr = parse_arrangement(_write_xml(tmp_path, xml))
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
def test_arrangement_properties_represent_zero_is_default(tmp_path):
xml = (
"<song>"
+ _TUNING_AND_TEMPLATES
+ '<arrangementProperties pathLead="1" pathRhythm="0" pathBass="0"'
+ ' bonusArr="0" represent="0"/>'
+ _EMPTY_LEVELS
+ "</song>"
)
arr = parse_arrangement(_write_xml(tmp_path, xml))
assert arr.represent == 0
assert arr.path_lead is True
def test_chord_level_fret_hand_mute_maps_to_fret_hand_mute(tmp_path):
"""Chord-wide fretHandMute on a template-expanded chord sets
fret_hand_mute (wire "fhm") on each synthetic note, not mute ("mt") —
matching _parse_note and preserving wire-format fidelity."""
xml = (
"<song>"
+ '<tuning string0="0" string1="0" string2="0" string3="0" string4="0" string5="0"/>'
+ '<chordTemplates count="1">'
+ '<chordTemplate chordName="P5" displayName="P5" fret0="3" fret1="5"/>'
+ "</chordTemplates>"
+ '<levels count="1">'
+ '<level difficulty="0">'
+ '<notes count="0"/>'
+ '<chords count="1">'
+ '<chord time="1.0" chordId="0" fretHandMute="1"/>'
+ "</chords>"
+ '<anchors count="0"/>'
+ '<handShapes count="0"/>'
+ "</level>"
+ "</levels>"
+ "</song>"
)
arr = parse_arrangement(_write_xml(tmp_path, xml))
assert len(arr.chords) == 1
notes = arr.chords[0].notes
assert notes # template expanded into synthetic notes
assert all(n.fret_hand_mute is True for n in notes)
assert all(n.mute is False for n in notes)
def test_chord_level_fret_hand_mute_propagates_to_chord_notes(tmp_path):
"""A <chord fretHandMute="1"> with explicit <chordNote> children sets
fret_hand_mute on children that don't override it, without touching mute."""
xml = (
"<song>"
+ _TUNING_AND_TEMPLATES
+ '<levels count="1">'
+ '<level difficulty="0">'
+ '<notes count="0"/>'
+ '<chords count="1">'
+ '<chord time="1.0" chordId="0" fretHandMute="1">'
+ '<chordNote time="1.0" string="0" fret="3"/>'
+ '<chordNote time="1.0" string="1" fret="5" fretHandMute="0"/>'
+ "</chord>"
+ "</chords>"
+ '<anchors count="0"/>'
+ '<handShapes count="0"/>'
+ "</level>"
+ "</levels>"
+ "</song>"
)
arr = parse_arrangement(_write_xml(tmp_path, xml))
notes = arr.chords[0].notes
assert len(notes) == 2
assert notes[0].fret_hand_mute is True # inherits chord-level flag
assert notes[1].fret_hand_mute is False # explicit override preserved
assert all(n.mute is False for n in notes)
def test_cent_offset_parsed_from_xml(tmp_path):
xml = (
"<song>"
+ "<centOffset>-1200.0</centOffset>"
+ _TUNING_AND_TEMPLATES
+ '<levels count="1">'
+ '<level difficulty="0">'
+ '<notes count="0"/><chords count="0"/>'
+ '<anchors count="0"/><handShapes count="0"/>'
+ "</level></levels>"
+ "</song>"
)
arr = parse_arrangement(_write_xml(tmp_path, xml))
assert arr.cent_offset == -1200.0
def test_cent_offset_defaults_to_zero_when_absent(tmp_path):
xml = (
"<song>"
+ _TUNING_AND_TEMPLATES
+ '<levels count="1">'
+ '<level difficulty="0">'
+ '<notes count="0"/><chords count="0"/>'
+ '<anchors count="0"/><handShapes count="0"/>'
+ "</level></levels>"
+ "</song>"
)
arr = parse_arrangement(_write_xml(tmp_path, xml))
assert arr.cent_offset == 0.0
def test_cent_offset_round_trips_through_wire_format(tmp_path):
from song import arrangement_to_wire, arrangement_from_wire
xml = (
"<song>"
+ "<centOffset>-1200</centOffset>"
+ _TUNING_AND_TEMPLATES
+ '<levels count="1">'
+ '<level difficulty="0">'
+ '<notes count="0"/><chords count="0"/>'
+ '<anchors count="0"/><handShapes count="0"/>'
+ "</level></levels>"
+ "</song>"
)
arr = parse_arrangement(_write_xml(tmp_path, xml))
wire = arrangement_to_wire(arr)
assert wire["centOffset"] == -1200.0
arr2 = arrangement_from_wire(wire)
assert arr2.cent_offset == -1200.0
def test_cent_offset_non_finite_sanitized_to_zero(tmp_path):
# Malformed custom song can carry NaN/Infinity, which float() accepts but which
# serialize to invalid JSON tokens over the song_info WebSocket. Parsing
# must coerce them to a finite 0.0 so the payload stays valid JSON.
for bad in ("NaN", "Infinity", "-Infinity", "inf", "nan"):
xml = (
"<song>"
+ f"<centOffset>{bad}</centOffset>"
+ _TUNING_AND_TEMPLATES
+ '<levels count="1">'
+ '<level difficulty="0">'
+ '<notes count="0"/><chords count="0"/>'
+ '<anchors count="0"/><handShapes count="0"/>'
+ "</level></levels>"
+ "</song>"
)
arr = parse_arrangement(_write_xml(tmp_path, xml))
assert arr.cent_offset == 0.0, f"{bad!r} should sanitize to 0.0"
def test_cent_offset_non_finite_sanitized_through_wire(tmp_path):
from song import arrangement_from_wire
# A wire dict carrying a non-finite centOffset (e.g. from a hand-edited or
# corrupt sloppak JSON) is coerced to 0.0 on the way back in.
arr = arrangement_from_wire({"centOffset": float("nan")})
assert arr.cent_offset == 0.0