| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481 |
- import XCTest
- import SwiftData
- @testable import MixBoard
- /// Integration tests that exercise full app workflows programmatically.
- /// These test the ViewModels, services, and data flow as a user would interact.
- final class IntegrationTests: XCTestCase {
- override func tearDown() {
- super.tearDown()
- TestHelpers.cleanupTestFiles()
- }
- // MARK: - Player ViewModel Flow
- @MainActor
- func testPlayerLoadAndPlayFlow() async throws {
- let url = try TestHelpers.createTestAudioFile(name: "play_test", duration: 2.0)
- let track = Track(title: "Test Song", artist: "Artist", filePath: url.path, duration: 2.0, fileFormat: "WAV")
- let playerVM = PlayerViewModel()
- // Initially stopped
- XCTAssertFalse(playerVM.isPlaying)
- XCTAssertNil(playerVM.currentTrack)
- XCTAssertNil(playerVM.currentPlayingEntryID)
- // Load and play
- playerVM.loadAndPlay(track)
- playerVM.syncForTest()
- XCTAssertTrue(playerVM.isPlaying)
- XCTAssertEqual(playerVM.currentTrack?.title, "Test Song")
- XCTAssertGreaterThan(playerVM.duration, 1.0)
- // Pause
- playerVM.togglePlayPause()
- playerVM.syncForTest()
- XCTAssertFalse(playerVM.isPlaying)
- // Resume
- playerVM.togglePlayPause()
- playerVM.syncForTest()
- XCTAssertTrue(playerVM.isPlaying)
- // Stop
- playerVM.stop()
- playerVM.syncForTest()
- XCTAssertFalse(playerVM.isPlaying)
- XCTAssertEqual(playerVM.currentTime, 0)
- }
- @MainActor
- func testPlayerSeekFlow() async throws {
- let url = try TestHelpers.createTestAudioFile(name: "seek_test", duration: 5.0)
- let track = Track(title: "Seek", filePath: url.path, duration: 5.0, fileFormat: "WAV")
- let playerVM = PlayerViewModel()
- playerVM.loadAndPlay(track)
- playerVM.syncForTest()
- // Seek to middle
- playerVM.seekToProgress(0.5)
- playerVM.syncForTest()
- // Time should be approximately half
- XCTAssertGreaterThan(playerVM.currentTime, 1.5)
- XCTAssertLessThan(playerVM.currentTime, 3.5)
- // Should still be playing
- XCTAssertTrue(playerVM.isPlaying)
- playerVM.stop()
- }
- @MainActor
- func testPlayerVolumeControl() {
- let playerVM = PlayerViewModel()
- XCTAssertEqual(playerVM.volume, 0.8, accuracy: 0.01) // default
- playerVM.volume = 0.5
- XCTAssertEqual(playerVM.volume, 0.5, accuracy: 0.01)
- playerVM.volume = 0.0
- XCTAssertEqual(playerVM.volume, 0.0, accuracy: 0.01)
- playerVM.volume = 1.0
- XCTAssertEqual(playerVM.volume, 1.0, accuracy: 0.01)
- }
- // MARK: - Playlist ViewModel Flow
- @MainActor
- func testPlaylistNextPreviousFlow() async throws {
- let urls = try TestHelpers.createTestAudioFiles(count: 3, duration: 1.0)
- let playlist = Playlist(name: "Nav Test")
- var tracks: [Track] = []
- for (i, url) in urls.enumerated() {
- let t = Track(title: "Track \(i+1)", filePath: url.path, duration: 1.0, fileFormat: "WAV")
- playlist.addTrack(t)
- tracks.append(t)
- }
- let playerVM = PlayerViewModel()
- // Play first track
- let entries = playlist.sortedEntries
- playerVM.loadAndPlay(tracks[0], entryID: entries[0].id, playlist: playlist)
- playerVM.syncForTest()
- XCTAssertEqual(playerVM.currentTrack?.title, "Track 1")
- XCTAssertEqual(playerVM.currentPlayingEntryID, entries[0].id)
- // Next
- playerVM.playNext()
- playerVM.syncForTest()
- XCTAssertEqual(playerVM.currentTrack?.title, "Track 2")
- // Next
- playerVM.playNext()
- playerVM.syncForTest()
- XCTAssertEqual(playerVM.currentTrack?.title, "Track 3")
- // Next at end — should stop
- playerVM.playNext()
- playerVM.syncForTest()
- XCTAssertFalse(playerVM.isPlaying)
- // Play last, then previous
- playerVM.loadAndPlay(tracks[2], entryID: entries[2].id, playlist: playlist)
- playerVM.syncForTest()
- playerVM.playPrevious() // currentTime < 3, go to previous
- playerVM.syncForTest()
- XCTAssertEqual(playerVM.currentTrack?.title, "Track 2")
- playerVM.stop()
- }
- @MainActor
- func testRepeatAllMode() async throws {
- let urls = try TestHelpers.createTestAudioFiles(count: 2, duration: 1.0)
- let playlist = Playlist(name: "Repeat Test")
- var tracks: [Track] = []
- for (i, url) in urls.enumerated() {
- let t = Track(title: "Track \(i+1)", filePath: url.path, duration: 1.0, fileFormat: "WAV")
- playlist.addTrack(t)
- tracks.append(t)
- }
- let playerVM = PlayerViewModel()
- playerVM.repeatMode = .all
- let entries = playlist.sortedEntries
- playerVM.loadAndPlay(tracks[1], entryID: entries[1].id, playlist: playlist)
- playerVM.syncForTest()
- XCTAssertEqual(playerVM.currentTrack?.title, "Track 2")
- // Next from last track should wrap to first
- playerVM.playNext()
- playerVM.syncForTest()
- XCTAssertEqual(playerVM.currentTrack?.title, "Track 1")
- XCTAssertTrue(playerVM.isPlaying)
- playerVM.stop()
- }
- @MainActor
- func testRepeatOneMode() async throws {
- let urls = try TestHelpers.createTestAudioFiles(count: 2, duration: 1.0)
- let playlist = Playlist(name: "Repeat1 Test")
- var tracks: [Track] = []
- for (i, url) in urls.enumerated() {
- let t = Track(title: "Track \(i+1)", filePath: url.path, duration: 1.0, fileFormat: "WAV")
- playlist.addTrack(t)
- tracks.append(t)
- }
- let playerVM = PlayerViewModel()
- playerVM.repeatMode = .one
- let entries = playlist.sortedEntries
- playerVM.loadAndPlay(tracks[0], entryID: entries[0].id, playlist: playlist)
- playerVM.syncForTest()
- // Next should replay same track
- playerVM.playNext()
- playerVM.syncForTest()
- XCTAssertEqual(playerVM.currentTrack?.title, "Track 1")
- playerVM.stop()
- }
- @MainActor
- func testShuffleMode() async throws {
- let urls = try TestHelpers.createTestAudioFiles(count: 5, duration: 0.5)
- let playlist = Playlist(name: "Shuffle Test")
- var tracks: [Track] = []
- for (i, url) in urls.enumerated() {
- let t = Track(title: "Track \(i+1)", filePath: url.path, duration: 0.5, fileFormat: "WAV")
- playlist.addTrack(t)
- tracks.append(t)
- }
- let playerVM = PlayerViewModel()
- playerVM.shuffleEnabled = true
- let entries = playlist.sortedEntries
- playerVM.loadAndPlay(tracks[0], entryID: entries[0].id, playlist: playlist)
- playerVM.syncForTest()
- let firstTitle = playerVM.currentTrack?.title
- // Next should play a different track (with 5 tracks, very likely)
- playerVM.playNext()
- playerVM.syncForTest()
- XCTAssertTrue(playerVM.isPlaying)
- // Can't guarantee it's different (random), but it should still be a valid track
- XCTAssertNotNil(playerVM.currentTrack)
- playerVM.stop()
- }
- @MainActor
- func testShuffleAndRepeatDefaults() {
- let playerVM = PlayerViewModel()
- XCTAssertFalse(playerVM.shuffleEnabled)
- XCTAssertEqual(playerVM.repeatMode, .off)
- }
- func testSessionExportFlow() throws {
- let outputDir = FileManager.default.temporaryDirectory
- .appendingPathComponent("IntegrationExport", isDirectory: true)
- try FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)
- defer { try? FileManager.default.removeItem(at: outputDir) }
- let playlist = Playlist(name: "Export Flow Test")
- let t1 = Track(title: "Song A", artist: "Art1", filePath: "/tmp/a.mp3", duration: 120, fileFormat: "MP3")
- let t2 = Track(title: "Song B", artist: "Art2", filePath: "/tmp/b.wav", duration: 180, fileFormat: "WAV")
- t1.bpm = 128
- t1.musicalKey = "Am"
- playlist.addTrack(t1)
- playlist.addTrack(t2, crossfadeDuration: 3.0)
- // Export Audition session
- let sesxURL = outputDir.appendingPathComponent("test.sesx")
- var options = ExportOptions.default
- options.copyAudioFiles = false
- try AuditionExporter.export(playlist: playlist, to: sesxURL, options: options)
- let sesx = try String(contentsOf: sesxURL, encoding: .utf8)
- XCTAssertTrue(sesx.contains("Song A"))
- XCTAssertTrue(sesx.contains("Song B"))
- XCTAssertTrue(sesx.contains("<audioClip"))
- XCTAssertTrue(sesx.contains("<masterTrack"))
- // Export with file renaming template
- options.fileNameTemplate = "{track} {artist} - {title}"
- // Can't actually copy since source files don't exist, but verify template integration
- let template = options.fileNameTemplate!
- let name = FileNameTemplate.generate(template: template, track: t1, playlistIndex: 0, totalTracks: 2)
- XCTAssertEqual(name, "01 Art1 - Song A")
- }
- // MARK: - Stitch Flow
- @MainActor
- func testStitchWithMarkersFlow() async throws {
- let outputDir = FileManager.default.temporaryDirectory
- .appendingPathComponent("IntegrationStitch", isDirectory: true)
- try FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)
- defer { try? FileManager.default.removeItem(at: outputDir) }
- let urls = try TestHelpers.createTestAudioFiles(count: 2, duration: 1.5)
- let playlist = Playlist(name: "Stitch Flow")
- for (i, url) in urls.enumerated() {
- let t = Track(title: "Part \(i+1)", artist: "DJ", filePath: url.path, duration: 1.5, fileFormat: "WAV")
- playlist.addTrack(t)
- }
- let outputURL = outputDir.appendingPathComponent("stitched.wav")
- let result = try await AudioStitcher.stitch(playlist: playlist, to: outputURL)
- // Verify markers
- XCTAssertEqual(result.markers.count, 2)
- XCTAssertEqual(result.markers[0].name, "Part 1")
- XCTAssertEqual(result.markers[1].name, "Part 2")
- XCTAssertEqual(result.markers[0].startTime, 0, accuracy: 0.01)
- XCTAssertGreaterThan(result.markers[1].startTime, 1.0)
- // Write all companion files
- let csvURL = outputDir.appendingPathComponent("markers.csv")
- try AudioStitcher.writeAuditionMarkers(result.markers, to: csvURL)
- let csv = try String(contentsOf: csvURL, encoding: .utf8)
- XCTAssertTrue(csv.contains("01. Part 1"))
- XCTAssertTrue(csv.contains("02. Part 2"))
- let listURL = outputDir.appendingPathComponent("tracklist.txt")
- try AudioStitcher.writeTrackList(result.markers, playlistName: "Stitch Flow", to: listURL)
- let list = try String(contentsOf: listURL, encoding: .utf8)
- XCTAssertTrue(list.contains("Total: 2 tracks"))
- }
- // MARK: - Theme Persistence
- func testThemeSkinPersistence() {
- let theme = AppTheme()
- let originalSkin = theme.currentSkin
- theme.currentSkin = .ocean
- XCTAssertEqual(theme.currentSkin, .ocean)
- // Create a new instance — should load saved
- let theme2 = AppTheme()
- XCTAssertEqual(theme2.currentSkin, .ocean)
- // Restore
- theme.currentSkin = originalSkin
- }
- // MARK: - Playlist Config Persistence
- func testPlaylistViewConfigPersistence() {
- let config = PlaylistViewConfig()
- let originalColumns = config.visibleColumns
- config.visibleColumns = [.title, .artist, .duration]
- let config2 = PlaylistViewConfig()
- XCTAssertEqual(config2.visibleColumns, [.title, .artist, .duration])
- // Restore
- config.visibleColumns = originalColumns
- }
- // MARK: - Status Message
- @MainActor
- func testStatusMessageAutoClears() async {
- let vm = PlaylistViewModel()
- vm.showStatus("Integration test", duration: 0.3)
- XCTAssertEqual(vm.statusMessage, "Integration test")
- try? await Task.sleep(for: .seconds(0.5))
- XCTAssertNil(vm.statusMessage)
- }
- // MARK: - Drag to Playlist (Data Flow)
- func testAddTrackToDifferentPlaylist() {
- let source = Playlist(name: "Source")
- let target = Playlist(name: "Target")
- let track = Track(title: "Shared Song", artist: "Artist", filePath: "/t.mp3", duration: 200, fileFormat: "MP3")
- source.addTrack(track)
- // Simulate drag: add same track to target
- target.addTrack(track)
- // Both playlists should have the track
- XCTAssertEqual(source.trackCount, 1)
- XCTAssertEqual(target.trackCount, 1)
- XCTAssertEqual(source.sortedEntries.first?.track?.title, "Shared Song")
- XCTAssertEqual(target.sortedEntries.first?.track?.title, "Shared Song")
- }
- // MARK: - Quick Add & Duplicate Detection
- @MainActor
- func testQuickAddToTarget() {
- let target = Playlist(name: "My Mix")
- let source = Playlist(name: "Source")
- let track = Track(title: "Banger", artist: "DJ", filePath: "/banger.mp3", duration: 200, fileFormat: "MP3")
- source.addTrack(track)
- let vm = PlaylistViewModel()
- vm.targetPlaylist = target
- // First add should succeed
- let added = vm.quickAddToTarget(track: track, context: modelContext)
- // Can't test with real ModelContext in unit test, but verify no crash
- XCTAssertNotNil(vm.targetPlaylist)
- }
- @MainActor
- func testDuplicateDetection() {
- let playlist = Playlist(name: "Mix")
- let track = Track(title: "Song", artist: "Art", filePath: "/song.mp3", duration: 180, fileFormat: "MP3")
- playlist.addTrack(track)
- let vm = PlaylistViewModel()
- let isDup = vm.isDuplicate(track: track, in: playlist)
- XCTAssertTrue(isDup)
- let track2 = Track(title: "Other", filePath: "/other.mp3", duration: 120, fileFormat: "MP3")
- let isDup2 = vm.isDuplicate(track: track2, in: playlist)
- XCTAssertFalse(isDup2)
- }
- @MainActor
- func testQuickAddNoTarget() {
- let vm = PlaylistViewModel()
- vm.targetPlaylist = nil
- let track = Track(title: "T", filePath: "/t.mp3", duration: 100, fileFormat: "MP3")
- let added = vm.quickAddToTarget(track: track, context: modelContext)
- XCTAssertFalse(added)
- XCTAssertNotNil(vm.statusMessage) // Should show error
- }
- @MainActor
- func testTargetPlaylistPersistence() {
- let vm = PlaylistViewModel()
- let playlist = Playlist(name: "Target Test")
- vm.targetPlaylist = playlist
- // Should save to UserDefaults under mixTarget0ID (slot 0)
- let saved = UserDefaults.standard.string(forKey: "mixTarget0ID")
- XCTAssertEqual(saved, playlist.id.uuidString)
- // All three slots should work
- let p2 = Playlist(name: "Mix 2 Test")
- let p3 = Playlist(name: "Mix 3 Test")
- vm.setMixTarget(1, playlist: p2)
- vm.setMixTarget(2, playlist: p3)
- XCTAssertEqual(UserDefaults.standard.string(forKey: "mixTarget1ID"), p2.id.uuidString)
- XCTAssertEqual(UserDefaults.standard.string(forKey: "mixTarget2ID"), p3.id.uuidString)
- XCTAssertEqual(vm.mixTargetName(0), "Target Test")
- XCTAssertEqual(vm.mixTargetName(1), "Mix 2 Test")
- XCTAssertEqual(vm.mixTargetName(2), "Mix 3 Test")
- // Cleanup
- UserDefaults.standard.removeObject(forKey: "mixTarget0ID")
- UserDefaults.standard.removeObject(forKey: "mixTarget1ID")
- UserDefaults.standard.removeObject(forKey: "mixTarget2ID")
- }
- // MARK: - Track Notes
- func testTrackNotes() {
- let track = Track(title: "Noted", filePath: "/n.mp3", duration: 100)
- XCTAssertEqual(track.notes, "")
- track.notes = "Great drop at 1:30, mix with Shook Ones"
- XCTAssertEqual(track.notes, "Great drop at 1:30, mix with Shook Ones")
- }
- // MARK: - Duplicate in addTracks
- @MainActor
- func testAddTracksSkipsDuplicates() {
- let playlist = Playlist(name: "Dedup Test")
- let t1 = Track(title: "A", filePath: "/a.mp3", duration: 60, fileFormat: "MP3")
- let t2 = Track(title: "B", filePath: "/b.mp3", duration: 60, fileFormat: "MP3")
- playlist.addTrack(t1)
- let vm = PlaylistViewModel()
- XCTAssertTrue(vm.isDuplicate(track: t1, in: playlist))
- XCTAssertFalse(vm.isDuplicate(track: t2, in: playlist))
- }
- }
- // MARK: - ModelContext for testing
- extension IntegrationTests {
- @MainActor var modelContext: ModelContext {
- let config = ModelConfiguration(isStoredInMemoryOnly: true)
- let container = try! ModelContainer(for: Track.self, Playlist.self, PlaylistEntry.self, CuePoint.self, PlaylistFolder.self, configurations: config)
- return container.mainContext
- }
- }
- extension PlayerViewModel {
- /// Manually sync state from engine for testing (normally done by timer).
- func syncForTest() {
- let engine = audioEngine
- engine.updateCurrentTime()
- isPlaying = engine.isPlaying
- currentTime = engine.currentTime
- duration = engine.duration
- currentTrack = engine.currentTrack
- }
- }
|