import XCTest import SwiftData @testable import MixBoard /// Tests for the Track model. final class TrackModelTests: XCTestCase { func testTrackCreation() { let track = Track( title: "Test Song", artist: "Test Artist", album: "Test Album", genre: "Rock", filePath: "/tmp/test.mp3", duration: 180, sampleRate: 44100, bitDepth: 16, channels: 2, fileFormat: "MP3", fileSizeBytes: 5_000_000 ) XCTAssertEqual(track.title, "Test Song") XCTAssertEqual(track.artist, "Test Artist") XCTAssertEqual(track.album, "Test Album") XCTAssertEqual(track.genre, "Rock") XCTAssertEqual(track.duration, 180) XCTAssertEqual(track.sampleRate, 44100) XCTAssertEqual(track.bitDepth, 16) XCTAssertEqual(track.channels, 2) XCTAssertEqual(track.fileFormat, "MP3") XCTAssertEqual(track.fileSizeBytes, 5_000_000) XCTAssertEqual(track.rating, 0) XCTAssertEqual(track.playCount, 0) XCTAssertFalse(track.isAnalyzed) XCTAssertNil(track.bpm) XCTAssertNil(track.musicalKey) XCTAssertTrue(track.cuePoints.isEmpty) } func testFormattedDuration() { let track = Track(title: "T", filePath: "/t", duration: 185) XCTAssertEqual(track.formattedDuration, "3:05") let track2 = Track(title: "T", filePath: "/t", duration: 60) XCTAssertEqual(track2.formattedDuration, "1:00") let track3 = Track(title: "T", filePath: "/t", duration: 0) XCTAssertEqual(track3.formattedDuration, "0:00") } func testFormattedBPM() { let track = Track(title: "T", filePath: "/t") XCTAssertEqual(track.formattedBPM, "—") track.bpm = 128.5 XCTAssertEqual(track.formattedBPM, "128.5") } func testFileURL() { let track = Track(title: "T", filePath: "/Users/test/music/song.mp3") XCTAssertEqual(track.fileURL.path, "/Users/test/music/song.mp3") } func testFormattedFileSize() { let track = Track(title: "T", filePath: "/t", fileSizeBytes: 1_048_576) let formatted = track.formattedFileSize // Should be approximately "1 MB" XCTAssertTrue(formatted.contains("MB") || formatted.contains("1")) } } /// Tests for the CuePoint model. final class CuePointModelTests: XCTestCase { func testCuePointCreation() { let cue = CuePoint(name: "Drop", timestamp: 30.5, type: .drop) XCTAssertEqual(cue.name, "Drop") XCTAssertEqual(cue.timestamp, 30.5) XCTAssertEqual(cue.type, .drop) XCTAssertNil(cue.endTimestamp) XCTAssertFalse(cue.isRegion) } func testCuePointRegion() { let cue = CuePoint(name: "Verse", timestamp: 10, endTimestamp: 40, type: .verse) XCTAssertTrue(cue.isRegion) XCTAssertEqual(cue.endTimestamp, 40) } func testCuePointComparable() { let c1 = CuePoint(timestamp: 10) let c2 = CuePoint(timestamp: 20) let c3 = CuePoint(timestamp: 5) let sorted = [c1, c2, c3].sorted() XCTAssertEqual(sorted[0].timestamp, 5) XCTAssertEqual(sorted[1].timestamp, 10) XCTAssertEqual(sorted[2].timestamp, 20) } func testFormattedTimestamp() { let cue = CuePoint(timestamp: 65.123) XCTAssertEqual(cue.formattedTimestamp, "01:05.123") } func testAllCuePointTypes() { XCTAssertEqual(CuePointType.allCases.count, 11) XCTAssertEqual(CuePointType.marker.rawValue, "Marker") XCTAssertEqual(CuePointType.drop.rawValue, "Drop") XCTAssertEqual(CuePointType.fadeOut.rawValue, "Fade Out") } } /// Tests for the Playlist model. final class PlaylistModelTests: XCTestCase { func testPlaylistCreation() { let pl = Playlist(name: "My Mix") XCTAssertEqual(pl.name, "My Mix") XCTAssertTrue(pl.entries.isEmpty) XCTAssertEqual(pl.trackCount, 0) XCTAssertEqual(pl.totalDuration, 0) XCTAssertNil(pl.targetBPM) } func testAddTrack() { let pl = Playlist(name: "Mix") let track = Track(title: "Song", filePath: "/t", duration: 120) pl.addTrack(track, crossfadeDuration: 2.0) XCTAssertEqual(pl.trackCount, 1) XCTAssertEqual(pl.entries.count, 1) XCTAssertEqual(pl.entries.first?.track?.title, "Song") XCTAssertEqual(pl.entries.first?.crossfadeDuration, 2.0) XCTAssertEqual(pl.entries.first?.position, 0) } func testSortedEntries() { let pl = Playlist(name: "Mix") let t1 = Track(title: "First", filePath: "/1", duration: 60) let t2 = Track(title: "Second", filePath: "/2", duration: 90) let t3 = Track(title: "Third", filePath: "/3", duration: 45) pl.addTrack(t1) pl.addTrack(t2) pl.addTrack(t3) let sorted = pl.sortedEntries XCTAssertEqual(sorted.count, 3) XCTAssertEqual(sorted[0].track?.title, "First") XCTAssertEqual(sorted[1].track?.title, "Second") XCTAssertEqual(sorted[2].track?.title, "Third") } func testFormattedTotalDuration() { let pl = Playlist(name: "Mix") pl.addTrack(Track(title: "A", filePath: "/a", duration: 3661)) let formatted = pl.formattedTotalDuration XCTAssertEqual(formatted, "1:01:01") } func testFormattedTotalDurationShort() { let pl = Playlist(name: "Mix") pl.addTrack(Track(title: "A", filePath: "/a", duration: 125)) XCTAssertEqual(pl.formattedTotalDuration, "2:05") } } /// Tests for PlaylistFolder model. final class PlaylistFolderTests: XCTestCase { func testFolderCreation() { let folder = PlaylistFolder(name: "Hip-Hop") XCTAssertEqual(folder.name, "Hip-Hop") XCTAssertTrue(folder.playlists.isEmpty) XCTAssertTrue(folder.isExpanded) XCTAssertEqual(folder.totalTrackCount, 0) } func testFolderWithPlaylists() { let folder = PlaylistFolder(name: "Mixes") let pl1 = Playlist(name: "Mix 1") let pl2 = Playlist(name: "Mix 2") pl1.addTrack(Track(title: "A", filePath: "/a", duration: 60)) pl2.addTrack(Track(title: "B", filePath: "/b", duration: 90)) pl2.addTrack(Track(title: "C", filePath: "/c", duration: 45)) pl1.folder = folder pl2.folder = folder folder.playlists = [pl1, pl2] XCTAssertEqual(folder.playlists.count, 2) XCTAssertEqual(folder.totalTrackCount, 3) } func testPlaylistFolderAssignment() { let folder = PlaylistFolder(name: "Test") let pl = Playlist(name: "My Playlist") XCTAssertNil(pl.folder) pl.folder = folder XCTAssertNotNil(pl.folder) XCTAssertEqual(pl.folder?.name, "Test") // Remove from folder pl.folder = nil XCTAssertNil(pl.folder) } func testFolderExpanded() { let folder = PlaylistFolder(name: "F") XCTAssertTrue(folder.isExpanded) folder.isExpanded = false XCTAssertFalse(folder.isExpanded) } } /// Tests for PlaylistEntry model. final class PlaylistEntryModelTests: XCTestCase { func testEffectiveDuration() { let track = Track(title: "T", filePath: "/t", duration: 180) let entry = PlaylistEntry(position: 0, track: track, startOffset: 10, endOffset: 170) XCTAssertEqual(entry.effectiveDuration, 160) } func testEffectiveDurationNoOffsets() { let track = Track(title: "T", filePath: "/t", duration: 180) let entry = PlaylistEntry(position: 0, track: track) XCTAssertEqual(entry.effectiveDuration, 180) } func testGainDefaults() { let entry = PlaylistEntry(position: 0, track: nil) XCTAssertEqual(entry.gainAdjustment, 0) XCTAssertEqual(entry.crossfadeDuration, 0) } } /// Tests for PlaylistViewConfig. final class PlaylistViewConfigTests: XCTestCase { func testDefaultColumns() { let defaultCols = PlaylistViewConfig.defaultColumns XCTAssertTrue(defaultCols.contains(.title)) XCTAssertTrue(defaultCols.contains(.artist)) XCTAssertTrue(defaultCols.contains(.duration)) } func testToggleColumn() { let config = PlaylistViewConfig() let initialCount = config.visibleColumns.count // Toggle off a column that exists if config.isColumnVisible(.title) { config.toggleColumn(.title) XCTAssertFalse(config.isColumnVisible(.title)) XCTAssertEqual(config.visibleColumns.count, initialCount - 1) } // Toggle it back on config.toggleColumn(.title) XCTAssertTrue(config.isColumnVisible(.title)) } func testResetToDefaults() { let config = PlaylistViewConfig() config.visibleColumns = [.title] config.showArtwork = false config.resetToDefaults() XCTAssertEqual(config.visibleColumns, PlaylistViewConfig.defaultColumns) XCTAssertTrue(config.showArtwork) } func testArtworkSizes() { XCTAssertEqual(PlaylistViewConfig.ArtworkSize.small.points, 32) XCTAssertEqual(PlaylistViewConfig.ArtworkSize.medium.points, 48) XCTAssertEqual(PlaylistViewConfig.ArtworkSize.large.points, 64) } } /// Tests for GroupTemplateResolver. final class GroupTemplateResolverTests: XCTestCase { func testEmptyTemplateReturnsEmpty() { let track = Track(title: "Test", artist: "Artist", album: "Album", filePath: "/music/test.mp3") let result = GroupTemplateResolver.resolve(template: "", for: track) XCTAssertEqual(result, "") } func testAlbumDateTemplate() { let track = Track(title: "Test", artist: "Artist", album: "My Album", filePath: "/music/test.mp3") track.year = 1995 let result = GroupTemplateResolver.resolve(template: "{Album} ({Date})", for: track) XCTAssertEqual(result, "My Album (1995)") } func testAlbumDateTemplateNoYear() { let track = Track(title: "Test", artist: "Artist", album: "My Album", filePath: "/music/test.mp3") let result = GroupTemplateResolver.resolve(template: "{Album} ({Date})", for: track) // No year → empty brackets cleaned up XCTAssertEqual(result, "My Album") } func testArtistAlbumTemplate() { let track = Track(title: "Song", artist: "Fagner", album: "Manera Fru Fru", filePath: "/music/test.mp3") let result = GroupTemplateResolver.resolve(template: "{Artist} — {Album}", for: track) XCTAssertEqual(result, "Fagner — Manera Fru Fru") } func testUnknownFallbacks() { let track = Track(title: "Test", filePath: "/music/test.mp3") let result = GroupTemplateResolver.resolve(template: "{Artist}", for: track) XCTAssertEqual(result, "Unknown Artist") } func testFolderPlaceholder() { let track = Track(title: "Test", filePath: "/music/batch1/test.mp3") let result = GroupTemplateResolver.resolve(template: "{Folder}", for: track) XCTAssertEqual(result, "batch1") } func testPresetsExist() { XCTAssertTrue(GroupTemplateResolver.presets.count >= 9) XCTAssertEqual(GroupTemplateResolver.presets.first?.template, "") } } /// Tests for AppTheme. final class AppThemeTests: XCTestCase { private var savedSkinRaw: String? override func setUp() { super.setUp() // Save current skin so tests don't pollute user preferences savedSkinRaw = UserDefaults.standard.string(forKey: "appThemeSkin") } override func tearDown() { // Restore original skin if let raw = savedSkinRaw { UserDefaults.standard.set(raw, forKey: "appThemeSkin") } else { UserDefaults.standard.removeObject(forKey: "appThemeSkin") } super.tearDown() } func testDefaultSkin() { let theme = AppTheme() XCTAssertTrue(AppTheme.Skin.allCases.contains(theme.currentSkin)) } func testSkinSwitch() { let theme = AppTheme() theme.currentSkin = .ocean XCTAssertEqual(theme.currentSkin, .ocean) theme.currentSkin = .warm XCTAssertEqual(theme.currentSkin, .warm) theme.currentSkin = .winampClassic XCTAssertEqual(theme.currentSkin, .winampClassic) theme.currentSkin = .foobarDark XCTAssertEqual(theme.currentSkin, .foobarDark) theme.currentSkin = .foobarLight XCTAssertEqual(theme.currentSkin, .foobarLight) theme.currentSkin = .win95 XCTAssertEqual(theme.currentSkin, .win95) } func testAllSkinsAvailable() { // 6 modern + 8 retro = 14 XCTAssertEqual(AppTheme.Skin.allCases.count, 14) } func testAllSkinsApplyWithoutCrash() { let theme = AppTheme() for skin in AppTheme.Skin.allCases { theme.currentSkin = skin // Verify key properties are set to reasonable values XCTAssertGreaterThan(theme.seekbarHeight, 0, "Seekbar height should be > 0 for \(skin.rawValue)") XCTAssertGreaterThan(theme.dataFontSize, 0, "Font size should be > 0 for \(skin.rawValue)") XCTAssertGreaterThan(theme.rowHeight, 0, "Row height should be > 0 for \(skin.rawValue)") } } func testRetroSkinsHaveDistinctColors() { let theme = AppTheme() // Winamp Classic should have green text theme.currentSkin = .winampClassic // Just verify it doesn't crash and has accent set XCTAssertEqual(theme.currentSkin, .winampClassic) // foobar2000 should be light/system-like theme.currentSkin = .foobarLight XCTAssertEqual(theme.rowHeight, 18) // foobar has tighter rows // Win95 should have thicker seekbar theme.currentSkin = .win95 XCTAssertEqual(theme.seekbarHeight, 10) // XP Luna theme.currentSkin = .xpLuna XCTAssertEqual(theme.seekbarHeight, 10) // Mac OS 9 theme.currentSkin = .macOSClassic XCTAssertEqual(theme.dataFontSize, 12) // slightly larger like classic Mac } func testSeekbarHeight() { let theme = AppTheme() theme.currentSkin = .dark XCTAssertEqual(theme.seekbarHeight, 8) } } /// Tests for AppState persistence. final class AppStateTests: XCTestCase { private func clearState() { UserDefaults.standard.removeObject(forKey: "appState.lastPlaylistID") UserDefaults.standard.removeObject(forKey: "appState.lastEntryID") UserDefaults.standard.removeObject(forKey: "appState.lastTrackFilePath") UserDefaults.standard.removeObject(forKey: "appState.lastPlaybackTime") } override func setUp() { super.setUp() clearState() } override func tearDown() { clearState() super.tearDown() } 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 testSaveAndLoadTrackPath() { AppState.saveLastTrack(filePath: "/music/test.mp3") XCTAssertEqual(AppState.lastTrackFilePath, "/music/test.mp3") } func testSaveAndLoadPlaybackTime() { AppState.savePlaybackTime(123.456) XCTAssertEqual(AppState.lastPlaybackTime, 123.456, accuracy: 0.001) } func testSavePlaybackStateAll() { let plID = UUID() let entryID = UUID() AppState.savePlaybackState( playlistID: plID, entryID: entryID, trackFilePath: "/test.wav", playbackTime: 42.5 ) XCTAssertEqual(AppState.lastPlaylistID, plID) XCTAssertEqual(AppState.lastEntryID, entryID) XCTAssertEqual(AppState.lastTrackFilePath, "/test.wav") XCTAssertEqual(AppState.lastPlaybackTime, 42.5, accuracy: 0.001) } func testDefaultsAreNil() { XCTAssertNil(AppState.lastPlaylistID) XCTAssertNil(AppState.lastEntryID) } }