From 9b4406119f51fd8d6ef2116af22e9737e225e8d6 Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Wed, 8 Jul 2026 17:03:41 +0200 Subject: [PATCH] test: add pytest coverage for rs2gp pure helpers (measures, events, timing) rs2gp.py imports the host lib module 'song' at top level; a conftest stub supplies arrangement_string_count so the module - and the deterministic helpers under test - can be imported standalone. requirements.txt pins PyGuitarPro since guitarpro. Duration objects are exercised directly by the sixteenth-note decomposition tests (real dependency, no need to stub it). Covers measure parsing (grouping, BPM estimation, end-time extrapolation), note/chord event merging + sort + high-density chord skipping, used-string-index collection, sixteenth-note quantization, and duration decompose/re-measure round-trips. Co-Authored-By: Claude Fable 5 --- requirements.txt | 4 + tests/conftest.py | 16 ++++ tests/test_rs2gp_helpers.py | 149 ++++++++++++++++++++++++++++++++++++ 3 files changed, 169 insertions(+) create mode 100644 requirements.txt create mode 100644 tests/conftest.py create mode 100644 tests/test_rs2gp_helpers.py diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d35f8c5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +# Runtime deps beyond what the host app already provides. +# PyGuitarPro: rs2gp.py builds GP5 files with the `guitarpro` package. +# Also installed by the org reusable CI so tests importing rs2gp.py work. +PyGuitarPro>=0.11 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8bf4d8c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,16 @@ +import sys +import types +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +# rs2gp.py imports `from song import arrangement_string_count` at module +# top-level — `song` is a host-provided lib module (lib/song.py, loaded via +# the desktop app's sys.path setup at runtime), not something this plugin +# repo ships or that's pip-installable. Stub just the one symbol rs2gp +# actually uses so the module — and the pure helper functions we're +# testing — can be imported standalone in CI. +if "song" not in sys.modules: + stub = types.ModuleType("song") + stub.arrangement_string_count = lambda arr: 6 + sys.modules["song"] = stub diff --git a/tests/test_rs2gp_helpers.py b/tests/test_rs2gp_helpers.py new file mode 100644 index 0000000..1b0e882 --- /dev/null +++ b/tests/test_rs2gp_helpers.py @@ -0,0 +1,149 @@ +"""Pure/deterministic helpers in rs2gp.py: measure parsing, event merging, +sixteenth-note quantization and duration decomposition. guitarpro's Duration +objects are used directly (real dependency, pip-installable) but nothing +here touches the GP5 binary writer or the host `song` module beyond the +stub in conftest.py.""" + +from types import SimpleNamespace + +import guitarpro +import rs2gp + + +def beat(time, measure=-1): + return SimpleNamespace(time=time, measure=measure) + + +# ── _parse_measures ─────────────────────────────────────────────────────────── + +def test_parse_measures_empty_input(): + assert rs2gp._parse_measures([]) == [] + assert rs2gp._parse_measures(None) == [] + + +def test_parse_measures_groups_by_measure_marker(): + beats = [ + beat(0.0, measure=0), beat(0.5), beat(1.0), beat(1.5), + beat(2.0, measure=1), beat(2.5), beat(3.0), beat(3.5), + ] + measures = rs2gp._parse_measures(beats) + assert len(measures) == 2 + assert measures[0]["num_beats"] == 4 + assert measures[0]["start_time"] == 0.0 + assert measures[0]["end_time"] == 2.0 + assert measures[1]["start_time"] == 2.0 + + +def test_parse_measures_estimates_bpm_from_beat_interval(): + # 0.5s between beats -> 120 BPM. + beats = [beat(0.0, measure=0), beat(0.5), beat(1.0), beat(1.5)] + measures = rs2gp._parse_measures(beats) + assert abs(measures[0]["bpm"] - 120.0) < 0.01 + + +def test_parse_measures_last_measure_extrapolates_end_time(): + beats = [beat(0.0, measure=0), beat(0.5), beat(1.0), beat(1.5)] + measures = rs2gp._parse_measures(beats) + # avg beat interval 0.5 -> end_time = last beat + 0.5 + assert measures[-1]["end_time"] == 2.0 + + +def test_parse_measures_single_beat_final_measure_reuses_prior_interval(): + beats = [ + beat(0.0, measure=0), beat(0.5), beat(1.0), beat(1.5), + beat(2.0, measure=1), # lone trailing beat, no follow-up + ] + measures = rs2gp._parse_measures(beats) + assert measures[1]["num_beats"] == 1 + # reuses measure 0's duration (2.0s) since there's no next group or 2nd beat + assert measures[1]["end_time"] == 2.0 + (measures[0]["end_time"] - measures[0]["start_time"]) + assert measures[1]["bpm"] == measures[0]["bpm"] + + +def test_fallback_measure_defaults_and_custom_length(): + m = rs2gp._fallback_measure(None) + assert m["end_time"] == 60.0 + assert m["num_beats"] == 4 + assert m["bpm"] == 120.0 + + m2 = rs2gp._fallback_measure(30.0) + assert m2["end_time"] == 30.0 + + +# ── _note_dict / _merge_events / _used_string_indices ────────────────────────── + +def make_note(string=0, fret=3, sustain=0.0, time=0.0, **extra): + defaults = dict(slide_to=None, bend=None, hammer_on=False, pull_off=False, + harmonic=False, harmonic_pinch=False, palm_mute=False, + mute=False, tremolo=False, accent=False, tap=False) + defaults.update(extra) + return SimpleNamespace(string=string, fret=fret, sustain=sustain, time=time, **defaults) + + +def test_note_dict_carries_declared_fields_and_defaults_missing_to_false(): + n = SimpleNamespace(string=1, fret=5, sustain=0.2) # no effect flags set + d = rs2gp._note_dict(n) + assert d["string"] == 1 + assert d["fret"] == 5 + assert d["hammer_on"] is False + assert d["accent"] is False + + +def test_merge_events_sorts_notes_and_chords_by_time(): + arr = SimpleNamespace( + notes=[make_note(time=1.0), make_note(time=0.0)], + chords=[SimpleNamespace(time=0.5, high_density=False, + notes=[make_note(time=0.5, string=2)])], + ) + events = rs2gp._merge_events(arr) + assert [e["time"] for e in events] == [0.0, 0.5, 1.0] + assert events[1]["type"] == "chord" + assert events[0]["type"] == "note" + + +def test_merge_events_skips_high_density_chords(): + arr = SimpleNamespace( + notes=[], + chords=[SimpleNamespace(time=0.0, high_density=True, notes=[])], + ) + assert rs2gp._merge_events(arr) == [] + + +def test_used_string_indices_covers_notes_and_chord_members(): + events = [ + {"type": "note", "string": 0}, + {"type": "chord", "chord_notes": [{"string": 2}, {"string": 3}]}, + ] + assert rs2gp._used_string_indices(events) == {0, 2, 3} + + +# ── _quantize_sixteenth ──────────────────────────────────────────────────────── + +def test_quantize_sixteenth_snaps_to_the_nearest_subdivision(): + beat_times = [0.0, 1.0, 2.0, 3.0] + # SUBDIV=8 32nd-note slots per beat, so beat 0 spans indices 0..7. + idx = rs2gp._quantize_sixteenth(0.0, beat_times, measure_end=4.0) + assert idx == 0 + idx_mid = rs2gp._quantize_sixteenth(1.0, beat_times, measure_end=4.0) + assert idx_mid == rs2gp.SUBDIV # exactly on beat 2 -> index SUBDIV*1 + + +# ── _decompose_sixteenths / _dur_sixteenths round-trip ────────────────────────── + +def test_decompose_sixteenths_zero_or_negative_yields_a_whole_rest(): + durs = rs2gp._decompose_sixteenths(0) + assert len(durs) == 1 + assert durs[0].value == 4 + + +def test_decompose_and_dur_sixteenths_round_trip_common_counts(): + for count in [1, 2, 3, 4, 6, 8, 12, 16, 24, 32, 17, 33]: + durs = rs2gp._decompose_sixteenths(count) + total = sum(rs2gp._dur_sixteenths(d) for d in durs) + assert total == count, f"count={count} -> durs summed to {total}" + + +def test_dur_sixteenths_applies_dotted_multiplier(): + d = guitarpro.Duration(value=4) + d.isDotted = True + assert rs2gp._dur_sixteenths(d) == 12 # base 8 * 1.5