"""Unit tests for ChordEngine — determinism, voice leading, inversions, emotions. Strict TDD: RED → GREEN → TRIANGULATE → REFACTOR for each spec requirement. """ from __future__ import annotations import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parents[1])) import pytest from src.composer.chords import ChordEngine, EMOTION_PROGRESSIONS # --------------------------------------------------------------------------- # R1: Determinism — same seed → same output # --------------------------------------------------------------------------- class TestDeterminism: """R1: ChordEngine(key, seed) MUST produce identical progressions.""" def test_same_seed_same_output(self): """GIVEN ChordEngine("Am", seed=42) WHEN progression(8) called twice THEN identical output.""" engine = ChordEngine("Am", seed=42) r1 = engine.progression(8) r2 = engine.progression(8) assert r1 == r2, "Same seed must produce identical progressions" assert len(r1) == 8, "8 bars @ 4 bpc = 8 chords" def test_different_seed_different_output(self): """Different seeds SHOULD produce different voicing choices.""" e1 = ChordEngine("Am", seed=42) e2 = ChordEngine("Am", seed=99) r1 = e1.progression(8) r2 = e2.progression(8) assert r1 != r2, ( "Different seeds should produce different voicings " "(rng used as voice-leading tiebreaker)" ) def test_same_seed_different_keys_differ(self): """Same seed with different keys should differ.""" e_am = ChordEngine("Am", seed=42) e_dm = ChordEngine("Dm", seed=42) r1 = e_am.progression(8) r2 = e_dm.progression(8) assert r1 != r2, "Different tonic should produce different pitch sets" # --------------------------------------------------------------------------- # R2: Voice leading ≤ 4 semitones per voice # --------------------------------------------------------------------------- class TestVoiceLeadingBounds: """R2: Voice leading MUST cap at 4 semitones per voice.""" def test_all_adjacent_pairs_within_4_semitones(self): """GIVEN any 2 consecutive chords from a progression WHEN computing voice leading THEN no voice moves more than 4 semitones.""" engine = ChordEngine("Am", seed=42) voicings = engine.progression(8, emotion="romantic") assert len(voicings) >= 2, "Need at least 2 chords to test voice leading" for i in range(len(voicings) - 1): a = voicings[i] b = voicings[i + 1] assert len(a) == len(b), ( f"Chords {i} and {i+1} have different voice counts: " f"{len(a)} vs {len(b)}" ) for j, (pa, pb) in enumerate(zip(a, b)): leap = abs(pb - pa) assert leap <= 4, ( f"Voice {j} leaped {leap} semitones " f"({pa}→{pb}) between chord {i} and {i+1}" ) def test_voice_leading_on_dark_progression(self): """Voice leading bounds hold for dark emotion too.""" engine = ChordEngine("Am", seed=42) voicings = engine.progression(8, emotion="dark") for i in range(len(voicings) - 1): for pa, pb in zip(voicings[i], voicings[i + 1]): assert abs(pb - pa) <= 4 def test_voice_leading_on_club_progression(self): """Voice leading bounds hold for club emotion.""" engine = ChordEngine("Am", seed=42) voicings = engine.progression(8, emotion="club") for i in range(len(voicings) - 1): for pa, pb in zip(voicings[i], voicings[i + 1]): assert abs(pb - pa) <= 4 def test_voice_leading_on_classic_progression(self): """Voice leading bounds hold for classic emotion.""" engine = ChordEngine("Am", seed=42) voicings = engine.progression(8, emotion="classic") for i in range(len(voicings) - 1): for pa, pb in zip(voicings[i], voicings[i + 1]): assert abs(pb - pa) <= 4 # --------------------------------------------------------------------------- # R3: Inversions # --------------------------------------------------------------------------- class TestInversions: """R3: SHALL support 3 inversion modes.""" def test_root_inversion_no_change(self): """Root inversion = identity (notes unchanged).""" engine = ChordEngine("Am", seed=0) voicing = [57, 60, 64] # Am root: A3, C4, E4 result = engine._apply_inversion(voicing, "root") assert sorted(result) == sorted(voicing), "Root inversion should not reorder notes" def test_first_inversion_bass_is_third(self): """First inversion: third becomes lowest note.""" engine = ChordEngine("Am", seed=0) voicing = [57, 60, 64] # Am root: A=root, C=third, E=fifth result = engine._apply_inversion(voicing, "first") assert min(result) == 60, ( f"First inversion bass should be third (60 = C4), got {min(result)}" ) def test_second_inversion_bass_is_fifth(self): """Second inversion: fifth becomes lowest note.""" engine = ChordEngine("Am", seed=0) voicing = [57, 60, 64] result = engine._apply_inversion(voicing, "second") assert min(result) == 64, ( f"Second inversion bass should be fifth (64 = E4), got {min(result)}" ) def test_first_inversion_on_major_chord(self): """First inversion works on major chords too.""" engine = ChordEngine("C", seed=0) # C major: C=60, E=64, G=67 voicing = [60, 64, 67] result = engine._apply_inversion(voicing, "first") assert min(result) == 64, "First inversion of Cmaj: bass = E4 (64)" # --------------------------------------------------------------------------- # R4: Emotion divergence # --------------------------------------------------------------------------- class TestEmotionDivergence: """R4: MUST support 4 emotion modes with distinct progressions.""" def test_all_four_distinct(self): """GIVEN ChordEngine("Am", seed=0) with 4 emotions WHEN progression(8) called per emotion THEN all 4 output sequences differ.""" emotions = ["romantic", "dark", "club", "classic"] results = {} for emo in emotions: engine = ChordEngine("Am", seed=0) results[emo] = engine.progression(8, emotion=emo) seen = set() for emo, prog in results.items(): encoded = str(prog) assert encoded not in seen, ( f"Emotion '{emo}' produced duplicate progression" ) seen.add(encoded) def test_invalid_emotion_falls_back_to_classic(self): """GIVEN unknown emotion 'angry' WHEN progression(8) called THEN defaults to classic progression, no error.""" engine = ChordEngine("Am", seed=0) classic = engine.progression(8, emotion="classic") angry = engine.progression(8, emotion="angry") assert angry == classic, ( "Unknown emotion should fall back to classic progression" ) def test_each_emotion_has_four_degrees(self): """Each emotion progression must have exactly 4 chord types.""" for emo, degrees in EMOTION_PROGRESSIONS.items(): assert len(degrees) == 4, ( f"Emotion '{emo}' has {len(degrees)} degrees, expected 4" ) for degree_offset, quality in degrees: assert isinstance(degree_offset, int), ( f"Degree offset must be int, got {type(degree_offset)}" ) assert quality in ("maj", "min", "dim", "aug", "7", "m7"), ( f"Unknown quality '{quality}'" ) # --------------------------------------------------------------------------- # R5: Empty / zero bars # --------------------------------------------------------------------------- class TestEdgeCases: """Edge cases from spec requirements.""" def test_zero_bars_returns_empty(self): """0 bars → empty list (R5).""" engine = ChordEngine("Am", seed=42) result = engine.progression(0) assert result == [], "0 bars should return empty list" def test_single_bar(self): """1 bar @ 4 bpc = 1 chord.""" engine = ChordEngine("Am", seed=42) result = engine.progression(1) assert len(result) == 1, f"1 bar = 1 chord, got {len(result)}" def test_partial_bar(self): """3 bars = 3 chords @ 4 bpc.""" engine = ChordEngine("Am", seed=42) result = engine.progression(3) assert len(result) == 3, f"3 bars = 3 chords @ 4 bpc" def test_each_chord_is_three_note_triad(self): """All chords should be 3-note triads (min/maj quality).""" engine = ChordEngine("Am", seed=42) for emotion in ("romantic", "dark", "club", "classic"): voicings = engine.progression(8, emotion=emotion) for i, voicing in enumerate(voicings): assert len(voicing) == 3, ( f"{emotion} chord {i}: expected 3 notes, got {len(voicing)}" ) # --------------------------------------------------------------------------- # R7: Integration with compose.py # --------------------------------------------------------------------------- class TestComposeIntegration: """R7: build_chords_track() SHALL delegate to ChordEngine.""" def test_build_chords_uses_chordengine(self): """Verify build_chords_track calls ChordEngine.progression.""" from unittest.mock import patch from scripts.compose import build_chords_track from src.core.schema import SectionDef sections = [SectionDef(name="verse", bars=4, energy=1.0)] offsets = [0.0] with patch("scripts.compose.ChordEngine") as MockEngine: mock_engine = MockEngine.return_value mock_engine.progression.return_value = [[57, 60, 64], [60, 64, 67]] track = build_chords_track( sections, offsets, "A", True, emotion="dark", inversion="first", ) # Verify ChordEngine was instantiated MockEngine.assert_called_once() # Verify progression() was called with correct args mock_engine.progression.assert_called_once_with( 4, emotion="dark", beats_per_chord=4, inversion="first", ) assert track.name == "Chords" assert len(track.clips) == 1 def test_compose_cli_emotion_flag_accepted(self, tmp_path): """CLI --emotion dark generates output (R7 integration).""" from unittest.mock import patch, MagicMock output = tmp_path / "test.rpp" with patch("scripts.compose.SampleSelector") as mock_cls: mock_sel = MagicMock() mock_sel._samples = [ { "role": "snare", "perceptual": {"tempo": 0}, "musical": {"key": "X"}, "character": "sharp", "original_path": "fake_clap.wav", "original_name": "fake_clap.wav", "file_hash": "clap123", }, ] mock_sel.select.return_value = [ MagicMock(sample={ "original_path": "fake_clap.wav", "file_hash": "clap123", }), ] mock_sel.select_diverse.return_value = [] mock_cls.return_value = mock_sel import importlib import scripts.compose as _mod orig_argv = sys.argv try: sys.argv = [ "compose", "--key", "Am", "--emotion", "dark", "--inversion", "root", "--output", str(output), ] importlib.reload(_mod) _mod.main() finally: sys.argv = orig_argv assert output.exists(), f"Expected {output} to exist"