feedBack/tests/test_sloppak_keys_load.py
Byron Gamatos e64378da78
feat(core): consume keys.json — song-level key/scale track (loader + WS) (#528)
The feedpak spec defines keys.json (instrument-independent key/scale-change
track, §7.7) but core never loaded it. Add it, mirroring the song_timeline /
drum_tab side-file pattern:

- lib/sloppak.py: LoadedSloppak gains a `keys` field; a permissive loader reads
  the manifest `keys:` key, path-safety-checks it, and stores a SANITIZED
  {version, events:[{t, key, scale?}]} — finite non-bool t (bad-t events dropped,
  not rewritten to 0), non-empty string key, optional string scale, sorted.
  Missing / unreadable / malformed -> None, never fatal. int-only version
  (a float/NaN version can't abort the load).
- server.py: stream a `keys` highway-WS message when present + a `has_keys`
  song_info flag so a consumer can light up a key/scale display.

Renderer/HUD surfacing is a thin follow-up; this lands the data plumbing so
the highway, plugins, and the upcoming scale-degree (`sd`) annotation can read
the active key/mode from the WS.

Codex-reviewed (2 rounds: version-int-coercion + bad-t-drop hardening); clean.
+7 loader tests (happy path, absent/permissive variants, sanitize/sort,
non-int-version no-abort). 150 sloppak/load tests pass.

Closes #525. Part of got-feedback/feedback#334.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 21:23:48 +02:00

128 lines
5.2 KiB
Python

"""End-to-end test for the sloppak loader recognising a `keys:` manifest key
(keys.json — the song-level, instrument-independent key/scale track, spec §7.7)
and surfacing the sanitized 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, keys_payload) -> Path:
"""Minimal directory-form sloppak; writes keys.json when a payload is given.
Unique filename per test (tmp_path leaf) so the module-level
resolve_source_dir cache isn't poisoned across tests."""
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": [], "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 keys_payload is not None:
(pak / "keys.json").write_text(json.dumps(keys_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_load_song_attaches_keys_when_manifest_opts_in(tmp_path: Path):
payload = {
"version": 1,
"events": [
{"t": 0.0, "key": "Em", "scale": "natural_minor"},
{"t": 2.0, "key": "G", "scale": "major"},
],
}
pak = _write_dir_sloppak(tmp_path, {"keys": "keys.json"}, payload)
loaded = _load(pak, tmp_path)
assert loaded.keys is not None
assert loaded.keys["version"] == 1
evs = loaded.keys["events"]
assert len(evs) == 2
assert evs[0] == {"t": 0.0, "key": "Em", "scale": "natural_minor"}
assert evs[1] == {"t": 2.0, "key": "G", "scale": "major"}
# ── Absent / permissive ──────────────────────────────────────────────────────
def test_load_song_keys_absent_when_manifest_silent(tmp_path: Path):
pak = _write_dir_sloppak(tmp_path, {}, None)
assert _load(pak, tmp_path).keys is None
def test_load_song_keys_absent_when_file_missing(tmp_path: Path):
pak = _write_dir_sloppak(tmp_path, {"keys": "nope.json"}, None)
assert _load(pak, tmp_path).keys is None
def test_load_song_keys_absent_when_invalid_json(tmp_path: Path):
pak = _write_dir_sloppak(tmp_path, {"keys": "keys.json"}, None)
(pak / "keys.json").write_text("not json {{{")
assert _load(pak, tmp_path).keys is None
def test_load_song_keys_ignored_when_events_not_a_list(tmp_path: Path):
pak = _write_dir_sloppak(tmp_path, {"keys": "keys.json"},
{"version": 1, "events": "nope"})
assert _load(pak, tmp_path).keys is None
# ── Sanitization ─────────────────────────────────────────────────────────────
def test_load_song_keys_sanitizes_and_sorts(tmp_path: Path):
payload = {
"version": 1,
"events": [
{"t": 2.0, "key": "G"}, # no scale -> omitted
{"t": 0.0, "key": "Em", "scale": "major"}, # out of order
{"t": 1.0}, # no key -> dropped
{"foo": "bar"}, # not an event -> dropped
{"t": 3.0, "key": ""}, # empty key -> dropped
{"t": "bad", "key": "X"}, # non-numeric t -> dropped
"garbage", # non-dict -> dropped
],
}
pak = _write_dir_sloppak(tmp_path, {"keys": "keys.json"}, payload)
evs = _load(pak, tmp_path).keys["events"]
assert evs == [
{"t": 0.0, "key": "Em", "scale": "major"},
{"t": 2.0, "key": "G"}, # scale absent, not null
]
def test_load_song_keys_nonint_version_does_not_abort_load(tmp_path: Path):
# json.loads accepts NaN; a float/NaN version must not raise int(NaN) and
# abort the load of an OPTIONAL side-file — it falls back to version 1.
payload = {"version": float("nan"), "events": [{"t": 0.0, "key": "C"}]}
pak = _write_dir_sloppak(tmp_path, {"keys": "keys.json"}, payload)
loaded = _load(pak, tmp_path)
assert loaded.keys is not None
assert loaded.keys["version"] == 1
assert loaded.keys["events"] == [{"t": 0.0, "key": "C"}]