feedBack/tests/test_sloppak_jsonc_load.py
Bret Mogilefsky b0338849f8
feat(core): read .jsonc data files (strip C-style comments) (feedpak-spec §8)
feedpak-spec §8 (FEP #3 / PR #13) allows .jsonc files (JSON with C-style
// line and /* */ block comments) anywhere .json is specified. A Reader MUST
strip comments before parsing. Core's sloppak/feedpak readers parsed every
side file with bare json.loads, so a .jsonc arrangement / notation / drum_tab
/ song_timeline / lyrics / keys would fail to load.

- lib/jsonc.py (new): shared parse_jsonc(text) + load_json(path). String-aware
  regex (mirrors the spec reference validator in feedpak-spec/tools/validate.py)
  — keeps comment-like text inside JSON string literals. load_json auto-detects
  .jsonc by suffix; plain .json goes straight through json.loads.
- lib/sloppak.py: import load_json; replace the 6 json.loads(...read_text...)
  side-file read sites (arrangement, notation, drum_tab, song_timeline,
  lyrics, keys) with load_json(<path>). Removed the now-unused `import json`.
- scripts/lift_keys_notation.py: import load_json; replace the 3 read sites
  (song_timeline, arrangement beats fallback, arrangement lift). `import json`
  stays (json.dumps write at the notation sidecar emit).

Additive (MINOR) change: older readers parse .jsonc as plain JSON and ignore
comments via the spec's forward-compatibility rules, so no existing pack needs
regeneration.

Tests: tests/test_sloppak_jsonc_load.py (16 tests) — parse_jsonc unit cases
(line/block/multiline/string-boundary/malformed/plain), and end-to-end loads
for all 6 side-file types via .jsonc with comments, plus the lift helper
reading .jsonc song_timeline + .jsonc arrangement beats, plus the
string-boundary preservation rule through the full loader. 122 sloppak/lift
tests pass.
2026-06-20 14:10:04 -07:00

336 lines
13 KiB
Python

"""Tests for .jsonc support in the sloppak/feedpak loaders (feedpak-spec §8).
When a manifest pointer resolves to a ``.jsonc`` file, the reader MUST strip
C-style comments (``//`` line and ``/* */`` block) before parsing. These tests
exercise every side-file reader in ``lib/sloppak.py`` (arrangement, notation,
drum_tab, song_timeline, lyrics, keys) plus the arrangement/song_timeline
reads in ``scripts/lift_keys_notation.py`` against ``.jsonc`` inputs, and pin
the string-boundary rule (comment-like text inside a JSON string survives).
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
import yaml
import sloppak as sloppak_mod
from jsonc import parse_jsonc
from scripts.lift_keys_notation import _load_song_beats
# ── jsonc.parse_jsonc unit tests ─────────────────────────────────────────────
def test_parse_jsonc_strips_line_comment():
assert parse_jsonc('{"a": 1 // c\n}') == {"a": 1}
def test_parse_jsonc_strips_block_comment():
assert parse_jsonc('{"a": /* x */ 2}') == {"a": 2}
def test_parse_jsonc_strips_multiline_block_comment():
text = '{\n /* multi\n line */\n "a": 1\n}'
assert parse_jsonc(text) == {"a": 1}
def test_parse_jsonc_preserves_comment_like_text_in_strings():
# ``//`` and ``/*`` inside a JSON string literal must NOT be treated as
# comments — the string-aware regex keeps them verbatim.
text = '{"url": "https://x/y", "note": "// not a comment /* still not */"}'
out = parse_jsonc(text)
assert out["url"] == "https://x/y"
assert out["note"] == "// not a comment /* still not */"
def test_parse_jsonc_rejects_malformed():
with pytest.raises(json.JSONDecodeError):
parse_jsonc('{"a": // comment breaks value\n}')
def test_parse_jsonc_plain_json_passes_through():
assert parse_jsonc('{"a": 1}') == {"a": 1}
# ── Fixture builder ──────────────────────────────────────────────────────────
def _base_arrangement(*, beats=None) -> dict:
return {
"name": "Lead", "tuning": [0, 0, 0, 0, 0, 0], "capo": 0,
"notes": [], "chords": [], "anchors": [], "handshapes": [],
"templates": [], "beats": beats or [], "sections": [],
}
def _write_sloppak(root: Path, *, manifest_extras: dict, side_files: dict) -> Path:
"""Build a minimal directory-form sloppak.
``side_files`` maps a pak-relative filename (e.g. ``"arrangements/lead.jsonc"``)
to the exact text to write — so the caller controls comments / extensions.
"""
pak = root / f"{root.name}.sloppak"
pak.mkdir()
(pak / "arrangements").mkdir()
# A default lead arrangement file the manifest references; can be overridden
# via side_files if the caller wants a .jsonc arrangement.
if "arrangements/lead.json" not in side_files and "arrangements/lead.jsonc" not in side_files:
(pak / "arrangements" / "lead.json").write_text(
json.dumps(_base_arrangement())
)
for rel, text in side_files.items():
target = pak / rel
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(text, encoding="utf-8")
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))
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)
# ── Arrangement .jsonc ───────────────────────────────────────────────────────
def test_load_arrangement_jsonc_with_comments(tmp_path: Path):
arr_text = (
'// lead chart\n'
'{\n'
' "name": "Lead",\n'
' "tuning": [0, 0, 0, 0, 0, 0],\n'
' "capo": 0,\n'
' /* no notes yet */\n'
' "notes": [{"t": 0.5, "s": 0, "f": 5, "sus": 0}],\n'
' "chords": [], "anchors": [], "handshapes": [], "templates": [],\n'
' "beats": [], "sections": []\n'
'}'
)
pak = _write_sloppak(
tmp_path,
manifest_extras={
"arrangements": [{"id": "lead", "name": "Lead",
"file": "arrangements/lead.jsonc"}]
},
side_files={"arrangements/lead.jsonc": arr_text},
)
loaded = _load(pak, tmp_path)
assert len(loaded.song.arrangements) == 1
arr = loaded.song.arrangements[0]
assert arr.name == "Lead"
assert len(arr.notes) == 1
assert arr.notes[0].fret == 5
def test_load_arrangement_jsonc_malformed_is_skipped(tmp_path: Path):
# A .jsonc file that is still invalid after stripping comments must be
# skipped gracefully (the loader's existing permissive path), not crash.
pak = _write_sloppak(
tmp_path,
manifest_extras={
"arrangements": [{"id": "lead", "name": "Lead",
"file": "arrangements/lead.jsonc"}]
},
side_files={"arrangements/lead.jsonc": '{"notes": // broken\n}'},
)
loaded = _load(pak, tmp_path)
# The malformed arrangement is skipped; load still returns a song shell.
assert len(loaded.song.arrangements) == 0
# ── Notation .jsonc ──────────────────────────────────────────────────────────
def test_load_notation_jsonc_with_comments(tmp_path: Path):
from tests.test_sloppak_notation_load import VALID_NOTATION
payload = VALID_NOTATION
text = (
'/* notation for keys */\n'
+ json.dumps(payload)
)
pak = _write_sloppak(
tmp_path,
manifest_extras={
"arrangements": [{"id": "lead", "name": "Lead",
"file": "arrangements/lead.json",
"notation": "notation_lead.jsonc"}]
},
side_files={"notation_lead.jsonc": text},
)
loaded = _load(pak, tmp_path)
assert loaded.notation_by_id is not None
assert "lead" in loaded.notation_by_id
# ── drum_tab .jsonc ──────────────────────────────────────────────────────────
def test_load_drum_tab_jsonc_with_comments(tmp_path: Path):
payload = {
"version": 1, "name": "Drums",
"kit": [{"id": "kick", "name": "Kick"}],
"hits": [{"t": 0.5, "p": "kick", "v": 110}],
}
text = '// drum tab\n' + json.dumps(payload)
pak = _write_sloppak(
tmp_path,
manifest_extras={"drum_tab": "drum_tab.jsonc"},
side_files={"drum_tab.jsonc": text},
)
loaded = _load(pak, tmp_path)
assert loaded.drum_tab is not None
assert loaded.drum_tab["hits"][0]["p"] == "kick"
# ── song_timeline .jsonc ─────────────────────────────────────────────────────
def test_load_song_timeline_jsonc_with_comments(tmp_path: Path):
payload = {
"beats": [{"time": 0.0, "measure": 0}, {"time": 0.5, "measure": 0}],
"sections": [{"name": "intro", "number": 0, "time": 0.0}],
}
text = '/* timeline */\n' + json.dumps(payload)
pak = _write_sloppak(
tmp_path,
manifest_extras={"song_timeline": "song_timeline.jsonc"},
side_files={"song_timeline.jsonc": text},
)
loaded = _load(pak, tmp_path)
assert loaded.song_timeline is not None
assert len(loaded.song.beats) == 2
assert loaded.song.sections[0].name == "intro"
# ── lyrics .jsonc ────────────────────────────────────────────────────────────
def test_load_lyrics_jsonc_with_comments(tmp_path: Path):
payload = [
{"w": "Hel", "t": 0.0, "d": 0.2},
{"w": "lo", "t": 0.2, "d": 0.3},
]
text = '// syllable lyrics\n' + json.dumps(payload)
pak = _write_sloppak(
tmp_path,
manifest_extras={"lyrics": "lyrics.jsonc"},
side_files={"lyrics.jsonc": text},
)
loaded = _load(pak, tmp_path)
assert len(loaded.song.lyrics) == 2
assert loaded.song.lyrics[0]["w"] == "Hel"
# ── keys .jsonc ──────────────────────────────────────────────────────────────
def test_load_keys_jsonc_with_comments(tmp_path: Path):
payload = {
"version": 1,
"events": [{"t": 0.0, "key": "Em", "scale": "natural_minor"}],
}
text = '/* key/scale track */\n' + json.dumps(payload)
pak = _write_sloppak(
tmp_path,
manifest_extras={"keys": "keys.jsonc"},
side_files={"keys.jsonc": text},
)
loaded = _load(pak, tmp_path)
assert loaded.keys is not None
assert loaded.keys["events"][0]["key"] == "Em"
# ── Comment-like text inside strings is preserved end-to-end ─────────────────
def test_load_arrangement_jsonc_preserves_comment_like_string_values(tmp_path: Path):
# A note whose fret label (if it had one) or a string field contains ``//``
# must survive the strip. We put a ``//`` inside the arrangement name and a
# ``/* */`` inside a beat section name to exercise both comment shapes.
arr_text = (
'{\n'
' "name": "Lead // solo",\n'
' "tuning": [0, 0, 0, 0, 0, 0], "capo": 0,\n'
' "notes": [], "chords": [], "anchors": [], "handshapes": [], "templates": [],\n'
' "beats": [],\n'
' "sections": [{"name": "verse /* important */", "number": 0, "time": 0.0}]\n'
'}'
)
pak = _write_sloppak(
tmp_path,
manifest_extras={
"arrangements": [{"id": "lead", "name": "Lead",
"file": "arrangements/lead.jsonc"}]
},
side_files={"arrangements/lead.jsonc": arr_text},
)
loaded = _load(pak, tmp_path)
arr = loaded.song.arrangements[0]
# The manifest's ``name: Lead`` overrides the arrangement JSON's name, so
# assert on the section name instead — it flows straight from the parsed
# .jsonc with no manifest override, proving the comment-like text inside
# the string survived the strip.
assert arr.name == "Lead"
assert loaded.song.sections[0].name == "verse /* important */"
# ── lift_keys_notation reads .jsonc arrangements / song_timeline ─────────────
def test_lift_keys_notation_loads_song_beats_from_jsonc_timeline(tmp_path: Path):
"""``_load_song_beats`` (the helper the lifter uses to find downbeats) must
read a ``.jsonc`` song_timeline when the manifest points at one."""
timeline_text = (
'// timeline\n'
+ json.dumps({
"beats": [{"time": 0.0, "measure": 0}, {"time": 0.5, "measure": 0},
{"time": 1.0, "measure": 1}],
"sections": [],
})
)
pak = tmp_path / "song.sloppak"
pak.mkdir()
(pak / "arrangements").mkdir()
(pak / "arrangements" / "keys.json").write_text(json.dumps(_base_arrangement()))
(pak / "song_timeline.jsonc").write_text(timeline_text, encoding="utf-8")
manifest = {
"title": "T", "artist": "A", "duration": 4.0,
"arrangements": [{"id": "keys", "name": "Keys",
"file": "arrangements/keys.json"}],
"song_timeline": "song_timeline.jsonc",
"stems": [{"id": "full", "file": "stems/full.ogg"}],
}
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
beats = _load_song_beats(pak, manifest)
assert len(beats) == 3
assert beats[2]["measure"] == 1
def test_lift_keys_notation_falls_back_to_jsonc_arrangement_beats(tmp_path: Path):
"""When no song_timeline is present, ``_load_song_beats`` falls back to the
first arrangement JSON carrying beats — and that arrangement may be ``.jsonc``."""
arr_text = (
'/* keys */\n'
+ json.dumps({
**_base_arrangement(),
"beats": [{"time": 0.0, "measure": 0}, {"time": 1.0, "measure": 1}],
})
)
pak = tmp_path / "song.sloppak"
pak.mkdir()
(pak / "arrangements").mkdir()
(pak / "arrangements" / "keys.jsonc").write_text(arr_text, encoding="utf-8")
manifest = {
"title": "T", "artist": "A", "duration": 4.0,
"arrangements": [{"id": "keys", "name": "Keys",
"file": "arrangements/keys.jsonc"}],
"stems": [{"id": "full", "file": "stems/full.ogg"}],
}
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
beats = _load_song_beats(pak, manifest)
assert len(beats) == 2
assert beats[1]["measure"] == 1