mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 11:19:24 +00:00
feedpak 1.2.0 added song-level `tempos` + `time_signatures` to song_timeline.json
and a per-chart `tempos` override on arrangements (§6.10). Core stored the raw
song_timeline dict but never consumed the maps, and didn't read per-chart tempos.
- song.py: shared `sanitize_tempos([{time,bpm}])` (finite non-bool time, finite
bpm>0, sorted); `Arrangement.tempos` field wired through arrangement_to_wire
(omitted when None/empty per §6.10) / arrangement_from_wire.
- sloppak.py: `_sanitize_time_signatures([{time,ts:[num,den]}])`;
LoadedSloppak.tempos / .time_signatures, loaded from song_timeline.json
INDEPENDENTLY of beats/sections (all are optional in 1.2.0).
- server.py: stream `tempos` + `time_signatures` highway-WS messages; the active
arrangement's per-chart `tempos` overrides the song-level map for that chart.
Renderer/UI surfacing is a thin follow-up; this lands the data plumbing.
Codex-reviewed: clean (no findings). +9 tests (sanitizers, per-chart wire
round-trip + omit-when-absent, song-level load/sanitize/absent + maps-without-
beats). 90 song/sloppak tests pass. (Pre-existing unrelated failure:
test_diagnostics_redact, fails on clean main too.)
Closes #526. Part of got-feedback/feedback#334.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
229 lines
8.6 KiB
Python
229 lines
8.6 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
|
|
|
|
|
|
# ── tempos + time_signatures (feedpak 1.2.0) ─────────────────────────────────
|
|
|
|
def test_song_timeline_tempos_and_time_signatures_loaded(tmp_path: Path):
|
|
payload = {
|
|
"version": 1, "beats": [], "sections": [],
|
|
"tempos": [{"time": 0.0, "bpm": 120}, {"time": 4.0, "bpm": 90}],
|
|
"time_signatures": [{"time": 0.0, "ts": [4, 4]}, {"time": 8.0, "ts": [6, 8]}],
|
|
}
|
|
pak = _write_dir_sloppak(tmp_path, {"song_timeline": "song_timeline.json"}, payload)
|
|
loaded = _load(pak, tmp_path)
|
|
assert loaded.tempos == [{"time": 0.0, "bpm": 120.0}, {"time": 4.0, "bpm": 90.0}]
|
|
assert loaded.time_signatures == [{"time": 0.0, "ts": [4, 4]},
|
|
{"time": 8.0, "ts": [6, 8]}]
|
|
|
|
|
|
def test_song_timeline_maps_absent_when_not_provided(tmp_path: Path):
|
|
payload = {"version": 1, "beats": [], "sections": []}
|
|
pak = _write_dir_sloppak(tmp_path, {"song_timeline": "song_timeline.json"}, payload)
|
|
loaded = _load(pak, tmp_path)
|
|
assert loaded.tempos is None and loaded.time_signatures is None
|
|
|
|
|
|
def test_song_timeline_maps_sanitized(tmp_path: Path):
|
|
payload = {
|
|
"version": 1, "beats": [], "sections": [],
|
|
"tempos": [{"time": 1.0, "bpm": 0}, {"time": 0.0, "bpm": 100}], # bpm 0 dropped + sorted
|
|
"time_signatures": [{"time": 0.0, "ts": [4, 4, 4]}, # 3-long dropped
|
|
{"time": 2.0, "ts": [3, 4]}],
|
|
}
|
|
pak = _write_dir_sloppak(tmp_path, {"song_timeline": "song_timeline.json"}, payload)
|
|
loaded = _load(pak, tmp_path)
|
|
assert loaded.tempos == [{"time": 0.0, "bpm": 100.0}]
|
|
assert loaded.time_signatures == [{"time": 2.0, "ts": [3, 4]}]
|
|
|
|
|
|
def test_song_timeline_maps_load_even_without_beats_or_sections(tmp_path: Path):
|
|
# tempos/time_signatures are independent of beats/sections — a payload that
|
|
# omits beats (invalid for the override path) must still surface the maps.
|
|
payload = {"version": 1, "tempos": [{"time": 0.0, "bpm": 100}]}
|
|
pak = _write_dir_sloppak(tmp_path, {"song_timeline": "song_timeline.json"}, payload)
|
|
loaded = _load(pak, tmp_path)
|
|
assert loaded.tempos == [{"time": 0.0, "bpm": 100.0}]
|
|
|
|
|
|
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"
|