diff --git a/lib/song.py b/lib/song.py index a1ba100..5d80027 100644 --- a/lib/song.py +++ b/lib/song.py @@ -68,6 +68,14 @@ class ChordTemplate: # Harmony annotation (§6.6) — key-independent voicing type, e.g. "open", # "triad", "shell", "drop2", "barre". Display/teaching only, never grading. voicing: str = "" + # Harmony annotation (§6.6) — the CAGED shape the fingering derives from, + # one of "C"/"A"/"G"/"E"/"D" ("" = unset). Display/teaching only, never grading. + caged: str = "" + # Harmony annotation (§6.6) — chromatic semitone offsets 0..11 above the + # chord root marking the quality-defining tones (e.g. dom7 -> [4, 10]). + # snake_case attr; rides the wire as camelCase "guideTones" (like + # display_name -> "displayName"). Display/teaching only, never grading. + guide_tones: list = field(default_factory=list) @dataclass @@ -319,9 +327,34 @@ def chord_template_to_wire(ct: ChordTemplate) -> dict: # Harmony voicing (§6.6) — default-omitted, only when non-empty. if ct.voicing: out["voicing"] = ct.voicing + # CAGED shape + guide tones (§6.6) — default-omitted, mirroring voicing. + if ct.caged: + out["caged"] = ct.caged + if ct.guide_tones: + out["guideTones"] = list(ct.guide_tones) return out +# §6.6 CAGED shape enum — the only values accepted off the wire. +_CAGED_SHAPES = ("C", "A", "G", "E", "D") + + +def _sanitize_caged(val) -> str: + """A wire `caged` is kept only when it is one of the CAGED shape letters; + anything else (None, int, list, unknown string) falls back to "".""" + return val if isinstance(val, str) and val in _CAGED_SHAPES else "" + + +def _sanitize_guide_tones(val) -> list: + """A wire `guideTones` is kept only as the int entries in 0..11; non-list + input, non-ints (bool is an int subclass — rejected), and out-of-range + values are dropped so a malformed value can't round-trip.""" + if not isinstance(val, list): + return [] + return [v for v in val + if isinstance(v, int) and not isinstance(v, bool) and 0 <= v <= 11] + + def _wire_int_optional(v, default=-1): """Parse optional wire ints; fall back to default on null/blank/invalid.""" if v is None: @@ -883,7 +916,9 @@ def arrangement_from_wire(d: dict) -> Arrangement: fingers=list(ct.get("fingers", [-1] * 6)), frets=list(ct.get("frets", [-1] * 6)), voicing=(ct.get("voicing") - if isinstance(ct.get("voicing"), str) else "")) + if isinstance(ct.get("voicing"), str) else ""), + caged=_sanitize_caged(ct.get("caged")), + guide_tones=_sanitize_guide_tones(ct.get("guideTones"))) for ct in d.get("templates", []) ], # `phrases` is optional — absent on single-level sources / older diff --git a/tests/test_song.py b/tests/test_song.py index 7f2da95..92af496 100644 --- a/tests/test_song.py +++ b/tests/test_song.py @@ -485,6 +485,73 @@ def test_template_voicing_tolerates_malformed(bad): assert arr.chord_templates[0].voicing == "" +def test_template_caged_round_trip(): + """A valid CAGED shape survives the template wire + arrangement round-trip.""" + ct = ChordTemplate(name="Am", display_name="Am", fingers=[-1, 0, 2, 2, 1, 0], + frets=[-1, 0, 2, 2, 1, 0], caged="E") + assert chord_template_to_wire(ct)["caged"] == "E" + arr = Arrangement(name="Rhythm", chord_templates=[ct]) + assert arrangement_from_wire(arrangement_to_wire(arr)).chord_templates[0] == ct + + +def test_template_caged_omitted_when_default(): + """An empty caged (the default) produces no `caged` key.""" + ct = ChordTemplate(name="Am", fingers=[-1] * 6, frets=[-1] * 6) + assert "caged" not in chord_template_to_wire(ct) + arr = arrangement_from_wire(arrangement_to_wire( + Arrangement(name="Rhythm", chord_templates=[ct]))) + assert arr.chord_templates[0].caged == "" + + +@pytest.mark.parametrize("bad", [None, 7, "X", "e", "", ["E"], {"c": "E"}]) +def test_template_caged_tolerates_malformed(bad): + """A non-enum caged on the wire falls back to the empty default.""" + arr = arrangement_from_wire({ + "name": "Rhythm", + "templates": [{"name": "Am", "fingers": [-1] * 6, "frets": [-1] * 6, + "caged": bad}], + }) + assert arr.chord_templates[0].caged == "" + + +def test_template_guide_tones_round_trip(): + """A non-empty guideTones list survives the template wire + round-trip.""" + ct = ChordTemplate(name="G7", display_name="G7", fingers=[3, 2, 0, 0, 0, 1], + frets=[3, 2, 0, 0, 0, 1], guide_tones=[4, 10]) + assert chord_template_to_wire(ct)["guideTones"] == [4, 10] + arr = Arrangement(name="Rhythm", chord_templates=[ct]) + assert arrangement_from_wire(arrangement_to_wire(arr)).chord_templates[0] == ct + + +def test_template_guide_tones_omitted_when_default(): + """An empty guide_tones (the default) produces no `guideTones` key.""" + ct = ChordTemplate(name="Am", fingers=[-1] * 6, frets=[-1] * 6) + assert "guideTones" not in chord_template_to_wire(ct) + arr = arrangement_from_wire(arrangement_to_wire( + Arrangement(name="Rhythm", chord_templates=[ct]))) + assert arr.chord_templates[0].guide_tones == [] + + +@pytest.mark.parametrize("raw,expected", [ + (None, []), + (12, []), + ("4,10", []), + ([12], []), + ([-1], []), + ([True, 3], [3]), # bool is an int subclass — rejected + ([4, "x", 10, 11], [4, 10, 11]), + ([0, 11], [0, 11]), # boundary values kept +]) +def test_template_guide_tones_tolerates_malformed(raw, expected): + """Non-int / out-of-range guideTones entries are dropped off the wire.""" + arr = arrangement_from_wire({ + "name": "Rhythm", + "templates": [{"name": "Am", "fingers": [-1] * 6, "frets": [-1] * 6, + "guideTones": raw}], + }) + assert arr.chord_templates[0].guide_tones == expected + + # ── Arrangement round-trip ─────────────────────────────────────────────────── def test_arrangement_empty_round_trip():