"""Tests for parse_chord_file() and transpose_to_canonical() in src/tokenizer.py. Fixture files live in tests/fixtures/: valid_c_major.chord — already canonical (C major, 8 bars, has a // comment) valid_fsharp_major.chord — F# major with a slash chord (F#/A#) valid_b_minor.chord — B minor valid_gsharp_minor.chord — G# minor with a slash chord (Bmaj7/F#) invalid_bar_count.chord — bar 1 has 5 positions instead of 4 invalid_chord_symbol.chord — bar 2 contains the invalid symbol 'Xyz' """ from pathlib import Path import pytest from src.chord_parser import ChordTokens, parse_chord_symbol from src.tokenizer import ChordFormatError, ChordPeriod, parse_chord_file, transpose_to_canonical FIXTURES = Path(__file__).parent / "fixtures" # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def fixture(name: str) -> Path: return FIXTURES / name # --------------------------------------------------------------------------- # parse_chord_file — valid files # --------------------------------------------------------------------------- class TestParseChordFile: def test_c_major_header_fields(self): p = parse_chord_file(fixture("valid_c_major.chord")) assert p.title == "C major test" assert p.key == "C_major" assert p.time == "4/4" assert p.subdivision == 4 assert p.style == "other" assert p.function == "chorus" def test_c_major_bar_count(self): p = parse_chord_file(fixture("valid_c_major.chord")) assert len(p.bars) == 8 def test_c_major_bar_positions(self): p = parse_chord_file(fixture("valid_c_major.chord")) assert p.bars[0] == ["C", ".", ".", "."] assert p.bars[2] == ["F/A", ".", ".", "."] # slash chord preserved verbatim assert p.bars[7] == ["G7", ".", ".", "."] def test_c_major_comments_stripped(self): # The first bar line ends with '// first half'; no '//' should bleed into positions. p = parse_chord_file(fixture("valid_c_major.chord")) for bar in p.bars: assert not any("//" in pos for pos in bar) def test_each_bar_has_expected_position_count(self): p = parse_chord_file(fixture("valid_c_major.chord")) for bar in p.bars: assert len(bar) == 4 # 4/4, subdivision=4 def test_fsharp_major_parses(self): p = parse_chord_file(fixture("valid_fsharp_major.chord")) assert p.key == "F#_major" assert len(p.bars) == 4 assert p.bars[0][0] == "F#maj7" assert p.bars[2][2] == "F#/A#" # slash chord preserved verbatim def test_b_minor_parses(self): p = parse_chord_file(fixture("valid_b_minor.chord")) assert p.key == "B_minor" assert p.function == "unspecified" # no function header field assert p.bars[0][0] == "Bm" assert p.bars[1][0] == "C#m7b5" def test_gsharp_minor_parses(self): p = parse_chord_file(fixture("valid_gsharp_minor.chord")) assert p.key == "G#_minor" assert len(p.bars) == 4 assert p.bars[2][0] == "Bmaj7/F#" # slash chord def test_hold_positions_preserved(self): p = parse_chord_file(fixture("valid_c_major.chord")) # Every position after the first in each bar is '.' for bar in p.bars: assert bar[1] == "." assert bar[2] == "." assert bar[3] == "." # --------------------------------------------------------------------------- # parse_chord_file — error cases # --------------------------------------------------------------------------- class TestParseChordFileErrors: def test_invalid_bar_count_raises(self): with pytest.raises(ChordFormatError): parse_chord_file(fixture("invalid_bar_count.chord")) def test_invalid_bar_count_error_mentions_bar_number(self): with pytest.raises(ChordFormatError) as exc_info: parse_chord_file(fixture("invalid_bar_count.chord")) assert "bar 1" in str(exc_info.value) def test_invalid_bar_count_error_mentions_filename(self): with pytest.raises(ChordFormatError) as exc_info: parse_chord_file(fixture("invalid_bar_count.chord")) assert "invalid_bar_count.chord" in str(exc_info.value) def test_invalid_chord_symbol_raises(self): with pytest.raises(ChordFormatError): parse_chord_file(fixture("invalid_chord_symbol.chord")) def test_invalid_chord_symbol_error_mentions_bar_number(self): with pytest.raises(ChordFormatError) as exc_info: parse_chord_file(fixture("invalid_chord_symbol.chord")) assert "bar 2" in str(exc_info.value) def test_invalid_chord_symbol_error_mentions_filename(self): with pytest.raises(ChordFormatError) as exc_info: parse_chord_file(fixture("invalid_chord_symbol.chord")) assert "invalid_chord_symbol.chord" in str(exc_info.value) # --------------------------------------------------------------------------- # transpose_to_canonical — F# major → C major (shift = 6) # --------------------------------------------------------------------------- class TestTransposeFsharpMajor: def setup_method(self): self._period = parse_chord_file(fixture("valid_fsharp_major.chord")) self._t = transpose_to_canonical(self._period) def test_key_updated_to_c_major(self): assert self._t.key == "C_major" def test_tonic_chord_becomes_c(self): # F#maj7 (bar 0) → Cmaj7 assert self._t.bars[0][0] == "Cmaj7" def test_second_degree_chord(self): # D#m7 (bar 1) → Am7 (D#=3, 3+6=9=A) assert self._t.bars[1][0] == "Am7" def test_fourth_degree_chord(self): # Bmaj7 (bar 2 pos 0) → Fmaj7 (B=11, 11+6=17→5=F) assert self._t.bars[2][0] == "Fmaj7" def test_fifth_degree_chord(self): # C# (bar 3) → G (C#=1, 1+6=7=G) tokens = parse_chord_symbol(self._t.bars[3][0]) assert tokens.root == "G" assert tokens.quality == "maj" def test_slash_chord_root_transposed(self): # F#/A# (bar 2, pos 2): root F# → C tokens = parse_chord_symbol(self._t.bars[2][2]) assert tokens.root == "C" def test_slash_chord_bass_transposed(self): # F#/A# (bar 2, pos 2): bass A#(=10) → E (10+6=16→4=E) tokens = parse_chord_symbol(self._t.bars[2][2]) assert tokens.bass == "E" def test_slash_chord_full_tokens(self): tokens = parse_chord_symbol(self._t.bars[2][2]) assert tokens == ChordTokens("C", "maj", "none", "E") def test_hold_positions_unchanged(self): # Bars 0, 1, 3 are single-chord bars: positions 1–3 must remain '.'. # Bar 2 has a chord at position 2 (F#/A# → Cmaj/E) — tested separately. for bar_idx in (0, 1, 3): assert all(pos == "." for pos in self._t.bars[bar_idx][1:]) def test_bar_count_preserved(self): assert len(self._t.bars) == len(self._period.bars) # --------------------------------------------------------------------------- # transpose_to_canonical — G# minor → A minor (shift = 1) # --------------------------------------------------------------------------- class TestTransposeGsharpMinor: def setup_method(self): self._period = parse_chord_file(fixture("valid_gsharp_minor.chord")) self._t = transpose_to_canonical(self._period) def test_key_updated_to_a_minor(self): assert self._t.key == "A_minor" def test_tonic_becomes_a(self): # G#m (bar 0) → Am (G#=8, 8+1=9=A) assert self._t.bars[0][0] == "Am" def test_second_degree_chord(self): # A#maj7 (bar 1) → Bmaj7 (A#=10, 10+1=11=B) assert self._t.bars[1][0] == "Bmaj7" def test_slash_chord_root_transposed(self): # Bmaj7/F# (bar 2): root B(=11) → C (11+1=0=C) tokens = parse_chord_symbol(self._t.bars[2][0]) assert tokens.root == "C" def test_slash_chord_bass_transposed(self): # Bmaj7/F# (bar 2): bass F#(=6) → G (6+1=7=G) tokens = parse_chord_symbol(self._t.bars[2][0]) assert tokens.bass == "G" def test_slash_chord_full_tokens(self): tokens = parse_chord_symbol(self._t.bars[2][0]) assert tokens == ChordTokens("C", "maj7", "none", "G") def test_fourth_bar(self): # D#7 (bar 3) → E7 (D#=3, 3+1=4=E) assert self._t.bars[3][0] == "E7" # --------------------------------------------------------------------------- # transpose_to_canonical — already canonical (C major) # --------------------------------------------------------------------------- class TestTransposeCMajorIdentity: def test_returns_same_object(self): # Fast path: shift == 0, original period returned unchanged. p = parse_chord_file(fixture("valid_c_major.chord")) t = transpose_to_canonical(p) assert t is p def test_key_unchanged(self): p = parse_chord_file(fixture("valid_c_major.chord")) assert transpose_to_canonical(p).key == "C_major" # --------------------------------------------------------------------------- # transpose_to_canonical — B minor → A minor (shift = 10) # --------------------------------------------------------------------------- class TestTransposeBMinor: def setup_method(self): self._period = parse_chord_file(fixture("valid_b_minor.chord")) self._t = transpose_to_canonical(self._period) def test_key_updated_to_a_minor(self): assert self._t.key == "A_minor" def test_tonic_becomes_a(self): # Bm (B=11): 11+10=21→9=A assert self._t.bars[0][0] == "Am" def test_half_diminished_chord(self): # C#m7b5 (C#=1): 1+10=11=B → Bm7b5 assert self._t.bars[1][0] == "Bm7b5" def test_major_chord_transposed(self): # D (D=2): 2+10=12→0=C → Cmaj tokens = parse_chord_symbol(self._t.bars[2][0]) assert tokens.root == "C" assert tokens.quality == "maj" def test_dominant_seventh_transposed(self): # F#7 (F#=6): 6+10=16→4=E → E7 assert self._t.bars[3][0] == "E7"