import SwiftData import XCTest @testable import MixBoard @MainActor final class SyncManagerTests: XCTestCase { private var container: ModelContainer! private var context: ModelContext! private var syncManager: SyncManager! override func setUp() async throws { let config = ModelConfiguration(isStoredInMemoryOnly: true) container = try ModelContainer( for: Track.self, CuePoint.self, Playlist.self, PlaylistEntry.self, PlaylistFolder.self, configurations: config ) context = ModelContext(container) syncManager = SyncManager() } override func tearDown() { // Clean up any exported sync file try? FileManager.default.removeItem(at: SyncManager.syncFileURL) } // MARK: - Sync File Paths func testSyncDirectoryExists() { let dir = SyncManager.syncDirectory var isDir: ObjCBool = false XCTAssertTrue(FileManager.default.fileExists(atPath: dir.path, isDirectory: &isDir)) XCTAssertTrue(isDir.boolValue) } func testSyncFileURLHasCorrectFilename() { let url = SyncManager.syncFileURL XCTAssertEqual(url.lastPathComponent, "mixboard-playlists.json") } // MARK: - Export func testExportEmptyPlaylists() { syncManager.exportPlaylists([]) XCTAssertTrue(FileManager.default.fileExists(atPath: SyncManager.syncFileURL.path)) XCTAssertNotNil(syncManager.lastSyncDate) XCTAssertFalse(syncManager.isSyncing) } func testExportSinglePlaylist() { let playlist = Playlist(name: "Friday Mix", notes: "Great set", color: "#FF0000") playlist.targetBPM = 128.0 let track = Track(title: "Anthem", artist: "DJ Test", filePath: "Music/anthem.mp3", fileName: "anthem.mp3", duration: 240) track.bpm = 126.5 track.musicalKey = "Am" playlist.addTrack(track, crossfadeDuration: 3.0) syncManager.exportPlaylists([playlist]) XCTAssertTrue(FileManager.default.fileExists(atPath: SyncManager.syncFileURL.path)) XCTAssertNil(syncManager.syncError) } func testExportMultiplePlaylists() { let p1 = Playlist(name: "Mix 1") let p2 = Playlist(name: "Mix 2") p1.addTrack(Track(title: "Song A", filePath: "Music/a.mp3", fileName: "a.mp3")) p2.addTrack(Track(title: "Song B", filePath: "Music/b.mp3", fileName: "b.mp3")) syncManager.exportPlaylists([p1, p2]) XCTAssertTrue(FileManager.default.fileExists(atPath: SyncManager.syncFileURL.path)) XCTAssertNil(syncManager.syncError) } // MARK: - Import func testImportFromExportedFile() throws { let playlist = Playlist(name: "Round Trip") let track = Track(title: "Test", artist: "Artist", filePath: "Music/test.mp3", fileName: "test.mp3", duration: 180) playlist.addTrack(track) syncManager.exportPlaylists([playlist]) let imported = try syncManager.importPlaylists(from: SyncManager.syncFileURL, context: context) XCTAssertEqual(imported.count, 1) XCTAssertEqual(imported[0].name, "Round Trip") XCTAssertEqual(imported[0].entries.count, 1) XCTAssertEqual(imported[0].entries[0].filename, "test.mp3") } func testImportInvalidJSON() { let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("invalid.json") try? "not json at all".data(using: .utf8)?.write(to: tempURL) defer { try? FileManager.default.removeItem(at: tempURL) } XCTAssertThrowsError(try syncManager.importPlaylists(from: tempURL, context: context)) } func testImportNonExistentFile() { let fakeURL = URL(fileURLWithPath: "/nonexistent/fake.json") XCTAssertThrowsError(try syncManager.importPlaylists(from: fakeURL, context: context)) } // MARK: - Merge func testMergeWithMatchingTracks() throws { // Insert a track into the DB let track = Track(title: "Matched", artist: "DJ", filePath: "Music/matched.mp3", fileName: "matched.mp3", duration: 200) context.insert(track) try context.save() // Create sync data referencing the same filename let syncEntry = SyncEntry(from: PlaylistEntry(position: 0, track: track)) let syncPlaylist = SyncPlaylist( from: { let pl = Playlist(name: "Imported Mix") pl.addTrack(track) return pl }() ) let result = syncManager.mergeImportedPlaylists([syncPlaylist], existingTracks: [track], context: context) XCTAssertEqual(result.created, 1) XCTAssertEqual(result.matched, 1) XCTAssertEqual(result.unmatched, 0) } func testMergeWithUnmatchedTracks() { // No tracks in the DB, so all entries will be unmatched let playlist = Playlist(name: "Unmatched") let fakeTrack = Track(title: "Missing", filePath: "Music/missing.mp3", fileName: "missing.mp3") playlist.addTrack(fakeTrack) let syncPlaylist = SyncPlaylist(from: playlist) let result = syncManager.mergeImportedPlaylists([syncPlaylist], existingTracks: [], context: context) XCTAssertEqual(result.created, 1) XCTAssertEqual(result.matched, 0) XCTAssertEqual(result.unmatched, 1) } func testMergeCreatesCorrectNumberOfPlaylists() { let p1 = Playlist(name: "Mix A") let p2 = Playlist(name: "Mix B") let p3 = Playlist(name: "Mix C") let syncs = [SyncPlaylist(from: p1), SyncPlaylist(from: p2), SyncPlaylist(from: p3)] let result = syncManager.mergeImportedPlaylists(syncs, existingTracks: [], context: context) XCTAssertEqual(result.created, 3) } func testMergePreservesCrossfadeSettings() throws { let track = Track(title: "Song", filePath: "Music/song.mp3", fileName: "song.mp3", duration: 300) context.insert(track) try context.save() let playlist = Playlist(name: "Mix") playlist.addTrack(track, crossfadeDuration: 5.0) // Modify the entry offsets let entry = playlist.sortedEntries.first! entry.startOffset = 10 entry.endOffset = 280 entry.gainAdjustment = 3.5 let syncPlaylist = SyncPlaylist(from: playlist) let result = syncManager.mergeImportedPlaylists([syncPlaylist], existingTracks: [track], context: context) XCTAssertEqual(result.matched, 1) // Verify the merged playlist has the correct settings let descriptor = FetchDescriptor(predicate: #Predicate { $0.name == "Mix" }) let fetched = try context.fetch(descriptor) XCTAssertEqual(fetched.count, 1) let mergedEntry = fetched.first?.sortedEntries.first XCTAssertEqual(mergedEntry?.crossfadeDuration, 5.0) XCTAssertEqual(mergedEntry?.startOffset, 10) XCTAssertEqual(mergedEntry?.endOffset, 280) XCTAssertEqual(mergedEntry?.gainAdjustment, 3.5) } // MARK: - Round-Trip Export → Import → Merge func testFullRoundTrip() throws { // Create and export let track1 = Track(title: "Alpha", artist: "DJ One", filePath: "Music/alpha.mp3", fileName: "alpha.mp3", duration: 200) track1.bpm = 128 track1.musicalKey = "Cm" let track2 = Track(title: "Beta", artist: "DJ Two", filePath: "Music/beta.flac", fileName: "beta.flac", duration: 180) let playlist = Playlist(name: "Export Test", notes: "Testing sync", color: "#00FF00") playlist.targetBPM = 130 playlist.addTrack(track1, crossfadeDuration: 3.0) playlist.addTrack(track2, crossfadeDuration: 2.5) syncManager.exportPlaylists([playlist]) XCTAssertTrue(FileManager.default.fileExists(atPath: SyncManager.syncFileURL.path)) // Import back let imported = try syncManager.importPlaylists(from: SyncManager.syncFileURL, context: context) XCTAssertEqual(imported.count, 1) let sp = imported[0] XCTAssertEqual(sp.name, "Export Test") XCTAssertEqual(sp.notes, "Testing sync") XCTAssertEqual(sp.color, "#00FF00") XCTAssertEqual(sp.targetBPM, 130) XCTAssertEqual(sp.entries.count, 2) XCTAssertEqual(sp.entries[0].filename, "alpha.mp3") XCTAssertEqual(sp.entries[0].bpm, 128) XCTAssertEqual(sp.entries[0].musicalKey, "Cm") XCTAssertEqual(sp.entries[0].crossfadeDuration, 3.0) XCTAssertEqual(sp.entries[1].filename, "beta.flac") XCTAssertEqual(sp.entries[1].crossfadeDuration, 2.5) // Merge with matching tracks in DB context.insert(track1) context.insert(track2) try context.save() let result = syncManager.mergeImportedPlaylists(imported, existingTracks: [track1, track2], context: context) XCTAssertEqual(result.created, 1) XCTAssertEqual(result.matched, 2) XCTAssertEqual(result.unmatched, 0) } // MARK: - SyncPayload Encoding func testSyncPayloadVersionField() throws { syncManager.exportPlaylists([]) let data = try Data(contentsOf: SyncManager.syncFileURL) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 let payload = try decoder.decode(SyncPayload.self, from: data) XCTAssertEqual(payload.version, 1) } func testSyncPayloadExportedFromField() throws { syncManager.exportPlaylists([]) let data = try Data(contentsOf: SyncManager.syncFileURL) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 let payload = try decoder.decode(SyncPayload.self, from: data) XCTAssertFalse(payload.exportedFrom.isEmpty) } // MARK: - State func testInitialState() { XCTAssertNil(syncManager.lastSyncDate) XCTAssertFalse(syncManager.isSyncing) XCTAssertNil(syncManager.syncError) } func testExportUpdatesLastSyncDate() { XCTAssertNil(syncManager.lastSyncDate) syncManager.exportPlaylists([]) XCTAssertNotNil(syncManager.lastSyncDate) } }