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

185 lines
6.4 KiB
Python

"""End-to-end tests for the sloppak loader recognising a `song_timeline:` 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,
song_timeline_payload: dict | None,
) -> Path:
"""Build a minimal directory-form sloppak that load_song will accept.
The lead arrangement intentionally carries beats and sections so that tests
can verify song_timeline overrides them (or that they survive when
song_timeline is absent).
"""
pak = root / f"{root.name}.sloppak"
pak.mkdir()
arr_dir = pak / "arrangements"
arr_dir.mkdir()
arr = {
"name": "Lead",
"tuning": [0, 0, 0, 0, 0, 0],
"capo": 0,
"notes": [],
"chords": [],
"anchors": [],
"handshapes": [],
"templates": [],
"beats": [{"time": 1.0, "measure": 1}],
"sections": [{"name": "arr_verse", "number": 1, "time": 1.0}],
}
(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 song_timeline_payload is not None:
(pak / "song_timeline.json").write_text(json.dumps(song_timeline_payload))
return pak
def _load(pak_path: Path, tmp_path: Path):
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_song_timeline_populates_beats_and_sections(tmp_path: Path):
payload = {
"version": 1,
"beats": [
{"time": 0.5, "measure": 1},
{"time": 1.0, "measure": -1},
],
"sections": [
{"name": "intro", "number": 1, "time": 0.0},
],
}
pak = _write_dir_sloppak(tmp_path, {"song_timeline": "song_timeline.json"}, payload)
loaded = _load(pak, tmp_path)
assert loaded.song_timeline is not None
assert len(loaded.song.beats) == 2
assert loaded.song.beats[0].time == 0.5
assert loaded.song.beats[1].measure == -1
assert len(loaded.song.sections) == 1
assert loaded.song.sections[0].name == "intro"
def test_song_timeline_absent_falls_through_to_arrangement_beats(tmp_path: Path):
pak = _write_dir_sloppak(tmp_path, {}, None)
loaded = _load(pak, tmp_path)
assert loaded.song_timeline is None
# Arrangement JSON beat should still be present.
assert len(loaded.song.beats) == 1
assert loaded.song.beats[0].time == 1.0
def test_song_timeline_overrides_arrangement_beats(tmp_path: Path):
payload = {
"version": 1,
"beats": [{"time": 0.5, "measure": 1}],
"sections": [],
}
pak = _write_dir_sloppak(tmp_path, {"song_timeline": "song_timeline.json"}, payload)
loaded = _load(pak, tmp_path)
# song_timeline beat (0.5) must win over arrangement beat (1.0).
assert loaded.song.beats[0].time == 0.5
# ── Error / security cases ───────────────────────────────────────────────────
def test_song_timeline_absent_when_file_missing(tmp_path: Path):
pak = _write_dir_sloppak(tmp_path, {"song_timeline": "nope.json"}, None)
loaded = _load(pak, tmp_path)
assert loaded.song_timeline is None
def test_song_timeline_absent_when_invalid_json(tmp_path: Path):
pak = _write_dir_sloppak(tmp_path, {"song_timeline": "song_timeline.json"}, None)
(pak / "song_timeline.json").write_text("not json {{{")
loaded = _load(pak, tmp_path)
assert loaded.song_timeline is None
def test_song_timeline_absent_when_beats_not_a_list(tmp_path: Path):
pak = _write_dir_sloppak(tmp_path, {"song_timeline": "song_timeline.json"}, None)
(pak / "song_timeline.json").write_text(json.dumps({"beats": "oops", "sections": []}))
loaded = _load(pak, tmp_path)
assert loaded.song_timeline is None
def test_song_timeline_absent_when_sections_not_a_list(tmp_path: Path):
pak = _write_dir_sloppak(tmp_path, {"song_timeline": "song_timeline.json"}, None)
(pak / "song_timeline.json").write_text(json.dumps({"beats": [], "sections": "oops"}))
loaded = _load(pak, tmp_path)
assert loaded.song_timeline is None
def test_song_timeline_absent_when_path_escapes_sloppak(tmp_path: Path):
pak = _write_dir_sloppak(tmp_path, {"song_timeline": "../outside.json"}, None)
loaded = _load(pak, tmp_path)
assert loaded.song_timeline is None
def test_song_timeline_absent_when_path_is_absolute(tmp_path: Path):
pak = _write_dir_sloppak(tmp_path, {"song_timeline": "/etc/passwd"}, None)
loaded = _load(pak, tmp_path)
assert loaded.song_timeline is None
def test_song_timeline_skips_non_dict_beat_entries(tmp_path: Path):
"""Non-dict items in the beats list are skipped; valid items are kept."""
payload = {
"version": 1,
"beats": [None, 42, {"time": 0.5, "measure": 1}, "bad"],
"sections": [],
}
pak = _write_dir_sloppak(tmp_path, {"song_timeline": "song_timeline.json"}, payload)
loaded = _load(pak, tmp_path)
# The timeline was accepted (valid structure overall).
assert loaded.song_timeline is not None
# Only the one valid dict entry should appear.
assert len(loaded.song.beats) == 1
assert loaded.song.beats[0].time == 0.5
def test_song_timeline_skips_non_dict_section_entries(tmp_path: Path):
"""Non-dict items in the sections list are skipped; valid items are kept."""
payload = {
"version": 1,
"beats": [],
"sections": [None, {"name": "verse", "number": 1, "time": 1.0}, 99],
}
pak = _write_dir_sloppak(tmp_path, {"song_timeline": "song_timeline.json"}, payload)
loaded = _load(pak, tmp_path)
assert loaded.song_timeline is not None
assert len(loaded.song.sections) == 1
assert loaded.song.sections[0].name == "verse"