Files
hamori/tests/test_chord_file_parser.py
T
H1K0 a473499fac feat: implement .chord file parser and canonical transposer; freeze requirements
src/tokenizer.py:
  - parse_chord_file(Path) → ChordPeriod: reads header + bar body, strips //
    comments, validates bar position counts and chord symbols, raises
    ChordFormatError with filename and bar number on any violation.
  - transpose_to_canonical(ChordPeriod) → ChordPeriod: shifts all chord roots
    and bass notes by the semitone offset to C major / A minor; fast-path
    returns the original object when shift == 0.

tests/test_chord_file_parser.py: 39 tests covering parsing of 4 valid fixtures
  (C major, F# major, B minor, G# minor), error messages for 2 invalid
  fixtures, and transposition correctness including slash chord root+bass.

tests/fixtures/: 6 .chord fixture files (4 valid, 2 invalid).

requirements.txt: pinned to current latest stable versions
  (torch 2.12.0, music21 10.1.0, pretty_midi 0.2.11, matplotlib 3.10.9,
  numpy 2.4.6, pandas 3.0.3, pytest 9.0.3); Python >= 3.11 noted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:27:57 +03:00

273 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 == "user"
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 13 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"