SyncManagerTests.swift 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. import SwiftData
  2. import XCTest
  3. @testable import MixBoard
  4. @MainActor
  5. final class SyncManagerTests: XCTestCase {
  6. private var container: ModelContainer!
  7. private var context: ModelContext!
  8. private var syncManager: SyncManager!
  9. override func setUp() async throws {
  10. let config = ModelConfiguration(isStoredInMemoryOnly: true)
  11. container = try ModelContainer(
  12. for: Track.self, CuePoint.self, Playlist.self, PlaylistEntry.self, PlaylistFolder.self,
  13. configurations: config
  14. )
  15. context = ModelContext(container)
  16. syncManager = SyncManager()
  17. }
  18. override func tearDown() {
  19. // Clean up any exported sync file
  20. try? FileManager.default.removeItem(at: SyncManager.syncFileURL)
  21. }
  22. // MARK: - Sync File Paths
  23. func testSyncDirectoryExists() {
  24. let dir = SyncManager.syncDirectory
  25. var isDir: ObjCBool = false
  26. XCTAssertTrue(FileManager.default.fileExists(atPath: dir.path, isDirectory: &isDir))
  27. XCTAssertTrue(isDir.boolValue)
  28. }
  29. func testSyncFileURLHasCorrectFilename() {
  30. let url = SyncManager.syncFileURL
  31. XCTAssertEqual(url.lastPathComponent, "mixboard-playlists.json")
  32. }
  33. // MARK: - Export
  34. func testExportEmptyPlaylists() {
  35. syncManager.exportPlaylists([])
  36. XCTAssertTrue(FileManager.default.fileExists(atPath: SyncManager.syncFileURL.path))
  37. XCTAssertNotNil(syncManager.lastSyncDate)
  38. XCTAssertFalse(syncManager.isSyncing)
  39. }
  40. func testExportSinglePlaylist() {
  41. let playlist = Playlist(name: "Friday Mix", notes: "Great set", color: "#FF0000")
  42. playlist.targetBPM = 128.0
  43. let track = Track(title: "Anthem", artist: "DJ Test", filePath: "Music/anthem.mp3", fileName: "anthem.mp3", duration: 240)
  44. track.bpm = 126.5
  45. track.musicalKey = "Am"
  46. playlist.addTrack(track, crossfadeDuration: 3.0)
  47. syncManager.exportPlaylists([playlist])
  48. XCTAssertTrue(FileManager.default.fileExists(atPath: SyncManager.syncFileURL.path))
  49. XCTAssertNil(syncManager.syncError)
  50. }
  51. func testExportMultiplePlaylists() {
  52. let p1 = Playlist(name: "Mix 1")
  53. let p2 = Playlist(name: "Mix 2")
  54. p1.addTrack(Track(title: "Song A", filePath: "Music/a.mp3", fileName: "a.mp3"))
  55. p2.addTrack(Track(title: "Song B", filePath: "Music/b.mp3", fileName: "b.mp3"))
  56. syncManager.exportPlaylists([p1, p2])
  57. XCTAssertTrue(FileManager.default.fileExists(atPath: SyncManager.syncFileURL.path))
  58. XCTAssertNil(syncManager.syncError)
  59. }
  60. // MARK: - Import
  61. func testImportFromExportedFile() throws {
  62. let playlist = Playlist(name: "Round Trip")
  63. let track = Track(title: "Test", artist: "Artist", filePath: "Music/test.mp3", fileName: "test.mp3", duration: 180)
  64. playlist.addTrack(track)
  65. syncManager.exportPlaylists([playlist])
  66. let imported = try syncManager.importPlaylists(from: SyncManager.syncFileURL, context: context)
  67. XCTAssertEqual(imported.count, 1)
  68. XCTAssertEqual(imported[0].name, "Round Trip")
  69. XCTAssertEqual(imported[0].entries.count, 1)
  70. XCTAssertEqual(imported[0].entries[0].filename, "test.mp3")
  71. }
  72. func testImportInvalidJSON() {
  73. let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("invalid.json")
  74. try? "not json at all".data(using: .utf8)?.write(to: tempURL)
  75. defer { try? FileManager.default.removeItem(at: tempURL) }
  76. XCTAssertThrowsError(try syncManager.importPlaylists(from: tempURL, context: context))
  77. }
  78. func testImportNonExistentFile() {
  79. let fakeURL = URL(fileURLWithPath: "/nonexistent/fake.json")
  80. XCTAssertThrowsError(try syncManager.importPlaylists(from: fakeURL, context: context))
  81. }
  82. // MARK: - Merge
  83. func testMergeWithMatchingTracks() throws {
  84. // Insert a track into the DB
  85. let track = Track(title: "Matched", artist: "DJ", filePath: "Music/matched.mp3", fileName: "matched.mp3", duration: 200)
  86. context.insert(track)
  87. try context.save()
  88. // Create sync data referencing the same filename
  89. let syncEntry = SyncEntry(from: PlaylistEntry(position: 0, track: track))
  90. let syncPlaylist = SyncPlaylist(
  91. from: {
  92. let pl = Playlist(name: "Imported Mix")
  93. pl.addTrack(track)
  94. return pl
  95. }()
  96. )
  97. let result = syncManager.mergeImportedPlaylists([syncPlaylist], existingTracks: [track], context: context)
  98. XCTAssertEqual(result.created, 1)
  99. XCTAssertEqual(result.matched, 1)
  100. XCTAssertEqual(result.unmatched, 0)
  101. }
  102. func testMergeWithUnmatchedTracks() {
  103. // No tracks in the DB, so all entries will be unmatched
  104. let playlist = Playlist(name: "Unmatched")
  105. let fakeTrack = Track(title: "Missing", filePath: "Music/missing.mp3", fileName: "missing.mp3")
  106. playlist.addTrack(fakeTrack)
  107. let syncPlaylist = SyncPlaylist(from: playlist)
  108. let result = syncManager.mergeImportedPlaylists([syncPlaylist], existingTracks: [], context: context)
  109. XCTAssertEqual(result.created, 1)
  110. XCTAssertEqual(result.matched, 0)
  111. XCTAssertEqual(result.unmatched, 1)
  112. }
  113. func testMergeCreatesCorrectNumberOfPlaylists() {
  114. let p1 = Playlist(name: "Mix A")
  115. let p2 = Playlist(name: "Mix B")
  116. let p3 = Playlist(name: "Mix C")
  117. let syncs = [SyncPlaylist(from: p1), SyncPlaylist(from: p2), SyncPlaylist(from: p3)]
  118. let result = syncManager.mergeImportedPlaylists(syncs, existingTracks: [], context: context)
  119. XCTAssertEqual(result.created, 3)
  120. }
  121. func testMergePreservesCrossfadeSettings() throws {
  122. let track = Track(title: "Song", filePath: "Music/song.mp3", fileName: "song.mp3", duration: 300)
  123. context.insert(track)
  124. try context.save()
  125. let playlist = Playlist(name: "Mix")
  126. playlist.addTrack(track, crossfadeDuration: 5.0)
  127. // Modify the entry offsets
  128. let entry = playlist.sortedEntries.first!
  129. entry.startOffset = 10
  130. entry.endOffset = 280
  131. entry.gainAdjustment = 3.5
  132. let syncPlaylist = SyncPlaylist(from: playlist)
  133. let result = syncManager.mergeImportedPlaylists([syncPlaylist], existingTracks: [track], context: context)
  134. XCTAssertEqual(result.matched, 1)
  135. // Verify the merged playlist has the correct settings
  136. let descriptor = FetchDescriptor<Playlist>(predicate: #Predicate { $0.name == "Mix" })
  137. let fetched = try context.fetch(descriptor)
  138. XCTAssertEqual(fetched.count, 1)
  139. let mergedEntry = fetched.first?.sortedEntries.first
  140. XCTAssertEqual(mergedEntry?.crossfadeDuration, 5.0)
  141. XCTAssertEqual(mergedEntry?.startOffset, 10)
  142. XCTAssertEqual(mergedEntry?.endOffset, 280)
  143. XCTAssertEqual(mergedEntry?.gainAdjustment, 3.5)
  144. }
  145. // MARK: - Round-Trip Export → Import → Merge
  146. func testFullRoundTrip() throws {
  147. // Create and export
  148. let track1 = Track(title: "Alpha", artist: "DJ One", filePath: "Music/alpha.mp3", fileName: "alpha.mp3", duration: 200)
  149. track1.bpm = 128
  150. track1.musicalKey = "Cm"
  151. let track2 = Track(title: "Beta", artist: "DJ Two", filePath: "Music/beta.flac", fileName: "beta.flac", duration: 180)
  152. let playlist = Playlist(name: "Export Test", notes: "Testing sync", color: "#00FF00")
  153. playlist.targetBPM = 130
  154. playlist.addTrack(track1, crossfadeDuration: 3.0)
  155. playlist.addTrack(track2, crossfadeDuration: 2.5)
  156. syncManager.exportPlaylists([playlist])
  157. XCTAssertTrue(FileManager.default.fileExists(atPath: SyncManager.syncFileURL.path))
  158. // Import back
  159. let imported = try syncManager.importPlaylists(from: SyncManager.syncFileURL, context: context)
  160. XCTAssertEqual(imported.count, 1)
  161. let sp = imported[0]
  162. XCTAssertEqual(sp.name, "Export Test")
  163. XCTAssertEqual(sp.notes, "Testing sync")
  164. XCTAssertEqual(sp.color, "#00FF00")
  165. XCTAssertEqual(sp.targetBPM, 130)
  166. XCTAssertEqual(sp.entries.count, 2)
  167. XCTAssertEqual(sp.entries[0].filename, "alpha.mp3")
  168. XCTAssertEqual(sp.entries[0].bpm, 128)
  169. XCTAssertEqual(sp.entries[0].musicalKey, "Cm")
  170. XCTAssertEqual(sp.entries[0].crossfadeDuration, 3.0)
  171. XCTAssertEqual(sp.entries[1].filename, "beta.flac")
  172. XCTAssertEqual(sp.entries[1].crossfadeDuration, 2.5)
  173. // Merge with matching tracks in DB
  174. context.insert(track1)
  175. context.insert(track2)
  176. try context.save()
  177. let result = syncManager.mergeImportedPlaylists(imported, existingTracks: [track1, track2], context: context)
  178. XCTAssertEqual(result.created, 1)
  179. XCTAssertEqual(result.matched, 2)
  180. XCTAssertEqual(result.unmatched, 0)
  181. }
  182. // MARK: - SyncPayload Encoding
  183. func testSyncPayloadVersionField() throws {
  184. syncManager.exportPlaylists([])
  185. let data = try Data(contentsOf: SyncManager.syncFileURL)
  186. let decoder = JSONDecoder()
  187. decoder.dateDecodingStrategy = .iso8601
  188. let payload = try decoder.decode(SyncPayload.self, from: data)
  189. XCTAssertEqual(payload.version, 1)
  190. }
  191. func testSyncPayloadExportedFromField() throws {
  192. syncManager.exportPlaylists([])
  193. let data = try Data(contentsOf: SyncManager.syncFileURL)
  194. let decoder = JSONDecoder()
  195. decoder.dateDecodingStrategy = .iso8601
  196. let payload = try decoder.decode(SyncPayload.self, from: data)
  197. XCTAssertFalse(payload.exportedFrom.isEmpty)
  198. }
  199. // MARK: - State
  200. func testInitialState() {
  201. XCTAssertNil(syncManager.lastSyncDate)
  202. XCTAssertFalse(syncManager.isSyncing)
  203. XCTAssertNil(syncManager.syncError)
  204. }
  205. func testExportUpdatesLastSyncDate() {
  206. XCTAssertNil(syncManager.lastSyncDate)
  207. syncManager.exportPlaylists([])
  208. XCTAssertNotNil(syncManager.lastSyncDate)
  209. }
  210. }