mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-21 20:31:21 +00:00
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.
336 lines
13 KiB
Python
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
|