mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-14 04:31:21 +00:00
Clean release snapshot
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
"""Tests for `convert_drum_track_to_drumtab()` — the GP→drum_tab.json bridge.
|
||||
|
||||
The function is hard to fixture against a real .gp file (we'd need to ship a
|
||||
test .gp with grace notes, ghost notes, hi-hat variants, and stamping), so we
|
||||
mock `_build_tempo_map` / `_build_playback_schedule` and feed a synthetic
|
||||
song object shaped like the bits of `guitarpro.Song` the converter actually
|
||||
reads. The piece-mapping, velocity passthrough, ghost / flam / choke
|
||||
extraction, and kit-legend building are all visible from this level.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import guitarpro
|
||||
import pytest
|
||||
|
||||
import gp2rs
|
||||
|
||||
|
||||
# ── Test fakes ────────────────────────────────────────────────────────────────
|
||||
|
||||
# Use the real guitarpro.NoteType enum — the converter compares against
|
||||
# `guitarpro.NoteType.rest` by identity, so substituting our own sentinel
|
||||
# would mean `note.type == guitarpro.NoteType.rest` is always False and the
|
||||
# rest-skip branch never gets tested.
|
||||
_NT_NORMAL = guitarpro.NoteType.normal
|
||||
_NT_REST = guitarpro.NoteType.rest
|
||||
|
||||
|
||||
def _fake_effect(*, ghost=False, grace=False, staccato=False):
|
||||
"""Minimal NoteEffect mock — converter only reads three booleans."""
|
||||
return SimpleNamespace(
|
||||
ghostNote=ghost,
|
||||
isGrace=grace,
|
||||
staccato=staccato,
|
||||
)
|
||||
|
||||
|
||||
def _fake_note(*, string_idx: int, value: int = 0, velocity: int = 95,
|
||||
effect=None, ntype=None):
|
||||
"""One drum note. `string_idx` selects the track string whose tuning
|
||||
value pins the MIDI piece; `value` adds to it (drums normally use 0)."""
|
||||
if effect is None:
|
||||
effect = _fake_effect()
|
||||
if ntype is None:
|
||||
ntype = _NT_NORMAL
|
||||
return SimpleNamespace(
|
||||
string=string_idx,
|
||||
value=value,
|
||||
velocity=velocity,
|
||||
effect=effect,
|
||||
type=ntype,
|
||||
)
|
||||
|
||||
|
||||
def _fake_track(string_midis: list[int], beats: list[tuple[float, list]]):
|
||||
"""Build a track with one measure, one voice, multiple beats.
|
||||
|
||||
`string_midis` is GP-order (each entry pins one drum string to a MIDI
|
||||
note — that's how percussion tracks encode pieces).
|
||||
|
||||
`beats` is [(beat_start_tick, [note, ...]), ...]. Tick values are
|
||||
treated as raw numbers by the patched `_tick_to_seconds` (identity
|
||||
scaling), so the tests pass plain floats here.
|
||||
"""
|
||||
strings = [SimpleNamespace(number=i + 1, value=v)
|
||||
for i, v in enumerate(string_midis)]
|
||||
voice_beats = [
|
||||
SimpleNamespace(start=tick, notes=notes) for tick, notes in beats
|
||||
]
|
||||
voice = SimpleNamespace(beats=voice_beats)
|
||||
measure = SimpleNamespace(voices=[voice])
|
||||
return SimpleNamespace(strings=strings, measures=[measure])
|
||||
|
||||
|
||||
def _setup(monkeypatch):
|
||||
"""Patch out the tempo / schedule heavy lifters so tests run without a
|
||||
real .gp file. Schedule yields one entry pointing at measure 0; tempo
|
||||
map is irrelevant because we also patch _tick_to_seconds to identity."""
|
||||
monkeypatch.setattr(gp2rs, "_build_tempo_map", lambda song: [])
|
||||
monkeypatch.setattr(
|
||||
gp2rs,
|
||||
"_build_playback_schedule",
|
||||
lambda song, tempo_map, expand_repeats: [
|
||||
gp2rs.PlaybackEntry(
|
||||
mh_index=0,
|
||||
pass_index=0,
|
||||
output_start_secs=0.0,
|
||||
mh_authored_start_secs=0.0,
|
||||
duration_secs=2.0,
|
||||
)
|
||||
],
|
||||
)
|
||||
# Treat tick as "ticks at 1 tick/second" — pure identity scaling keeps
|
||||
# the assertion math obvious. Real conversion math is exercised by
|
||||
# test_gp2rs.py; here we only care about the drum-tab extraction.
|
||||
monkeypatch.setattr(gp2rs, "_tick_to_seconds", lambda tick, tempo_map: float(tick))
|
||||
|
||||
|
||||
# ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_basic_kick_snare_pattern(monkeypatch):
|
||||
"""A simple kick/snare/HH-closed pattern round-trips with correct piece-
|
||||
ids and velocity preserved verbatim."""
|
||||
_setup(monkeypatch)
|
||||
# Strings 1-3 pin kick (36), snare (38), hh_closed (42).
|
||||
track = _fake_track(
|
||||
string_midis=[36, 38, 42],
|
||||
beats=[
|
||||
(0, [_fake_note(string_idx=1, velocity=110)]), # kick
|
||||
(1.0, [_fake_note(string_idx=2, velocity=92), # snare
|
||||
_fake_note(string_idx=3, velocity=70)]), # hh_closed
|
||||
],
|
||||
)
|
||||
song = SimpleNamespace(tracks=[track])
|
||||
|
||||
out = gp2rs.convert_drum_track_to_drumtab(song, 0)
|
||||
assert out["version"] == 1
|
||||
assert out["name"] == "Drums"
|
||||
hits = out["hits"]
|
||||
assert [(h["t"], h["p"], h["v"]) for h in hits] == [
|
||||
(0.0, "kick", 110),
|
||||
(1.0, "snare", 92),
|
||||
(1.0, "hh_closed", 70),
|
||||
]
|
||||
# Kit legend names every piece-id we emitted, deduplicated.
|
||||
assert {k["id"] for k in out["kit"]} == {"kick", "snare", "hh_closed"}
|
||||
|
||||
|
||||
def test_ghost_note_flag(monkeypatch):
|
||||
_setup(monkeypatch)
|
||||
track = _fake_track(
|
||||
string_midis=[38],
|
||||
beats=[(0, [_fake_note(string_idx=1, velocity=40,
|
||||
effect=_fake_effect(ghost=True))])],
|
||||
)
|
||||
song = SimpleNamespace(tracks=[track])
|
||||
hit = gp2rs.convert_drum_track_to_drumtab(song, 0)["hits"][0]
|
||||
assert hit["p"] == "snare"
|
||||
assert hit.get("g") is True
|
||||
|
||||
|
||||
def test_flam_from_grace_note(monkeypatch):
|
||||
"""A GP grace note on a drum lane → `f: true`. Drum charts use grace
|
||||
almost exclusively to mark flams."""
|
||||
_setup(monkeypatch)
|
||||
track = _fake_track(
|
||||
string_midis=[38],
|
||||
beats=[(0, [_fake_note(string_idx=1,
|
||||
effect=_fake_effect(grace=True))])],
|
||||
)
|
||||
song = SimpleNamespace(tracks=[track])
|
||||
hit = gp2rs.convert_drum_track_to_drumtab(song, 0)["hits"][0]
|
||||
assert hit.get("f") is True
|
||||
|
||||
|
||||
def test_choke_on_cymbal_only(monkeypatch):
|
||||
"""Staccato on a cymbal piece becomes a short choke tail. Staccato on a
|
||||
drum piece (snare/tom/kick) is ignored — drums can't be choked."""
|
||||
_setup(monkeypatch)
|
||||
# String 1 → crash_r (57), string 2 → snare (38).
|
||||
track = _fake_track(
|
||||
string_midis=[57, 38],
|
||||
beats=[
|
||||
(0, [_fake_note(string_idx=1, effect=_fake_effect(staccato=True))]),
|
||||
(1.0, [_fake_note(string_idx=2, effect=_fake_effect(staccato=True))]),
|
||||
],
|
||||
)
|
||||
song = SimpleNamespace(tracks=[track])
|
||||
hits = gp2rs.convert_drum_track_to_drumtab(song, 0)["hits"]
|
||||
assert hits[0]["p"] == "crash_r"
|
||||
assert hits[0].get("k") == pytest.approx(0.08)
|
||||
assert hits[1]["p"] == "snare"
|
||||
assert "k" not in hits[1]
|
||||
|
||||
|
||||
def test_hihat_openness_from_midi_note(monkeypatch):
|
||||
"""GP encodes closed/open/pedal hi-hat on distinct MIDI notes (42/46/44).
|
||||
The drum-tab path must surface them as three distinct piece-ids so hit
|
||||
detection in the drums plugin can reject a closed-hat strike on an open-
|
||||
hat note."""
|
||||
_setup(monkeypatch)
|
||||
# Strings pin closed/open/pedal hi-hat.
|
||||
track = _fake_track(
|
||||
string_midis=[42, 46, 44],
|
||||
beats=[
|
||||
(0, [_fake_note(string_idx=1)]), # closed
|
||||
(1.0, [_fake_note(string_idx=2)]), # open
|
||||
(2.0, [_fake_note(string_idx=3)]), # pedal
|
||||
],
|
||||
)
|
||||
song = SimpleNamespace(tracks=[track])
|
||||
pids = [h["p"] for h in gp2rs.convert_drum_track_to_drumtab(song, 0)["hits"]]
|
||||
assert pids == ["hh_closed", "hh_open", "hh_pedal"]
|
||||
|
||||
|
||||
def test_rest_notes_dropped(monkeypatch):
|
||||
_setup(monkeypatch)
|
||||
track = _fake_track(
|
||||
string_midis=[36],
|
||||
beats=[(0, [_fake_note(string_idx=1, ntype=_NT_REST)])],
|
||||
)
|
||||
song = SimpleNamespace(tracks=[track])
|
||||
assert gp2rs.convert_drum_track_to_drumtab(song, 0)["hits"] == []
|
||||
|
||||
|
||||
def test_unmapped_percussion_silently_skipped(monkeypatch):
|
||||
"""Cowbell (MIDI 56) isn't in the vocab — must be skipped, not crash."""
|
||||
_setup(monkeypatch)
|
||||
track = _fake_track(
|
||||
string_midis=[56, 36],
|
||||
beats=[
|
||||
(0, [_fake_note(string_idx=1)]), # cowbell — skip
|
||||
(1.0, [_fake_note(string_idx=2)]), # kick — keep
|
||||
],
|
||||
)
|
||||
song = SimpleNamespace(tracks=[track])
|
||||
hits = gp2rs.convert_drum_track_to_drumtab(song, 0)["hits"]
|
||||
assert len(hits) == 1
|
||||
assert hits[0]["p"] == "kick"
|
||||
|
||||
|
||||
def test_unmapped_percussion_reported_via_out_unmapped(monkeypatch):
|
||||
"""Opting in via out_unmapped records the dropped MIDI notes (count +
|
||||
times) so a caller can surface a warning / mapping UI."""
|
||||
_setup(monkeypatch)
|
||||
track = _fake_track(
|
||||
string_midis=[56, 36, 54],
|
||||
beats=[
|
||||
(0.0, [_fake_note(string_idx=1)]), # cowbell — drop
|
||||
(1.0, [_fake_note(string_idx=2)]), # kick — keep
|
||||
(1.5, [_fake_note(string_idx=3)]), # tambourine — drop
|
||||
(2.0, [_fake_note(string_idx=1)]), # cowbell again — drop
|
||||
],
|
||||
)
|
||||
song = SimpleNamespace(tracks=[track])
|
||||
unmapped: dict[int, dict] = {}
|
||||
hits = gp2rs.convert_drum_track_to_drumtab(
|
||||
song, 0, out_unmapped=unmapped)["hits"]
|
||||
# Only the kick survives.
|
||||
assert [h["p"] for h in hits] == ["kick"]
|
||||
# Both unmapped MIDIs are reported with correct counts.
|
||||
assert set(unmapped.keys()) == {56, 54}
|
||||
assert unmapped[56]["count"] == 2
|
||||
assert unmapped[54]["count"] == 1
|
||||
# Times are captured (rounded to 3 dp).
|
||||
assert unmapped[56]["times"] == [0.0, 2.0]
|
||||
assert unmapped[54]["times"] == [1.5]
|
||||
|
||||
|
||||
def test_zero_velocity_omitted_from_wire(monkeypatch):
|
||||
"""Velocity outside 1-127 must be dropped from the hit so the wire format
|
||||
stays clean. A corrupt GP file shouldn't propagate `v: 0` downstream."""
|
||||
_setup(monkeypatch)
|
||||
track = _fake_track(
|
||||
string_midis=[36],
|
||||
beats=[(0, [_fake_note(string_idx=1, velocity=0)])],
|
||||
)
|
||||
song = SimpleNamespace(tracks=[track])
|
||||
hit = gp2rs.convert_drum_track_to_drumtab(song, 0)["hits"][0]
|
||||
assert "v" not in hit
|
||||
|
||||
|
||||
def test_hits_sorted_by_time(monkeypatch):
|
||||
"""Out-of-order beats (from a multi-voice measure) come back sorted."""
|
||||
_setup(monkeypatch)
|
||||
track = _fake_track(
|
||||
string_midis=[36, 38],
|
||||
beats=[
|
||||
(2.0, [_fake_note(string_idx=1)]), # later kick
|
||||
(0.0, [_fake_note(string_idx=2)]), # earlier snare
|
||||
(1.0, [_fake_note(string_idx=1)]),
|
||||
],
|
||||
)
|
||||
song = SimpleNamespace(tracks=[track])
|
||||
hits = gp2rs.convert_drum_track_to_drumtab(song, 0)["hits"]
|
||||
assert [h["t"] for h in hits] == [0.0, 1.0, 2.0]
|
||||
|
||||
|
||||
def test_custom_arrangement_name_used(monkeypatch):
|
||||
_setup(monkeypatch)
|
||||
track = _fake_track(string_midis=[36], beats=[(0, [_fake_note(string_idx=1)])])
|
||||
song = SimpleNamespace(tracks=[track])
|
||||
out = gp2rs.convert_drum_track_to_drumtab(song, 0, arrangement_name="Verse Drums")
|
||||
assert out["name"] == "Verse Drums"
|
||||
Reference in New Issue
Block a user