"""Tests for src/core/schema.py — SongDefinition, TrackDef, ClipDef, MidiNote, CCEvent.""" import dataclasses import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parents[1])) import pytest from src.core.schema import SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote, CCEvent class TestCCEvent: """Test CCEvent dataclass (Phase 1: schema).""" def test_ccevent_round_trip(self): """CCEvent round-trips correctly through dataclass construction.""" evt = CCEvent(controller=11, time=0.5, value=50) assert evt.controller == 11 assert evt.time == 0.5 assert evt.value == 50 def test_ccevent_defaults(self): """CCEvent has no default fields — all are required.""" from dataclasses import fields as dc_fields field_names = [f.name for f in dc_fields(CCEvent)] no_default = [] for f in dc_fields(CCEvent): if f.default is f.default_factory is dataclasses.MISSING: no_default.append(f.name) assert "controller" in no_default assert "time" in no_default assert "value" in no_default def test_clipdef_with_midi_cc(self): """ClipDef with midi_cc field.""" cc = [CCEvent(11, 0.0, 50), CCEvent(11, 0.18, 127)] clip = ClipDef(position=0.0, length=16.0, name="Test", midi_cc=cc) assert len(clip.midi_cc) == 2 assert clip.midi_cc[0].controller == 11 assert clip.midi_cc[0].time == 0.0 assert clip.midi_cc[0].value == 50 def test_clipdef_midi_cc_default_is_empty(self): """ClipDef.midi_cc defaults to empty list.""" clip = ClipDef(position=0.0, length=16.0, name="Test") assert clip.midi_cc == [] def test_song_validate_empty_midi_cc(self): """Song.validate() passes with empty midi_cc (no regression).""" meta = SongMeta(bpm=95, key="Am") note = MidiNote(pitch=60, start=0.0, duration=1.0, velocity=100) clip = ClipDef(position=0.0, length=16.0, name="Test", midi_notes=[note], midi_cc=[]) track = TrackDef(name="Test", clips=[clip]) song = SongDefinition(meta=meta, tracks=[track]) errors = song.validate() assert errors == [] class TestSongDefinitionInstantiation: """Test SongDefinition instantiation with valid data.""" def test_song_definition_with_valid_data(self): """SongDefinition instantiates with meta and tracks.""" meta = SongMeta(bpm=95, key="Am", title="Test Song") song = SongDefinition(meta=meta, tracks=[]) assert song.meta.bpm == 95 assert song.meta.key == "Am" assert song.meta.title == "Test Song" assert song.tracks == [] def test_song_definition_with_multiple_tracks(self): """SongDefinition accepts multiple TrackDef entries.""" meta = SongMeta(bpm=95, key="Am") track1 = TrackDef(name="Kick", volume=0.85) track2 = TrackDef(name="Bass", volume=0.80) song = SongDefinition(meta=meta, tracks=[track1, track2]) assert len(song.tracks) == 2 assert song.tracks[0].name == "Kick" assert song.tracks[1].name == "Bass" class TestTrackDefWithAudioClip: """Test TrackDef with audio clip (sample_path).""" def test_track_with_audio_clip(self): """TrackDef with audio clip has audio_path set.""" clip = ClipDef( position=0.0, length=16.0, name="Kick Loop", audio_path="C:/samples/kick.wav", ) track = TrackDef(name="Drums", clips=[clip]) assert len(track.clips) == 1 assert track.clips[0].is_audio assert track.clips[0].audio_path == "C:/samples/kick.wav" assert not track.clips[0].is_midi def test_track_with_multiple_audio_clips(self): """TrackDef can hold multiple audio clips.""" clip1 = ClipDef(position=0.0, length=16.0, audio_path="C:/samples/kick.wav") clip2 = ClipDef(position=16.0, length=16.0, audio_path="C:/samples/kick2.wav") track = TrackDef(name="Drums", clips=[clip1, clip2]) assert len(track.clips) == 2 assert track.clips[0].audio_path == "C:/samples/kick.wav" assert track.clips[1].audio_path == "C:/samples/kick2.wav" class TestClipDefWithMidiNotes: """Test ClipDef with MIDI notes.""" def test_clip_with_midi_notes(self): """ClipDef with midi_notes is identified as MIDI clip.""" note = MidiNote(pitch=36, start=0.0, duration=1.0, velocity=100) clip = ClipDef( position=0.0, length=16.0, name="Kick Pattern", midi_notes=[note], ) assert clip.is_midi assert not clip.is_audio assert len(clip.midi_notes) == 1 assert clip.midi_notes[0].pitch == 36 def test_clip_with_multiple_midi_notes(self): """ClipDef can hold multiple MidiNote entries.""" notes = [ MidiNote(pitch=36, start=0.0, duration=0.25, velocity=115), MidiNote(pitch=36, start=1.5, duration=0.25, velocity=105), MidiNote(pitch=38, start=2.0, duration=0.15, velocity=100), ] clip = ClipDef(position=0.0, length=16.0, name="Drum Pattern", midi_notes=notes) assert len(clip.midi_notes) == 3 assert clip.midi_notes[0].pitch == 36 assert clip.midi_notes[1].pitch == 36 assert clip.midi_notes[2].pitch == 38 class TestValidationNegativeBPM: """Test validation: negative BPM raises ValueError.""" def test_negative_bpm_raises_value_error(self): """SongDefinition.validate() returns error for negative BPM.""" meta = SongMeta(bpm=-10, key="Am") song = SongDefinition(meta=meta, tracks=[]) errors = song.validate() assert any("bpm" in e.lower() for e in errors) def test_zero_bpm_raises_value_error(self): """SongDefinition.validate() returns error for zero BPM.""" meta = SongMeta(bpm=0, key="Am") song = SongDefinition(meta=meta, tracks=[]) errors = song.validate() assert any("bpm" in e.lower() for e in errors) def test_valid_bpm_passes(self): """SongDefinition.validate() passes for BPM 20-999.""" meta = SongMeta(bpm=95, key="Am") song = SongDefinition(meta=meta, tracks=[]) errors = song.validate() assert not any("bpm" in e.lower() for e in errors) class TestMidiNote: """Test MidiNote dataclass.""" def test_midi_note_defaults(self): """MidiNote has sensible defaults for velocity.""" note = MidiNote(pitch=60, start=0.0, duration=1.0) assert note.pitch == 60 assert note.start == 0.0 assert note.duration == 1.0 assert note.velocity == 64 # default def test_midi_note_explicit_velocity(self): """MidiNote accepts explicit velocity.""" note = MidiNote(pitch=60, start=0.0, duration=1.0, velocity=127) assert note.velocity == 127 def test_midi_note_velocity_clamping(self): """MidiNote does NOT clamp — accepts any int (caller's responsibility).""" note = MidiNote(pitch=60, start=0.0, duration=1.0, velocity=200) assert note.velocity == 200 class TestClipDefVolMult: """Test ClipDef.vol_mult default and behavior.""" def test_vol_mult_default_is_one(self): """ClipDef.vol_mult defaults to 1.0.""" clip = ClipDef(position=0.0, length=16.0, name="Test") assert clip.vol_mult == 1.0 def test_vol_mult_custom_value(self): """ClipDef.vol_mult accepts custom value.""" clip = ClipDef(position=0.0, length=16.0, name="Test", vol_mult=0.7) assert clip.vol_mult == 0.7 def test_audio_clip_vol_mult_default_is_one(self): """Audio clip with default vol_mult=1.0 has no D_VOL side effect.""" clip = ClipDef(position=0.0, length=16.0, audio_path="test.wav", vol_mult=1.0) assert clip.is_audio assert clip.vol_mult == 1.0 def test_midi_clip_vol_mult_default_is_one(self): """MIDI clip with default vol_mult=1.0 has no velocity scaling.""" note = MidiNote(pitch=60, start=0.0, duration=1.0, velocity=100) clip = ClipDef(position=0.0, length=16.0, midi_notes=[note], vol_mult=1.0) assert clip.is_midi assert clip.vol_mult == 1.0