import XCTest import SwiftUI @testable import MixBoard // MARK: - Model Tests final class ModelTests: XCTestCase { func testTrackInit() { let track = Track( title: "Test Song", artist: "Test Artist", filePath: "Music/test.mp3", fileName: "test.mp3", duration: 180 ) XCTAssertEqual(track.title, "Test Song") XCTAssertEqual(track.artist, "Test Artist") XCTAssertEqual(track.fileName, "test.mp3") XCTAssertEqual(track.formattedDuration, "3:00") XCTAssertFalse(track.isAnalyzed) XCTAssertNil(track.bpm) } func testTrackDefaults() { let track = Track(title: "Minimal", filePath: "Music/minimal.wav") XCTAssertEqual(track.artist, "") XCTAssertEqual(track.album, "") XCTAssertEqual(track.genre, "") XCTAssertEqual(track.playCount, 0) XCTAssertEqual(track.rating, 0) XCTAssertNil(track.lastPlayed) XCTAssertNil(track.color) XCTAssertNil(track.waveformData) XCTAssertEqual(track.notes, "") XCTAssertTrue(track.cuePoints.isEmpty) } func testTrackFormattedBPM() { let track = Track(title: "Song", filePath: "Music/song.mp3") XCTAssertEqual(track.formattedBPM, "—") track.bpm = 128.5 XCTAssertEqual(track.formattedBPM, "128.5") } func testTrackFormattedDurationEdgeCases() { let track0 = Track(title: "Zero", filePath: "Music/z.mp3", duration: 0) XCTAssertEqual(track0.formattedDuration, "0:00") let trackLong = Track(title: "Long", filePath: "Music/l.mp3", duration: 3661) XCTAssertEqual(trackLong.formattedDuration, "61:01") } func testFileNameExtraction() { let track = Track(title: "Song", filePath: "Music/subfolder/Artist - Song.flac") XCTAssertEqual(track.fileName, "Artist - Song.flac") } func testFileNameExplicit() { let track = Track(title: "Song", filePath: "Music/song.mp3", fileName: "custom.mp3") XCTAssertEqual(track.fileName, "custom.mp3") } func testTrackFileURL() { let track = Track(title: "Song", filePath: "Music/test.mp3") let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! XCTAssertEqual(track.fileURL, docs.appendingPathComponent("Music/test.mp3")) } } // MARK: - Playlist Tests final class PlaylistTests: XCTestCase { func testPlaylistInit() { let playlist = Playlist(name: "Friday Mix") XCTAssertEqual(playlist.name, "Friday Mix") XCTAssertEqual(playlist.color, "#2196F3") XCTAssertEqual(playlist.trackCount, 0) XCTAssertNil(playlist.targetBPM) XCTAssertTrue(playlist.entries.isEmpty) XCTAssertNil(playlist.folder) } func testPlaylistAddTrack() { let playlist = Playlist(name: "My Mix") let track = Track(title: "Song", filePath: "Music/song.mp3") XCTAssertEqual(playlist.trackCount, 0) playlist.addTrack(track, crossfadeDuration: 2.0) XCTAssertEqual(playlist.trackCount, 1) let entry = playlist.sortedEntries.first XCTAssertEqual(entry?.position, 0) XCTAssertEqual(entry?.crossfadeDuration, 2.0) } func testPlaylistAddMultipleTracks() { let playlist = Playlist(name: "Mix") let t1 = Track(title: "First", filePath: "Music/first.mp3", duration: 200) let t2 = Track(title: "Second", filePath: "Music/second.mp3", duration: 180) let t3 = Track(title: "Third", filePath: "Music/third.mp3", duration: 240) playlist.addTrack(t1) playlist.addTrack(t2) playlist.addTrack(t3) XCTAssertEqual(playlist.trackCount, 3) let sorted = playlist.sortedEntries XCTAssertEqual(sorted[0].track?.title, "First") XCTAssertEqual(sorted[1].track?.title, "Second") XCTAssertEqual(sorted[2].track?.title, "Third") XCTAssertEqual(sorted[0].position, 0) XCTAssertEqual(sorted[1].position, 1) XCTAssertEqual(sorted[2].position, 2) } func testPlaylistTotalDuration() { 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) playlist.addTrack(t1) playlist.addTrack(t2) XCTAssertEqual(playlist.totalDuration, 380, accuracy: 0.01) } func testPlaylistFormattedTotalDuration() { let playlist = Playlist(name: "Mix") playlist.addTrack(Track(title: "A", filePath: "Music/a.mp3", duration: 3700)) XCTAssertTrue(playlist.formattedTotalDuration.contains(":")) } func testPlaylistRemoveEntry() { let playlist = Playlist(name: "Mix") 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") playlist.addTrack(t1) playlist.addTrack(t2) playlist.addTrack(t3) playlist.removeEntry(at: 1) // Remove "B" XCTAssertEqual(playlist.trackCount, 2) let sorted = playlist.sortedEntries XCTAssertEqual(sorted[0].track?.title, "A") XCTAssertEqual(sorted[1].track?.title, "C") // Positions should be re-indexed XCTAssertEqual(sorted[0].position, 0) XCTAssertEqual(sorted[1].position, 1) } func testPlaylistMoveEntry() { let playlist = Playlist(name: "Mix") 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") playlist.addTrack(t1) playlist.addTrack(t2) playlist.addTrack(t3) // Move C from position 2 to position 0 playlist.moveEntry(from: 2, to: 0) let sorted = playlist.sortedEntries XCTAssertEqual(sorted[0].track?.title, "C") XCTAssertEqual(sorted[1].track?.title, "A") XCTAssertEqual(sorted[2].track?.title, "B") } func testPlaylistDateModifiedUpdates() { let playlist = Playlist(name: "Mix") let originalDate = playlist.dateModified // Small delay to ensure date changes let track = Track(title: "Song", filePath: "Music/song.mp3") playlist.addTrack(track) XCTAssertGreaterThanOrEqual(playlist.dateModified, originalDate) } } // MARK: - PlaylistEntry Tests final class PlaylistEntryTests: XCTestCase { func testEntryEffectiveDuration() { let track = Track(title: "Song", filePath: "Music/song.mp3", duration: 300) let entry = PlaylistEntry(position: 0, track: track) XCTAssertEqual(entry.effectiveDuration, 300, accuracy: 0.01) } func testEntryEffectiveDurationWithOffsets() { let track = Track(title: "Song", filePath: "Music/song.mp3", duration: 300) let entry = PlaylistEntry(position: 0, track: track, startOffset: 10, endOffset: 280) XCTAssertEqual(entry.effectiveDuration, 270, accuracy: 0.01) } func testEntryEffectiveDurationNoTrack() { let entry = PlaylistEntry(position: 0, track: nil) XCTAssertEqual(entry.effectiveDuration, 0) } func testEntryDefaults() { let entry = PlaylistEntry(position: 5, track: nil) XCTAssertEqual(entry.position, 5) XCTAssertEqual(entry.crossfadeDuration, 0) XCTAssertEqual(entry.startOffset, 0) XCTAssertEqual(entry.endOffset, 0) XCTAssertEqual(entry.gainAdjustment, 0) XCTAssertEqual(entry.notes, "") } } // MARK: - CuePoint Tests final class CuePointTests: XCTestCase { func testCuePointFormatTime() { XCTAssertEqual(CuePoint.formatTime(0), "00:00.000") XCTAssertEqual(CuePoint.formatTime(125.456), "02:05.456") XCTAssertEqual(CuePoint.formatTime(59.999), "00:59.999") } func testCuePointInit() { let cue = CuePoint(name: "Drop", timestamp: 45.5, type: .drop) XCTAssertEqual(cue.name, "Drop") XCTAssertEqual(cue.timestamp, 45.5) XCTAssertEqual(cue.type, .drop) XCTAssertFalse(cue.isRegion) } func testCuePointRegion() { let cue = CuePoint(name: "Verse", timestamp: 30, endTimestamp: 90, type: .verse) XCTAssertTrue(cue.isRegion) XCTAssertEqual(cue.endTimestamp, 90) } func testCuePointComparable() { let a = CuePoint(timestamp: 10) let b = CuePoint(timestamp: 20) let c = CuePoint(timestamp: 5) XCTAssertTrue(c < a) XCTAssertTrue(a < b) let sorted = [b, a, c].sorted() XCTAssertEqual(sorted.map(\.timestamp), [5, 10, 20]) } func testCuePointTypes() { XCTAssertEqual(CuePointType.allCases.count, 11) XCTAssertEqual(CuePointType.marker.rawValue, "Marker") XCTAssertEqual(CuePointType.fadeOut.rawValue, "Fade Out") } } // MARK: - PlaylistFolder Tests final class PlaylistFolderTests: XCTestCase { func testFolderInit() { let folder = PlaylistFolder(name: "My Mixes") XCTAssertEqual(folder.name, "My Mixes") XCTAssertTrue(folder.isExpanded) XCTAssertTrue(folder.playlists.isEmpty) XCTAssertEqual(folder.totalTrackCount, 0) } } // MARK: - AppState Tests final class AppStateTests: XCTestCase { override func tearDown() { // Clean up UserDefaults UserDefaults.standard.removeObject(forKey: "appState.lastPlaylistID") UserDefaults.standard.removeObject(forKey: "appState.lastEntryID") UserDefaults.standard.removeObject(forKey: "appState.lastTrackFilePath") UserDefaults.standard.removeObject(forKey: "appState.lastPlaybackTime") } func testSaveAndLoadPlaylistID() { let id = UUID() AppState.saveLastPlaylist(id: id) XCTAssertEqual(AppState.lastPlaylistID, id) } func testSaveAndLoadEntryID() { let id = UUID() AppState.saveLastEntry(id: id) XCTAssertEqual(AppState.lastEntryID, id) } func testSaveAndLoadTrackFilePath() { AppState.saveLastTrack(filePath: "Music/test.mp3") XCTAssertEqual(AppState.lastTrackFilePath, "Music/test.mp3") } func testSaveAndLoadPlaybackTime() { AppState.savePlaybackTime(42.5) XCTAssertEqual(AppState.lastPlaybackTime, 42.5, accuracy: 0.01) } func testSavePlaybackStateAll() { let playlistID = UUID() let entryID = UUID() AppState.savePlaybackState( playlistID: playlistID, entryID: entryID, trackFilePath: "Music/combined.flac", playbackTime: 99.9 ) XCTAssertEqual(AppState.lastPlaylistID, playlistID) XCTAssertEqual(AppState.lastEntryID, entryID) XCTAssertEqual(AppState.lastTrackFilePath, "Music/combined.flac") XCTAssertEqual(AppState.lastPlaybackTime, 99.9, accuracy: 0.01) } func testDefaultsAreNil() { XCTAssertNil(AppState.lastPlaylistID) XCTAssertNil(AppState.lastEntryID) } } // MARK: - Sync Encoding/Decoding Tests final class SyncTests: XCTestCase { func testSyncPayloadRoundTrip() throws { let playlist = Playlist(name: "Test Mix", notes: "Great tracks", color: "#FF5722") playlist.targetBPM = 128.0 let t1 = Track(title: "Song A", artist: "Artist A", filePath: "Music/a.mp3", fileName: "a.mp3", duration: 200) t1.bpm = 126 t1.musicalKey = "Am" let t2 = Track(title: "Song B", artist: "Artist B", filePath: "Music/b.flac", fileName: "b.flac", duration: 180) playlist.addTrack(t1, crossfadeDuration: 3.0) playlist.addTrack(t2, crossfadeDuration: 2.0) let syncPlaylist = SyncPlaylist(from: playlist) let payload = SyncPayload(version: 1, exportedAt: Date(), exportedFrom: "Test", playlists: [syncPlaylist]) let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 encoder.outputFormatting = .prettyPrinted let data = try encoder.encode(payload) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 let decoded = try decoder.decode(SyncPayload.self, from: data) XCTAssertEqual(decoded.version, 1) XCTAssertEqual(decoded.playlists.count, 1) let dp = decoded.playlists[0] XCTAssertEqual(dp.name, "Test Mix") XCTAssertEqual(dp.notes, "Great tracks") XCTAssertEqual(dp.color, "#FF5722") XCTAssertEqual(dp.targetBPM, 128.0) XCTAssertEqual(dp.entries.count, 2) XCTAssertEqual(dp.entries[0].filename, "a.mp3") XCTAssertEqual(dp.entries[0].title, "Song A") XCTAssertEqual(dp.entries[0].artist, "Artist A") XCTAssertEqual(dp.entries[0].bpm, 126) XCTAssertEqual(dp.entries[0].musicalKey, "Am") XCTAssertEqual(dp.entries[0].crossfadeDuration, 3.0) XCTAssertEqual(dp.entries[1].filename, "b.flac") XCTAssertEqual(dp.entries[1].title, "Song B") } func testSyncEmptyPlaylist() throws { let playlist = Playlist(name: "Empty") let sp = SyncPlaylist(from: playlist) XCTAssertTrue(sp.entries.isEmpty) let payload = SyncPayload(version: 1, exportedAt: Date(), exportedFrom: "iPhone", playlists: [sp]) let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 let data = try encoder.encode(payload) let decoded = try JSONDecoder().apply { $0.dateDecodingStrategy = .iso8601 }.decode(SyncPayload.self, from: data) XCTAssertEqual(decoded.playlists[0].entries.count, 0) } func testSyncEntryWithNoTrack() { let entry = PlaylistEntry(position: 0, track: nil, notes: "Missing track") let syncEntry = SyncEntry(from: entry) XCTAssertEqual(syncEntry.filename, "unknown") XCTAssertEqual(syncEntry.title, "Unknown") XCTAssertEqual(syncEntry.notes, "Missing track") } func testSyncMultiplePlaylists() throws { let p1 = Playlist(name: "Mix 1") let p2 = Playlist(name: "Mix 2") p1.addTrack(Track(title: "Song", filePath: "Music/s.mp3")) let payload = SyncPayload( version: 1, exportedAt: Date(), exportedFrom: "iPad", playlists: [SyncPlaylist(from: p1), SyncPlaylist(from: p2)] ) let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 let data = try encoder.encode(payload) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 let decoded = try decoder.decode(SyncPayload.self, from: data) XCTAssertEqual(decoded.playlists.count, 2) XCTAssertEqual(decoded.exportedFrom, "iPad") } } // MARK: - MetadataService Tests final class MetadataServiceTests: XCTestCase { func testSupportedFormats() { let supported = ["mp3", "wav", "aif", "aiff", "flac", "m4a", "aac", "caf", "alac", "ogg"] for ext in supported { XCTAssertTrue(MetadataService.isSupportedAudioFile(URL(fileURLWithPath: "/test.\(ext)")), "\(ext) should be supported") } } func testUnsupportedFormats() { let unsupported = ["txt", "jpg", "pdf", "mp4", "wma", "doc", "zip"] for ext in unsupported { XCTAssertFalse(MetadataService.isSupportedAudioFile(URL(fileURLWithPath: "/test.\(ext)")), "\(ext) should not be supported") } } func testCaseInsensitive() { XCTAssertTrue(MetadataService.isSupportedAudioFile(URL(fileURLWithPath: "/test.MP3"))) XCTAssertTrue(MetadataService.isSupportedAudioFile(URL(fileURLWithPath: "/test.Flac"))) XCTAssertTrue(MetadataService.isSupportedAudioFile(URL(fileURLWithPath: "/test.WAV"))) } } // MARK: - AppTheme Tests final class AppThemeTests: XCTestCase { func testDefaultSkin() { let theme = AppTheme() XCTAssertNotNil(theme.currentSkin) } func testWinampSkin() { let theme = AppTheme() theme.currentSkin = .winamp XCTAssertTrue(theme.useDarkMode) XCTAssertEqual(theme.preferredColorScheme, .dark) XCTAssertEqual(theme.cornerRadius, 4) } func testFoobarSkin() { let theme = AppTheme() theme.currentSkin = .foobarLight XCTAssertFalse(theme.useDarkMode) XCTAssertEqual(theme.preferredColorScheme, .light) XCTAssertEqual(theme.cornerRadius, 6) } func testFoobarDarkSkin() { let theme = AppTheme() theme.currentSkin = .foobarDark XCTAssertTrue(theme.useDarkMode) XCTAssertEqual(theme.preferredColorScheme, .dark) } func testVinylSkin() { let theme = AppTheme() theme.currentSkin = .vinyl XCTAssertTrue(theme.useDarkMode) XCTAssertEqual(theme.preferredColorScheme, .dark) } func testObsidianSkin() { let theme = AppTheme() theme.currentSkin = .obsidian XCTAssertTrue(theme.useDarkMode) XCTAssertEqual(theme.cornerRadius, 12) } func testWmpSkin() { let theme = AppTheme() theme.currentSkin = .wmp XCTAssertTrue(theme.useDarkMode) XCTAssertEqual(theme.cornerRadius, 8) } func testSkinCount() { XCTAssertEqual(AppTheme.Skin.allCases.count, 7) } func testSkinPersistence() { let theme = AppTheme() theme.currentSkin = .foobarLight XCTAssertEqual(UserDefaults.standard.string(forKey: "appThemeSkin"), "foobar Light") theme.currentSkin = .winamp XCTAssertEqual(UserDefaults.standard.string(forKey: "appThemeSkin"), "Winamp") } } // MARK: - Color Extension Tests final class ColorTests: XCTestCase { func testValidHexColors() { XCTAssertNotNil(Color(hex: "#FF0000")) XCTAssertNotNil(Color(hex: "00FF00")) XCTAssertNotNil(Color(hex: "#2196F3")) } func testInvalidHexColors() { XCTAssertNil(Color(hex: "")) XCTAssertNil(Color(hex: "XYZ")) XCTAssertNil(Color(hex: "#12345")) // 5 chars } } // MARK: - Helper extension JSONDecoder { func apply(_ configure: (JSONDecoder) -> Void) -> JSONDecoder { configure(self) return self } }