IntegrationTests.swift 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  1. import XCTest
  2. import SwiftData
  3. @testable import MixBoard
  4. /// Integration tests that exercise full app workflows programmatically.
  5. /// These test the ViewModels, services, and data flow as a user would interact.
  6. final class IntegrationTests: XCTestCase {
  7. override func tearDown() {
  8. super.tearDown()
  9. TestHelpers.cleanupTestFiles()
  10. }
  11. // MARK: - Player ViewModel Flow
  12. @MainActor
  13. func testPlayerLoadAndPlayFlow() async throws {
  14. let url = try TestHelpers.createTestAudioFile(name: "play_test", duration: 2.0)
  15. let track = Track(title: "Test Song", artist: "Artist", filePath: url.path, duration: 2.0, fileFormat: "WAV")
  16. let playerVM = PlayerViewModel()
  17. // Initially stopped
  18. XCTAssertFalse(playerVM.isPlaying)
  19. XCTAssertNil(playerVM.currentTrack)
  20. XCTAssertNil(playerVM.currentPlayingEntryID)
  21. // Load and play
  22. playerVM.loadAndPlay(track)
  23. playerVM.syncForTest()
  24. XCTAssertTrue(playerVM.isPlaying)
  25. XCTAssertEqual(playerVM.currentTrack?.title, "Test Song")
  26. XCTAssertGreaterThan(playerVM.duration, 1.0)
  27. // Pause
  28. playerVM.togglePlayPause()
  29. playerVM.syncForTest()
  30. XCTAssertFalse(playerVM.isPlaying)
  31. // Resume
  32. playerVM.togglePlayPause()
  33. playerVM.syncForTest()
  34. XCTAssertTrue(playerVM.isPlaying)
  35. // Stop
  36. playerVM.stop()
  37. playerVM.syncForTest()
  38. XCTAssertFalse(playerVM.isPlaying)
  39. XCTAssertEqual(playerVM.currentTime, 0)
  40. }
  41. @MainActor
  42. func testPlayerSeekFlow() async throws {
  43. let url = try TestHelpers.createTestAudioFile(name: "seek_test", duration: 5.0)
  44. let track = Track(title: "Seek", filePath: url.path, duration: 5.0, fileFormat: "WAV")
  45. let playerVM = PlayerViewModel()
  46. playerVM.loadAndPlay(track)
  47. playerVM.syncForTest()
  48. // Seek to middle
  49. playerVM.seekToProgress(0.5)
  50. playerVM.syncForTest()
  51. // Time should be approximately half
  52. XCTAssertGreaterThan(playerVM.currentTime, 1.5)
  53. XCTAssertLessThan(playerVM.currentTime, 3.5)
  54. // Should still be playing
  55. XCTAssertTrue(playerVM.isPlaying)
  56. playerVM.stop()
  57. }
  58. @MainActor
  59. func testPlayerVolumeControl() {
  60. let playerVM = PlayerViewModel()
  61. XCTAssertEqual(playerVM.volume, 0.8, accuracy: 0.01) // default
  62. playerVM.volume = 0.5
  63. XCTAssertEqual(playerVM.volume, 0.5, accuracy: 0.01)
  64. playerVM.volume = 0.0
  65. XCTAssertEqual(playerVM.volume, 0.0, accuracy: 0.01)
  66. playerVM.volume = 1.0
  67. XCTAssertEqual(playerVM.volume, 1.0, accuracy: 0.01)
  68. }
  69. // MARK: - Playlist ViewModel Flow
  70. @MainActor
  71. func testPlaylistNextPreviousFlow() async throws {
  72. let urls = try TestHelpers.createTestAudioFiles(count: 3, duration: 1.0)
  73. let playlist = Playlist(name: "Nav Test")
  74. var tracks: [Track] = []
  75. for (i, url) in urls.enumerated() {
  76. let t = Track(title: "Track \(i+1)", filePath: url.path, duration: 1.0, fileFormat: "WAV")
  77. playlist.addTrack(t)
  78. tracks.append(t)
  79. }
  80. let playerVM = PlayerViewModel()
  81. // Play first track
  82. let entries = playlist.sortedEntries
  83. playerVM.loadAndPlay(tracks[0], entryID: entries[0].id, playlist: playlist)
  84. playerVM.syncForTest()
  85. XCTAssertEqual(playerVM.currentTrack?.title, "Track 1")
  86. XCTAssertEqual(playerVM.currentPlayingEntryID, entries[0].id)
  87. // Next
  88. playerVM.playNext()
  89. playerVM.syncForTest()
  90. XCTAssertEqual(playerVM.currentTrack?.title, "Track 2")
  91. // Next
  92. playerVM.playNext()
  93. playerVM.syncForTest()
  94. XCTAssertEqual(playerVM.currentTrack?.title, "Track 3")
  95. // Next at end — should stop
  96. playerVM.playNext()
  97. playerVM.syncForTest()
  98. XCTAssertFalse(playerVM.isPlaying)
  99. // Play last, then previous
  100. playerVM.loadAndPlay(tracks[2], entryID: entries[2].id, playlist: playlist)
  101. playerVM.syncForTest()
  102. playerVM.playPrevious() // currentTime < 3, go to previous
  103. playerVM.syncForTest()
  104. XCTAssertEqual(playerVM.currentTrack?.title, "Track 2")
  105. playerVM.stop()
  106. }
  107. @MainActor
  108. func testRepeatAllMode() async throws {
  109. let urls = try TestHelpers.createTestAudioFiles(count: 2, duration: 1.0)
  110. let playlist = Playlist(name: "Repeat Test")
  111. var tracks: [Track] = []
  112. for (i, url) in urls.enumerated() {
  113. let t = Track(title: "Track \(i+1)", filePath: url.path, duration: 1.0, fileFormat: "WAV")
  114. playlist.addTrack(t)
  115. tracks.append(t)
  116. }
  117. let playerVM = PlayerViewModel()
  118. playerVM.repeatMode = .all
  119. let entries = playlist.sortedEntries
  120. playerVM.loadAndPlay(tracks[1], entryID: entries[1].id, playlist: playlist)
  121. playerVM.syncForTest()
  122. XCTAssertEqual(playerVM.currentTrack?.title, "Track 2")
  123. // Next from last track should wrap to first
  124. playerVM.playNext()
  125. playerVM.syncForTest()
  126. XCTAssertEqual(playerVM.currentTrack?.title, "Track 1")
  127. XCTAssertTrue(playerVM.isPlaying)
  128. playerVM.stop()
  129. }
  130. @MainActor
  131. func testRepeatOneMode() async throws {
  132. let urls = try TestHelpers.createTestAudioFiles(count: 2, duration: 1.0)
  133. let playlist = Playlist(name: "Repeat1 Test")
  134. var tracks: [Track] = []
  135. for (i, url) in urls.enumerated() {
  136. let t = Track(title: "Track \(i+1)", filePath: url.path, duration: 1.0, fileFormat: "WAV")
  137. playlist.addTrack(t)
  138. tracks.append(t)
  139. }
  140. let playerVM = PlayerViewModel()
  141. playerVM.repeatMode = .one
  142. let entries = playlist.sortedEntries
  143. playerVM.loadAndPlay(tracks[0], entryID: entries[0].id, playlist: playlist)
  144. playerVM.syncForTest()
  145. // Next should replay same track
  146. playerVM.playNext()
  147. playerVM.syncForTest()
  148. XCTAssertEqual(playerVM.currentTrack?.title, "Track 1")
  149. playerVM.stop()
  150. }
  151. @MainActor
  152. func testShuffleMode() async throws {
  153. let urls = try TestHelpers.createTestAudioFiles(count: 5, duration: 0.5)
  154. let playlist = Playlist(name: "Shuffle Test")
  155. var tracks: [Track] = []
  156. for (i, url) in urls.enumerated() {
  157. let t = Track(title: "Track \(i+1)", filePath: url.path, duration: 0.5, fileFormat: "WAV")
  158. playlist.addTrack(t)
  159. tracks.append(t)
  160. }
  161. let playerVM = PlayerViewModel()
  162. playerVM.shuffleEnabled = true
  163. let entries = playlist.sortedEntries
  164. playerVM.loadAndPlay(tracks[0], entryID: entries[0].id, playlist: playlist)
  165. playerVM.syncForTest()
  166. let firstTitle = playerVM.currentTrack?.title
  167. // Next should play a different track (with 5 tracks, very likely)
  168. playerVM.playNext()
  169. playerVM.syncForTest()
  170. XCTAssertTrue(playerVM.isPlaying)
  171. // Can't guarantee it's different (random), but it should still be a valid track
  172. XCTAssertNotNil(playerVM.currentTrack)
  173. playerVM.stop()
  174. }
  175. @MainActor
  176. func testShuffleAndRepeatDefaults() {
  177. let playerVM = PlayerViewModel()
  178. XCTAssertFalse(playerVM.shuffleEnabled)
  179. XCTAssertEqual(playerVM.repeatMode, .off)
  180. }
  181. func testSessionExportFlow() throws {
  182. let outputDir = FileManager.default.temporaryDirectory
  183. .appendingPathComponent("IntegrationExport", isDirectory: true)
  184. try FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)
  185. defer { try? FileManager.default.removeItem(at: outputDir) }
  186. let playlist = Playlist(name: "Export Flow Test")
  187. let t1 = Track(title: "Song A", artist: "Art1", filePath: "/tmp/a.mp3", duration: 120, fileFormat: "MP3")
  188. let t2 = Track(title: "Song B", artist: "Art2", filePath: "/tmp/b.wav", duration: 180, fileFormat: "WAV")
  189. t1.bpm = 128
  190. t1.musicalKey = "Am"
  191. playlist.addTrack(t1)
  192. playlist.addTrack(t2, crossfadeDuration: 3.0)
  193. // Export Audition session
  194. let sesxURL = outputDir.appendingPathComponent("test.sesx")
  195. var options = ExportOptions.default
  196. options.copyAudioFiles = false
  197. try AuditionExporter.export(playlist: playlist, to: sesxURL, options: options)
  198. let sesx = try String(contentsOf: sesxURL, encoding: .utf8)
  199. XCTAssertTrue(sesx.contains("Song A"))
  200. XCTAssertTrue(sesx.contains("Song B"))
  201. XCTAssertTrue(sesx.contains("<audioClip"))
  202. XCTAssertTrue(sesx.contains("<masterTrack"))
  203. // Export with file renaming template
  204. options.fileNameTemplate = "{track} {artist} - {title}"
  205. // Can't actually copy since source files don't exist, but verify template integration
  206. let template = options.fileNameTemplate!
  207. let name = FileNameTemplate.generate(template: template, track: t1, playlistIndex: 0, totalTracks: 2)
  208. XCTAssertEqual(name, "01 Art1 - Song A")
  209. }
  210. // MARK: - Stitch Flow
  211. @MainActor
  212. func testStitchWithMarkersFlow() async throws {
  213. let outputDir = FileManager.default.temporaryDirectory
  214. .appendingPathComponent("IntegrationStitch", isDirectory: true)
  215. try FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)
  216. defer { try? FileManager.default.removeItem(at: outputDir) }
  217. let urls = try TestHelpers.createTestAudioFiles(count: 2, duration: 1.5)
  218. let playlist = Playlist(name: "Stitch Flow")
  219. for (i, url) in urls.enumerated() {
  220. let t = Track(title: "Part \(i+1)", artist: "DJ", filePath: url.path, duration: 1.5, fileFormat: "WAV")
  221. playlist.addTrack(t)
  222. }
  223. let outputURL = outputDir.appendingPathComponent("stitched.wav")
  224. let result = try await AudioStitcher.stitch(playlist: playlist, to: outputURL)
  225. // Verify markers
  226. XCTAssertEqual(result.markers.count, 2)
  227. XCTAssertEqual(result.markers[0].name, "Part 1")
  228. XCTAssertEqual(result.markers[1].name, "Part 2")
  229. XCTAssertEqual(result.markers[0].startTime, 0, accuracy: 0.01)
  230. XCTAssertGreaterThan(result.markers[1].startTime, 1.0)
  231. // Write all companion files
  232. let csvURL = outputDir.appendingPathComponent("markers.csv")
  233. try AudioStitcher.writeAuditionMarkers(result.markers, to: csvURL)
  234. let csv = try String(contentsOf: csvURL, encoding: .utf8)
  235. XCTAssertTrue(csv.contains("01. Part 1"))
  236. XCTAssertTrue(csv.contains("02. Part 2"))
  237. let listURL = outputDir.appendingPathComponent("tracklist.txt")
  238. try AudioStitcher.writeTrackList(result.markers, playlistName: "Stitch Flow", to: listURL)
  239. let list = try String(contentsOf: listURL, encoding: .utf8)
  240. XCTAssertTrue(list.contains("Total: 2 tracks"))
  241. }
  242. // MARK: - Theme Persistence
  243. func testThemeSkinPersistence() {
  244. let theme = AppTheme()
  245. let originalSkin = theme.currentSkin
  246. theme.currentSkin = .ocean
  247. XCTAssertEqual(theme.currentSkin, .ocean)
  248. // Create a new instance — should load saved
  249. let theme2 = AppTheme()
  250. XCTAssertEqual(theme2.currentSkin, .ocean)
  251. // Restore
  252. theme.currentSkin = originalSkin
  253. }
  254. // MARK: - Playlist Config Persistence
  255. func testPlaylistViewConfigPersistence() {
  256. let config = PlaylistViewConfig()
  257. let originalColumns = config.visibleColumns
  258. config.visibleColumns = [.title, .artist, .duration]
  259. let config2 = PlaylistViewConfig()
  260. XCTAssertEqual(config2.visibleColumns, [.title, .artist, .duration])
  261. // Restore
  262. config.visibleColumns = originalColumns
  263. }
  264. // MARK: - Status Message
  265. @MainActor
  266. func testStatusMessageAutoClears() async {
  267. let vm = PlaylistViewModel()
  268. vm.showStatus("Integration test", duration: 0.3)
  269. XCTAssertEqual(vm.statusMessage, "Integration test")
  270. try? await Task.sleep(for: .seconds(0.5))
  271. XCTAssertNil(vm.statusMessage)
  272. }
  273. // MARK: - Drag to Playlist (Data Flow)
  274. func testAddTrackToDifferentPlaylist() {
  275. let source = Playlist(name: "Source")
  276. let target = Playlist(name: "Target")
  277. let track = Track(title: "Shared Song", artist: "Artist", filePath: "/t.mp3", duration: 200, fileFormat: "MP3")
  278. source.addTrack(track)
  279. // Simulate drag: add same track to target
  280. target.addTrack(track)
  281. // Both playlists should have the track
  282. XCTAssertEqual(source.trackCount, 1)
  283. XCTAssertEqual(target.trackCount, 1)
  284. XCTAssertEqual(source.sortedEntries.first?.track?.title, "Shared Song")
  285. XCTAssertEqual(target.sortedEntries.first?.track?.title, "Shared Song")
  286. }
  287. // MARK: - Quick Add & Duplicate Detection
  288. @MainActor
  289. func testQuickAddToTarget() {
  290. let target = Playlist(name: "My Mix")
  291. let source = Playlist(name: "Source")
  292. let track = Track(title: "Banger", artist: "DJ", filePath: "/banger.mp3", duration: 200, fileFormat: "MP3")
  293. source.addTrack(track)
  294. let vm = PlaylistViewModel()
  295. vm.targetPlaylist = target
  296. // First add should succeed
  297. let added = vm.quickAddToTarget(track: track, context: modelContext)
  298. // Can't test with real ModelContext in unit test, but verify no crash
  299. XCTAssertNotNil(vm.targetPlaylist)
  300. }
  301. @MainActor
  302. func testDuplicateDetection() {
  303. let playlist = Playlist(name: "Mix")
  304. let track = Track(title: "Song", artist: "Art", filePath: "/song.mp3", duration: 180, fileFormat: "MP3")
  305. playlist.addTrack(track)
  306. let vm = PlaylistViewModel()
  307. let isDup = vm.isDuplicate(track: track, in: playlist)
  308. XCTAssertTrue(isDup)
  309. let track2 = Track(title: "Other", filePath: "/other.mp3", duration: 120, fileFormat: "MP3")
  310. let isDup2 = vm.isDuplicate(track: track2, in: playlist)
  311. XCTAssertFalse(isDup2)
  312. }
  313. @MainActor
  314. func testQuickAddNoTarget() {
  315. let vm = PlaylistViewModel()
  316. vm.targetPlaylist = nil
  317. let track = Track(title: "T", filePath: "/t.mp3", duration: 100, fileFormat: "MP3")
  318. let added = vm.quickAddToTarget(track: track, context: modelContext)
  319. XCTAssertFalse(added)
  320. XCTAssertNotNil(vm.statusMessage) // Should show error
  321. }
  322. @MainActor
  323. func testTargetPlaylistPersistence() {
  324. let vm = PlaylistViewModel()
  325. let playlist = Playlist(name: "Target Test")
  326. vm.targetPlaylist = playlist
  327. // Should save to UserDefaults under mixTarget0ID (slot 0)
  328. let saved = UserDefaults.standard.string(forKey: "mixTarget0ID")
  329. XCTAssertEqual(saved, playlist.id.uuidString)
  330. // All three slots should work
  331. let p2 = Playlist(name: "Mix 2 Test")
  332. let p3 = Playlist(name: "Mix 3 Test")
  333. vm.setMixTarget(1, playlist: p2)
  334. vm.setMixTarget(2, playlist: p3)
  335. XCTAssertEqual(UserDefaults.standard.string(forKey: "mixTarget1ID"), p2.id.uuidString)
  336. XCTAssertEqual(UserDefaults.standard.string(forKey: "mixTarget2ID"), p3.id.uuidString)
  337. XCTAssertEqual(vm.mixTargetName(0), "Target Test")
  338. XCTAssertEqual(vm.mixTargetName(1), "Mix 2 Test")
  339. XCTAssertEqual(vm.mixTargetName(2), "Mix 3 Test")
  340. // Cleanup
  341. UserDefaults.standard.removeObject(forKey: "mixTarget0ID")
  342. UserDefaults.standard.removeObject(forKey: "mixTarget1ID")
  343. UserDefaults.standard.removeObject(forKey: "mixTarget2ID")
  344. }
  345. // MARK: - Track Notes
  346. func testTrackNotes() {
  347. let track = Track(title: "Noted", filePath: "/n.mp3", duration: 100)
  348. XCTAssertEqual(track.notes, "")
  349. track.notes = "Great drop at 1:30, mix with Shook Ones"
  350. XCTAssertEqual(track.notes, "Great drop at 1:30, mix with Shook Ones")
  351. }
  352. // MARK: - Duplicate in addTracks
  353. @MainActor
  354. func testAddTracksSkipsDuplicates() {
  355. let playlist = Playlist(name: "Dedup Test")
  356. let t1 = Track(title: "A", filePath: "/a.mp3", duration: 60, fileFormat: "MP3")
  357. let t2 = Track(title: "B", filePath: "/b.mp3", duration: 60, fileFormat: "MP3")
  358. playlist.addTrack(t1)
  359. let vm = PlaylistViewModel()
  360. XCTAssertTrue(vm.isDuplicate(track: t1, in: playlist))
  361. XCTAssertFalse(vm.isDuplicate(track: t2, in: playlist))
  362. }
  363. }
  364. // MARK: - ModelContext for testing
  365. extension IntegrationTests {
  366. @MainActor var modelContext: ModelContext {
  367. let config = ModelConfiguration(isStoredInMemoryOnly: true)
  368. let container = try! ModelContainer(for: Track.self, Playlist.self, PlaylistEntry.self, CuePoint.self, PlaylistFolder.self, configurations: config)
  369. return container.mainContext
  370. }
  371. }
  372. extension PlayerViewModel {
  373. /// Manually sync state from engine for testing (normally done by timer).
  374. func syncForTest() {
  375. let engine = audioEngine
  376. engine.updateCurrentTime()
  377. isPlaying = engine.isPlaying
  378. currentTime = engine.currentTime
  379. duration = engine.duration
  380. currentTrack = engine.currentTrack
  381. }
  382. }