ModelTests.swift 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531
  1. import XCTest
  2. import SwiftUI
  3. @testable import MixBoard
  4. // MARK: - Model Tests
  5. final class ModelTests: XCTestCase {
  6. func testTrackInit() {
  7. let track = Track(
  8. title: "Test Song",
  9. artist: "Test Artist",
  10. filePath: "Music/test.mp3",
  11. fileName: "test.mp3",
  12. duration: 180
  13. )
  14. XCTAssertEqual(track.title, "Test Song")
  15. XCTAssertEqual(track.artist, "Test Artist")
  16. XCTAssertEqual(track.fileName, "test.mp3")
  17. XCTAssertEqual(track.formattedDuration, "3:00")
  18. XCTAssertFalse(track.isAnalyzed)
  19. XCTAssertNil(track.bpm)
  20. }
  21. func testTrackDefaults() {
  22. let track = Track(title: "Minimal", filePath: "Music/minimal.wav")
  23. XCTAssertEqual(track.artist, "")
  24. XCTAssertEqual(track.album, "")
  25. XCTAssertEqual(track.genre, "")
  26. XCTAssertEqual(track.playCount, 0)
  27. XCTAssertEqual(track.rating, 0)
  28. XCTAssertNil(track.lastPlayed)
  29. XCTAssertNil(track.color)
  30. XCTAssertNil(track.waveformData)
  31. XCTAssertEqual(track.notes, "")
  32. XCTAssertTrue(track.cuePoints.isEmpty)
  33. }
  34. func testTrackFormattedBPM() {
  35. let track = Track(title: "Song", filePath: "Music/song.mp3")
  36. XCTAssertEqual(track.formattedBPM, "—")
  37. track.bpm = 128.5
  38. XCTAssertEqual(track.formattedBPM, "128.5")
  39. }
  40. func testTrackFormattedDurationEdgeCases() {
  41. let track0 = Track(title: "Zero", filePath: "Music/z.mp3", duration: 0)
  42. XCTAssertEqual(track0.formattedDuration, "0:00")
  43. let trackLong = Track(title: "Long", filePath: "Music/l.mp3", duration: 3661)
  44. XCTAssertEqual(trackLong.formattedDuration, "61:01")
  45. }
  46. func testFileNameExtraction() {
  47. let track = Track(title: "Song", filePath: "Music/subfolder/Artist - Song.flac")
  48. XCTAssertEqual(track.fileName, "Artist - Song.flac")
  49. }
  50. func testFileNameExplicit() {
  51. let track = Track(title: "Song", filePath: "Music/song.mp3", fileName: "custom.mp3")
  52. XCTAssertEqual(track.fileName, "custom.mp3")
  53. }
  54. func testTrackFileURL() {
  55. let track = Track(title: "Song", filePath: "Music/test.mp3")
  56. let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
  57. XCTAssertEqual(track.fileURL, docs.appendingPathComponent("Music/test.mp3"))
  58. }
  59. }
  60. // MARK: - Playlist Tests
  61. final class PlaylistTests: XCTestCase {
  62. func testPlaylistInit() {
  63. let playlist = Playlist(name: "Friday Mix")
  64. XCTAssertEqual(playlist.name, "Friday Mix")
  65. XCTAssertEqual(playlist.color, "#2196F3")
  66. XCTAssertEqual(playlist.trackCount, 0)
  67. XCTAssertNil(playlist.targetBPM)
  68. XCTAssertTrue(playlist.entries.isEmpty)
  69. XCTAssertNil(playlist.folder)
  70. }
  71. func testPlaylistAddTrack() {
  72. let playlist = Playlist(name: "My Mix")
  73. let track = Track(title: "Song", filePath: "Music/song.mp3")
  74. XCTAssertEqual(playlist.trackCount, 0)
  75. playlist.addTrack(track, crossfadeDuration: 2.0)
  76. XCTAssertEqual(playlist.trackCount, 1)
  77. let entry = playlist.sortedEntries.first
  78. XCTAssertEqual(entry?.position, 0)
  79. XCTAssertEqual(entry?.crossfadeDuration, 2.0)
  80. }
  81. func testPlaylistAddMultipleTracks() {
  82. let playlist = Playlist(name: "Mix")
  83. let t1 = Track(title: "First", filePath: "Music/first.mp3", duration: 200)
  84. let t2 = Track(title: "Second", filePath: "Music/second.mp3", duration: 180)
  85. let t3 = Track(title: "Third", filePath: "Music/third.mp3", duration: 240)
  86. playlist.addTrack(t1)
  87. playlist.addTrack(t2)
  88. playlist.addTrack(t3)
  89. XCTAssertEqual(playlist.trackCount, 3)
  90. let sorted = playlist.sortedEntries
  91. XCTAssertEqual(sorted[0].track?.title, "First")
  92. XCTAssertEqual(sorted[1].track?.title, "Second")
  93. XCTAssertEqual(sorted[2].track?.title, "Third")
  94. XCTAssertEqual(sorted[0].position, 0)
  95. XCTAssertEqual(sorted[1].position, 1)
  96. XCTAssertEqual(sorted[2].position, 2)
  97. }
  98. func testPlaylistTotalDuration() {
  99. let playlist = Playlist(name: "Mix")
  100. let t1 = Track(title: "A", filePath: "Music/a.mp3", duration: 200)
  101. let t2 = Track(title: "B", filePath: "Music/b.mp3", duration: 180)
  102. playlist.addTrack(t1)
  103. playlist.addTrack(t2)
  104. XCTAssertEqual(playlist.totalDuration, 380, accuracy: 0.01)
  105. }
  106. func testPlaylistFormattedTotalDuration() {
  107. let playlist = Playlist(name: "Mix")
  108. playlist.addTrack(Track(title: "A", filePath: "Music/a.mp3", duration: 3700))
  109. XCTAssertTrue(playlist.formattedTotalDuration.contains(":"))
  110. }
  111. func testPlaylistRemoveEntry() {
  112. let playlist = Playlist(name: "Mix")
  113. let t1 = Track(title: "A", filePath: "Music/a.mp3")
  114. let t2 = Track(title: "B", filePath: "Music/b.mp3")
  115. let t3 = Track(title: "C", filePath: "Music/c.mp3")
  116. playlist.addTrack(t1)
  117. playlist.addTrack(t2)
  118. playlist.addTrack(t3)
  119. playlist.removeEntry(at: 1) // Remove "B"
  120. XCTAssertEqual(playlist.trackCount, 2)
  121. let sorted = playlist.sortedEntries
  122. XCTAssertEqual(sorted[0].track?.title, "A")
  123. XCTAssertEqual(sorted[1].track?.title, "C")
  124. // Positions should be re-indexed
  125. XCTAssertEqual(sorted[0].position, 0)
  126. XCTAssertEqual(sorted[1].position, 1)
  127. }
  128. func testPlaylistMoveEntry() {
  129. let playlist = Playlist(name: "Mix")
  130. let t1 = Track(title: "A", filePath: "Music/a.mp3")
  131. let t2 = Track(title: "B", filePath: "Music/b.mp3")
  132. let t3 = Track(title: "C", filePath: "Music/c.mp3")
  133. playlist.addTrack(t1)
  134. playlist.addTrack(t2)
  135. playlist.addTrack(t3)
  136. // Move C from position 2 to position 0
  137. playlist.moveEntry(from: 2, to: 0)
  138. let sorted = playlist.sortedEntries
  139. XCTAssertEqual(sorted[0].track?.title, "C")
  140. XCTAssertEqual(sorted[1].track?.title, "A")
  141. XCTAssertEqual(sorted[2].track?.title, "B")
  142. }
  143. func testPlaylistDateModifiedUpdates() {
  144. let playlist = Playlist(name: "Mix")
  145. let originalDate = playlist.dateModified
  146. // Small delay to ensure date changes
  147. let track = Track(title: "Song", filePath: "Music/song.mp3")
  148. playlist.addTrack(track)
  149. XCTAssertGreaterThanOrEqual(playlist.dateModified, originalDate)
  150. }
  151. }
  152. // MARK: - PlaylistEntry Tests
  153. final class PlaylistEntryTests: XCTestCase {
  154. func testEntryEffectiveDuration() {
  155. let track = Track(title: "Song", filePath: "Music/song.mp3", duration: 300)
  156. let entry = PlaylistEntry(position: 0, track: track)
  157. XCTAssertEqual(entry.effectiveDuration, 300, accuracy: 0.01)
  158. }
  159. func testEntryEffectiveDurationWithOffsets() {
  160. let track = Track(title: "Song", filePath: "Music/song.mp3", duration: 300)
  161. let entry = PlaylistEntry(position: 0, track: track, startOffset: 10, endOffset: 280)
  162. XCTAssertEqual(entry.effectiveDuration, 270, accuracy: 0.01)
  163. }
  164. func testEntryEffectiveDurationNoTrack() {
  165. let entry = PlaylistEntry(position: 0, track: nil)
  166. XCTAssertEqual(entry.effectiveDuration, 0)
  167. }
  168. func testEntryDefaults() {
  169. let entry = PlaylistEntry(position: 5, track: nil)
  170. XCTAssertEqual(entry.position, 5)
  171. XCTAssertEqual(entry.crossfadeDuration, 0)
  172. XCTAssertEqual(entry.startOffset, 0)
  173. XCTAssertEqual(entry.endOffset, 0)
  174. XCTAssertEqual(entry.gainAdjustment, 0)
  175. XCTAssertEqual(entry.notes, "")
  176. }
  177. }
  178. // MARK: - CuePoint Tests
  179. final class CuePointTests: XCTestCase {
  180. func testCuePointFormatTime() {
  181. XCTAssertEqual(CuePoint.formatTime(0), "00:00.000")
  182. XCTAssertEqual(CuePoint.formatTime(125.456), "02:05.456")
  183. XCTAssertEqual(CuePoint.formatTime(59.999), "00:59.999")
  184. }
  185. func testCuePointInit() {
  186. let cue = CuePoint(name: "Drop", timestamp: 45.5, type: .drop)
  187. XCTAssertEqual(cue.name, "Drop")
  188. XCTAssertEqual(cue.timestamp, 45.5)
  189. XCTAssertEqual(cue.type, .drop)
  190. XCTAssertFalse(cue.isRegion)
  191. }
  192. func testCuePointRegion() {
  193. let cue = CuePoint(name: "Verse", timestamp: 30, endTimestamp: 90, type: .verse)
  194. XCTAssertTrue(cue.isRegion)
  195. XCTAssertEqual(cue.endTimestamp, 90)
  196. }
  197. func testCuePointComparable() {
  198. let a = CuePoint(timestamp: 10)
  199. let b = CuePoint(timestamp: 20)
  200. let c = CuePoint(timestamp: 5)
  201. XCTAssertTrue(c < a)
  202. XCTAssertTrue(a < b)
  203. let sorted = [b, a, c].sorted()
  204. XCTAssertEqual(sorted.map(\.timestamp), [5, 10, 20])
  205. }
  206. func testCuePointTypes() {
  207. XCTAssertEqual(CuePointType.allCases.count, 11)
  208. XCTAssertEqual(CuePointType.marker.rawValue, "Marker")
  209. XCTAssertEqual(CuePointType.fadeOut.rawValue, "Fade Out")
  210. }
  211. }
  212. // MARK: - PlaylistFolder Tests
  213. final class PlaylistFolderTests: XCTestCase {
  214. func testFolderInit() {
  215. let folder = PlaylistFolder(name: "My Mixes")
  216. XCTAssertEqual(folder.name, "My Mixes")
  217. XCTAssertTrue(folder.isExpanded)
  218. XCTAssertTrue(folder.playlists.isEmpty)
  219. XCTAssertEqual(folder.totalTrackCount, 0)
  220. }
  221. }
  222. // MARK: - AppState Tests
  223. final class AppStateTests: XCTestCase {
  224. override func tearDown() {
  225. // Clean up UserDefaults
  226. UserDefaults.standard.removeObject(forKey: "appState.lastPlaylistID")
  227. UserDefaults.standard.removeObject(forKey: "appState.lastEntryID")
  228. UserDefaults.standard.removeObject(forKey: "appState.lastTrackFilePath")
  229. UserDefaults.standard.removeObject(forKey: "appState.lastPlaybackTime")
  230. }
  231. func testSaveAndLoadPlaylistID() {
  232. let id = UUID()
  233. AppState.saveLastPlaylist(id: id)
  234. XCTAssertEqual(AppState.lastPlaylistID, id)
  235. }
  236. func testSaveAndLoadEntryID() {
  237. let id = UUID()
  238. AppState.saveLastEntry(id: id)
  239. XCTAssertEqual(AppState.lastEntryID, id)
  240. }
  241. func testSaveAndLoadTrackFilePath() {
  242. AppState.saveLastTrack(filePath: "Music/test.mp3")
  243. XCTAssertEqual(AppState.lastTrackFilePath, "Music/test.mp3")
  244. }
  245. func testSaveAndLoadPlaybackTime() {
  246. AppState.savePlaybackTime(42.5)
  247. XCTAssertEqual(AppState.lastPlaybackTime, 42.5, accuracy: 0.01)
  248. }
  249. func testSavePlaybackStateAll() {
  250. let playlistID = UUID()
  251. let entryID = UUID()
  252. AppState.savePlaybackState(
  253. playlistID: playlistID,
  254. entryID: entryID,
  255. trackFilePath: "Music/combined.flac",
  256. playbackTime: 99.9
  257. )
  258. XCTAssertEqual(AppState.lastPlaylistID, playlistID)
  259. XCTAssertEqual(AppState.lastEntryID, entryID)
  260. XCTAssertEqual(AppState.lastTrackFilePath, "Music/combined.flac")
  261. XCTAssertEqual(AppState.lastPlaybackTime, 99.9, accuracy: 0.01)
  262. }
  263. func testDefaultsAreNil() {
  264. XCTAssertNil(AppState.lastPlaylistID)
  265. XCTAssertNil(AppState.lastEntryID)
  266. }
  267. }
  268. // MARK: - Sync Encoding/Decoding Tests
  269. final class SyncTests: XCTestCase {
  270. func testSyncPayloadRoundTrip() throws {
  271. let playlist = Playlist(name: "Test Mix", notes: "Great tracks", color: "#FF5722")
  272. playlist.targetBPM = 128.0
  273. let t1 = Track(title: "Song A", artist: "Artist A", filePath: "Music/a.mp3", fileName: "a.mp3", duration: 200)
  274. t1.bpm = 126
  275. t1.musicalKey = "Am"
  276. let t2 = Track(title: "Song B", artist: "Artist B", filePath: "Music/b.flac", fileName: "b.flac", duration: 180)
  277. playlist.addTrack(t1, crossfadeDuration: 3.0)
  278. playlist.addTrack(t2, crossfadeDuration: 2.0)
  279. let syncPlaylist = SyncPlaylist(from: playlist)
  280. let payload = SyncPayload(version: 1, exportedAt: Date(), exportedFrom: "Test", playlists: [syncPlaylist])
  281. let encoder = JSONEncoder()
  282. encoder.dateEncodingStrategy = .iso8601
  283. encoder.outputFormatting = .prettyPrinted
  284. let data = try encoder.encode(payload)
  285. let decoder = JSONDecoder()
  286. decoder.dateDecodingStrategy = .iso8601
  287. let decoded = try decoder.decode(SyncPayload.self, from: data)
  288. XCTAssertEqual(decoded.version, 1)
  289. XCTAssertEqual(decoded.playlists.count, 1)
  290. let dp = decoded.playlists[0]
  291. XCTAssertEqual(dp.name, "Test Mix")
  292. XCTAssertEqual(dp.notes, "Great tracks")
  293. XCTAssertEqual(dp.color, "#FF5722")
  294. XCTAssertEqual(dp.targetBPM, 128.0)
  295. XCTAssertEqual(dp.entries.count, 2)
  296. XCTAssertEqual(dp.entries[0].filename, "a.mp3")
  297. XCTAssertEqual(dp.entries[0].title, "Song A")
  298. XCTAssertEqual(dp.entries[0].artist, "Artist A")
  299. XCTAssertEqual(dp.entries[0].bpm, 126)
  300. XCTAssertEqual(dp.entries[0].musicalKey, "Am")
  301. XCTAssertEqual(dp.entries[0].crossfadeDuration, 3.0)
  302. XCTAssertEqual(dp.entries[1].filename, "b.flac")
  303. XCTAssertEqual(dp.entries[1].title, "Song B")
  304. }
  305. func testSyncEmptyPlaylist() throws {
  306. let playlist = Playlist(name: "Empty")
  307. let sp = SyncPlaylist(from: playlist)
  308. XCTAssertTrue(sp.entries.isEmpty)
  309. let payload = SyncPayload(version: 1, exportedAt: Date(), exportedFrom: "iPhone", playlists: [sp])
  310. let encoder = JSONEncoder()
  311. encoder.dateEncodingStrategy = .iso8601
  312. let data = try encoder.encode(payload)
  313. let decoded = try JSONDecoder().apply { $0.dateDecodingStrategy = .iso8601 }.decode(SyncPayload.self, from: data)
  314. XCTAssertEqual(decoded.playlists[0].entries.count, 0)
  315. }
  316. func testSyncEntryWithNoTrack() {
  317. let entry = PlaylistEntry(position: 0, track: nil, notes: "Missing track")
  318. let syncEntry = SyncEntry(from: entry)
  319. XCTAssertEqual(syncEntry.filename, "unknown")
  320. XCTAssertEqual(syncEntry.title, "Unknown")
  321. XCTAssertEqual(syncEntry.notes, "Missing track")
  322. }
  323. func testSyncMultiplePlaylists() throws {
  324. let p1 = Playlist(name: "Mix 1")
  325. let p2 = Playlist(name: "Mix 2")
  326. p1.addTrack(Track(title: "Song", filePath: "Music/s.mp3"))
  327. let payload = SyncPayload(
  328. version: 1,
  329. exportedAt: Date(),
  330. exportedFrom: "iPad",
  331. playlists: [SyncPlaylist(from: p1), SyncPlaylist(from: p2)]
  332. )
  333. let encoder = JSONEncoder()
  334. encoder.dateEncodingStrategy = .iso8601
  335. let data = try encoder.encode(payload)
  336. let decoder = JSONDecoder()
  337. decoder.dateDecodingStrategy = .iso8601
  338. let decoded = try decoder.decode(SyncPayload.self, from: data)
  339. XCTAssertEqual(decoded.playlists.count, 2)
  340. XCTAssertEqual(decoded.exportedFrom, "iPad")
  341. }
  342. }
  343. // MARK: - MetadataService Tests
  344. final class MetadataServiceTests: XCTestCase {
  345. func testSupportedFormats() {
  346. let supported = ["mp3", "wav", "aif", "aiff", "flac", "m4a", "aac", "caf", "alac", "ogg"]
  347. for ext in supported {
  348. XCTAssertTrue(MetadataService.isSupportedAudioFile(URL(fileURLWithPath: "/test.\(ext)")),
  349. "\(ext) should be supported")
  350. }
  351. }
  352. func testUnsupportedFormats() {
  353. let unsupported = ["txt", "jpg", "pdf", "mp4", "wma", "doc", "zip"]
  354. for ext in unsupported {
  355. XCTAssertFalse(MetadataService.isSupportedAudioFile(URL(fileURLWithPath: "/test.\(ext)")),
  356. "\(ext) should not be supported")
  357. }
  358. }
  359. func testCaseInsensitive() {
  360. XCTAssertTrue(MetadataService.isSupportedAudioFile(URL(fileURLWithPath: "/test.MP3")))
  361. XCTAssertTrue(MetadataService.isSupportedAudioFile(URL(fileURLWithPath: "/test.Flac")))
  362. XCTAssertTrue(MetadataService.isSupportedAudioFile(URL(fileURLWithPath: "/test.WAV")))
  363. }
  364. }
  365. // MARK: - AppTheme Tests
  366. final class AppThemeTests: XCTestCase {
  367. func testDefaultSkin() {
  368. let theme = AppTheme()
  369. XCTAssertNotNil(theme.currentSkin)
  370. }
  371. func testWinampSkin() {
  372. let theme = AppTheme()
  373. theme.currentSkin = .winamp
  374. XCTAssertTrue(theme.useDarkMode)
  375. XCTAssertEqual(theme.preferredColorScheme, .dark)
  376. XCTAssertEqual(theme.cornerRadius, 4)
  377. }
  378. func testFoobarSkin() {
  379. let theme = AppTheme()
  380. theme.currentSkin = .foobarLight
  381. XCTAssertFalse(theme.useDarkMode)
  382. XCTAssertEqual(theme.preferredColorScheme, .light)
  383. XCTAssertEqual(theme.cornerRadius, 6)
  384. }
  385. func testFoobarDarkSkin() {
  386. let theme = AppTheme()
  387. theme.currentSkin = .foobarDark
  388. XCTAssertTrue(theme.useDarkMode)
  389. XCTAssertEqual(theme.preferredColorScheme, .dark)
  390. }
  391. func testVinylSkin() {
  392. let theme = AppTheme()
  393. theme.currentSkin = .vinyl
  394. XCTAssertTrue(theme.useDarkMode)
  395. XCTAssertEqual(theme.preferredColorScheme, .dark)
  396. }
  397. func testObsidianSkin() {
  398. let theme = AppTheme()
  399. theme.currentSkin = .obsidian
  400. XCTAssertTrue(theme.useDarkMode)
  401. XCTAssertEqual(theme.cornerRadius, 12)
  402. }
  403. func testWmpSkin() {
  404. let theme = AppTheme()
  405. theme.currentSkin = .wmp
  406. XCTAssertTrue(theme.useDarkMode)
  407. XCTAssertEqual(theme.cornerRadius, 8)
  408. }
  409. func testSkinCount() {
  410. XCTAssertEqual(AppTheme.Skin.allCases.count, 7)
  411. }
  412. func testSkinPersistence() {
  413. let theme = AppTheme()
  414. theme.currentSkin = .foobarLight
  415. XCTAssertEqual(UserDefaults.standard.string(forKey: "appThemeSkin"), "foobar Light")
  416. theme.currentSkin = .winamp
  417. XCTAssertEqual(UserDefaults.standard.string(forKey: "appThemeSkin"), "Winamp")
  418. }
  419. }
  420. // MARK: - Color Extension Tests
  421. final class ColorTests: XCTestCase {
  422. func testValidHexColors() {
  423. XCTAssertNotNil(Color(hex: "#FF0000"))
  424. XCTAssertNotNil(Color(hex: "00FF00"))
  425. XCTAssertNotNil(Color(hex: "#2196F3"))
  426. }
  427. func testInvalidHexColors() {
  428. XCTAssertNil(Color(hex: ""))
  429. XCTAssertNil(Color(hex: "XYZ"))
  430. XCTAssertNil(Color(hex: "#12345")) // 5 chars
  431. }
  432. }
  433. // MARK: - Helper
  434. extension JSONDecoder {
  435. func apply(_ configure: (JSONDecoder) -> Void) -> JSONDecoder {
  436. configure(self)
  437. return self
  438. }
  439. }