mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-22 12:52:29 +00:00
389 lines
14 KiB
Python
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
|