"""Tests for transitions-fx — build_fx_track() and FX_TRANSITIONS map. Strict TDD: tests written BEFORE implementation (per TDD cycle). """ import sys from pathlib import Path from unittest.mock import MagicMock sys.path.insert(0, str(Path(__file__).parents[1])) import pytest # ============================================================================ # Phase 1: RED — FX_TRANSITIONS dict + FX_ROLE constant # ============================================================================ class TestFxTransitionsDict: """1.1 FX_TRANSITIONS dict: 7 boundaries → 8 clips.""" def test_fx_transitions_has_correct_boundary_count(self): """FX_TRANSITIONS covers 7 section boundaries (indices 2-8).""" from scripts.compose import FX_TRANSITIONS assert isinstance(FX_TRANSITIONS, dict), "FX_TRANSITIONS must be a dict" # 7 boundaries: indices 2,3,4,5,6,7,8 (NOT 0,1 — intro/verse have no FX) expected_boundaries = {2, 3, 4, 5, 6, 7, 8} assert set(FX_TRANSITIONS.keys()) == expected_boundaries, ( f"Expected boundaries {sorted(expected_boundaries)}, " f"got {sorted(FX_TRANSITIONS.keys())}" ) def test_fx_transitions_total_clips_8(self): """FX_TRANSITIONS defines exactly 8 clips (one boundary has riser+impact).""" from scripts.compose import FX_TRANSITIONS total = 0 for vals in FX_TRANSITIONS.values(): if isinstance(vals, list): total += len(vals) else: total += 1 assert total == 8, f"Expected 8 total clips, got {total}" def test_fx_transitions_boundary_2_sweep(self): """verse→pre-chorus: sweep at position 46, length 2, fade_in 0.3.""" from scripts.compose import FX_TRANSITIONS entry = FX_TRANSITIONS[2] # Boundary 2 has a single tuple (not nested list for single-clip boundaries) assert isinstance(entry, tuple), f"Boundary 2 should be a single tuple, got {type(entry)}" fx_type, offset, length, fade_in, fade_out = entry assert fx_type == "sweep" assert offset == -2, f"Expected offset -2 (boundary beat 48 - 2 = 46), got {offset}" assert length == 2 assert fade_in == 0.3 assert fade_out == 0.0 def test_fx_transitions_boundary_3_has_riser_and_impact(self): """pre-chorus→chorus: list of 2 tuples (riser + impact).""" from scripts.compose import FX_TRANSITIONS entries = FX_TRANSITIONS[3] assert isinstance(entries, list), f"Boundary 3 must be a list for riser+impact, got {type(entries)}" assert len(entries) == 2, f"Boundary 3 must have 2 entries, got {len(entries)}" # Riser fx_type, offset, length, fade_in, fade_out = entries[0] assert fx_type == "riser" assert offset == -4, f"Expected offset -4 (boundary beat 64 - 4 = 60), got {offset}" assert length == 4 assert fade_in == 1.5 assert fade_out == 0.0 # Impact fx_type, offset, length, fade_in, fade_out = entries[1] assert fx_type == "impact" assert offset == 0, f"Expected offset 0 (boundary beat 64 + 0 = 64), got {offset}" assert length == 2 assert fade_in == 0.0 assert fade_out == 0.3 def test_fx_transitions_boundary_4_transition(self): """chorus→verse2: transition at position 94, length 2, fades 0.2/0.2.""" from scripts.compose import FX_TRANSITIONS entry = FX_TRANSITIONS[4] fx_type, offset, length, fade_in, fade_out = entry assert fx_type == "transition" assert offset == -2, f"Expected offset -2 (boundary beat 96 - 2 = 94), got {offset}" assert length == 2 assert fade_in == 0.2 assert fade_out == 0.2 def test_fx_transitions_boundary_5_riser(self): """verse2→chorus2: riser at position 124, length 4, fade_in 1.0.""" from scripts.compose import FX_TRANSITIONS entry = FX_TRANSITIONS[5] fx_type, offset, length, fade_in, fade_out = entry assert fx_type == "riser" assert offset == -4, f"Expected offset -4 (boundary beat 128 - 4 = 124), got {offset}" assert length == 4 assert fade_in == 1.0 assert fade_out == 0.0 def test_fx_transitions_boundary_6_sweep(self): """chorus2→bridge: sweep at position 158, length 2, fades 0.2/0.2.""" from scripts.compose import FX_TRANSITIONS entry = FX_TRANSITIONS[6] fx_type, offset, length, fade_in, fade_out = entry assert fx_type == "sweep" assert offset == -2, f"Expected offset -2 (boundary beat 160 - 2 = 158), got {offset}" assert length == 2 assert fade_in == 0.2 assert fade_out == 0.2 def test_fx_transitions_boundary_7_riser(self): """bridge→final: riser at position 172, length 4, fade_in 1.0.""" from scripts.compose import FX_TRANSITIONS entry = FX_TRANSITIONS[7] fx_type, offset, length, fade_in, fade_out = entry assert fx_type == "riser" assert offset == -4, f"Expected offset -4 (boundary beat 176 - 4 = 172), got {offset}" assert length == 4 assert fade_in == 1.0 assert fade_out == 0.0 def test_fx_transitions_boundary_8_sweep(self): """final→outro: sweep at position 206, length 2, fades 0.3/0.5.""" from scripts.compose import FX_TRANSITIONS entry = FX_TRANSITIONS[8] fx_type, offset, length, fade_in, fade_out = entry assert fx_type == "sweep" assert offset == -2, f"Expected offset -2 (boundary beat 208 - 2 = 206), got {offset}" assert length == 2 assert fade_in == 0.3 assert fade_out == 0.5 class TestFxRoleConstant: """1.2 FX_ROLE = 'fx' constant referencing ATONAL_ROLES.""" def test_fx_role_equals_fx(self): """FX_ROLE is the string 'fx'.""" from scripts.compose import FX_ROLE assert FX_ROLE == "fx" def test_fx_role_in_atonal_roles(self): """FX_ROLE value is in ATONAL_ROLES (skip key scoring).""" from scripts.compose import FX_ROLE from src.selector import ATONAL_ROLES assert FX_ROLE in ATONAL_ROLES, "fx must be in ATONAL_ROLES for neutral key scoring" # ============================================================================ # Phase 2: RED — build_fx_track() function # ============================================================================ def _make_fx_selector(samples=None): """Build a mock SampleSelector that returns FX samples via select_one.""" if samples is None: samples = [ {"original_path": f"fx_sample_{i}.wav", "original_name": f"FX {i}"} for i in range(5) ] mock = MagicMock() mock.select_one.return_value = samples[0] if samples else None return mock def _make_sections_and_offsets(): """Return sections and offsets matching production SECTIONS layout.""" from scripts.compose import SECTIONS from src.core.schema import SectionDef sections = [] for name, bars, energy, _has_lead in SECTIONS: sections.append(SectionDef(name=name, bars=bars, energy=energy)) offsets = [] off = 0.0 for sec in sections: offsets.append(off) off += sec.bars return sections, offsets class TestBuildFxTrack: """4.1-4.3 Unit tests for build_fx_track().""" def test_returns_trackdef_with_8_clips(self): """build_fx_track produces a TrackDef with exactly 8 clips.""" from scripts.compose import build_fx_track, build_section_structure sections, offsets = build_section_structure() selector = _make_fx_selector() track = build_fx_track(sections, offsets, selector, seed=0) assert track.name == "Transition FX" assert len(track.clips) == 8, f"Expected 8 clips, got {len(track.clips)}" def test_clip_positions_match_design(self): """Clip positions match the design boundary map values.""" from scripts.compose import build_fx_track, build_section_structure sections, offsets = build_section_structure() selector = _make_fx_selector() track = build_fx_track(sections, offsets, selector, seed=0) positions = sorted(c.position for c in track.clips) expected = [46.0, 60.0, 64.0, 94.0, 124.0, 158.0, 172.0, 206.0] assert positions == expected, ( f"Expected positions {expected}, got {positions}" ) def test_all_clips_have_audio_path(self): """All 8 clips have audio_path set (not None).""" from scripts.compose import build_fx_track, build_section_structure sections, offsets = build_section_structure() selector = _make_fx_selector() track = build_fx_track(sections, offsets, selector, seed=0) for i, clip in enumerate(track.clips): assert clip.audio_path is not None, ( f"Clip {i} (pos={clip.position}) has None audio_path" ) assert isinstance(clip.audio_path, str), ( f"Clip {i} audio_path must be a string" ) assert len(clip.audio_path) > 0, ( f"Clip {i} audio_path must not be empty" ) def test_fade_values_match_design(self): """Fade in/out values match the design table for each clip.""" from scripts.compose import build_fx_track, build_section_structure sections, offsets = build_section_structure() selector = _make_fx_selector() track = build_fx_track(sections, offsets, selector, seed=0) # Build expected map: position → (fade_in, fade_out) expected_fades = { 46.0: (0.3, 0.0), # sweep 60.0: (1.5, 0.0), # riser 64.0: (0.0, 0.3), # impact 94.0: (0.2, 0.2), # transition 124.0: (1.0, 0.0), # riser 158.0: (0.2, 0.2), # sweep 172.0: (1.0, 0.0), # riser 206.0: (0.3, 0.5), # sweep } for clip in track.clips: exp_fi, exp_fo = expected_fades.get(clip.position, (None, None)) assert exp_fi is not None, f"Unexpected clip position: {clip.position}" assert clip.fade_in == pytest.approx(exp_fi), ( f"Clip at {clip.position}: fade_in={clip.fade_in}, expected {exp_fi}" ) assert clip.fade_out == pytest.approx(exp_fo), ( f"Clip at {clip.position}: fade_out={clip.fade_out}, expected {exp_fo}" ) def test_riser_has_fade_in_gt_zero(self): """Spec requirement: riser clips have fade_in > 0.""" from scripts.compose import build_fx_track, build_section_structure from scripts.compose import FX_TRANSITIONS sections, offsets = build_section_structure() selector = _make_fx_selector() track = build_fx_track(sections, offsets, selector, seed=0) # Find which entries are risers riser_positions = set() for bound_idx, entries in FX_TRANSITIONS.items(): items = entries if isinstance(entries, list) else [entries] for fx_type, offset, length, fi, fo in items: if fx_type == "riser": boundary_beat = offsets[bound_idx] * 4.0 riser_positions.add(boundary_beat + offset) for clip in track.clips: if clip.position in riser_positions: assert clip.fade_in > 0, ( f"Riser at {clip.position} must have fade_in > 0" ) def test_impact_has_fade_out_gt_zero(self): """Spec requirement: impact clips have fade_out > 0.""" from scripts.compose import build_fx_track, build_section_structure from scripts.compose import FX_TRANSITIONS sections, offsets = build_section_structure() selector = _make_fx_selector() track = build_fx_track(sections, offsets, selector, seed=0) # Find which entries are impacts impact_positions = set() for bound_idx, entries in FX_TRANSITIONS.items(): items = entries if isinstance(entries, list) else [entries] for fx_type, offset, length, fi, fo in items: if fx_type == "impact": boundary_beat = offsets[bound_idx] * 4.0 impact_positions.add(boundary_beat + offset) for clip in track.clips: if clip.position in impact_positions: assert clip.fade_out > 0, ( f"Impact at {clip.position} must have fade_out > 0" ) def test_track_volume_is_0_72(self): """Spec requirement: FX track volume = 0.72.""" from scripts.compose import build_fx_track, build_section_structure sections, offsets = build_section_structure() selector = _make_fx_selector() track = build_fx_track(sections, offsets, selector, seed=0) assert track.volume == 0.72, f"Expected volume 0.72, got {track.volume}" def test_track_has_send_level(self): """Spec requirement: FX track sends to Reverb (0.08) and Delay (0.05).""" from scripts.compose import build_fx_track, build_section_structure sections, offsets = build_section_structure() selector = _make_fx_selector() track = build_fx_track(sections, offsets, selector, seed=0) assert track.send_level == {0: 0.08, 1: 0.05}, ( f"Expected send_level {{0: 0.08, 1: 0.05}}, got {track.send_level}" ) def test_deterministic_with_same_seed(self): """Same seed produces identical output (deterministic via select_one).""" from scripts.compose import build_fx_track, build_section_structure sections, offsets = build_section_structure() selector1 = _make_fx_selector() selector2 = _make_fx_selector() track1 = build_fx_track(sections, offsets, selector1, seed=42) track2 = build_fx_track(sections, offsets, selector2, seed=42) paths1 = [c.audio_path for c in track1.clips] paths2 = [c.audio_path for c in track2.clips] assert paths1 == paths2, "Same seed must produce same sample paths" def test_different_seed_may_produce_different(self): """Different seeds use different calls to select_one (variation).""" from scripts.compose import build_fx_track, build_section_structure sections, offsets = build_section_structure() selector = _make_fx_selector() track = build_fx_track(sections, offsets, selector, seed=0) # Verify that select_one was called with the correct role all_calls = selector.select_one.call_args_list for call in all_calls: assert call[1]["role"] == "fx", ( f"select_one must be called with role='fx', got {call}" ) # ============================================================================ # Phase 3: RED — Integration (build_fx_track in main) # ============================================================================ class TestFxTrackIntegration: """3.1-3.2 Integration: build_fx_track called in main(), send wiring works.""" def test_transition_fx_track_in_main_output(self, tmp_path): """main() produces RPP output containing 'Transition FX' track.""" output = _mock_main_fx(tmp_path) assert output.exists(), f"Expected {output} to exist" content = output.read_text(encoding="utf-8") assert "Transition FX" in content, ( "Expected 'Transition FX' track in RPP output" ) def test_fx_track_has_audio_source(self, tmp_path): """FX track clips produce SOURCE WAVE entries.""" output = _mock_main_fx(tmp_path) content = output.read_text(encoding="utf-8") # Count SOURCE WAVE entries — should include FX clips wave_count = content.count("SOURCE WAVE") assert wave_count > 2, f"Expected multiple WAVE sources, got {wave_count}" def test_fx_track_has_aux_sends(self, tmp_path): """FX track has AUXRECV for reverb/delay sends.""" output = _mock_main_fx(tmp_path) content = output.read_text(encoding="utf-8") assert "AUXRECV" in content, "Expected send routing for FX track" def test_fx_track_after_clap_before_pad(self, tmp_path): """Track ordering: Clap → FX → Pad.""" output = _mock_main_fx(tmp_path) content = output.read_text(encoding="utf-8") # RPP format: