feedBack/tests/test_gp2rs_gpx.py
Byron Gamatos c8e0ad3f75
fix(gp-import): correct bass string count, lead/rhythm roles, preview note count (#601)
Four tester-reported GP-import issues, all in the converter/parse layer:

* String count (bugs 2 & 4): <tuning> is padded to 6 slots, so a 4-string
  bass, 5-string bass and 6-string guitar were byte-identical and the real
  count was lost — a 5-string bass played on 4 strings and a 4-string bass
  showed a phantom B in the editor. Record the authoritative count in a new
  <tuning stringCount=N> attribute (gp2rs._build_xml) and trim the padded
  tail back to it on read (song.parse_arrangement). All consumers already
  trust a non-6 tuning length (arrangement_string_count, the editor's
  _stringCountFor and build-time _normalize_tuning_to_count), so this fixes
  the create-mode preview AND the built sloppak with no consumer changes.

* Lead/Rhythm reversed (bug 3): guitar arrangements were named by appearance
  order (first guitar -> Lead), swapping roles for files that list Rhythm
  before Lead. Honor 'lead'/'rhythm' in the GP track name; unhinted tracks
  keep positional fallback. Applied to both convert_file's fallback (the
  editor's track_indices-without-names path) and _auto_select_gpx, with
  cross-role dedup so name-based and positional labels can't collide.

* Preview note count (bug 1): the importer's per-track count included
  tie-continuation notes, which are folded into the previous note's sustain
  and never become separate RS notes (260 shown vs 241 imported). Exclude
  tie destinations so the preview matches the imported result.

Adds regression tests for all three. Bug 5 (no stems from synced audio) is
environment-dependent (best-effort demucs backend) and not addressed here.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 18:58:35 +02:00

1162 lines
49 KiB
Python

"""Tests for lib/gp2rs_gpx.py — the Guitar Pro 6 (.gpx) import path.
Fixture-free: every test exercises a pure helper with hand-built inputs
(ElementTree fragments, tuning lists, crafted container headers). The binary
BCFZ/BCFS round-trip needs a real .gpx and is covered by manual validation in
the PR; here we pin the input-validation guards and the conversion helpers
that are easy to drive without a fixture.
"""
import struct
import xml.etree.ElementTree as ET
import pytest
import gp2rs_gpx
from gp2rs_gpx import convert_file
from gp2rs_gpx import (
_decompress_bcfz,
_parse_bcfs,
_safe_filename_stem,
_note_is_tie,
_note_has_vibrato,
_beat_has_tremolo,
_note_midi,
_gpx_percussion_midis,
_gpx_tuning,
_gp6_element_variation_to_midi,
_GPX_MAX_DECOMPRESSED,
_find_piano_pairs,
convert_vocal_track_to_pitch_sidecar,
_collect_tone_events,
_inject_tones,
_resolve_pending_slides,
_gpx_bend_shape,
_gpif_left_fingering,
)
from gp2rs import RsNote
# ── _safe_filename_stem ─────────────────────────────────────────────────────
@pytest.mark.parametrize("name, expected", [
("Lead Guitar", "Lead_Guitar"),
("AC/DC", "AC_DC"),
(r"..\..\evil", "evil"),
("../../etc/passwd", "etc_passwd"),
("C:\\Windows\\x", "C_Windows_x"),
("", "track"),
("...", "track"),
("Bass (5)", "Bass_5"),
])
def test_safe_filename_stem(name, expected):
out = _safe_filename_stem(name)
assert out == expected
# Never contains a path separator or traversal segment.
assert "/" not in out and "\\" not in out
assert ".." not in out
# ── _gpx_bend_shape (bn / bt / bnv, §6.2.1) ─────────────────────────────────
def _bend_props(**vals):
"""Build a GPIF property map {name: <Property> element} for the given
bend Float values, e.g. _bend_props(BendOriginValue=0, BendMiddleValue=100)."""
tp = {}
for name, num in vals.items():
tp[name] = ET.fromstring(
f'<Property name="{name}"><Float>{num}</Float></Property>')
return tp
def test_gpx_bend_shape_round_trip_curve():
"""origin/middle/destination value+offset → 3-point bnv; value/divisor=semis."""
tp = _bend_props(
BendOriginValue=0, BendOriginOffset=0,
BendMiddleValue=100, BendMiddleOffset1=50, # 100/50 = 2 semitones
BendDestinationValue=0, BendDestinationOffset=100,
)
peak, intent, curve = _gpx_bend_shape(tp, divisor=50.0, sustain=1.0)
assert peak == 2.0
assert intent == 4 # round-trip (up then back down)
assert curve == [
{"t": 0.0, "v": 0.0}, {"t": 0.5, "v": 2.0}, {"t": 1.0, "v": 0.0}]
def test_gpx_bend_shape_falls_back_to_even_spacing_without_offsets():
tp = _bend_props(BendOriginValue=0, BendDestinationValue=100) # no offsets
peak, intent, curve = _gpx_bend_shape(tp, divisor=50.0, sustain=1.0)
assert peak == 2.0
assert intent == 0 # plain up
# origin defaults to 0%, destination to 100%.
assert curve == [{"t": 0.0, "v": 0.0}, {"t": 1.0, "v": 2.0}]
def test_gpx_bend_shape_no_props_and_zero_length():
assert _gpx_bend_shape({}, divisor=50.0, sustain=1.0) == (0.0, 0, None)
# Peak + intent still derived for a zero-length note, but no curve.
peak, intent, curve = _gpx_bend_shape(
_bend_props(BendOriginValue=0, BendDestinationValue=100),
divisor=50.0, sustain=0.0)
assert peak == 2.0 and intent == 0 and curve is None
# ── _decompress_bcfz / _parse_bcfs input guards ─────────────────────────────
def test_decompress_bcfz_rejects_bad_magic():
with pytest.raises(ValueError):
_decompress_bcfz(b"XXXX" + b"\x00" * 8)
def test_decompress_bcfz_rejects_oversized_declared_size():
# 4 bytes after the magic are read verbatim as a little-endian uint32 = the
# declared decompressed size. Declare > cap -> ValueError before allocating.
blob = b"BCFZ" + struct.pack("<I", _GPX_MAX_DECOMPRESSED + 1)
with pytest.raises(ValueError):
_decompress_bcfz(blob)
def test_parse_bcfs_rejects_bad_magic():
with pytest.raises(ValueError):
_parse_bcfs(b"NOPE" + b"\x00" * 16)
# ── _note_is_tie ────────────────────────────────────────────────────────────
def test_note_is_tie_destination():
el = ET.fromstring('<Note><Tie destination="true"/></Note>')
assert _note_is_tie(el) is True
def test_note_is_tie_origin_only_is_not_tie():
el = ET.fromstring('<Note><Tie origin="true"/></Note>')
assert _note_is_tie(el) is False
def test_note_is_tie_absent():
assert _note_is_tie(ET.fromstring("<Note/>")) is False
# ── _gp6_element_variation_to_midi ──────────────────────────────────────────
def test_element_variation_out_of_range_is_none():
assert _gp6_element_variation_to_midi(9999, 0) is None
assert _gp6_element_variation_to_midi(-1, 0) is None
def test_element_variation_known_pieces():
# Element 0 = kick (GM 35), element 1 = snare (GM 38). Pin exact values so a
# mis-edit of the _GP6_EV / _ART_TO_MIDI tables is caught.
assert _gp6_element_variation_to_midi(0, 0) == 35
assert _gp6_element_variation_to_midi(1, 0) == 38
# ── _gpx_tuning ─────────────────────────────────────────────────────────────
# GPX string pitches are high->low (index 0 = highest string).
_INSTRUMENT_SET = """
<Track>
<InstrumentSet><Type>drumKit</Type><Elements>
<Element><Name>Snare</Name><Articulations>
<Articulation><OutputMidiNumber>38</OutputMidiNumber></Articulation>
<Articulation><OutputMidiNumber>37</OutputMidiNumber></Articulation>
</Articulations></Element>
<Element><Name>Kick</Name><Articulations>
<Articulation><OutputMidiNumber>36</OutputMidiNumber></Articulation>
</Articulations></Element>
</Elements></InstrumentSet>
</Track>
"""
def test_percussion_midis_flattens_articulations_in_order():
track_el = ET.fromstring(_INSTRUMENT_SET)
# Flattened across Elements in document order: snare hit, snare side, kick.
assert _gpx_percussion_midis(track_el) == [38, 37, 36]
def test_percussion_midis_empty_without_instrument_set():
assert _gpx_percussion_midis(ET.fromstring("<Track/>")) == []
def test_note_midi_decodes_percussion_articulation_index():
perc = [38, 37, 36]
# GP8 drum note: the piece is a direct <InstrumentArticulation> child
# indexing into the InstrumentSet articulation list (NOT a Property).
note = ET.fromstring(
'<Note><Properties>'
'<Property name="ConcertPitch"><Pitch><Step>C</Step><Octave>-1</Octave></Pitch></Property>'
'</Properties><InstrumentArticulation>2</InstrumentArticulation></Note>'
)
assert _note_midi(note, [], perc) == 36 # index 2 → kick
# Out-of-range index → None (skipped), not a crash.
bad = ET.fromstring('<Note><InstrumentArticulation>99</InstrumentArticulation></Note>')
assert _note_midi(bad, [], perc) is None
# A -1 sentinel (unparseable OutputMidiNumber) → None, not an invalid note.
note2 = ET.fromstring('<Note><InstrumentArticulation>1</InstrumentArticulation></Note>')
assert _note_midi(note2, [], [38, -1, 36]) is None
def test_tuning_6string_guitar_standard_is_zero():
# E B G D A E (MIDI 64 59 55 50 45 40)
assert _gpx_tuning({"string_pitches": [64, 59, 55, 50, 45, 40]}) == [0, 0, 0, 0, 0, 0]
def test_tuning_6string_guitar_eb_is_minus_one():
assert _gpx_tuning({"string_pitches": [63, 58, 54, 49, 44, 39]}) == [-1, -1, -1, -1, -1, -1]
def test_tuning_4string_bass_standard_is_zero():
# G D A E (high->low): 43 38 33 28
assert _gpx_tuning({"string_pitches": [43, 38, 33, 28]}) == [0, 0, 0, 0]
def test_tuning_5string_low_b_standard_is_zero():
# low-B 5-string, high->low: G D A E B = 43 38 33 28 23
assert _gpx_tuning({"string_pitches": [43, 38, 33, 28, 23]}) == [0, 0, 0, 0, 0]
def test_tuning_5string_high_c_standard_is_zero():
# high-C 5-string, high->low: C G D A E = 48 43 38 33 28.
# Regression guard: previously forced the low-B reference and produced
# non-zero offsets for a standard-tuned high-C bass.
assert _gpx_tuning({"string_pitches": [48, 43, 38, 33, 28]}) == [0, 0, 0, 0, 0]
def test_tuning_empty_pitches_defaults_six_zero():
assert _gpx_tuning({"string_pitches": []}) == [0, 0, 0, 0, 0, 0]
def test_tuning_6string_guitar_ascending_is_zero():
# GP8/.gp lists tuning pitches low->high (the opposite of GP6 .gpx). The
# offsets must still be all-zero for E-standard — `_gpx_tuning` is order-
# agnostic so a GP8 import isn't mirrored.
assert _gpx_tuning({"string_pitches": [40, 45, 50, 55, 59, 64]}) == [0, 0, 0, 0, 0, 0]
def test_tuning_4string_bass_ascending_is_zero():
assert _gpx_tuning({"string_pitches": [28, 33, 38, 43]}) == [0, 0, 0, 0]
# ── _find_piano_pairs ───────────────────────────────────────────────────────
def test_find_piano_pairs_returns_rh_to_lh_map():
# "Piano RH"/"Piano LH" share a stem -> map {rh: lh} (LH merges into RH at
# import time), LH consumed.
tracks = [{"name": "Piano RH"}, {"name": "Piano LH"}, {"name": "Lead Guitar"}]
names = {0: "Keys", 1: "Keys 2", 2: "Lead"}
filtered, merge_map = _find_piano_pairs([0, 1, 2], tracks, names)
assert merge_map == {0: 1}
assert filtered == [0, 2] # LH (1) removed, order otherwise preserved
def test_find_piano_pairs_no_lh_no_merge():
# An RH with no matching LH stem is left untouched.
tracks = [{"name": "Piano RH"}, {"name": "Synth Pad"}]
names = {0: "Keys", 1: "Keys 2"}
filtered, merge_map = _find_piano_pairs([0, 1], tracks, names)
assert merge_map == {}
assert filtered == [0, 1]
def test_find_piano_pairs_ignores_non_keys_tracks():
# "rh"/"lh" word boundaries on guitar tracks must not trigger a merge:
# only piano/keys/keyboard-named (or names[]=Keys*) tracks are considered.
tracks = [{"name": "Rhythm Guitar RH"}, {"name": "Lead Guitar LH"}]
names = {0: "Rhythm", 1: "Lead"}
filtered, merge_map = _find_piano_pairs([0, 1], tracks, names)
assert merge_map == {}
assert filtered == [0, 1]
# ── convert_vocal_track_to_pitch_sidecar ────────────────────────────────────
# Drives the per-syllable pitch extraction with a one-bar / one-beat GPX tree
# (String+Fret note encoding) — no real .gpx needed.
def _vocal_sidecar_args(*, with_lyric: bool):
"""Build the minimal ET fragments for a single quarter-note vocal beat at
middle C (string_pitches[0]=60, String 0 + Fret 0). Returns a dict of
keyword args (expanded with **) for convert_vocal_track_to_pitch_sidecar."""
lyric = "<Lyrics><Line>la</Line></Lyrics>" if with_lyric else ""
beat = ET.fromstring(
f'<Beat><Rhythm ref="r0"/>{lyric}<Notes>0</Notes></Beat>'
)
note = ET.fromstring(
'<Note>'
'<Property name="String"><String>0</String></Property>'
'<Property name="Fret"><Fret>0</Fret></Property>'
'</Note>'
)
masterbar = ET.fromstring('<MasterBar><Time>4/4</Time><Bars>0</Bars></MasterBar>')
return dict(
root=ET.fromstring('<GPIF/>'), # no MasterTrack -> 120 BPM
track={'string_pitches': [60]},
raw_idx=0,
masterbars=[masterbar],
bars_by_id={'0': ET.fromstring('<Bar><Voices>0</Voices></Bar>')},
voices_dict={'0': ET.fromstring('<Voice><Beats>0</Beats></Voice>')},
beats_dict={'0': beat},
notes_dict={'0': note},
rhythms_dict={'r0': ET.fromstring('<Rhythm><NoteValue>Quarter</NoteValue></Rhythm>')},
)
def test_vocal_pitch_sidecar_emits_lyric_note():
out = convert_vocal_track_to_pitch_sidecar(**_vocal_sidecar_args(with_lyric=True))
# Quarter note (1.0 qn) at 120 BPM = 1.0 * 60/120 = 0.5 s; pitch = 60.
assert out == {"version": 1, "notes": [{"t": 0.0, "d": 0.5, "midi": 60}]}
def test_vocal_pitch_sidecar_skips_beat_without_lyric():
out = convert_vocal_track_to_pitch_sidecar(**_vocal_sidecar_args(with_lyric=False))
assert out == {"version": 1, "notes": []}
# ── _collect_tone_events ────────────────────────────────────────────────────
def _tone_args(banks, tempo_map=((0, 120.0),)):
"""One 4/4 bar with one quarter-note beat per entry in `banks`; a None entry
means a beat with no <Bank>. Returns positional args for _collect_tone_events.
With the default 120 BPM map a quarter note is 0.5 s."""
beat_ids = " ".join(str(i) for i in range(len(banks)))
beats_dict = {}
for i, b in enumerate(banks):
bank_el = f"<Bank>{b}</Bank>" if b is not None else ""
beats_dict[str(i)] = ET.fromstring(f'<Beat><Rhythm ref="r0"/>{bank_el}</Beat>')
return (
0, # raw_idx
[ET.fromstring('<MasterBar><Time>4/4</Time><Bars>0</Bars></MasterBar>')],
{"0": ET.fromstring("<Bar><Voices>0</Voices></Bar>")},
{"0": ET.fromstring(f"<Voice><Beats>{beat_ids}</Beats></Voice>")},
beats_dict,
{"r0": ET.fromstring("<Rhythm><NoteValue>Quarter</NoteValue></Rhythm>")},
[tuple(t) for t in tempo_map], # tempo_map
0.0, # audio_offset
)
def test_collect_tone_events_emits_per_bank():
events = _collect_tone_events(*_tone_args(["Clean", "Dist"]))
assert events == [(0.0, "Clean"), (0.5, "Dist")]
def test_collect_tone_events_dedupes_consecutive_identical():
# Consecutive identical banks collapse to a single transition.
events = _collect_tone_events(*_tone_args(["Clean", "Clean", "Dist"]))
assert events == [(0.0, "Clean"), (1.0, "Dist")]
def test_collect_tone_events_empty_when_no_banks():
assert _collect_tone_events(*_tone_args([None, None])) == []
def test_collect_tone_events_honors_base_tempo_bpm():
# No bar-0 tempo event -> the base tempo_bpm seeds the timeline (matching
# convert_file). At 60 BPM a quarter note is 1.0 s, so the second bank
# change lands at 1.0 s (not 0.5 s as it would at the hardcoded 120).
events = _collect_tone_events(*_tone_args(["Clean", "Dist"], tempo_map=[]),
tempo_bpm=60.0)
assert events == [(0.0, "Clean"), (1.0, "Dist")]
# ── _inject_tones ───────────────────────────────────────────────────────────
def test_inject_tones_adds_tonebase_and_tones():
out = _inject_tones("<song><arrangement>Lead</arrangement></song>",
[(0.0, "Clean"), (4.5, "Dist")])
root = ET.fromstring(out)
assert root.findtext("tonebase") == "Clean" # base = first tone
tones = root.find("tones")
assert tones.get("count") == "2"
tone_els = tones.findall("tone")
assert [t.get("name") for t in tone_els] == ["Clean", "Dist"]
assert [t.get("id") for t in tone_els] == ["0", "1"]
assert [t.get("time") for t in tone_els] == ["0.000", "4.500"]
def test_inject_tones_does_not_bloat_whitespace():
# Re-pretty-printing an already-indented arrangement must not stack blank
# lines (regression guard for the double-pretty-print whitespace explosion).
pretty = "<song>\n <arrangement>Lead</arrangement>\n <notes count=\"0\"/>\n</song>\n"
out = _inject_tones(pretty, [(0.0, "Clean"), (4.5, "Dist")])
assert not any(line.strip() == "" for line in out.splitlines())
def test_inject_tones_noop_without_events():
xml = "<song><arrangement>Lead</arrangement></song>"
assert _inject_tones(xml, []) == xml
def test_inject_tones_preserves_existing_tonebase():
out = _inject_tones("<song><tonebase>Existing</tonebase></song>",
[(0.0, "Clean")])
root = ET.fromstring(out)
bases = root.findall("tonebase")
assert len(bases) == 1 and bases[0].text == "Existing"
def test_inject_tones_fills_empty_tonebase():
# An empty/whitespace <tonebase> gets populated with the first tone name.
out = _inject_tones("<song><tonebase> </tonebase></song>", [(0.0, "Clean")])
root = ET.fromstring(out)
bases = root.findall("tonebase")
assert len(bases) == 1 and bases[0].text == "Clean"
# ── convert_file end-to-end: Piano LH/RH merge ──────────────────────────────
# Drives the real converter with a hand-built GPIF tree (via monkeypatched
# _load_gpif) to cover the in-converter merge + rename that the helper tests
# can't reach. Two keys tracks (Piano RH / Piano LH), one quarter-note each.
_GPIF_PIANO = """
<GPIF>
<Score><Title>T</Title><Artist>A</Artist></Score>
<Tracks>
<Track id="0"><Name>Piano RH</Name>
<Property name="Tuning"><Pitches>72</Pitches></Property></Track>
<Track id="1"><Name>Piano LH</Name>
<Property name="Tuning"><Pitches>48</Pitches></Property></Track>
</Tracks>
<MasterBars>
<MasterBar><Time>4/4</Time><Bars>0 1</Bars></MasterBar>
</MasterBars>
<Bars>
<Bar id="0"><Voices>0</Voices></Bar>
<Bar id="1"><Voices>1</Voices></Bar>
</Bars>
<Voices>
<Voice id="0"><Beats>0</Beats></Voice>
<Voice id="1"><Beats>1</Beats></Voice>
</Voices>
<Beats>
<Beat id="0"><Rhythm ref="r0"/><Notes>0</Notes></Beat>
<Beat id="1"><Rhythm ref="r0"/><Notes>1</Notes></Beat>
</Beats>
<Notes>
<Note id="0">
<Property name="String"><String>0</String></Property>
<Property name="Fret"><Fret>0</Fret></Property></Note>
<Note id="1">
<Property name="String"><String>0</String></Property>
<Property name="Fret"><Fret>0</Fret></Property></Note>
</Notes>
<Rhythms><Rhythm id="r0"><NoteValue>Quarter</NoteValue></Rhythm></Rhythms>
</GPIF>
"""
def test_convert_file_merges_piano_lh_into_rh(tmp_path, monkeypatch):
monkeypatch.setattr(gp2rs_gpx, "_load_gpif", lambda _p: ET.fromstring(_GPIF_PIANO))
out_files = convert_file(
"dummy.gpx", str(tmp_path),
track_indices=[0, 1],
arrangement_names={0: "Keys", 1: "Keys 2"},
)
# (1) LH is consumed by the merge -> a single combined arrangement file.
assert len(out_files) == 1
root = ET.parse(out_files[0]).getroot()
# (2) "Keys 2" collapses to the standard "Keys" name (not "Piano"), so the
# piano-highway auto-select (arr_name.startswith("keys")) still matches.
assert root.findtext("arrangement") == "Keys"
# (3) both hands' notes are present. Keys encoding packs MIDI as
# string=midi//24, fret=midi%24: RH pitch 72 -> string 3, LH pitch 48 ->
# string 2. Collect every emitted note (single <note> + chord <chordNote>).
strings = {
n.get("string")
for n in root.iter()
if n.tag in ("note", "chordNote")
}
assert "3" in strings # RH (midi 72)
assert "2" in strings # LH (midi 48) merged in
# ── convert_file end-to-end: GP8 ascending-tuning string order ──────────────
# GP8/.gp lists tuning low->high, so GPIF String index 0 = low E. A low-E note
# must land on RS string 0 (RS string 0 = lowest), not be mirrored to high-e.
_GPIF_GUITAR_ASCENDING = """
<GPIF>
<Score><Title>T</Title><Artist>A</Artist></Score>
<Tracks>
<Track id="0"><Name>Lead Guitar</Name>
<Property name="Tuning"><Pitches>40 45 50 55 59 64</Pitches></Property></Track>
</Tracks>
<MasterBars>
<MasterBar><Time>4/4</Time><Bars>0</Bars></MasterBar>
</MasterBars>
<Bars>
<Bar id="0"><Voices>0</Voices></Bar>
</Bars>
<Voices>
<Voice id="0"><Beats>0 1</Beats></Voice>
</Voices>
<Beats>
<Beat id="0"><Rhythm ref="r0"/><Notes>0</Notes></Beat>
<Beat id="1"><Rhythm ref="r0"/><Notes>1</Notes></Beat>
</Beats>
<Notes>
<Note id="0">
<Property name="String"><String>0</String></Property>
<Property name="Fret"><Fret>0</Fret></Property></Note>
<Note id="1">
<Property name="String"><String>5</String></Property>
<Property name="Fret"><Fret>0</Fret></Property></Note>
</Notes>
<Rhythms><Rhythm id="r0"><NoteValue>Quarter</NoteValue></Rhythm></Rhythms>
</GPIF>
"""
_GPIF_BASS_NEUTRAL_NAME = """
<GPIF>
<Score><Title>T</Title><Artist>A</Artist></Score>
<Tracks>
<Track id="0"><Name>Track 1</Name>
<Property name="Tuning"><Pitches>28 33 38 43</Pitches></Property></Track>
</Tracks>
<MasterBars><MasterBar><Time>4/4</Time><Bars>0</Bars></MasterBar></MasterBars>
<Bars><Bar id="0"><Voices>0</Voices></Bar></Bars>
<Voices><Voice id="0"><Beats>0</Beats></Voice></Voices>
<Beats><Beat id="0"><Rhythm ref="r0"/><Notes>0</Notes></Beat></Beats>
<Notes>
<Note id="0">
<Property name="String"><String>0</String></Property>
<Property name="Fret"><Fret>0</Fret></Property></Note>
</Notes>
<Rhythms><Rhythm id="r0"><NoteValue>Quarter</NoteValue></Rhythm></Rhythms>
</GPIF>
"""
def test_convert_file_bass_named_bass_not_lead(tmp_path, monkeypatch):
# A bass track (top string <= C3/48) with a neutral name must become a
# "Bass" arrangement, not the "Lead" default — otherwise it imports as
# guitar. Pitch-based detection so 5/6-string basses are covered too.
monkeypatch.setattr(gp2rs_gpx, "_load_gpif",
lambda _p: ET.fromstring(_GPIF_BASS_NEUTRAL_NAME))
out_files = convert_file("dummy.gp", str(tmp_path), track_indices=[0])
root = ET.parse(out_files[0]).getroot()
assert root.findtext("arrangement") == "Bass"
def test_convert_file_gp8_ascending_tuning_not_mirrored(tmp_path, monkeypatch):
monkeypatch.setattr(gp2rs_gpx, "_load_gpif", lambda _p: ET.fromstring(_GPIF_GUITAR_ASCENDING))
out_files = convert_file(
"dummy.gp", str(tmp_path),
track_indices=[0], arrangement_names={0: "Lead"},
)
root = ET.parse(out_files[0]).getroot()
# Tuning is E-standard -> all-zero offsets with string0 = low E.
tun = root.find("tuning")
assert [int(tun.get(f"string{i}")) for i in range(6)] == [0, 0, 0, 0, 0, 0]
# GPIF String 0 (open low E) -> RS string 0; String 5 (open high e) -> 5.
placed = {(int(n.get("string")), int(n.get("fret")))
for n in root.iter() if n.tag == "note"}
assert (0, 0) in placed # low-E open note on RS string 0 (was 5 before fix)
assert (5, 0) in placed # high-e open note on RS string 5
# Beat 0 carries a beat-level <Tremolo>; beat 1 does not. End-to-end proof that
# the picked beat's note serializes tremolo="1" and the other stays "0".
_GPIF_GUITAR_TREMOLO = """
<GPIF>
<Score><Title>T</Title><Artist>A</Artist></Score>
<Tracks>
<Track id="0"><Name>Lead Guitar</Name>
<Property name="Tuning"><Pitches>40 45 50 55 59 64</Pitches></Property></Track>
</Tracks>
<MasterBars>
<MasterBar><Time>4/4</Time><Bars>0</Bars></MasterBar>
</MasterBars>
<Bars>
<Bar id="0"><Voices>0</Voices></Bar>
</Bars>
<Voices>
<Voice id="0"><Beats>0 1</Beats></Voice>
</Voices>
<Beats>
<Beat id="0"><Rhythm ref="r0"/><Tremolo>1/8</Tremolo><Notes>0</Notes></Beat>
<Beat id="1"><Rhythm ref="r0"/><Notes>1</Notes></Beat>
</Beats>
<Notes>
<Note id="0">
<Property name="String"><String>0</String></Property>
<Property name="Fret"><Fret>0</Fret></Property></Note>
<Note id="1">
<Property name="String"><String>5</String></Property>
<Property name="Fret"><Fret>0</Fret></Property></Note>
</Notes>
<Rhythms><Rhythm id="r0"><NoteValue>Quarter</NoteValue></Rhythm></Rhythms>
</GPIF>
"""
def test_convert_file_gp8_tremolo_beat_flags_note(tmp_path, monkeypatch):
monkeypatch.setattr(gp2rs_gpx, "_load_gpif", lambda _p: ET.fromstring(_GPIF_GUITAR_TREMOLO))
out_files = convert_file(
"dummy.gp", str(tmp_path),
track_indices=[0], arrangement_names={0: "Lead"},
)
root = ET.parse(out_files[0]).getroot()
tremolo_by_string = {int(n.get("string")): n.get("tremolo")
for n in root.iter() if n.tag == "note"}
# Beat 0 note (RS string 0) was tremolo-picked; beat 1 note (string 5) wasn't.
assert tremolo_by_string[0] == "1"
assert tremolo_by_string[5] == "0"
def test_vocal_pitch_sidecar_sorts_multi_voice_by_time():
# Two voices in one bar. Voice 0 (traversed first) emits its lyric note at
# t=0.5 (a no-lyric quarter precedes it); voice 1 (traversed second) emits
# at t=0.0. Output must be chronological regardless of traversal order.
def _beat(nid, *, lyric):
ly = "<Lyrics><Line>la</Line></Lyrics>" if lyric else ""
return ET.fromstring(f'<Beat><Rhythm ref="r0"/>{ly}<Notes>{nid}</Notes></Beat>')
def _note():
return ET.fromstring(
'<Note><Property name="String"><String>0</String></Property>'
'<Property name="Fret"><Fret>0</Fret></Property></Note>'
)
out = convert_vocal_track_to_pitch_sidecar(
root=ET.fromstring('<GPIF/>'),
track={'string_pitches': [60]},
raw_idx=0,
masterbars=[ET.fromstring('<MasterBar><Time>4/4</Time><Bars>0</Bars></MasterBar>')],
bars_by_id={'0': ET.fromstring('<Bar><Voices>0 1</Voices></Bar>')},
voices_dict={
'0': ET.fromstring('<Voice><Beats>0 1</Beats></Voice>'), # rest, then lyric@0.5
'1': ET.fromstring('<Voice><Beats>2</Beats></Voice>'), # lyric@0.0
},
beats_dict={
'0': _beat('0', lyric=False),
'1': _beat('1', lyric=True),
'2': _beat('2', lyric=True),
},
notes_dict={'0': _note(), '1': _note(), '2': _note()},
rhythms_dict={'r0': ET.fromstring('<Rhythm><NoteValue>Quarter</NoteValue></Rhythm>')},
)
times = [n['t'] for n in out['notes']]
assert times == sorted(times) # chronological
assert times == [0.0, 0.5]
def test_notes_by_id_prefers_tablature_on_duplicate_ids():
# Malformed pool: same id twice — a real String/Fret note then a
# degenerate articulation-only twin. The TAB note must win so the
# referencing track's notes don't vanish.
from gp2rs_gpx import _notes_by_id
root = ET.fromstring(
"<GPIF><Notes>"
"<Note id=\"0\"><Properties>"
"<Property name=\"String\"><String>1</String></Property>"
"<Property name=\"Fret\"><Fret>3</Fret></Property>"
"</Properties></Note>"
"<Note id=\"0\"><Properties>"
"<Property name=\"ConcertPitch\"><Pitch><Step>C</Step><Octave>-1</Octave></Pitch></Property>"
"</Properties><InstrumentArticulation>8</InstrumentArticulation></Note>"
"</Notes></GPIF>"
)
nd = _notes_by_id(root)
names = {p.get("name") for p in nd["0"].findall(".//Property")}
assert "String" in names and "Fret" in names
def test_gpx_bend_scale_autodetects():
from gp2rs_gpx import _gpx_bend_scale
small = ET.fromstring('<GPIF><Note><Properties>'
'<Property name="BendDestinationValue"><Float>100</Float></Property>'
'</Properties></Note></GPIF>')
big = ET.fromstring('<GPIF><Note><Properties>'
'<Property name="BendDestinationValue"><Float>7500</Float></Property>'
'</Properties></Note></GPIF>')
assert _gpx_bend_scale(small) == 50.0
assert _gpx_bend_scale(big) == 2500.0
# ── _resolve_pending_slides (grace-slide sustain fix) ───────────────────────
def test_resolve_grace_slide_zero_sustain_gets_gap_sustain():
# A grace note imported as a shift-slide is short, so its sustain was
# zeroed by the >0.2s rule. The highway won't draw a slide trail for a
# sus<=0 note, so the resolver stretches it to span the gap to its target
# — making the slide renderable. A grace ornament's target IS re-struck,
# so link_next is NOT set (the main note keeps its gem); only the gap
# sustain + slide_to are applied.
grace = RsNote(time=80.0, string=4, fret=5, sustain=0.0)
target = RsNote(time=80.125, string=4, fret=4, sustain=1.0)
_resolve_pending_slides([grace, target], [], [(grace, 4, 1, True)]) # is_grace
assert grace.slide_to == 4
assert grace.link_next is False # main note's gem stays visible
assert grace.sustain == pytest.approx(0.125) # spans gap → renderable
assert target.sustain == pytest.approx(1.0) # untouched
assert target.slide_to == -1
def test_resolve_normal_slide_sets_link_next_and_keeps_sustain():
# A normal (non-grace) shift-slide suppresses its target gem via link_next
# and must NOT have its authored sustain stretched.
a = RsNote(time=0.0, string=0, fret=1, sustain=1.0)
b = RsNote(time=1.0, string=0, fret=3, sustain=1.0)
_resolve_pending_slides([a, b], [], [(a, 0, 2, False)]) # 2 = legato/shift
assert a.slide_to == 3
assert a.link_next is True
assert a.sustain == pytest.approx(1.0)
def test_resolve_three_tuple_back_compat_defaults_to_normal():
# A legacy 3-tuple (no is_grace) is treated as a normal slide (link_next set).
a = RsNote(time=0.0, string=0, fret=1, sustain=1.0)
b = RsNote(time=1.0, string=0, fret=3, sustain=1.0)
_resolve_pending_slides([a, b], [], [(a, 0, 2)])
assert a.slide_to == 3
assert a.link_next is True
def test_resolve_unpitched_slide_out_flags():
down = RsNote(time=0.0, string=0, fret=7, sustain=1.0)
up = RsNote(time=1.0, string=0, fret=7, sustain=1.0)
_resolve_pending_slides([down, up], [], [(down, 0, 4), (up, 0, 8)])
assert down.slide_unpitch_to == 2 # max(1, 7-5)
assert up.slide_unpitch_to == 12 # 7+5
assert down.sustain == pytest.approx(1.0)
def test_resolve_grace_slide_without_target_is_noop():
# No following note on the string → no slide, no sustain stretch, no crash.
grace = RsNote(time=80.0, string=4, fret=5, sustain=0.0)
_resolve_pending_slides([grace], [], [(grace, 4, 1)])
assert grace.slide_to == -1
assert grace.sustain == 0.0
def test_resolve_grace_slide_same_fret_target_no_slide():
# Slide to an identical fret isn't a slide; leave the note alone.
grace = RsNote(time=80.0, string=4, fret=5, sustain=0.0)
target = RsNote(time=80.125, string=4, fret=5, sustain=1.0)
_resolve_pending_slides([grace, target], [], [(grace, 4, 1)])
assert grace.slide_to == -1
assert grace.sustain == 0.0
# ── _note_has_vibrato (GP7/GP8 note vibrato import) ─────────────────────────
def test_note_vibrato_direct_element():
# GP7/GP8 encodes note vibrato as a direct <Vibrato> child of <Note>,
# NOT a <Property> — the regression this fixes.
for strength in ("Slight", "Wide"):
n = ET.fromstring(f'<Note id="1"><Vibrato>{strength}</Vibrato>'
'<Properties></Properties></Note>')
tp = {p.get('name'): p for p in n.findall('.//Property')}
assert _note_has_vibrato(n, tp) is True
def test_note_vibrato_property_form_also_detected():
# Defensive: a <Property name="Vibrato"> form is still recognised.
n = ET.fromstring('<Note id="1"><Properties>'
'<Property name="Vibrato"><Enable/></Property>'
'</Properties></Note>')
tp = {p.get('name'): p for p in n.findall('.//Property')}
assert _note_has_vibrato(n, tp) is True
def test_note_vibrato_absent_is_false():
n = ET.fromstring('<Note id="1"><Properties>'
'<Property name="PalmMuted"><Enable/></Property>'
'</Properties></Note>')
tp = {p.get('name'): p for p in n.findall('.//Property')}
assert _note_has_vibrato(n, tp) is False
def test_note_vibrato_ignores_whammy_trembar_property():
# VibratoWTremBar is a beat-level whammy property, handled separately; it
# must NOT be read as note vibrato by this note-level helper.
n = ET.fromstring('<Note id="1"><Properties>'
'<Property name="VibratoWTremBar"><Strength>Slight</Strength></Property>'
'</Properties></Note>')
tp = {p.get('name'): p for p in n.findall('.//Property')}
assert _note_has_vibrato(n, tp) is False
# ── _beat_has_tremolo (GP6/7/8 tremolo-picking import) ──────────────────────
def test_beat_tremolo_direct_element():
# GPIF encodes tremolo picking as a direct <Tremolo>rate</Tremolo> child of
# <Beat> — the regression this fixes (GPX never read it). Rate-agnostic.
for rate in ("1/8", "1/16", "1/32"):
b = ET.fromstring(f'<Beat id="1"><Rhythm ref="1"/>'
f'<Tremolo>{rate}</Tremolo></Beat>')
assert _beat_has_tremolo(b) is True
def test_beat_tremolo_absent_is_false():
b = ET.fromstring('<Beat id="1"><Rhythm ref="1"/><Notes>1</Notes></Beat>')
assert _beat_has_tremolo(b) is False
def test_beat_tremolo_is_direct_child_only():
# Matched as a direct child (not `.//`), so a <Tremolo> buried deeper must
# NOT trigger — guards against a false positive from unrelated nested markup.
b = ET.fromstring('<Beat id="1"><Properties>'
'<Property name="X"><Tremolo>1/8</Tremolo></Property>'
'</Properties></Beat>')
assert _beat_has_tremolo(b) is False
def test_beat_tremolo_independent_of_whammy_trembar():
# VibratoWTremBar is the separate whammy-bar effect; a beat carrying only
# that (no <Tremolo>) is not tremolo picking.
b = ET.fromstring('<Beat id="1"><Properties>'
'<Property name="VibratoWTremBar"><Strength>Slight</Strength></Property>'
'</Properties></Beat>')
assert _beat_has_tremolo(b) is False
# ── _gpif_left_fingering (GP7/GP8 per-note fret-hand finger -> fg) ───────────
# GPIF stores a single note's fret-hand finger as a direct <LeftFingering>
# child of <Note> (NOT a <Property>), with classical p-i-m-a-c letter codes —
# verified against real GP8 exports (Open / I / M observed). Maps to the same
# RS finger integers as the chord-diagram path (§6.2.2). Teaching mark only.
@pytest.mark.parametrize("code, expected", [
("Open", -1), ("P", 0), ("I", 1), ("M", 2), ("A", 3), ("C", 4),
("i", 1), ("m", 2), # case-insensitive
("index", 1), ("ring", 3), # word forms also accepted
])
def test_gpif_left_fingering_letter_codes(code, expected):
n = ET.fromstring(f'<Note id="1"><LeftFingering>{code}</LeftFingering>'
'<Properties></Properties></Note>')
assert _gpif_left_fingering(n) == expected
def test_gpif_left_fingering_absent_or_unknown_is_unset():
# No <LeftFingering> child, or an unrecognised value -> -1 (never fabricate).
assert _gpif_left_fingering(ET.fromstring('<Note id="1"/>')) == -1
assert _gpif_left_fingering(
ET.fromstring('<Note id="1"><LeftFingering>Z</LeftFingering></Note>')) == -1
assert _gpif_left_fingering(
ET.fromstring('<Note id="1"><LeftFingering></LeftFingering></Note>')) == -1
# ── convert_file: GP8 chord-diagram name + fingering extraction (E3) ─────────
# GP7/GP8 GPIF carries authored chord diagrams under a track's
# Property[@name="DiagramCollection"]. Each Item gives the chord name and a
# <Diagram> with per-string fret + finger. A played voicing matching that
# fret pattern must import with the diagram's name + fingers; a chart without
# a DiagramCollection must import with blank name + all-(-1) fingers.
def _gpif_chord_diagram(diagram_block: str) -> str:
# A two-note chord (low E fret 3 + A fret 2) on a low->high tuned guitar.
return f"""
<GPIF>
<Score><Title>T</Title><Artist>A</Artist></Score>
<Tracks>
<Track id="0"><Name>Lead Guitar</Name>
<Property name="Tuning"><Pitches>40 45 50 55 59 64</Pitches></Property>
{diagram_block}
</Track>
</Tracks>
<MasterBars><MasterBar><Time>4/4</Time><Bars>0</Bars></MasterBar></MasterBars>
<Bars><Bar id="0"><Voices>0</Voices></Bar></Bars>
<Voices><Voice id="0"><Beats>0</Beats></Voice></Voices>
<Beats><Beat id="0"><Rhythm ref="r0"/><Notes>0 1</Notes></Beat></Beats>
<Notes>
<Note id="0">
<Property name="String"><String>0</String></Property>
<Property name="Fret"><Fret>3</Fret></Property></Note>
<Note id="1">
<Property name="String"><String>1</String></Property>
<Property name="Fret"><Fret>2</Fret></Property></Note>
</Notes>
<Rhythms><Rhythm id="r0"><NoteValue>Quarter</NoteValue></Rhythm></Rhythms>
</GPIF>
"""
_DIAGRAM_BLOCK = """
<Property name="DiagramCollection"><Items>
<Item id="1" name="G5">
<Diagram stringCount="6" fretCount="5" baseFret="0">
<Fret string="0" fret="3"/>
<Fret string="1" fret="2"/>
<Fingering>
<Position finger="Middle" fret="3" string="0"/>
<Position finger="Index" fret="2" string="1"/>
</Fingering>
</Diagram>
</Item>
</Items></Property>
"""
def _convert_first_chord_template(monkeypatch, tmp_path, gpif):
monkeypatch.setattr(gp2rs_gpx, "_load_gpif", lambda _p: ET.fromstring(gpif))
out_files = convert_file(
"dummy.gp", str(tmp_path),
track_indices=[0], arrangement_names={0: "Lead"},
)
root = ET.parse(out_files[0]).getroot()
cts = root.findall(".//chordTemplates/chordTemplate")
assert len(cts) == 1
return cts[0]
def test_convert_file_gp8_chord_diagram_enriches_template(tmp_path, monkeypatch):
ct = _convert_first_chord_template(
monkeypatch, tmp_path, _gpif_chord_diagram(_DIAGRAM_BLOCK))
# Diagram name + per-string fingering land on the matching voicing.
assert ct.get("chordName") == "G5"
# RS string 0 = low E (fret 3, Middle=2), string 1 = A (fret 2, Index=1).
assert ct.get("fret0") == "3" and ct.get("finger0") == "2"
assert ct.get("fret1") == "2" and ct.get("finger1") == "1"
# Unplayed strings stay -1 for both fret and finger.
assert [ct.get(f"finger{i}") for i in range(2, 6)] == ["-1"] * 4
def test_convert_file_gp8_no_diagram_leaves_template_blank(tmp_path, monkeypatch):
# Same chart, no DiagramCollection -> identical import to before E3.
ct = _convert_first_chord_template(
monkeypatch, tmp_path, _gpif_chord_diagram(""))
assert ct.get("chordName") == ""
assert [ct.get(f"finger{i}") for i in range(6)] == ["-1"] * 6
# Fret pattern itself is unchanged (the join key still works).
assert ct.get("fret0") == "3" and ct.get("fret1") == "2"
# ── GP import correctness fixes (tester-reported) ───────────────────────────
# Two guitar tracks, Rhythm listed BEFORE Lead. The importer used to name
# arrangements purely by appearance order (first guitar -> "Lead"), which
# swapped the roles for any file that lists Rhythm first.
_GPIF_RHYTHM_BEFORE_LEAD = """
<GPIF>
<Score><Title>T</Title><Artist>A</Artist></Score>
<Tracks>
<Track id="0"><Name>Rhythm Guitar</Name>
<Property name="Tuning"><Pitches>40 45 50 55 59 64</Pitches></Property></Track>
<Track id="1"><Name>Lead Guitar</Name>
<Property name="Tuning"><Pitches>40 45 50 55 59 64</Pitches></Property></Track>
</Tracks>
<MasterBars><MasterBar><Time>4/4</Time><Bars>0 1</Bars></MasterBar></MasterBars>
<Bars>
<Bar id="0"><Voices>0</Voices></Bar>
<Bar id="1"><Voices>1</Voices></Bar>
</Bars>
<Voices>
<Voice id="0"><Beats>0</Beats></Voice>
<Voice id="1"><Beats>1</Beats></Voice>
</Voices>
<Beats>
<Beat id="0"><Rhythm ref="r0"/><Notes>0</Notes></Beat>
<Beat id="1"><Rhythm ref="r0"/><Notes>1</Notes></Beat>
</Beats>
<Notes>
<Note id="0"><Property name="String"><String>0</String></Property><Property name="Fret"><Fret>0</Fret></Property></Note>
<Note id="1"><Property name="String"><String>0</String></Property><Property name="Fret"><Fret>0</Fret></Property></Note>
</Notes>
<Rhythms><Rhythm id="r0"><NoteValue>Quarter</NoteValue></Rhythm></Rhythms>
</GPIF>
"""
def test_convert_file_guitar_roles_follow_gp_name_not_order(tmp_path, monkeypatch):
# Editor path: track_indices but no arrangement_names → convert_file's
# fallback naming. The Rhythm track (listed first) must stay "Rhythm" and
# the Lead track "Lead" — not be swapped by appearance order.
monkeypatch.setattr(gp2rs_gpx, "_load_gpif",
lambda _p: ET.fromstring(_GPIF_RHYTHM_BEFORE_LEAD))
out_files = convert_file("dummy.gp", str(tmp_path), track_indices=[0, 1])
names = [ET.parse(f).getroot().findtext("arrangement") for f in out_files]
assert names == ["Rhythm", "Lead"]
def _gpif_bass(pitches: str) -> str:
return f"""
<GPIF>
<Score><Title>T</Title><Artist>A</Artist></Score>
<Tracks>
<Track id="0"><Name>Bass</Name>
<Property name="Tuning"><Pitches>{pitches}</Pitches></Property></Track>
</Tracks>
<MasterBars><MasterBar><Time>4/4</Time><Bars>0</Bars></MasterBar></MasterBars>
<Bars><Bar id="0"><Voices>0</Voices></Bar></Bars>
<Voices><Voice id="0"><Beats>0</Beats></Voice></Voices>
<Beats><Beat id="0"><Rhythm ref="r0"/><Notes>0</Notes></Beat></Beats>
<Notes>
<Note id="0"><Property name="String"><String>0</String></Property>
<Property name="Fret"><Fret>0</Fret></Property></Note>
</Notes>
<Rhythms><Rhythm id="r0"><NoteValue>Quarter</NoteValue></Rhythm></Rhythms>
</GPIF>
"""
@pytest.mark.parametrize("pitches,expected", [
("28 33 38 43", 4), # 4-string E-A-D-G
("23 28 33 38 43", 5), # 5-string low-B
])
def test_convert_file_bass_string_count_round_trips(tmp_path, monkeypatch, pitches, expected):
# The <tuning> element pads to 6 slots (RS2014 schema), which erased the
# 4-vs-5-string distinction. The serializer now records the real count in
# `stringCount`; parse_arrangement trims to it so a 5-string bass reports 5
# (was 4 → it rendered/played on 4 strings).
from song import parse_arrangement, arrangement_string_count
monkeypatch.setattr(gp2rs_gpx, "_load_gpif",
lambda _p: ET.fromstring(_gpif_bass(pitches)))
out_files = convert_file("dummy.gp", str(tmp_path), track_indices=[0])
root = ET.parse(out_files[0]).getroot()
assert root.find("tuning").get("stringCount") == str(expected)
arr = parse_arrangement(out_files[0])
assert len(arr.tuning) == expected
assert arrangement_string_count(arr) == expected
# A track with one normal note and one tie-DESTINATION note. The tie is folded
# into the previous note's sustain, so it is not a separate RS note; the
# importer's preview count must exclude it (matches the imported result).
_GPIF_WITH_TIE = """
<GPIF>
<Score><Title>T</Title><Artist>A</Artist></Score>
<Tracks>
<Track id="0"><Name>Lead</Name>
<Property name="Tuning"><Pitches>40 45 50 55 59 64</Pitches></Property></Track>
</Tracks>
<MasterBars><MasterBar><Time>4/4</Time><Bars>0</Bars></MasterBar></MasterBars>
<Bars><Bar id="0"><Voices>0</Voices></Bar></Bars>
<Voices><Voice id="0"><Beats>0 1</Beats></Voice></Voices>
<Beats>
<Beat id="0"><Rhythm ref="r0"/><Notes>0</Notes></Beat>
<Beat id="1"><Rhythm ref="r0"/><Notes>1</Notes></Beat>
</Beats>
<Notes>
<Note id="0"><Property name="String"><String>0</String></Property><Property name="Fret"><Fret>3</Fret></Property></Note>
<Note id="1"><Tie destination="true"/><Property name="String"><String>0</String></Property><Property name="Fret"><Fret>3</Fret></Property></Note>
</Notes>
<Rhythms><Rhythm id="r0"><NoteValue>Quarter</NoteValue></Rhythm></Rhythms>
</GPIF>
"""
def test_list_tracks_note_count_excludes_tie_continuations(monkeypatch):
monkeypatch.setattr(gp2rs_gpx, "_load_gpif",
lambda _p: ET.fromstring(_GPIF_WITH_TIE))
tracks = gp2rs_gpx.list_tracks("dummy.gp")
# 2 raw notes, 1 of them a tie destination → 1 importable note.
assert tracks[0]["notes"] == 1
# A hinted "Lead Guitar" followed by an UNHINTED guitar: the unhinted one must
# advance to the next canonical role ("Rhythm"), not collide into "Lead 2".
_GPIF_LEAD_THEN_UNHINTED = """
<GPIF>
<Score><Title>T</Title><Artist>A</Artist></Score>
<Tracks>
<Track id="0"><Name>Lead Guitar</Name>
<Property name="Tuning"><Pitches>40 45 50 55 59 64</Pitches></Property></Track>
<Track id="1"><Name>Guitar 2</Name>
<Property name="Tuning"><Pitches>40 45 50 55 59 64</Pitches></Property></Track>
</Tracks>
<MasterBars><MasterBar><Time>4/4</Time><Bars>0 1</Bars></MasterBar></MasterBars>
<Bars>
<Bar id="0"><Voices>0</Voices></Bar>
<Bar id="1"><Voices>1</Voices></Bar>
</Bars>
<Voices>
<Voice id="0"><Beats>0</Beats></Voice>
<Voice id="1"><Beats>1</Beats></Voice>
</Voices>
<Beats>
<Beat id="0"><Rhythm ref="r0"/><Notes>0</Notes></Beat>
<Beat id="1"><Rhythm ref="r0"/><Notes>1</Notes></Beat>
</Beats>
<Notes>
<Note id="0"><Property name="String"><String>0</String></Property><Property name="Fret"><Fret>0</Fret></Property></Note>
<Note id="1"><Property name="String"><String>0</String></Property><Property name="Fret"><Fret>0</Fret></Property></Note>
</Notes>
<Rhythms><Rhythm id="r0"><NoteValue>Quarter</NoteValue></Rhythm></Rhythms>
</GPIF>
"""
def test_convert_file_unhinted_guitar_takes_next_canonical_role(tmp_path, monkeypatch):
# Mix of hinted + unhinted guitars spreads across Lead → Rhythm, not Lead/Lead 2.
monkeypatch.setattr(gp2rs_gpx, "_load_gpif",
lambda _p: ET.fromstring(_GPIF_LEAD_THEN_UNHINTED))
out_files = convert_file("dummy.gp", str(tmp_path), track_indices=[0, 1])
names = [ET.parse(f).getroot().findtext("arrangement") for f in out_files]
assert names == ["Lead", "Rhythm"]
def test_auto_select_gpx_unhinted_guitar_takes_next_canonical_role():
# _auto_select_gpx path (auto-select-all): same spread rule.
root = ET.fromstring(_GPIF_LEAD_THEN_UNHINTED)
tracks = gp2rs_gpx._gpif_tracks(root)
_indices, names = gp2rs_gpx._auto_select_gpx(tracks)
assert sorted(names.values()) == ["Lead", "Rhythm"]
# Codex scenario: unhinted guitar BEFORE a later hinted "Rhythm Guitar". The
# real rhythm track must still get the canonical "Rhythm" (two-pass reserves
# hinted roles first); the unhinted one takes the leftover canonical role.
_GPIF_UNHINTED_BEFORE_RHYTHM = """
<GPIF>
<Score><Title>T</Title><Artist>A</Artist></Score>
<Tracks>
<Track id="0"><Name>Lead Guitar</Name>
<Property name="Tuning"><Pitches>40 45 50 55 59 64</Pitches></Property></Track>
<Track id="1"><Name>Guitar 2</Name>
<Property name="Tuning"><Pitches>40 45 50 55 59 64</Pitches></Property></Track>
<Track id="2"><Name>Rhythm Guitar</Name>
<Property name="Tuning"><Pitches>40 45 50 55 59 64</Pitches></Property></Track>
</Tracks>
<MasterBars><MasterBar><Time>4/4</Time><Bars>0 1 2</Bars></MasterBar></MasterBars>
<Bars>
<Bar id="0"><Voices>0</Voices></Bar>
<Bar id="1"><Voices>1</Voices></Bar>
<Bar id="2"><Voices>2</Voices></Bar>
</Bars>
<Voices>
<Voice id="0"><Beats>0</Beats></Voice>
<Voice id="1"><Beats>1</Beats></Voice>
<Voice id="2"><Beats>2</Beats></Voice>
</Voices>
<Beats>
<Beat id="0"><Rhythm ref="r0"/><Notes>0</Notes></Beat>
<Beat id="1"><Rhythm ref="r0"/><Notes>1</Notes></Beat>
<Beat id="2"><Rhythm ref="r0"/><Notes>2</Notes></Beat>
</Beats>
<Notes>
<Note id="0"><Property name="String"><String>0</String></Property><Property name="Fret"><Fret>0</Fret></Property></Note>
<Note id="1"><Property name="String"><String>0</String></Property><Property name="Fret"><Fret>0</Fret></Property></Note>
<Note id="2"><Property name="String"><String>0</String></Property><Property name="Fret"><Fret>0</Fret></Property></Note>
</Notes>
<Rhythms><Rhythm id="r0"><NoteValue>Quarter</NoteValue></Rhythm></Rhythms>
</GPIF>
"""
def test_convert_file_unhinted_does_not_steal_later_rhythm(tmp_path, monkeypatch):
monkeypatch.setattr(gp2rs_gpx, "_load_gpif",
lambda _p: ET.fromstring(_GPIF_UNHINTED_BEFORE_RHYTHM))
out_files = convert_file("dummy.gp", str(tmp_path), track_indices=[0, 1, 2])
names = [ET.parse(f).getroot().findtext("arrangement") for f in out_files]
# Hinted roles reserved first → real Rhythm keeps canonical "Rhythm";
# the unhinted middle track takes the leftover canonical role.
assert names[0] == "Lead"
assert names[2] == "Rhythm"
assert names[1] == "Combo"
assert "Rhythm 2" not in names and "Lead 2" not in names
def test_auto_select_gpx_unhinted_does_not_steal_later_rhythm():
root = ET.fromstring(_GPIF_UNHINTED_BEFORE_RHYTHM)
tracks = gp2rs_gpx._gpif_tracks(root)
indices, names = gp2rs_gpx._auto_select_gpx(tracks)
assert names[indices[0]] == "Lead"
assert names[indices[2]] == "Rhythm"
assert names[indices[1]] == "Combo"