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

389 lines
14 KiB
Python

"""End-to-end tests for per-arrangement notation file loading in the sloppak loader.
Tests Option B architecture: notation key lives on each arrangement entry in the
manifest, not at the top level.
"""
from __future__ import annotations
import json
from pathlib import Path
import yaml
import sloppak as sloppak_mod
def _write_dir_sloppak_with_notation(root, notation_entries, extra_manifest=None):
"""
notation_entries is a list of dicts:
{
"id": str, # arrangement id
"name": str, # arrangement name (optional, defaults to id)
"include_file": bool, # whether to write arrangements/<id>.json
"notation_filename": str | None, # e.g. "notation_keys.json"; None = no notation key
"notation_payload": dict | None, # written to notation_filename if not None
}
extra_manifest: additional top-level manifest keys (dict | None)
"""
pak = root / f"{root.name}.sloppak"
pak.mkdir()
arr_dir = pak / "arrangements"
arr_dir.mkdir()
arrangements = []
for e in notation_entries:
arr_id = e["id"]
name = e.get("name", arr_id)
entry: dict = {"id": arr_id, "name": name}
if e.get("include_file"):
arr_file = f"arrangements/{arr_id}.json"
entry["file"] = arr_file
arr_content = {
"notes": [], "chords": [], "anchors": [],
"handshapes": [], "templates": [],
}
(arr_dir / f"{arr_id}.json").write_text(json.dumps(arr_content))
if e.get("notation_filename") is not None:
entry["notation"] = e["notation_filename"]
if e.get("notation_payload") is not None:
(pak / e["notation_filename"]).write_text(json.dumps(e["notation_payload"]))
arrangements.append(entry)
manifest: dict = {
"title": "Test",
"artist": "Tester",
"album": "",
"year": 2026,
"duration": 10.0,
"arrangements": arrangements,
"stems": [{"id": "full", "file": "stems/full.ogg", "default": True}],
}
if extra_manifest:
manifest.update(extra_manifest)
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
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)
VALID_NOTATION = {"version": 1, "staves": [], "measures": []}
# ── Happy path ───────────────────────────────────────────────────────────────
def test_single_arrangement_with_notation(tmp_path: Path):
pak = _write_dir_sloppak_with_notation(tmp_path, [
{
"id": "keys",
"name": "Keys",
"include_file": True,
"notation_filename": "notation_keys.json",
"notation_payload": VALID_NOTATION,
},
])
loaded = _load(pak, tmp_path)
assert loaded.notation_by_id is not None
assert "keys" in loaded.notation_by_id
assert loaded.notation_by_id["keys"] == VALID_NOTATION
assert len(loaded.song.arrangements) == 1
def test_two_arrangements_both_with_notation(tmp_path: Path):
notation_a = {"version": 1, "staves": [{"id": "a"}], "measures": []}
notation_b = {"version": 1, "staves": [{"id": "b"}], "measures": []}
pak = _write_dir_sloppak_with_notation(tmp_path, [
{
"id": "lead",
"include_file": True,
"notation_filename": "notation_lead.json",
"notation_payload": notation_a,
},
{
"id": "bass",
"include_file": True,
"notation_filename": "notation_bass.json",
"notation_payload": notation_b,
},
])
loaded = _load(pak, tmp_path)
assert loaded.notation_by_id is not None
assert loaded.notation_by_id["lead"] == notation_a
assert loaded.notation_by_id["bass"] == notation_b
assert len(loaded.song.arrangements) == 2
def test_arrangement_notation_key_no_file(tmp_path: Path):
"""Arrangement with notation: but no file: key still loads into song.arrangements."""
pak = _write_dir_sloppak_with_notation(tmp_path, [
{
"id": "keys",
"name": "Keys",
"include_file": False,
"notation_filename": "notation_keys.json",
"notation_payload": VALID_NOTATION,
},
])
loaded = _load(pak, tmp_path)
assert len(loaded.song.arrangements) == 1
assert loaded.notation_by_id is not None
assert loaded.notation_by_id["keys"] == VALID_NOTATION
def test_no_arrangement_has_notation_key(tmp_path: Path):
"""When no arrangement carries a notation key, notation_by_id is None."""
pak = _write_dir_sloppak_with_notation(tmp_path, [
{
"id": "lead",
"include_file": True,
"notation_filename": None,
"notation_payload": None,
},
])
loaded = _load(pak, tmp_path)
assert loaded.notation_by_id is None
assert len(loaded.song.arrangements) == 1
def test_mixed_one_notation_one_without(tmp_path: Path):
"""One arrangement has notation, one does not — both load; only one in notation_by_id."""
pak = _write_dir_sloppak_with_notation(tmp_path, [
{
"id": "lead",
"include_file": True,
"notation_filename": "notation_lead.json",
"notation_payload": VALID_NOTATION,
},
{
"id": "bass",
"include_file": True,
"notation_filename": None,
"notation_payload": None,
},
])
loaded = _load(pak, tmp_path)
assert len(loaded.song.arrangements) == 2
assert loaded.notation_by_id is not None
assert "lead" in loaded.notation_by_id
assert "bass" not in loaded.notation_by_id
# ── Isolation / error cases ──────────────────────────────────────────────────
def test_notation_file_missing_arrangement_still_loads(tmp_path: Path):
"""notation_filename set but file not written — arr loads, notation_by_id is None."""
pak = _write_dir_sloppak_with_notation(tmp_path, [
{
"id": "keys",
"include_file": True,
"notation_filename": "notation_keys.json",
"notation_payload": None, # file not written
},
])
loaded = _load(pak, tmp_path)
assert len(loaded.song.arrangements) == 1
assert loaded.notation_by_id is None
def test_notation_file_invalid_json_arrangement_still_loads(tmp_path: Path):
"""Notation file present but unparseable JSON — arr loads, key absent."""
pak = _write_dir_sloppak_with_notation(tmp_path, [
{
"id": "keys",
"include_file": True,
"notation_filename": "notation_keys.json",
"notation_payload": None,
},
])
(pak / "notation_keys.json").write_text("not json {{{")
loaded = _load(pak, tmp_path)
assert len(loaded.song.arrangements) == 1
assert loaded.notation_by_id is None
def test_notation_file_fails_validation_arrangement_still_loads(tmp_path: Path):
"""Notation file present but measures is not a list — arr loads, key absent."""
invalid_notation = {"version": 1, "staves": [], "measures": "not a list"}
pak = _write_dir_sloppak_with_notation(tmp_path, [
{
"id": "keys",
"include_file": True,
"notation_filename": "notation_keys.json",
"notation_payload": invalid_notation,
},
])
loaded = _load(pak, tmp_path)
assert len(loaded.song.arrangements) == 1
assert loaded.notation_by_id is None
def test_notation_path_traversal_arrangement_still_loads(tmp_path: Path):
"""notation: "../outside.json" is a path traversal — arr loads, key absent."""
pak = _write_dir_sloppak_with_notation(tmp_path, [
{
"id": "keys",
"include_file": True,
"notation_filename": "../outside.json",
"notation_payload": None, # don't attempt write outside pak
},
])
loaded = _load(pak, tmp_path)
assert len(loaded.song.arrangements) == 1
assert loaded.notation_by_id is None
def test_notation_absolute_path_arrangement_still_loads(tmp_path: Path):
"""notation: "/etc/passwd" absolute path — arr loads, key absent."""
pak = _write_dir_sloppak_with_notation(tmp_path, [
{
"id": "keys",
"include_file": True,
"notation_filename": "/etc/passwd",
"notation_payload": None,
},
])
loaded = _load(pak, tmp_path)
assert len(loaded.song.arrangements) == 1
assert loaded.notation_by_id is None
# ── id edge cases ────────────────────────────────────────────────────────────
def test_arrangement_without_id_skips_notation(tmp_path: Path):
"""Arrangement entry missing 'id' field — notation skipped, arr still loads."""
pak = _write_dir_sloppak_with_notation(tmp_path, [
{
"id": "keys",
"include_file": True,
"notation_filename": "notation_keys.json",
"notation_payload": VALID_NOTATION,
},
])
manifest = yaml.safe_load((pak / "manifest.yaml").read_text())
del manifest["arrangements"][0]["id"]
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
loaded = _load(pak, tmp_path)
assert len(loaded.song.arrangements) == 1
assert loaded.notation_by_id is None
def test_arrangement_empty_id_skips_notation(tmp_path: Path):
"""Arrangement entry with id: '' — notation skipped, arr still loads."""
pak = _write_dir_sloppak_with_notation(tmp_path, [
{
"id": "keys",
"include_file": True,
"notation_filename": "notation_keys.json",
"notation_payload": VALID_NOTATION,
},
])
manifest = yaml.safe_load((pak / "manifest.yaml").read_text())
manifest["arrangements"][0]["id"] = ""
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
loaded = _load(pak, tmp_path)
assert len(loaded.song.arrangements) == 1
assert loaded.notation_by_id is None
# ── arrangement_ids parallel-index alignment ─────────────────────────────────
def test_malformed_notation_key_dict_does_not_create_phantom_arrangement(tmp_path: Path):
"""A notation key whose value is a dict (not a string) with no file must not
create a phantom placeholder arrangement."""
pak = _write_dir_sloppak_with_notation(tmp_path, [
{
# No file, notation value is a dict — truthy but not a valid path.
"id": "bad",
"include_file": False,
"notation_filename": None,
"notation_payload": None,
"_raw_notation": {"nested": "dict"},
},
])
# Patch the manifest to inject the malformed notation key directly.
import yaml # noqa: PLC0415
manifest_path = pak / "manifest.yaml"
manifest = yaml.safe_load(manifest_path.read_text())
manifest["arrangements"][0]["notation"] = {"nested": "dict"}
manifest_path.write_text(yaml.safe_dump(manifest, sort_keys=False))
loaded = _load(pak, tmp_path)
assert len(loaded.song.arrangements) == 0, (
"malformed notation key (dict) with no file must not create a phantom arrangement"
)
def test_malformed_notation_key_list_does_not_create_phantom_arrangement(tmp_path: Path):
"""A notation key whose value is a list with no file must not create a
phantom placeholder arrangement."""
pak = _write_dir_sloppak_with_notation(tmp_path, [
{
"id": "bad",
"include_file": False,
"notation_filename": None,
"notation_payload": None,
},
])
import yaml # noqa: PLC0415
manifest_path = pak / "manifest.yaml"
manifest = yaml.safe_load(manifest_path.read_text())
manifest["arrangements"][0]["notation"] = ["not", "a", "path"]
manifest_path.write_text(yaml.safe_dump(manifest, sort_keys=False))
loaded = _load(pak, tmp_path)
assert len(loaded.song.arrangements) == 0, (
"malformed notation key (list) with no file must not create a phantom arrangement"
)
def test_arrangement_ids_aligned_with_song_arrangements(tmp_path: Path):
"""arrangement_ids is parallel to song.arrangements (not manifest list).
When a manifest entry is skipped (no file, no notation), the entries that
*do* load must still be reachable by their compacted index, not the raw
manifest index.
"""
# Build a sloppak with three manifest entries: the first has neither file
# nor notation so it is skipped by load_song. The second and third load
# normally.
pak = _write_dir_sloppak_with_notation(tmp_path, [
{
# Skipped: no file, no notation key.
"id": "ghost",
"include_file": False,
"notation_filename": None,
"notation_payload": None,
},
{
"id": "lead",
"include_file": True,
"notation_filename": "notation_lead.json",
"notation_payload": VALID_NOTATION,
},
{
"id": "keys",
"include_file": True,
"notation_filename": "notation_keys.json",
"notation_payload": VALID_NOTATION,
},
])
loaded = _load(pak, tmp_path)
# Only two arrangements should have loaded (ghost was skipped).
assert len(loaded.song.arrangements) == 2
# arrangement_ids must be parallel: index 0 → lead, index 1 → keys.
assert loaded.arrangement_ids == ["lead", "keys"]
# Notation lookup via compacted index must be correct.
assert loaded.notation_by_id is not None
assert "lead" in loaded.notation_by_id
assert "keys" in loaded.notation_by_id