| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283 |
- import XCTest
- import AVFoundation
- @testable import MixBoard
- /// End-to-end integration tests exercising full workflows.
- final class E2EWorkflowTests: XCTestCase {
- private var outputDir: URL!
- override func setUp() {
- super.setUp()
- outputDir = FileManager.default.temporaryDirectory
- .appendingPathComponent("MixBoardE2E", isDirectory: true)
- try? FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)
- }
- override func tearDown() {
- super.tearDown()
- try? FileManager.default.removeItem(at: outputDir)
- TestHelpers.cleanupTestFiles()
- }
- // MARK: - Full Import → Playlist → Export Workflow
- func testImportAndBuildPlaylistWorkflow() async throws {
- // 1. Create test audio files
- let urls = try TestHelpers.createTestAudioFiles(count: 3, duration: 2.0)
- XCTAssertEqual(urls.count, 3)
- // 2. Read metadata for each
- var tracks: [Track] = []
- for url in urls {
- let metadata = try await MetadataService.readMetadata(from: url)
- let track = Track(
- title: metadata.title,
- artist: "Test Artist",
- filePath: url.path,
- duration: metadata.duration,
- sampleRate: metadata.sampleRate,
- bitDepth: metadata.bitDepth,
- channels: metadata.channels,
- fileFormat: metadata.fileFormat,
- fileSizeBytes: metadata.fileSizeBytes
- )
- tracks.append(track)
- }
- XCTAssertEqual(tracks.count, 3)
- // 3. Build a playlist
- let playlist = Playlist(name: "E2E Test Mix")
- for track in tracks {
- playlist.addTrack(track, crossfadeDuration: 0.5)
- }
- XCTAssertEqual(playlist.trackCount, 3)
- XCTAssertGreaterThan(playlist.totalDuration, 5.0)
- // 4. Export as CUE sheet
- let cueURL = outputDir.appendingPathComponent("e2e_test.cue")
- var options = ExportOptions.default
- options.copyAudioFiles = false
- try CueSheetExporter.export(playlist: playlist, to: cueURL, options: options)
- let cueContent = try String(contentsOf: cueURL, encoding: .utf8)
- XCTAssertTrue(cueContent.contains("TITLE \"E2E Test Mix\""))
- XCTAssertTrue(cueContent.contains("TRACK 01"))
- XCTAssertTrue(cueContent.contains("TRACK 02"))
- XCTAssertTrue(cueContent.contains("TRACK 03"))
- // 5. Export as Audition session
- let sesxURL = outputDir.appendingPathComponent("e2e_test.sesx")
- try AuditionExporter.export(playlist: playlist, to: sesxURL, options: options)
- let sesxContent = try String(contentsOf: sesxURL, encoding: .utf8)
- XCTAssertTrue(sesxContent.contains("<!DOCTYPE sesx>"))
- XCTAssertTrue(sesxContent.contains("<audioClip"))
- // 6. Export as M3U
- let m3uURL = outputDir.appendingPathComponent("e2e_test.m3u")
- try M3UExporter.export(playlist: playlist, to: m3uURL, options: options)
- let m3uContent = try String(contentsOf: m3uURL, encoding: .utf8)
- XCTAssertTrue(m3uContent.contains("#EXTM3U"))
- }
- // MARK: - Audio Stitch Workflow
- @MainActor
- func testStitchWorkflow() async throws {
- // 1. Create test audio files
- let urls = try TestHelpers.createTestAudioFiles(count: 3, duration: 1.0)
- // 2. Build playlist with tracks
- let playlist = Playlist(name: "Stitch Test")
- for url in urls {
- let metadata = try await MetadataService.readMetadata(from: url)
- let track = Track(
- title: url.deletingPathExtension().lastPathComponent,
- artist: "Test",
- filePath: url.path,
- duration: metadata.duration,
- fileFormat: metadata.fileFormat
- )
- playlist.addTrack(track)
- }
- // 3. Stitch to single file
- let outputURL = outputDir.appendingPathComponent("stitched.wav")
- let result = try await AudioStitcher.stitch(
- playlist: playlist,
- to: outputURL,
- options: .default
- )
- // 4. Verify output
- XCTAssertTrue(FileManager.default.fileExists(atPath: outputURL.path))
- XCTAssertEqual(result.markers.count, 3)
- XCTAssertGreaterThan(result.totalDuration, 2.5) // 3 tracks × ~1s
- // Verify markers are sequential
- for i in 1..<result.markers.count {
- XCTAssertGreaterThanOrEqual(
- result.markers[i].startTime,
- result.markers[i - 1].endTime - 0.01,
- "Markers should be sequential"
- )
- }
- // 5. Verify stitched file is readable
- let stitchedFile = try AVAudioFile(forReading: outputURL)
- XCTAssertGreaterThan(stitchedFile.length, 0)
- // 6. Write companion markers CSV
- let csvURL = outputDir.appendingPathComponent("markers.csv")
- try AudioStitcher.writeAuditionMarkers(result.markers, to: csvURL)
- let csv = try String(contentsOf: csvURL, encoding: .utf8)
- XCTAssertTrue(csv.contains("Name\tStart"))
- XCTAssertTrue(csv.contains("test_track_1"))
- // 7. Write CUE sheet
- let cueURL = outputDir.appendingPathComponent("stitched.cue")
- try AudioStitcher.writeCueSheet(
- result.markers,
- audioFileName: "stitched.wav",
- playlistName: "Stitch Test",
- to: cueURL
- )
- let cue = try String(contentsOf: cueURL, encoding: .utf8)
- XCTAssertTrue(cue.contains("FILE \"stitched.wav\" WAVE"))
- // 8. Write track list
- let listURL = outputDir.appendingPathComponent("tracklist.txt")
- try AudioStitcher.writeTrackList(result.markers, playlistName: "Stitch Test", to: listURL)
- let list = try String(contentsOf: listURL, encoding: .utf8)
- XCTAssertTrue(list.contains("Stitch Test"))
- XCTAssertTrue(list.contains("Total: 3 tracks"))
- }
- // MARK: - Waveform → Analysis Workflow
- func testAnalysisWorkflow() async throws {
- let url = try TestHelpers.createTestAudioFile(name: "analysis_e2e", duration: 5.0, frequency: 440)
- // 1. Read metadata
- let metadata = try await MetadataService.readMetadata(from: url)
- XCTAssertGreaterThan(metadata.duration, 4.0)
- // 2. Generate waveform
- let waveform = try await WaveformGenerator.generateWaveform(fileURL: url, resolution: 200)
- XCTAssertEqual(waveform.count, 200)
- // 3. Detect BPM (may fail with test-generated audio)
- do {
- let bpm = try await BPMDetector.detectBPM(fileURL: url)
- XCTAssertGreaterThanOrEqual(bpm, 60)
- XCTAssertLessThanOrEqual(bpm, 200)
- } catch {
- // Acceptable for test WAV
- }
- // 4. Detect key (may fail with test-generated audio)
- do {
- let keyResult = try await KeyDetector.detectKey(fileURL: url)
- XCTAssertFalse(keyResult.key.isEmpty)
- } catch {
- // Acceptable for test WAV
- }
- }
- // MARK: - Multi-Format Export Consistency
- func testAllFormatsExport() throws {
- let playlist = Playlist(name: "Multi Format Test")
- let track = Track(title: "Song", artist: "Artist", filePath: "/tmp/song.mp3", duration: 120, fileFormat: "MP3")
- playlist.addTrack(track)
- var options = ExportOptions.default
- options.copyAudioFiles = false
- // Export in all formats and verify files are created
- for format in MixExporter.ExportFormat.allCases {
- let url = outputDir.appendingPathComponent("test_\(format.rawValue).\(format.fileExtension)")
- try MixExporter.export(playlist: playlist, format: format, to: url, options: options)
- // DAWproject writes to a different path
- let readURL: URL
- if format == .dawProject {
- readURL = url.deletingPathExtension().appendingPathExtension("dawproject.xml")
- } else {
- readURL = url
- }
- let content = try String(contentsOf: readURL, encoding: .utf8)
- XCTAssertGreaterThan(content.count, 10, "Format \(format.name) should produce non-empty output")
- }
- }
- // MARK: - Playlist Operations
- func testPlaylistCRUDOperations() {
- // Create
- let pl = Playlist(name: "CRUD Test")
- XCTAssertEqual(pl.name, "CRUD Test")
- XCTAssertEqual(pl.trackCount, 0)
- // Add tracks
- let t1 = Track(title: "A", filePath: "/a", duration: 60)
- let t2 = Track(title: "B", filePath: "/b", duration: 90)
- let t3 = Track(title: "C", filePath: "/c", duration: 120)
- pl.addTrack(t1)
- pl.addTrack(t2)
- pl.addTrack(t3)
- XCTAssertEqual(pl.trackCount, 3)
- XCTAssertEqual(pl.sortedEntries[0].track?.title, "A")
- XCTAssertEqual(pl.sortedEntries[2].track?.title, "C")
- // Move
- pl.moveEntry(from: 0, to: 2)
- XCTAssertEqual(pl.sortedEntries[0].track?.title, "B")
- XCTAssertEqual(pl.sortedEntries[2].track?.title, "A")
- // Remove
- pl.removeEntry(at: 1)
- XCTAssertEqual(pl.trackCount, 2)
- }
- // MARK: - Cue Points
- func testCuePointWorkflow() {
- let track = Track(title: "T", filePath: "/t", duration: 300)
- let intro = CuePoint(name: "Intro", timestamp: 0, type: .intro)
- let verse = CuePoint(name: "Verse 1", timestamp: 30, type: .verse)
- let drop = CuePoint(name: "Drop", timestamp: 120, type: .drop)
- let outro = CuePoint(name: "Outro", timestamp: 270, type: .outro)
- track.cuePoints = [drop, outro, intro, verse]
- let sorted = track.cuePoints.sorted()
- XCTAssertEqual(sorted[0].name, "Intro")
- XCTAssertEqual(sorted[1].name, "Verse 1")
- XCTAssertEqual(sorted[2].name, "Drop")
- XCTAssertEqual(sorted[3].name, "Outro")
- }
- // MARK: - Crossfade Timeline Calculation
- func testCrossfadeTimeline() {
- let pl = Playlist(name: "CF Test")
- let t1 = Track(title: "A", filePath: "/a", duration: 60)
- let t2 = Track(title: "B", filePath: "/b", duration: 60)
- let t3 = Track(title: "C", filePath: "/c", duration: 60)
- pl.addTrack(t1, crossfadeDuration: 0)
- pl.addTrack(t2, crossfadeDuration: 5) // 5s overlap with A
- pl.addTrack(t3, crossfadeDuration: 10) // 10s overlap with B
- // Total = 60 + 60 + 60 = 180 (raw), but crossfades subtract from effective
- // However, playlist.totalDuration sums track durations (not considering crossfade)
- XCTAssertEqual(pl.totalDuration, 180)
- XCTAssertEqual(pl.trackCount, 3)
- }
- }
|