"""Tests for src/external_converters/mcgill_to_chord.py. Fixture: tests/fixtures/mcgill_test/0001/salami_chords.txt 4/4 song in C major, two sections in the real McGill v2 2-column format: A, verse : | C:maj | F:maj | G:7 | C:maj | (4 bars) B, chorus : | F:maj | C:maj | G:7 | C:maj | (4 bars) Expected output: 2 .chord files, each with 4 bars, key=C_major, time=4/4. """ from pathlib import Path import pytest from src.external_converters.mcgill_to_chord import ( _bar_str_to_positions, _harte_to_chord_symbol, _parse_annotation_line, _parse_metre, _parse_salami_file, convert_song, ) from src.tokenizer import parse_chord_file FIXTURES = Path(__file__).parent / "fixtures" / "mcgill_test" TEST_SONG = FIXTURES / "0001" # --------------------------------------------------------------------------- # Harte chord symbol conversion # --------------------------------------------------------------------------- class TestHarteConversion: def test_simple_major(self): assert _harte_to_chord_symbol("C:maj") == "Cmaj" def test_flat_minor_seventh(self): assert _harte_to_chord_symbol("Bb:min7") == "A#m7" def test_half_diminished(self): assert _harte_to_chord_symbol("E:hdim7") == "Em7b5" def test_dominant_seventh(self): assert _harte_to_chord_symbol("G:7") == "G7" def test_major_seventh(self): assert _harte_to_chord_symbol("D:maj7") == "Dmaj7" def test_minor(self): assert _harte_to_chord_symbol("A:min") == "Am" def test_diminished_seventh(self): assert _harte_to_chord_symbol("B:dim7") == "Bdim7" def test_augmented(self): assert _harte_to_chord_symbol("C:aug") == "Caug" def test_slash_chord_absolute_bass(self): assert _harte_to_chord_symbol("C:maj/E") == "Cmaj/E" def test_slash_chord_flat_bass_normalised(self): assert _harte_to_chord_symbol("G:maj/Bb") == "Gmaj/A#" def test_slash_chord_interval_fifth(self): # '/5' = perfect 5th (7 semitones) above root C → G assert _harte_to_chord_symbol("C:maj/5") == "Cmaj/G" def test_slash_chord_interval_b3(self): # '/b3' = minor 3rd (3 semitones) above root F → Ab = G# assert _harte_to_chord_symbol("F:min/b3") == "Fm/G#" def test_slash_chord_interval_3(self): # '/3' = major 3rd (4 semitones) above root C → E assert _harte_to_chord_symbol("C:7/3") == "C7/E" def test_no_chord_returns_none(self): assert _harte_to_chord_symbol("N") is None def test_unknown_returns_none(self): assert _harte_to_chord_symbol("X") is None def test_empty_returns_none(self): assert _harte_to_chord_symbol("") is None def test_extended_dominant_ninth(self): assert _harte_to_chord_symbol("G:9") == "G79" def test_major_ninth(self): assert _harte_to_chord_symbol("C:maj9") == "Cmaj79" def test_parenthetical_flat_nine(self): assert _harte_to_chord_symbol("C:7(b9)") == "C7b9" def test_parenthetical_sharp_eleven(self): assert _harte_to_chord_symbol("F:maj7(#11)") == "Fmaj7#11" def test_sharp_root(self): assert _harte_to_chord_symbol("F#:min7") == "F#m7" def test_output_is_parseable(self): from src.chord_parser import parse_chord_symbol for harte in ("C:maj", "Bb:min7", "E:hdim7", "G:7", "D:maj7", "C:maj/E", "C:maj/5", "F:min/b3"): sym = _harte_to_chord_symbol(harte) assert sym is not None parse_chord_symbol(sym) # --------------------------------------------------------------------------- # Salami file parsing (2-column format) # --------------------------------------------------------------------------- class TestParseSalamiFile: def test_header_parsed(self): header, _ = _parse_salami_file(TEST_SONG / "salami_chords.txt") assert header["artist"] == "Test Artist" assert header["title"] == "Test Song" assert header["metre"] == "4/4" assert header["tonic"] == "C" def test_data_line_count(self): _, lines = _parse_salami_file(TEST_SONG / "salami_chords.txt") # 4 lines: silence, A/verse, B/chorus, silence assert len(lines) == 4 def test_first_line_is_silence(self): _, lines = _parse_salami_file(TEST_SONG / "salami_chords.txt") ts, annotation = lines[0] assert ts == 0.0 assert annotation == "silence" def test_returns_two_tuples(self): _, lines = _parse_salami_file(TEST_SONG / "salami_chords.txt") for item in lines: assert len(item) == 2 # --------------------------------------------------------------------------- # Annotation line parsing # --------------------------------------------------------------------------- class TestParseAnnotationLine: def test_silence_returns_empty(self): letter, func, bars = _parse_annotation_line("silence") assert letter is None and func is None and bars == [] def test_end_returns_empty(self): letter, func, bars = _parse_annotation_line("end") assert letter is None and func is None and bars == [] def test_continuation_arrow_returns_empty(self): letter, func, bars = _parse_annotation_line("->") assert bars == [] def test_section_letter_extracted(self): letter, _, _ = _parse_annotation_line("A, verse, | C:maj | F:maj |") assert letter == "A" def test_function_extracted(self): _, func, _ = _parse_annotation_line("A, verse, | C:maj | F:maj |") assert func == "verse" def test_chorus_function(self): _, func, _ = _parse_annotation_line("B, chorus, | F:maj | C:maj |") assert func == "chorus" def test_bar_count(self): _, _, bars = _parse_annotation_line( "A, verse, | C:maj | F:maj | G:7 | C:maj |" ) assert len(bars) == 4 def test_bar_contents(self): _, _, bars = _parse_annotation_line( "A, verse, | C:maj | F:maj | G:7 | C:maj |" ) assert bars == ["C:maj", "F:maj", "G:7", "C:maj"] def test_continuation_line_no_letter(self): letter, func, bars = _parse_annotation_line("| C:maj | F:maj |") assert letter is None assert func is None assert bars == ["C:maj", "F:maj"] def test_repeat_xN(self): _, _, bars = _parse_annotation_line("| C:maj | x4") assert bars == ["C:maj"] * 4 def test_trailing_annotation_ignored(self): _, _, bars = _parse_annotation_line( "A, intro, | Ab:maj | Db:maj | Ab:maj | G:7 |, (synth)" ) assert len(bars) == 4 assert bars[0] == "Ab:maj" def test_multi_chord_bar_preserved(self): _, _, bars = _parse_annotation_line("| G:hdim7 C:7 | F:min |") assert bars[0] == "G:hdim7 C:7" assert bars[1] == "F:min" # --------------------------------------------------------------------------- # Bar string to positions # --------------------------------------------------------------------------- class TestBarStrToPositions: def test_single_chord_fills_position_zero(self): pos = _bar_str_to_positions("C:maj", 4) assert pos[0] == "Cmaj" def test_single_chord_rest_are_holds(self): pos = _bar_str_to_positions("C:maj", 4) assert pos[1:] == [".", ".", "."] def test_two_chords_distributed(self): pos = _bar_str_to_positions("C:maj D:min", 4) assert pos[0] == "Cmaj" assert pos[2] == "Dm" assert pos[1] == "." assert pos[3] == "." def test_four_chords_direct_map(self): # Harte notation: 4 elements → 4 positions, direct 1-to-1 mapping pos = _bar_str_to_positions("C:maj A:min F:maj G:7", 4) assert pos == ["Cmaj", "Am", "Fmaj", "G7"] def test_explicit_hold_tokens(self): pos = _bar_str_to_positions("C:maj . F:maj .", 4) assert pos == ["Cmaj", ".", "Fmaj", "."] def test_nc_mapped(self): pos = _bar_str_to_positions("N", 4) assert pos[0] == "NC" def test_unknown_mapped(self): pos = _bar_str_to_positions("X", 4) assert pos[0] == "?" def test_unrecognized_returns_none(self): # Starts with a note letter so passes filter, but quality is unknown assert _bar_str_to_positions("C:xyz", 4) is None def test_performance_annotation_filtered(self): # "(voice" is not a chord — should be ignored pos = _bar_str_to_positions("C:maj (voice", 4) assert pos is not None assert pos[0] == "Cmaj" def test_result_length(self): for n in (3, 4, 6): pos = _bar_str_to_positions("C:maj", n) assert len(pos) == n def test_interval_bass_resolved(self): # C:maj/5 → Cmaj/G pos = _bar_str_to_positions("C:maj/5", 4) assert pos[0] == "Cmaj/G" # --------------------------------------------------------------------------- # Metre parsing # --------------------------------------------------------------------------- class TestParseMetre: def test_4_4(self): assert _parse_metre("4/4") == ("4/4", 4) def test_3_4(self): assert _parse_metre("3/4") == ("3/4", 4) def test_6_8(self): assert _parse_metre("6/8") == ("6/8", 8) def test_integer_4(self): assert _parse_metre("4") == ("4/4", 4) def test_unsupported(self): sig, sub = _parse_metre("7/8") assert sig is None assert sub == 0 # --------------------------------------------------------------------------- # Full period conversion # --------------------------------------------------------------------------- class TestFullConversion: def test_returns_two_periods(self, tmp_path): assert convert_song(TEST_SONG, tmp_path) == 2 def test_output_files_exist(self, tmp_path): convert_song(TEST_SONG, tmp_path) assert len(list(tmp_path.glob("*.chord"))) == 2 def test_output_files_are_parseable(self, tmp_path): convert_song(TEST_SONG, tmp_path) for f in tmp_path.glob("*.chord"): assert parse_chord_file(f) is not None def test_verse_has_four_bars(self, tmp_path): convert_song(TEST_SONG, tmp_path) verse_files = sorted(tmp_path.glob("*verse*.chord")) assert len(verse_files) == 1 assert len(parse_chord_file(verse_files[0]).bars) == 4 def test_chorus_has_four_bars(self, tmp_path): convert_song(TEST_SONG, tmp_path) chorus_files = sorted(tmp_path.glob("*chorus*.chord")) assert len(chorus_files) == 1 assert len(parse_chord_file(chorus_files[0]).bars) == 4 def test_header_time_and_subdivision(self, tmp_path): convert_song(TEST_SONG, tmp_path) for f in tmp_path.glob("*.chord"): p = parse_chord_file(f) assert p.time == "4/4" assert p.subdivision == 4 def test_style_is_other(self, tmp_path): convert_song(TEST_SONG, tmp_path) for f in tmp_path.glob("*.chord"): assert parse_chord_file(f).style == "other" def test_key_is_c_major(self, tmp_path): convert_song(TEST_SONG, tmp_path) for f in tmp_path.glob("*.chord"): assert parse_chord_file(f).key == "C_major" def test_function_tags(self, tmp_path): convert_song(TEST_SONG, tmp_path) funcs = {parse_chord_file(f).function for f in tmp_path.glob("*.chord")} assert funcs == {"verse", "chorus"} def test_filenames_contain_song_id(self, tmp_path): convert_song(TEST_SONG, tmp_path) names = {f.name for f in tmp_path.glob("*.chord")} assert all("0001" in name for name in names) def test_bar_positions_are_valid_chords(self, tmp_path): from src.chord_parser import parse_chord_symbol convert_song(TEST_SONG, tmp_path) for f in tmp_path.glob("*.chord"): p = parse_chord_file(f) for bar in p.bars: first = bar[0] if first not in (".", "NC", "?"): parse_chord_symbol(first) def test_missing_salami_returns_zero(self, tmp_path): empty_song = tmp_path / "empty" empty_song.mkdir() assert convert_song(empty_song, tmp_path / "out") == 0