mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-21 12:21:49 +00:00
203 lines
7.6 KiB
Python
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}"
|
|
)
|