Files
pytheory/tests/test_audit_fixes.py
Claude ae8296c51d fix(theory): nashville flat degrees + analyze() secondary-dominant labels
Two follow-ups to the correctness audit:

- nashville() crashed on a flat degree like "b7" (it stripped the trailing
  7 as a seventh marker, then int("b") raised). Parse a leading b/# as an
  accidental and take the degree from the first digit, so "b7" builds the
  borrowed bVII (Bb major); the diatonic, "m", and seventh cases are
  unchanged.
- Chord.analyze() gains secondary_dominants=True, returning "V7/V" instead
  of the bare "II7" — the inverse of progression("V7/V"), so generate ->
  analyze round-trips.

+2 regression tests in test_audit_fixes.py; full suite 1182 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ijpMiuST8kXnBoB5pSu8t
2026-06-22 03:44:59 +00:00

202 lines
7.1 KiB
Python

"""Regression tests for the correctness/theory audit fixes.
Each test pins a specific bug found in the adversarial audit so it can't
silently come back. Grouped by the original issue numbers.
"""
import random
import pytest
from pytheory import Tone, Chord, Fretboard, Maqam
from pytheory.scales import TonedScale, Key
def _ts(tonic, scale="major"):
return TonedScale(tonic=Tone.from_string(tonic))[scale]
# ── #14+15: fretboard matches chord tones by pitch class, not spelling ──
@pytest.mark.parametrize("sym", ["Eb", "Ab", "Db", "Gb", "Fm", "Cm",
"Ebm7", "Abmaj7", "Bb", "C", "G", "Am"])
def test_flat_key_chords_cover_all_tones(sym):
fb = Fretboard.guitar()
want = {t.midi % 12 for t in Chord.from_symbol(sym).tones}
got = {t.midi % 12 for t in fb.chord(sym).tones}
assert want.issubset(got), f"{sym}: missing {want - got}"
# ── #1/2/17: Maqam Saba has its diminished 4th (Fab), no major 3rd ──
def test_saba_has_diminished_fourth():
saba = Maqam.get("Saba")
assert saba.degree_names() == ["Do", "Re↓", "Mib", "Fab", "Sol", "Lab", "Sib"]
# The signature flat 4th, not a major 3rd; on C it reads as Fb (≈E).
assert saba.note_names("C") == ["C", "D", "Eb", "E", "G", "Ab", "Bb"]
# ── #9: accidental Roman numerals are chromatic from the parallel major ──
def test_accidental_numerals_in_minor():
minor = _ts("C", "minor")
assert minor.progression("bVI")[0].tones[0].name == "Ab" # not G
assert minor.progression("bIII")[0].tones[0].name == "Eb" # not D
assert minor.progression("bVII")[0].tones[0].name == "Bb" # not A
# bare diatonic numerals are unchanged
assert minor.progression("VI")[0].tones[0].name == "Ab"
def test_accidental_numerals_in_major_unchanged():
major = _ts("C")
assert major.progression("bVII")[0].tones[0].name == "Bb"
assert major.progression("#IV")[0].tones[0].name == "F#"
# ── #10: extreme-key scales spell one letter per degree ──
@pytest.mark.parametrize("key,expected", [
("Gb", ["Gb", "Ab", "Bb", "Cb", "Db", "Eb", "F"]),
("F#", ["F#", "G#", "A#", "B", "C#", "D#", "E#"]),
("C#", ["C#", "D#", "E#", "F#", "G#", "A#", "B#"]),
("Cb", ["Cb", "Db", "Eb", "Fb", "Gb", "Ab", "Bb"]),
])
def test_extreme_key_spelling(key, expected):
names = [t.name for t in _ts(key).tones[:7]]
assert names == expected
assert len({n[0] for n in names}) == 7 # each letter exactly once
def test_extreme_key_signature():
assert Key("C#", "major").signature["sharps"] == 7
assert Key("Cb", "major").signature["flats"] == 7
# octave-transpose in scale.chord() keeps the flat spelling (no Eb→D#)
def test_flat_key_triad_fifth_spelling():
assert [t.name for t in _ts("Eb").triad(3)] == ["Ab", "C", "Eb"]
assert [t.name for t in _ts("Gb").triad(3)] == ["Cb", "Eb", "Gb"]
# ── #5: from_symbol requires the whole suffix to be consumed ──
def test_from_symbol_rejects_unconsumed_suffix():
with pytest.raises(ValueError):
Chord.from_symbol("C7b9")
with pytest.raises(ValueError):
Chord.from_symbol("Cmajgarbage")
def test_from_symbol_supports_suspended_dominants():
assert [t.name for t in Chord.from_symbol("E7sus4").tones] == ["E", "A", "B", "D"]
# ── #6: 13th chord drops the avoid-note 11th ──
def test_thirteenth_omits_eleventh():
pcs = {t.midi % 12 for t in Chord.from_symbol("C13").tones}
assert (5 + 0) % 12 not in {(p - 0) % 12 for p in pcs} # no F (natural 11th)
# ── #8: 6th chords identify by root order, not as the relative 7th ──
def test_sixth_chords_identify():
assert Chord.from_symbol("C6").identify() == "C major 6th"
assert Chord.from_symbol("Cm6").identify() == "C minor 6th"
assert Chord.from_symbol("Am7").identify() == "A minor 7th"
# ── #11: Nashville 'm' forces minor with readable spelling ──
def test_nashville_m_forces_minor():
major = _ts("C")
assert [t.name for t in major.nashville("4m")[0].tones] == ["F", "Ab", "C"]
assert [t.name for t in major.nashville("5m")[0].tones] == ["G", "Bb", "D"]
# ── Nashville flat degrees (b7 used to crash on int("b")) ──
def test_nashville_flat_degree_borrows():
major = _ts("C")
assert major.nashville("b7")[0].identify() == "Bb major" # ♭VII
assert major.nashville("b3")[0].identify() == "Eb major"
assert major.nashville("b6")[0].identify() == "Ab major"
# The diatonic and 'm' cases still work alongside it.
assert major.nashville("57")[0].identify() == "G dominant 7th"
assert major.nashville("17")[0].identify() == "C major 7th"
assert major.nashville("4m")[0].identify() == "F minor"
# ── analyze() can label secondary dominants (inverse of progression) ──
def test_analyze_secondary_dominants():
assert Chord.from_symbol("D7").analyze("C") == "II7" # default
assert Chord.from_symbol("D7").analyze("C", secondary_dominants=True) == "V7/V"
assert Chord.from_symbol("E7").analyze("C", secondary_dominants=True) == "V7/vi"
assert Chord.from_symbol("G7").analyze("C", secondary_dominants=True) == "V7"
ch = Key("C", "major").progression("V7/V")[0]
assert ch.analyze("C", secondary_dominants=True) == "V7/V" # round-trip
# ── #18: garbage Roman numerals are rejected ──
@pytest.mark.parametrize("bad", ["XYZ", "", "IIII", "VIII", "foo"])
def test_numeral_garbage_rejected(bad):
with pytest.raises(ValueError):
_ts("C").progression(bad)
# ── #4: negative harmony spells reflected chords with flats ──
def test_negative_harmony_flat_spelling():
neg = Chord.from_symbol("G7").negative_harmony("C")
assert {t.name for t in neg.tones} == {"D", "F", "Ab", "C"}
# ── #19: from_midi → full_name → from_string roundtrips at octave -1 ──
@pytest.mark.parametrize("midi", [0, 5, 11, 12, 60, 127])
def test_octave_minus_one_roundtrip(midi):
t = Tone.from_midi(midi)
assert Tone.from_string(t.full_name).midi == midi
# ── #23: harmony/dissonance/beat_pulse return floats, even when empty ──
def test_empty_chord_metrics_are_floats():
c = Chord(tones=[Tone.from_string("C4")])
assert isinstance(c.harmony, float)
assert isinstance(c.dissonance, float)
assert isinstance(c.beat_pulse, float)
# ── #12: ensemble render never reseeds the global RNG ──
def test_ensemble_render_preserves_global_rng():
from pytheory.rhythm import Score
from pytheory.play import render_score
random.seed(1234)
before = [random.random() for _ in range(5)]
random.seed(1234)
s = Score(bpm=120)
p = s.part("strings", synth="saw", ensemble=4)
for n in ("C4", "E4", "G4"):
p.add(n, 0.5)
render_score(s)
after = [random.random() for _ in range(5)]
# The render must not have advanced/reseeded the global RNG stream.
assert before == after
# ── #22: play()/save() reject a Score with a helpful TypeError ──
def test_play_rejects_score():
from pytheory import play
from pytheory.rhythm import Score
s = Score(bpm=120)
s.part("p").add("C4", 1)
with pytest.raises(TypeError, match="play_score|render_score|save_midi"):
play(s)