E2EWorkflowTests.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. import XCTest
  2. import AVFoundation
  3. @testable import MixBoard
  4. /// End-to-end integration tests exercising full workflows.
  5. final class E2EWorkflowTests: XCTestCase {
  6. private var outputDir: URL!
  7. override func setUp() {
  8. super.setUp()
  9. outputDir = FileManager.default.temporaryDirectory
  10. .appendingPathComponent("MixBoardE2E", isDirectory: true)
  11. try? FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)
  12. }
  13. override func tearDown() {
  14. super.tearDown()
  15. try? FileManager.default.removeItem(at: outputDir)
  16. TestHelpers.cleanupTestFiles()
  17. }
  18. // MARK: - Full Import → Playlist → Export Workflow
  19. func testImportAndBuildPlaylistWorkflow() async throws {
  20. // 1. Create test audio files
  21. let urls = try TestHelpers.createTestAudioFiles(count: 3, duration: 2.0)
  22. XCTAssertEqual(urls.count, 3)
  23. // 2. Read metadata for each
  24. var tracks: [Track] = []
  25. for url in urls {
  26. let metadata = try await MetadataService.readMetadata(from: url)
  27. let track = Track(
  28. title: metadata.title,
  29. artist: "Test Artist",
  30. filePath: url.path,
  31. duration: metadata.duration,
  32. sampleRate: metadata.sampleRate,
  33. bitDepth: metadata.bitDepth,
  34. channels: metadata.channels,
  35. fileFormat: metadata.fileFormat,
  36. fileSizeBytes: metadata.fileSizeBytes
  37. )
  38. tracks.append(track)
  39. }
  40. XCTAssertEqual(tracks.count, 3)
  41. // 3. Build a playlist
  42. let playlist = Playlist(name: "E2E Test Mix")
  43. for track in tracks {
  44. playlist.addTrack(track, crossfadeDuration: 0.5)
  45. }
  46. XCTAssertEqual(playlist.trackCount, 3)
  47. XCTAssertGreaterThan(playlist.totalDuration, 5.0)
  48. // 4. Export as CUE sheet
  49. let cueURL = outputDir.appendingPathComponent("e2e_test.cue")
  50. var options = ExportOptions.default
  51. options.copyAudioFiles = false
  52. try CueSheetExporter.export(playlist: playlist, to: cueURL, options: options)
  53. let cueContent = try String(contentsOf: cueURL, encoding: .utf8)
  54. XCTAssertTrue(cueContent.contains("TITLE \"E2E Test Mix\""))
  55. XCTAssertTrue(cueContent.contains("TRACK 01"))
  56. XCTAssertTrue(cueContent.contains("TRACK 02"))
  57. XCTAssertTrue(cueContent.contains("TRACK 03"))
  58. // 5. Export as Audition session
  59. let sesxURL = outputDir.appendingPathComponent("e2e_test.sesx")
  60. try AuditionExporter.export(playlist: playlist, to: sesxURL, options: options)
  61. let sesxContent = try String(contentsOf: sesxURL, encoding: .utf8)
  62. XCTAssertTrue(sesxContent.contains("<!DOCTYPE sesx>"))
  63. XCTAssertTrue(sesxContent.contains("<audioClip"))
  64. // 6. Export as M3U
  65. let m3uURL = outputDir.appendingPathComponent("e2e_test.m3u")
  66. try M3UExporter.export(playlist: playlist, to: m3uURL, options: options)
  67. let m3uContent = try String(contentsOf: m3uURL, encoding: .utf8)
  68. XCTAssertTrue(m3uContent.contains("#EXTM3U"))
  69. }
  70. // MARK: - Audio Stitch Workflow
  71. @MainActor
  72. func testStitchWorkflow() async throws {
  73. // 1. Create test audio files
  74. let urls = try TestHelpers.createTestAudioFiles(count: 3, duration: 1.0)
  75. // 2. Build playlist with tracks
  76. let playlist = Playlist(name: "Stitch Test")
  77. for url in urls {
  78. let metadata = try await MetadataService.readMetadata(from: url)
  79. let track = Track(
  80. title: url.deletingPathExtension().lastPathComponent,
  81. artist: "Test",
  82. filePath: url.path,
  83. duration: metadata.duration,
  84. fileFormat: metadata.fileFormat
  85. )
  86. playlist.addTrack(track)
  87. }
  88. // 3. Stitch to single file
  89. let outputURL = outputDir.appendingPathComponent("stitched.wav")
  90. let result = try await AudioStitcher.stitch(
  91. playlist: playlist,
  92. to: outputURL,
  93. options: .default
  94. )
  95. // 4. Verify output
  96. XCTAssertTrue(FileManager.default.fileExists(atPath: outputURL.path))
  97. XCTAssertEqual(result.markers.count, 3)
  98. XCTAssertGreaterThan(result.totalDuration, 2.5) // 3 tracks × ~1s
  99. // Verify markers are sequential
  100. for i in 1..<result.markers.count {
  101. XCTAssertGreaterThanOrEqual(
  102. result.markers[i].startTime,
  103. result.markers[i - 1].endTime - 0.01,
  104. "Markers should be sequential"
  105. )
  106. }
  107. // 5. Verify stitched file is readable
  108. let stitchedFile = try AVAudioFile(forReading: outputURL)
  109. XCTAssertGreaterThan(stitchedFile.length, 0)
  110. // 6. Write companion markers CSV
  111. let csvURL = outputDir.appendingPathComponent("markers.csv")
  112. try AudioStitcher.writeAuditionMarkers(result.markers, to: csvURL)
  113. let csv = try String(contentsOf: csvURL, encoding: .utf8)
  114. XCTAssertTrue(csv.contains("Name\tStart"))
  115. XCTAssertTrue(csv.contains("test_track_1"))
  116. // 7. Write CUE sheet
  117. let cueURL = outputDir.appendingPathComponent("stitched.cue")
  118. try AudioStitcher.writeCueSheet(
  119. result.markers,
  120. audioFileName: "stitched.wav",
  121. playlistName: "Stitch Test",
  122. to: cueURL
  123. )
  124. let cue = try String(contentsOf: cueURL, encoding: .utf8)
  125. XCTAssertTrue(cue.contains("FILE \"stitched.wav\" WAVE"))
  126. // 8. Write track list
  127. let listURL = outputDir.appendingPathComponent("tracklist.txt")
  128. try AudioStitcher.writeTrackList(result.markers, playlistName: "Stitch Test", to: listURL)
  129. let list = try String(contentsOf: listURL, encoding: .utf8)
  130. XCTAssertTrue(list.contains("Stitch Test"))
  131. XCTAssertTrue(list.contains("Total: 3 tracks"))
  132. }
  133. // MARK: - Waveform → Analysis Workflow
  134. func testAnalysisWorkflow() async throws {
  135. let url = try TestHelpers.createTestAudioFile(name: "analysis_e2e", duration: 5.0, frequency: 440)
  136. // 1. Read metadata
  137. let metadata = try await MetadataService.readMetadata(from: url)
  138. XCTAssertGreaterThan(metadata.duration, 4.0)
  139. // 2. Generate waveform
  140. let waveform = try await WaveformGenerator.generateWaveform(fileURL: url, resolution: 200)
  141. XCTAssertEqual(waveform.count, 200)
  142. // 3. Detect BPM (may fail with test-generated audio)
  143. do {
  144. let bpm = try await BPMDetector.detectBPM(fileURL: url)
  145. XCTAssertGreaterThanOrEqual(bpm, 60)
  146. XCTAssertLessThanOrEqual(bpm, 200)
  147. } catch {
  148. // Acceptable for test WAV
  149. }
  150. // 4. Detect key (may fail with test-generated audio)
  151. do {
  152. let keyResult = try await KeyDetector.detectKey(fileURL: url)
  153. XCTAssertFalse(keyResult.key.isEmpty)
  154. } catch {
  155. // Acceptable for test WAV
  156. }
  157. }
  158. // MARK: - Multi-Format Export Consistency
  159. func testAllFormatsExport() throws {
  160. let playlist = Playlist(name: "Multi Format Test")
  161. let track = Track(title: "Song", artist: "Artist", filePath: "/tmp/song.mp3", duration: 120, fileFormat: "MP3")
  162. playlist.addTrack(track)
  163. var options = ExportOptions.default
  164. options.copyAudioFiles = false
  165. // Export in all formats and verify files are created
  166. for format in MixExporter.ExportFormat.allCases {
  167. let url = outputDir.appendingPathComponent("test_\(format.rawValue).\(format.fileExtension)")
  168. try MixExporter.export(playlist: playlist, format: format, to: url, options: options)
  169. // DAWproject writes to a different path
  170. let readURL: URL
  171. if format == .dawProject {
  172. readURL = url.deletingPathExtension().appendingPathExtension("dawproject.xml")
  173. } else {
  174. readURL = url
  175. }
  176. let content = try String(contentsOf: readURL, encoding: .utf8)
  177. XCTAssertGreaterThan(content.count, 10, "Format \(format.name) should produce non-empty output")
  178. }
  179. }
  180. // MARK: - Playlist Operations
  181. func testPlaylistCRUDOperations() {
  182. // Create
  183. let pl = Playlist(name: "CRUD Test")
  184. XCTAssertEqual(pl.name, "CRUD Test")
  185. XCTAssertEqual(pl.trackCount, 0)
  186. // Add tracks
  187. let t1 = Track(title: "A", filePath: "/a", duration: 60)
  188. let t2 = Track(title: "B", filePath: "/b", duration: 90)
  189. let t3 = Track(title: "C", filePath: "/c", duration: 120)
  190. pl.addTrack(t1)
  191. pl.addTrack(t2)
  192. pl.addTrack(t3)
  193. XCTAssertEqual(pl.trackCount, 3)
  194. XCTAssertEqual(pl.sortedEntries[0].track?.title, "A")
  195. XCTAssertEqual(pl.sortedEntries[2].track?.title, "C")
  196. // Move
  197. pl.moveEntry(from: 0, to: 2)
  198. XCTAssertEqual(pl.sortedEntries[0].track?.title, "B")
  199. XCTAssertEqual(pl.sortedEntries[2].track?.title, "A")
  200. // Remove
  201. pl.removeEntry(at: 1)
  202. XCTAssertEqual(pl.trackCount, 2)
  203. }
  204. // MARK: - Cue Points
  205. func testCuePointWorkflow() {
  206. let track = Track(title: "T", filePath: "/t", duration: 300)
  207. let intro = CuePoint(name: "Intro", timestamp: 0, type: .intro)
  208. let verse = CuePoint(name: "Verse 1", timestamp: 30, type: .verse)
  209. let drop = CuePoint(name: "Drop", timestamp: 120, type: .drop)
  210. let outro = CuePoint(name: "Outro", timestamp: 270, type: .outro)
  211. track.cuePoints = [drop, outro, intro, verse]
  212. let sorted = track.cuePoints.sorted()
  213. XCTAssertEqual(sorted[0].name, "Intro")
  214. XCTAssertEqual(sorted[1].name, "Verse 1")
  215. XCTAssertEqual(sorted[2].name, "Drop")
  216. XCTAssertEqual(sorted[3].name, "Outro")
  217. }
  218. // MARK: - Crossfade Timeline Calculation
  219. func testCrossfadeTimeline() {
  220. let pl = Playlist(name: "CF Test")
  221. let t1 = Track(title: "A", filePath: "/a", duration: 60)
  222. let t2 = Track(title: "B", filePath: "/b", duration: 60)
  223. let t3 = Track(title: "C", filePath: "/c", duration: 60)
  224. pl.addTrack(t1, crossfadeDuration: 0)
  225. pl.addTrack(t2, crossfadeDuration: 5) // 5s overlap with A
  226. pl.addTrack(t3, crossfadeDuration: 10) // 10s overlap with B
  227. // Total = 60 + 60 + 60 = 180 (raw), but crossfades subtract from effective
  228. // However, playlist.totalDuration sums track durations (not considering crossfade)
  229. XCTAssertEqual(pl.totalDuration, 180)
  230. XCTAssertEqual(pl.trackCount, 3)
  231. }
  232. }