feedBack/tests/test_sloppak_drumtab_load.py
2026-06-16 18:47:13 +02:00

203 lines
7.6 KiB
Python

"""End-to-end test for the sloppak loader recognising a `drum_tab:` manifest
key and surfacing the parsed payload on the LoadedSloppak."""
from __future__ import annotations
import json
from pathlib import Path
import yaml
import sloppak as sloppak_mod
def _write_dir_sloppak(root: Path, manifest_extras: dict, drum_tab_payload: dict | None) -> Path:
"""Build a minimal directory-form sloppak that load_song will accept.
Uses the tmp_path leaf name to make the sloppak filename unique per test,
avoiding the module-level ``resolve_source_dir`` cache being poisoned by
a previous test that happened to share the same "song.sloppak" filename.
"""
pak = root / f"{root.name}.sloppak"
pak.mkdir()
arr_dir = pak / "arrangements"
arr_dir.mkdir()
# One trivial guitar arrangement — load_song bails on empty arrangements
# for non-drum-only sloppaks, and the wire format demands at least the
# standard keys exist (an empty list is fine for each).
arr = {
"name": "Lead",
"tuning": [0, 0, 0, 0, 0, 0],
"capo": 0,
"notes": [],
"chords": [],
"anchors": [],
"handshapes": [],
"templates": [],
"beats": [],
"sections": [],
}
(arr_dir / "lead.json").write_text(json.dumps(arr))
manifest = {
"title": "Test",
"artist": "Tester",
"album": "",
"year": 2026,
"duration": 10.0,
"arrangements": [{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"}],
"stems": [{"id": "full", "file": "stems/full.ogg", "default": True}],
}
manifest.update(manifest_extras)
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
if drum_tab_payload is not None:
(pak / "drum_tab.json").write_text(json.dumps(drum_tab_payload))
return pak
def _load(pak_path: Path, tmp_path: Path):
"""Invoke sloppak_mod.load_song using its DLC-relative API."""
dlc_root = pak_path.parent
cache = tmp_path / "cache"
cache.mkdir()
return sloppak_mod.load_song(pak_path.name, dlc_root, cache)
# ── Happy path ───────────────────────────────────────────────────────────────
def test_load_song_attaches_drum_tab_when_manifest_opts_in(tmp_path: Path):
payload = {
"version": 1,
"name": "Drums",
"kit": [{"id": "kick", "name": "Kick"}, {"id": "snare", "name": "Snare"}],
"hits": [
{"t": 0.5, "p": "kick", "v": 110},
{"t": 0.75, "p": "snare", "v": 92},
],
}
pak = _write_dir_sloppak(tmp_path, {"drum_tab": "drum_tab.json"}, payload)
loaded = _load(pak, tmp_path)
assert loaded.drum_tab is not None
assert len(loaded.drum_tab["hits"]) == 2
assert loaded.drum_tab["kit"][0]["id"] == "kick"
def test_load_song_drum_tab_absent_when_manifest_silent(tmp_path: Path):
pak = _write_dir_sloppak(tmp_path, {}, None)
loaded = _load(pak, tmp_path)
assert loaded.drum_tab is None
def test_load_song_drum_tab_absent_when_file_missing(tmp_path: Path):
pak = _write_dir_sloppak(tmp_path, {"drum_tab": "nope.json"}, None)
loaded = _load(pak, tmp_path)
# Pointing at a missing file disables drums silently — don't crash the load.
assert loaded.drum_tab is None
def test_load_song_drum_tab_absent_when_payload_invalid_json(tmp_path: Path):
pak = _write_dir_sloppak(tmp_path, {"drum_tab": "drum_tab.json"}, None)
(pak / "drum_tab.json").write_text("not json {{{")
loaded = _load(pak, tmp_path)
assert loaded.drum_tab is None
def test_load_song_drum_tab_absent_when_payload_fails_validation(tmp_path: Path):
# `hits` must be a list per validate_drum_tab — this is a hard rejection.
pak = _write_dir_sloppak(
tmp_path,
{"drum_tab": "drum_tab.json"},
{"version": 1, "hits": "not a list"}, # type: ignore[arg-type]
)
loaded = _load(pak, tmp_path)
assert loaded.drum_tab is None
def test_load_song_drum_tab_round_trips_unknown_pieces(tmp_path: Path):
"""A future piece-id must round-trip — older clients then render it as a
generic rectangle rather than dropping it."""
payload = {
"version": 1,
"kit": [{"id": "cowbell", "name": "Cowbell"}],
"hits": [{"t": 1.0, "p": "cowbell", "v": 100}],
}
pak = _write_dir_sloppak(tmp_path, {"drum_tab": "drum_tab.json"}, payload)
loaded = _load(pak, tmp_path)
assert loaded.drum_tab is not None
assert loaded.drum_tab["hits"][0]["p"] == "cowbell"
# ── Security / path-traversal branches ──────────────────────────────────────
def test_load_song_drum_tab_absent_when_path_escapes_sloppak(tmp_path: Path):
"""A manifest drum_tab value that escapes the sloppak directory via '../'
must be silently rejected — drums disabled, load continues."""
pak = _write_dir_sloppak(tmp_path, {"drum_tab": "../outside.json"}, None)
loaded = _load(pak, tmp_path)
assert loaded.drum_tab is None
def test_load_song_drum_tab_absent_when_path_is_absolute(tmp_path: Path):
"""An absolute drum_tab path in the manifest must be silently rejected."""
pak = _write_dir_sloppak(tmp_path, {"drum_tab": "/etc/passwd"}, None)
loaded = _load(pak, tmp_path)
assert loaded.drum_tab is None
# ── drum-only (no guitar arrangements) ──────────────────────────────────────
def _write_drum_only_sloppak(root: Path, drum_tab_payload: dict) -> Path:
"""A sloppak with no guitar arrangements — only a drum_tab entry."""
pak = root / f"{root.name}.sloppak"
pak.mkdir()
drum_tab_path = pak / "drum_tab.json"
drum_tab_path.write_text(json.dumps(drum_tab_payload))
manifest = {
"title": "Drum Only",
"artist": "Tester",
"album": "",
"year": 2026,
"duration": 5.0,
"arrangements": [],
"drum_tab": "drum_tab.json",
"stems": [],
}
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
return pak
def test_drum_only_arrangement_ids_parallel_to_song_arrangements(tmp_path: Path):
"""arrangement_ids must stay parallel to song.arrangements for drum-only sloppaks.
When load_song synthesises a placeholder Drums arrangement (because the
manifest has no guitar arrangements but has a drum_tab key), it must also
append a corresponding None to arrangement_ids so
len(arrangement_ids) == len(song.arrangements) is preserved.
"""
payload = {
"version": 1,
"name": "Drums",
"kit": [{"id": "kick", "name": "Kick"}],
"hits": [{"t": 0.5, "p": "kick", "v": 110}, {"t": 1.0, "p": "kick", "v": 90}],
}
pak = _write_drum_only_sloppak(tmp_path, payload)
loaded = _load(pak, tmp_path)
assert loaded.drum_tab is not None
assert len(loaded.song.arrangements) == 1, (
"expected the synthetic Drums placeholder to be present"
)
assert loaded.song.arrangements[0].name == "Drums", (
f"synthesised placeholder must be named 'Drums', got {loaded.song.arrangements[0].name!r}"
)
assert len(loaded.arrangement_ids) == len(loaded.song.arrangements), (
f"arrangement_ids ({loaded.arrangement_ids!r}) must be parallel to "
f"song.arrangements (len={len(loaded.song.arrangements)})"
)
assert loaded.arrangement_ids == [None], (
f"synthesised Drums placeholder must map to None in arrangement_ids, got {loaded.arrangement_ids!r}"
)