mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 11:19:24 +00:00
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.
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user