"""Tests for src/chord_parser.py. Coverage: - All 18 canonical qualities with root C - At least 2 examples per extension (including shorthand-expanded forms) - Slash chords with various bass notes (including sharp/flat basses) - Both sharp and flat root spellings (flat → sharp normalization) - All examples from the §4.6 parse table in chord_format_spec.md - Invalid inputs → ChordParseError """ import pytest from src.chord_parser import ChordParseError, ChordTokens, parse_chord_symbol # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def parse(symbol: str) -> ChordTokens: return parse_chord_symbol(symbol) # --------------------------------------------------------------------------- # §4.6 — full parse table examples (spec-mandated) # --------------------------------------------------------------------------- class TestSpecExamples: def test_C(self): t = parse("C") assert t == ChordTokens("C", "maj", "none", "root") def test_Am(self): t = parse("Am") assert t == ChordTokens("A", "m", "none", "root") def test_Fsharpm7(self): t = parse("F#m7") assert t == ChordTokens("F#", "m7", "none", "root") def test_Cmaj9_shorthand(self): # Shorthand: maj9 → quality=maj7, extension=9 t = parse("Cmaj9") assert t == ChordTokens("C", "maj7", "9", "root") def test_G7sus4(self): t = parse("G7sus4") assert t == ChordTokens("G", "7sus4", "none", "root") def test_F_slash_G(self): t = parse("F/G") assert t == ChordTokens("F", "maj", "none", "G") def test_Bb7b9_slash_D(self): # Bb normalises to A# t = parse("Bb7b9/D") assert t == ChordTokens("A#", "7", "b9", "D") def test_Em7b5(self): t = parse("Em7b5") assert t == ChordTokens("E", "m7b5", "none", "root") def test_Dsharpdim7(self): t = parse("D#dim7") assert t == ChordTokens("D#", "dim7", "none", "root") # --------------------------------------------------------------------------- # All 18 qualities with root C (primary spellings) # --------------------------------------------------------------------------- class TestAllQualities: """One test per canonical quality, using the primary spelling.""" def test_maj(self): assert parse("C") == ChordTokens("C", "maj", "none", "root") def test_m(self): assert parse("Cm") == ChordTokens("C", "m", "none", "root") def test_dim(self): assert parse("Cdim") == ChordTokens("C", "dim", "none", "root") def test_aug(self): assert parse("Caug") == ChordTokens("C", "aug", "none", "root") def test_sus2(self): assert parse("Csus2") == ChordTokens("C", "sus2", "none", "root") def test_sus4(self): assert parse("Csus4") == ChordTokens("C", "sus4", "none", "root") def test_maj7(self): assert parse("Cmaj7") == ChordTokens("C", "maj7", "none", "root") def test_m7(self): assert parse("Cm7") == ChordTokens("C", "m7", "none", "root") def test_7(self): assert parse("C7") == ChordTokens("C", "7", "none", "root") def test_m7b5(self): assert parse("Cm7b5") == ChordTokens("C", "m7b5", "none", "root") def test_dim7(self): assert parse("Cdim7") == ChordTokens("C", "dim7", "none", "root") def test_mM7(self): assert parse("CmM7") == ChordTokens("C", "mM7", "none", "root") def test_7sus4(self): assert parse("C7sus4") == ChordTokens("C", "7sus4", "none", "root") def test_aug7(self): assert parse("Caug7") == ChordTokens("C", "aug7", "none", "root") def test_6(self): assert parse("C6") == ChordTokens("C", "6", "none", "root") def test_m6(self): assert parse("Cm6") == ChordTokens("C", "m6", "none", "root") def test_add9(self): assert parse("Cadd9") == ChordTokens("C", "add9", "none", "root") def test_m_add9(self): assert parse("Cm(add9)") == ChordTokens("C", "m(add9)", "none", "root") # --------------------------------------------------------------------------- # Alternative quality spellings # --------------------------------------------------------------------------- class TestQualityAlternatives: # minor: m / min / - def test_min_spelling(self): assert parse("Cmin").quality == "m" def test_dash_spelling(self): assert parse("C-").quality == "m" # dim: ° def test_degree_dim(self): assert parse("C°").quality == "dim" # aug: + def test_plus_aug(self): assert parse("C+").quality == "aug" # sus: sus alone → sus4 def test_sus_alone(self): assert parse("Csus").quality == "sus4" # maj7 alternatives: M7, Δ7, Δ def test_M7(self): assert parse("CM7").quality == "maj7" def test_delta7(self): assert parse("CΔ7").quality == "maj7" def test_delta(self): assert parse("CΔ").quality == "maj7" # maj6 → quality=6 def test_maj6(self): assert parse("Cmaj6").quality == "6" # m7 alternatives: min7, -7 def test_min7(self): assert parse("Cmin7").quality == "m7" def test_dash7(self): assert parse("C-7").quality == "m7" # m7b5 alternatives: ø, ø7, min7b5 def test_half_dim_ø(self): assert parse("Cø").quality == "m7b5" def test_half_dim_ø7(self): assert parse("Cø7").quality == "m7b5" def test_min7b5(self): assert parse("Cmin7b5").quality == "m7b5" # dim7: °7 def test_degree7(self): assert parse("C°7").quality == "dim7" # mM7 alternatives: m(maj7), minMaj7 def test_m_maj7_parens(self): assert parse("Cm(maj7)").quality == "mM7" def test_minMaj7(self): assert parse("CminMaj7").quality == "mM7" # aug7 alternatives: +7, 7#5 def test_plus7(self): assert parse("C+7").quality == "aug7" def test_7sharp5(self): assert parse("C7#5").quality == "aug7" # 7sus4 alternative: 7sus def test_7sus(self): assert parse("C7sus").quality == "7sus4" # m6 alternative: min6 def test_min6(self): assert parse("Cmin6").quality == "m6" # add9 alternative: 2 def test_2_for_add9(self): assert parse("C2").quality == "add9" # m(add9) alternatives: madd9, m(add2) def test_madd9(self): assert parse("Cmadd9").quality == "m(add9)" def test_m_add2(self): assert parse("Cm(add2)").quality == "m(add9)" # --------------------------------------------------------------------------- # Extensions — at least 2 examples each, including shorthands # --------------------------------------------------------------------------- class TestExtensions: # extension=9 def test_ext_9_via_shorthand_dominant(self): # C9 → quality=7 (dominant 7th implied), extension=9 t = parse("C9") assert t.quality == "7" assert t.extension == "9" def test_ext_9_via_maj_shorthand(self): # Fmaj9 → quality=maj7, extension=9 t = parse("Fmaj9") assert t.quality == "maj7" assert t.extension == "9" def test_ext_9_explicit(self): # G7 + explicit 9 t = parse("G7") assert t.extension == "none" # G9 = dominant 9th t2 = parse("G9") assert t2.quality == "7" assert t2.extension == "9" def test_ext_9_minor_shorthand(self): # Cm9 → quality=m7, extension=9 t = parse("Cm9") assert t.quality == "m7" assert t.extension == "9" # extension=b9 def test_ext_b9_dominant(self): t = parse("G7b9") assert t == ChordTokens("G", "7", "b9", "root") def test_ext_b9_minor7(self): t = parse("Cm7b9") assert t == ChordTokens("C", "m7", "b9", "root") # extension=#9 def test_ext_sharp9_dominant(self): t = parse("C7#9") assert t == ChordTokens("C", "7", "#9", "root") def test_ext_sharp9_aug7(self): t = parse("Gaug7#9") assert t == ChordTokens("G", "aug7", "#9", "root") # extension=11 def test_ext_11_dominant_shorthand(self): # C11 → quality=7, extension=11 t = parse("C11") assert t.quality == "7" assert t.extension == "11" def test_ext_11_minor_shorthand(self): # Cm11 → quality=m7, extension=11 t = parse("Cm11") assert t.quality == "m7" assert t.extension == "11" # extension=#11 def test_ext_sharp11_maj7(self): t = parse("Cmaj7#11") assert t == ChordTokens("C", "maj7", "#11", "root") def test_ext_sharp11_dominant(self): t = parse("G7#11") assert t == ChordTokens("G", "7", "#11", "root") # extension=13 def test_ext_13_dominant_shorthand(self): # C13 → quality=7, extension=13 t = parse("C13") assert t.quality == "7" assert t.extension == "13" def test_ext_13_maj_shorthand(self): # Fmaj13 → quality=maj7, extension=13 t = parse("Fmaj13") assert t.quality == "maj7" assert t.extension == "13" def test_ext_13_minor_shorthand(self): # Cm13 → quality=m7, extension=13 t = parse("Cm13") assert t.quality == "m7" assert t.extension == "13" # extension=b13 def test_ext_b13_dominant(self): t = parse("C7b13") assert t == ChordTokens("C", "7", "b13", "root") def test_ext_b13_minor7(self): t = parse("Gm7b13") assert t == ChordTokens("G", "m7", "b13", "root") # --------------------------------------------------------------------------- # Slash chords (§4.5) # --------------------------------------------------------------------------- class TestSlashChords: def test_F_slash_A(self): t = parse("F/A") assert t == ChordTokens("F", "maj", "none", "A") def test_G_slash_B(self): t = parse("G/B") assert t == ChordTokens("G", "maj", "none", "B") def test_F_slash_G_on_chord(self): t = parse("F/G") assert t == ChordTokens("F", "maj", "none", "G") def test_Em7_slash_G(self): t = parse("Em7/G") assert t == ChordTokens("E", "m7", "none", "G") def test_D_slash_Fsharp(self): t = parse("D/F#") assert t == ChordTokens("D", "maj", "none", "F#") def test_Dm9_slash_F(self): # Dm9 is a shorthand: quality=m7, extension=9 t = parse("Dm9/F") assert t == ChordTokens("D", "m7", "9", "F") def test_slash_bass_flat_normalised(self): # Bass Ab → G# t = parse("C/Ab") assert t.bass == "G#" def test_slash_bass_sharp(self): t = parse("Cmaj7/E") assert t == ChordTokens("C", "maj7", "none", "E") def test_cmaj7_slash_Bflat(self): # Bass Bb → A# t = parse("Cmaj7/Bb") assert t.bass == "A#" # --------------------------------------------------------------------------- # Root spellings — sharp and flat # --------------------------------------------------------------------------- class TestRootNormalization: def test_sharp_roots(self): for root in ("C#", "D#", "F#", "G#", "A#"): t = parse(f"{root}m7") assert t.root == root def test_flat_to_sharp_normalization(self): cases = [ ("Db", "C#"), ("Eb", "D#"), ("Gb", "F#"), ("Ab", "G#"), ("Bb", "A#"), ("Cb", "B"), ("Fb", "E"), ] for flat, sharp in cases: t = parse(f"{flat}maj7") assert t.root == sharp, f"{flat} should normalise to {sharp}" def test_natural_roots(self): for root in ("C", "D", "E", "F", "G", "A", "B"): t = parse(f"{root}") assert t.root == root # --------------------------------------------------------------------------- # Invalid inputs → ChordParseError # --------------------------------------------------------------------------- class TestInvalidInputs: def test_empty_string(self): with pytest.raises(ChordParseError): parse("") def test_whitespace_only(self): with pytest.raises(ChordParseError): parse(" ") def test_unknown_root(self): with pytest.raises(ChordParseError): parse("Xyz") def test_trailing_slash_no_bass(self): with pytest.raises(ChordParseError): parse("C7/") def test_invalid_bass_note(self): with pytest.raises(ChordParseError): parse("C/Z") def test_invalid_bass_number(self): with pytest.raises(ChordParseError): parse("C/4") def test_multiple_slashes(self): with pytest.raises(ChordParseError): parse("C/E/G") def test_unknown_quality(self): with pytest.raises(ChordParseError): parse("Cxyz") def test_slash_with_no_chord(self): with pytest.raises(ChordParseError): parse("/G") def test_lowercase_root(self): with pytest.raises(ChordParseError): parse("cmaj7")