import SwiftData import XCTest @testable import MixBoard // MARK: - Test Helpers /// Creates an in-memory ModelContainer for testing SwiftData operations. func makeInMemoryContainer() throws -> ModelContainer { let config = ModelConfiguration(isStoredInMemoryOnly: true) return try ModelContainer( for: Track.self, CuePoint.self, Playlist.self, PlaylistEntry.self, PlaylistFolder.self, configurations: config ) } // MARK: - PlaylistViewModelTests @MainActor final class PlaylistViewModelTests: XCTestCase { private var container: ModelContainer! private var context: ModelContext! private var vm: PlaylistViewModel! override func setUp() async throws { container = try makeInMemoryContainer() context = ModelContext(container) vm = PlaylistViewModel() } override func tearDown() { // Clean up UserDefaults keys used by mix targets for slot in 0..<3 { UserDefaults.standard.removeObject(forKey: "mixTarget\(slot)ID") } } // MARK: - Initial State func testInitialState() { XCTAssertNil(vm.selectedPlaylist) XCTAssertFalse(vm.showExportSheet) XCTAssertNil(vm.exportError) XCTAssertNil(vm.statusMessage) XCTAssertEqual(vm.defaultCrossfadeDuration, 2.0) } func testInitialMixTargetsNil() { XCTAssertEqual(vm.mixTargets.count, 3) XCTAssertNil(vm.mixTargets[0]) XCTAssertNil(vm.mixTargets[1]) XCTAssertNil(vm.mixTargets[2]) } // MARK: - Mix Targets func testSetMixTargetValidSlot() { let playlist = Playlist(name: "Mix A") context.insert(playlist) try? context.save() vm.setMixTarget(0, playlist: playlist) XCTAssertEqual(vm.mixTargets[0]?.name, "Mix A") XCTAssertEqual(UserDefaults.standard.string(forKey: "mixTarget0ID"), playlist.id.uuidString) } func testSetMixTargetAllSlots() { let p0 = Playlist(name: "Red Mix") let p1 = Playlist(name: "Blue Mix") let p2 = Playlist(name: "Gold Mix") context.insert(p0); context.insert(p1); context.insert(p2) try? context.save() vm.setMixTarget(0, playlist: p0) vm.setMixTarget(1, playlist: p1) vm.setMixTarget(2, playlist: p2) XCTAssertEqual(vm.mixTargets[0]?.name, "Red Mix") XCTAssertEqual(vm.mixTargets[1]?.name, "Blue Mix") XCTAssertEqual(vm.mixTargets[2]?.name, "Gold Mix") } func testSetMixTargetInvalidSlot() { let playlist = Playlist(name: "Test") vm.setMixTarget(-1, playlist: playlist) vm.setMixTarget(3, playlist: playlist) // Should not crash, and mixTargets should remain unchanged XCTAssertNil(vm.mixTargets[0]) XCTAssertNil(vm.mixTargets[1]) XCTAssertNil(vm.mixTargets[2]) } func testSetMixTargetNilClearsUserDefaults() { let playlist = Playlist(name: "Target") context.insert(playlist) try? context.save() vm.setMixTarget(0, playlist: playlist) XCTAssertNotNil(UserDefaults.standard.string(forKey: "mixTarget0ID")) vm.setMixTarget(0, playlist: nil) XCTAssertNil(vm.mixTargets[0]) XCTAssertNil(UserDefaults.standard.string(forKey: "mixTarget0ID")) } func testMixTargetNameWithPlaylist() { let playlist = Playlist(name: "Friday Night") vm.setMixTarget(1, playlist: playlist) XCTAssertEqual(vm.mixTargetName(1), "Friday Night") } func testMixTargetNameWithoutPlaylist() { XCTAssertEqual(vm.mixTargetName(0), "Mix 1") XCTAssertEqual(vm.mixTargetName(1), "Mix 2") XCTAssertEqual(vm.mixTargetName(2), "Mix 3") } func testMixTargetNameInvalidSlot() { XCTAssertEqual(vm.mixTargetName(5), "Mix 6") } func testRestoreTargetPlaylist() { let playlist = Playlist(name: "Saved Target") context.insert(playlist) try? context.save() // Simulate persisted state UserDefaults.standard.set(playlist.id.uuidString, forKey: "mixTarget0ID") vm.restoreTargetPlaylist(from: [playlist]) XCTAssertEqual(vm.mixTargets[0]?.name, "Saved Target") } func testRestoreTargetPlaylistNoMatch() { UserDefaults.standard.set(UUID().uuidString, forKey: "mixTarget0ID") vm.restoreTargetPlaylist(from: []) XCTAssertNil(vm.mixTargets[0]) } // MARK: - Legacy targetPlaylist func testLegacyTargetPlaylist() { let playlist = Playlist(name: "Legacy") vm.targetPlaylist = playlist XCTAssertEqual(vm.mixTargets[0]?.name, "Legacy") XCTAssertEqual(vm.targetPlaylist?.name, "Legacy") } // MARK: - Playlist CRUD func testCreatePlaylist() { let playlist = vm.createPlaylist(name: "New Mix", context: context) XCTAssertEqual(playlist.name, "New Mix") let descriptor = FetchDescriptor() let fetched = try? context.fetch(descriptor) XCTAssertEqual(fetched?.count, 1) XCTAssertEqual(fetched?.first?.name, "New Mix") } func testDeletePlaylist() { let playlist = vm.createPlaylist(name: "To Delete", context: context) vm.selectedPlaylist = playlist vm.deletePlaylist(playlist, context: context) let descriptor = FetchDescriptor() let fetched = try? context.fetch(descriptor) XCTAssertEqual(fetched?.count, 0) XCTAssertNil(vm.selectedPlaylist) } func testDeletePlaylistDoesNotClearOtherSelection() { let p1 = vm.createPlaylist(name: "Keep", context: context) let p2 = vm.createPlaylist(name: "Delete", context: context) vm.selectedPlaylist = p1 vm.deletePlaylist(p2, context: context) XCTAssertEqual(vm.selectedPlaylist?.name, "Keep") } func testRenamePlaylist() { let playlist = vm.createPlaylist(name: "Original", context: context) let originalDate = playlist.dateModified vm.renamePlaylist(playlist, to: "Renamed", context: context) XCTAssertEqual(playlist.name, "Renamed") XCTAssertGreaterThanOrEqual(playlist.dateModified, originalDate) } // MARK: - Track Management func testAddTrackToPlaylist() { let playlist = vm.createPlaylist(name: "Mix", context: context) let track = Track(title: "Song", filePath: "Music/song.mp3") context.insert(track) vm.addTrack(track, to: playlist, context: context) XCTAssertEqual(playlist.trackCount, 1) XCTAssertEqual(playlist.sortedEntries.first?.crossfadeDuration, 2.0) // default } func testAddDuplicateTrackShowsWarning() { let playlist = vm.createPlaylist(name: "Mix", context: context) let track = Track(title: "Song", filePath: "Music/song.mp3") context.insert(track) vm.addTrack(track, to: playlist, context: context) vm.addTrack(track, to: playlist, context: context) // Second add should be rejected (duplicate) XCTAssertEqual(playlist.trackCount, 1) XCTAssertNotNil(vm.statusMessage) } func testAddTracksMultiple() { let playlist = vm.createPlaylist(name: "Mix", context: context) let t1 = Track(title: "A", filePath: "Music/b.mp3") // intentionally out of alphabetical order let t2 = Track(title: "B", filePath: "Music/a.mp3") context.insert(t1); context.insert(t2) vm.addTracks([t1, t2], to: playlist, context: context) XCTAssertEqual(playlist.trackCount, 2) // Should be sorted by filePath let sorted = playlist.sortedEntries XCTAssertEqual(sorted[0].track?.filePath, "Music/a.mp3") XCTAssertEqual(sorted[1].track?.filePath, "Music/b.mp3") } func testAddTracksSkipsDuplicates() { let playlist = vm.createPlaylist(name: "Mix", context: context) let track = Track(title: "Song", filePath: "Music/song.mp3") context.insert(track) vm.addTrack(track, to: playlist, context: context) vm.addTracks([track], to: playlist, context: context) XCTAssertEqual(playlist.trackCount, 1) } func testRemoveEntry() { let playlist = vm.createPlaylist(name: "Mix", context: context) let track = Track(title: "Song", filePath: "Music/song.mp3") context.insert(track) vm.addTrack(track, to: playlist, context: context) let entry = playlist.sortedEntries.first! vm.removeEntry(entry, from: playlist, context: context) XCTAssertEqual(playlist.trackCount, 0) } func testMoveEntry() { let playlist = vm.createPlaylist(name: "Mix", context: context) let t1 = Track(title: "A", filePath: "Music/a.mp3") let t2 = Track(title: "B", filePath: "Music/b.mp3") let t3 = Track(title: "C", filePath: "Music/c.mp3") context.insert(t1); context.insert(t2); context.insert(t3) playlist.addTrack(t1); playlist.addTrack(t2); playlist.addTrack(t3) vm.moveEntry(in: playlist, from: 2, to: 0, context: context) let sorted = playlist.sortedEntries XCTAssertEqual(sorted[0].track?.title, "C") XCTAssertEqual(sorted[1].track?.title, "A") XCTAssertEqual(sorted[2].track?.title, "B") } // MARK: - Quick Add func testQuickAddToMixNoTarget() { let track = Track(title: "Song", filePath: "Music/song.mp3") context.insert(track) let result = vm.quickAddToMix(slot: 0, track: track, context: context) XCTAssertFalse(result) XCTAssertNotNil(vm.statusMessage) } func testQuickAddToMixSuccess() { let playlist = vm.createPlaylist(name: "Target", context: context) let track = Track(title: "Song", filePath: "Music/song.mp3") context.insert(track) vm.setMixTarget(0, playlist: playlist) let result = vm.quickAddToMix(slot: 0, track: track, context: context) XCTAssertTrue(result) XCTAssertEqual(playlist.trackCount, 1) } func testQuickAddToTargetLegacy() { let playlist = vm.createPlaylist(name: "Target", context: context) let track = Track(title: "Song", filePath: "Music/song.mp3") context.insert(track) vm.targetPlaylist = playlist let result = vm.quickAddToTarget(track: track, context: context) XCTAssertTrue(result) XCTAssertEqual(playlist.trackCount, 1) } func testQuickAddDuplicate() { let playlist = vm.createPlaylist(name: "Target", context: context) let track = Track(title: "Song", filePath: "Music/song.mp3") context.insert(track) vm.setMixTarget(0, playlist: playlist) _ = vm.quickAddToMix(slot: 0, track: track, context: context) let result2 = vm.quickAddToMix(slot: 0, track: track, context: context) XCTAssertFalse(result2) XCTAssertEqual(playlist.trackCount, 1) } // MARK: - Crossfade & Gain func testUpdateCrossfade() { let playlist = vm.createPlaylist(name: "Mix", context: context) let track = Track(title: "Song", filePath: "Music/song.mp3") context.insert(track) vm.addTrack(track, to: playlist, context: context) let entry = playlist.sortedEntries.first! vm.updateCrossfade(for: entry, duration: 5.0, context: context) XCTAssertEqual(entry.crossfadeDuration, 5.0) } func testUpdateGain() { let playlist = vm.createPlaylist(name: "Mix", context: context) let track = Track(title: "Song", filePath: "Music/song.mp3") context.insert(track) vm.addTrack(track, to: playlist, context: context) let entry = playlist.sortedEntries.first! vm.updateGain(for: entry, gain: 3.5, context: context) XCTAssertEqual(entry.gainAdjustment, 3.5) } // MARK: - Cue Points func testAddCuePoint() { let track = Track(title: "Song", filePath: "Music/song.mp3") context.insert(track) vm.addCuePoint(to: track, at: 30.0, type: .drop, name: "The Drop", context: context) XCTAssertEqual(track.cuePoints.count, 1) XCTAssertEqual(track.cuePoints.first?.name, "The Drop") XCTAssertEqual(track.cuePoints.first?.type, .drop) XCTAssertEqual(track.cuePoints.first?.timestamp, 30.0) } func testRemoveCuePoint() { let track = Track(title: "Song", filePath: "Music/song.mp3") context.insert(track) vm.addCuePoint(to: track, at: 10.0, name: "Marker", context: context) XCTAssertEqual(track.cuePoints.count, 1) let cuePoint = track.cuePoints.first! vm.removeCuePoint(cuePoint, from: track, context: context) XCTAssertEqual(track.cuePoints.count, 0) } // MARK: - Mix Duration func testMixDurationEmpty() { let playlist = Playlist(name: "Empty") XCTAssertEqual(vm.mixDuration(for: playlist), 0) } func testMixDurationSingleTrack() { let playlist = Playlist(name: "Mix") let track = Track(title: "Song", filePath: "Music/song.mp3", duration: 200) playlist.addTrack(track) XCTAssertEqual(vm.mixDuration(for: playlist), 200, accuracy: 0.01) } func testMixDurationWithCrossfades() { let playlist = Playlist(name: "Mix") let t1 = Track(title: "A", filePath: "Music/a.mp3", duration: 200) let t2 = Track(title: "B", filePath: "Music/b.mp3", duration: 180) let t3 = Track(title: "C", filePath: "Music/c.mp3", duration: 240) playlist.addTrack(t1, crossfadeDuration: 0) playlist.addTrack(t2, crossfadeDuration: 5) playlist.addTrack(t3, crossfadeDuration: 3) // total = 200 + 180 + 240 - 5 - 3 = 612 XCTAssertEqual(vm.mixDuration(for: playlist), 612, accuracy: 0.01) } // MARK: - Show Status func testShowStatus() { vm.showStatus("Test message") XCTAssertEqual(vm.statusMessage, "Test message") } func testShowStatusNilInitially() { XCTAssertNil(vm.statusMessage) } // MARK: - isDuplicate func testIsDuplicateWithContext() { let playlist = vm.createPlaylist(name: "Mix", context: context) let track = Track(title: "Song", filePath: "Music/song.mp3") context.insert(track) XCTAssertFalse(vm.isDuplicate(track: track, in: playlist, context: context)) vm.addTrack(track, to: playlist, context: context) XCTAssertTrue(vm.isDuplicate(track: track, in: playlist, context: context)) } func testIsDuplicateWithoutContext() { let playlist = Playlist(name: "Mix") let track = Track(title: "Song", filePath: "Music/song.mp3") XCTAssertFalse(vm.isDuplicate(track: track, in: playlist)) playlist.addTrack(track) XCTAssertTrue(vm.isDuplicate(track: track, in: playlist)) } }