feedBack/tests/test_drums_lib.py
Sin 4148b0e72e Purge external-format terminology from code, tests and docs
Reword comments/docstrings/strings and rename identifiers that referenced
the external game and its file formats:

- format-id "psarc" -> "archive"; local vars psarc_path -> song_path,
  psarc_base -> tone_base
- lyrics provenance value "sng" -> "notechart" (legacy "sng" still accepted)
- highway_3d fret-ghost scope value "rocksmith" -> "chords" (invalid/legacy
  values fall back to the default, preserving behaviour)
- neutralise references in prose, test names/data, .gitattributes and docs

No functional change beyond the renamed identifiers; all Python compiles.
2026-06-16 19:36:53 +01:00

184 lines
7.8 KiB
Python

"""Unit tests for lib/drums.py — piece vocabulary, presets, and wire helpers."""
from __future__ import annotations
import drums
# ── Piece vocabulary ──────────────────────────────────────────────────────────
def test_pieces_have_required_fields():
"""Every piece entry must have midi (list), category, shape, color."""
required = {"midi", "category", "shape", "color"}
for pid, meta in drums.PIECES.items():
assert required <= set(meta.keys()), f"{pid} missing fields"
assert isinstance(meta["midi"], list) and meta["midi"], f"{pid} midi must be non-empty list"
assert meta["category"] in {"kick", "drum", "cymbal"}, f"{pid} bad category"
assert isinstance(meta["color"], str) and meta["color"].startswith("#")
def test_midi_to_piece_round_trip_for_gm_drums():
"""Every MIDI note listed in PIECES round-trips through midi_to_piece."""
for pid, meta in drums.PIECES.items():
for m in meta["midi"]:
resolved = drums.midi_to_piece(m)
# The reverse map prefers the earliest piece-id for a shared MIDI.
# The resolved id must still map back to a piece whose midi list
# contains m, even if it isn't `pid` (e.g. some toms share notes).
assert resolved is not None, f"MIDI {m} (from {pid}) not in reverse map"
assert m in drums.PIECES[resolved]["midi"]
def test_midi_to_piece_unknown_returns_none():
"""MIDI notes outside the GM percussion range we map return None."""
# 60 is middle C — not a drum note.
assert drums.midi_to_piece(60) is None
# 200 is out of MIDI range.
assert drums.midi_to_piece(200) is None
def test_open_and_closed_hihat_are_distinct():
"""hh_open and hh_closed must NOT share MIDI notes — the highway relies on
this to reject a closed-hat strike on an open-hat note."""
closed = set(drums.PIECES["hh_closed"]["midi"])
opened = set(drums.PIECES["hh_open"]["midi"])
assert closed & opened == set(), "hh_closed and hh_open MUST NOT share MIDI"
def test_piece_to_default_midi_returns_list_copy():
"""piece_to_default_midi must return a fresh list (callers may mutate)."""
mids = drums.piece_to_default_midi("kick")
assert mids == [35, 36]
mids.append(99)
assert drums.piece_to_default_midi("kick") == [35, 36], "must return a copy"
def test_piece_helpers_have_fallbacks_for_unknown_ids():
"""Unknown piece-ids must round-trip with safe defaults — a newer sloppak's
unknown piece should still render rather than crash."""
assert drums.piece_to_default_midi("future_piece") == []
assert drums.piece_default_shape("future_piece") == "rect"
assert drums.piece_default_color("future_piece").startswith("#")
assert drums.piece_category("future_piece") == "drum"
# ── Presets ──────────────────────────────────────────────────────────────────
def test_presets_are_well_formed():
"""Each preset must be a list of lanes with non-empty `pieces` and a label."""
for name, preset in drums.PRESETS.items():
assert isinstance(preset, list) and preset, f"preset {name} empty"
for lane in preset:
assert isinstance(lane, dict)
assert "pieces" in lane and lane["pieces"], f"preset {name}: lane missing pieces"
assert "label" in lane and lane["label"], f"preset {name}: lane missing label"
# Every piece-id referenced must exist in PIECES — presets aren't
# the place to invent new piece-ids.
for pid in lane["pieces"]:
assert pid in drums.PIECES, f"preset {name}: unknown piece-id {pid}"
def test_phaseshift8_preserves_legacy_lane_order():
"""The phase_shift_8 preset must match the v3 drums plugin's HH/Sn/T1/T2/
T3/Cr/Ri/Ki order so existing sloppaks keep their familiar layout."""
labels = [lane["label"] for lane in drums.PRESET_PHASESHIFT8]
assert labels == ["HH", "Sn", "T1", "T2", "T3", "Cr", "Ri", "Ki"]
def test_every_piece_is_routed_by_every_preset():
"""Adding a new piece-id to PIECES without routing it in every preset
would let hits for that piece vanish from the highway under that
preset. This guards against that class of regression."""
all_pieces = set(drums.PIECES.keys())
for name, preset in drums.PRESETS.items():
routed = {pid for lane in preset for pid in lane["pieces"]}
missing = all_pieces - routed
assert not missing, (
f"preset {name!r} doesn't route: {sorted(missing)}. "
f"Add them to an existing lane (grouped presets) or to their "
f"own lane (e-kit presets).")
# ── Wire helpers ─────────────────────────────────────────────────────────────
def test_validate_drum_tab_accepts_minimal_payload():
ok, reason = drums.validate_drum_tab({"hits": []})
assert ok, reason
def test_validate_drum_tab_rejects_non_object():
ok, _ = drums.validate_drum_tab([])
assert not ok
ok, _ = drums.validate_drum_tab(None)
assert not ok
def test_validate_drum_tab_rejects_missing_hits():
ok, reason = drums.validate_drum_tab({"version": 1})
assert not ok
assert "hits" in reason
def test_validate_drum_tab_rejects_non_int_version():
ok, _ = drums.validate_drum_tab({"version": "1", "hits": []})
assert not ok
def test_validate_drum_tab_accepts_unknown_version():
"""An unknown schema version is logged but the payload is still accepted —
forward-compat per Principle IV (backwards-compatible custom song library)."""
ok, _ = drums.validate_drum_tab({"version": 99, "hits": []})
assert ok
def test_hit_to_wire_canonicalises_known_fields():
out = drums.hit_to_wire({"t": 0.5001, "p": "kick", "v": 110, "g": True, "f": False, "k": 0.08})
assert out == {"t": 0.5, "p": "kick", "v": 110, "g": True, "k": 0.08}
def test_hit_to_wire_drops_malformed():
assert drums.hit_to_wire({"t": 0.5}) is None # no piece
assert drums.hit_to_wire({"p": "kick"}) is None # no time
assert drums.hit_to_wire({"t": "x", "p": "kick"}) is None
assert drums.hit_to_wire({"t": 0.5, "p": ""}) is None
assert drums.hit_to_wire("not a dict") is None
def test_hit_to_wire_drops_out_of_range_velocity():
"""Velocity must be 1-127. 0 / 200 / negative get silently dropped."""
out = drums.hit_to_wire({"t": 0.5, "p": "kick", "v": 0})
assert "v" not in out
out = drums.hit_to_wire({"t": 0.5, "p": "kick", "v": 200})
assert "v" not in out
out = drums.hit_to_wire({"t": 0.5, "p": "kick", "v": -1})
assert "v" not in out
def test_hits_to_wire_drops_bad_and_sorts():
hits = [
{"t": 2.0, "p": "snare"},
{"t": 1.0, "p": "kick"},
{"t": "bad", "p": "kick"}, # dropped
{"t": 0.5, "p": "ride", "v": 90},
]
out = drums.hits_to_wire(hits)
assert [h["t"] for h in out] == [0.5, 1.0, 2.0]
assert [h["p"] for h in out] == ["ride", "kick", "snare"]
def test_normalise_kit_dedupes_and_fills_names():
kit = [
{"id": "kick", "name": "Kick"},
{"id": "snare"}, # name derived from id
{"id": "kick", "name": "duplicate"}, # dedupe wins by first occurrence
{"id": "", "name": "empty id"}, # dropped
"not a dict", # dropped
]
out = drums.normalise_kit(kit)
assert out == [{"id": "kick", "name": "Kick"}, {"id": "snare", "name": "Snare"}]
def test_normalise_kit_handles_none():
assert drums.normalise_kit(None) == []
assert drums.normalise_kit("not a list") == []