PlaylistViewModelTests.swift 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  1. import SwiftData
  2. import XCTest
  3. @testable import MixBoard
  4. // MARK: - Test Helpers
  5. /// Creates an in-memory ModelContainer for testing SwiftData operations.
  6. func makeInMemoryContainer() throws -> ModelContainer {
  7. let config = ModelConfiguration(isStoredInMemoryOnly: true)
  8. return try ModelContainer(
  9. for: Track.self, CuePoint.self, Playlist.self, PlaylistEntry.self, PlaylistFolder.self,
  10. configurations: config
  11. )
  12. }
  13. // MARK: - PlaylistViewModelTests
  14. @MainActor
  15. final class PlaylistViewModelTests: XCTestCase {
  16. private var container: ModelContainer!
  17. private var context: ModelContext!
  18. private var vm: PlaylistViewModel!
  19. override func setUp() async throws {
  20. container = try makeInMemoryContainer()
  21. context = ModelContext(container)
  22. vm = PlaylistViewModel()
  23. }
  24. override func tearDown() {
  25. // Clean up UserDefaults keys used by mix targets
  26. for slot in 0..<3 {
  27. UserDefaults.standard.removeObject(forKey: "mixTarget\(slot)ID")
  28. }
  29. }
  30. // MARK: - Initial State
  31. func testInitialState() {
  32. XCTAssertNil(vm.selectedPlaylist)
  33. XCTAssertFalse(vm.showExportSheet)
  34. XCTAssertNil(vm.exportError)
  35. XCTAssertNil(vm.statusMessage)
  36. XCTAssertEqual(vm.defaultCrossfadeDuration, 2.0)
  37. }
  38. func testInitialMixTargetsNil() {
  39. XCTAssertEqual(vm.mixTargets.count, 3)
  40. XCTAssertNil(vm.mixTargets[0])
  41. XCTAssertNil(vm.mixTargets[1])
  42. XCTAssertNil(vm.mixTargets[2])
  43. }
  44. // MARK: - Mix Targets
  45. func testSetMixTargetValidSlot() {
  46. let playlist = Playlist(name: "Mix A")
  47. context.insert(playlist)
  48. try? context.save()
  49. vm.setMixTarget(0, playlist: playlist)
  50. XCTAssertEqual(vm.mixTargets[0]?.name, "Mix A")
  51. XCTAssertEqual(UserDefaults.standard.string(forKey: "mixTarget0ID"), playlist.id.uuidString)
  52. }
  53. func testSetMixTargetAllSlots() {
  54. let p0 = Playlist(name: "Red Mix")
  55. let p1 = Playlist(name: "Blue Mix")
  56. let p2 = Playlist(name: "Gold Mix")
  57. context.insert(p0); context.insert(p1); context.insert(p2)
  58. try? context.save()
  59. vm.setMixTarget(0, playlist: p0)
  60. vm.setMixTarget(1, playlist: p1)
  61. vm.setMixTarget(2, playlist: p2)
  62. XCTAssertEqual(vm.mixTargets[0]?.name, "Red Mix")
  63. XCTAssertEqual(vm.mixTargets[1]?.name, "Blue Mix")
  64. XCTAssertEqual(vm.mixTargets[2]?.name, "Gold Mix")
  65. }
  66. func testSetMixTargetInvalidSlot() {
  67. let playlist = Playlist(name: "Test")
  68. vm.setMixTarget(-1, playlist: playlist)
  69. vm.setMixTarget(3, playlist: playlist)
  70. // Should not crash, and mixTargets should remain unchanged
  71. XCTAssertNil(vm.mixTargets[0])
  72. XCTAssertNil(vm.mixTargets[1])
  73. XCTAssertNil(vm.mixTargets[2])
  74. }
  75. func testSetMixTargetNilClearsUserDefaults() {
  76. let playlist = Playlist(name: "Target")
  77. context.insert(playlist)
  78. try? context.save()
  79. vm.setMixTarget(0, playlist: playlist)
  80. XCTAssertNotNil(UserDefaults.standard.string(forKey: "mixTarget0ID"))
  81. vm.setMixTarget(0, playlist: nil)
  82. XCTAssertNil(vm.mixTargets[0])
  83. XCTAssertNil(UserDefaults.standard.string(forKey: "mixTarget0ID"))
  84. }
  85. func testMixTargetNameWithPlaylist() {
  86. let playlist = Playlist(name: "Friday Night")
  87. vm.setMixTarget(1, playlist: playlist)
  88. XCTAssertEqual(vm.mixTargetName(1), "Friday Night")
  89. }
  90. func testMixTargetNameWithoutPlaylist() {
  91. XCTAssertEqual(vm.mixTargetName(0), "Mix 1")
  92. XCTAssertEqual(vm.mixTargetName(1), "Mix 2")
  93. XCTAssertEqual(vm.mixTargetName(2), "Mix 3")
  94. }
  95. func testMixTargetNameInvalidSlot() {
  96. XCTAssertEqual(vm.mixTargetName(5), "Mix 6")
  97. }
  98. func testRestoreTargetPlaylist() {
  99. let playlist = Playlist(name: "Saved Target")
  100. context.insert(playlist)
  101. try? context.save()
  102. // Simulate persisted state
  103. UserDefaults.standard.set(playlist.id.uuidString, forKey: "mixTarget0ID")
  104. vm.restoreTargetPlaylist(from: [playlist])
  105. XCTAssertEqual(vm.mixTargets[0]?.name, "Saved Target")
  106. }
  107. func testRestoreTargetPlaylistNoMatch() {
  108. UserDefaults.standard.set(UUID().uuidString, forKey: "mixTarget0ID")
  109. vm.restoreTargetPlaylist(from: [])
  110. XCTAssertNil(vm.mixTargets[0])
  111. }
  112. // MARK: - Legacy targetPlaylist
  113. func testLegacyTargetPlaylist() {
  114. let playlist = Playlist(name: "Legacy")
  115. vm.targetPlaylist = playlist
  116. XCTAssertEqual(vm.mixTargets[0]?.name, "Legacy")
  117. XCTAssertEqual(vm.targetPlaylist?.name, "Legacy")
  118. }
  119. // MARK: - Playlist CRUD
  120. func testCreatePlaylist() {
  121. let playlist = vm.createPlaylist(name: "New Mix", context: context)
  122. XCTAssertEqual(playlist.name, "New Mix")
  123. let descriptor = FetchDescriptor<Playlist>()
  124. let fetched = try? context.fetch(descriptor)
  125. XCTAssertEqual(fetched?.count, 1)
  126. XCTAssertEqual(fetched?.first?.name, "New Mix")
  127. }
  128. func testDeletePlaylist() {
  129. let playlist = vm.createPlaylist(name: "To Delete", context: context)
  130. vm.selectedPlaylist = playlist
  131. vm.deletePlaylist(playlist, context: context)
  132. let descriptor = FetchDescriptor<Playlist>()
  133. let fetched = try? context.fetch(descriptor)
  134. XCTAssertEqual(fetched?.count, 0)
  135. XCTAssertNil(vm.selectedPlaylist)
  136. }
  137. func testDeletePlaylistDoesNotClearOtherSelection() {
  138. let p1 = vm.createPlaylist(name: "Keep", context: context)
  139. let p2 = vm.createPlaylist(name: "Delete", context: context)
  140. vm.selectedPlaylist = p1
  141. vm.deletePlaylist(p2, context: context)
  142. XCTAssertEqual(vm.selectedPlaylist?.name, "Keep")
  143. }
  144. func testRenamePlaylist() {
  145. let playlist = vm.createPlaylist(name: "Original", context: context)
  146. let originalDate = playlist.dateModified
  147. vm.renamePlaylist(playlist, to: "Renamed", context: context)
  148. XCTAssertEqual(playlist.name, "Renamed")
  149. XCTAssertGreaterThanOrEqual(playlist.dateModified, originalDate)
  150. }
  151. // MARK: - Track Management
  152. func testAddTrackToPlaylist() {
  153. let playlist = vm.createPlaylist(name: "Mix", context: context)
  154. let track = Track(title: "Song", filePath: "Music/song.mp3")
  155. context.insert(track)
  156. vm.addTrack(track, to: playlist, context: context)
  157. XCTAssertEqual(playlist.trackCount, 1)
  158. XCTAssertEqual(playlist.sortedEntries.first?.crossfadeDuration, 2.0) // default
  159. }
  160. func testAddDuplicateTrackShowsWarning() {
  161. let playlist = vm.createPlaylist(name: "Mix", context: context)
  162. let track = Track(title: "Song", filePath: "Music/song.mp3")
  163. context.insert(track)
  164. vm.addTrack(track, to: playlist, context: context)
  165. vm.addTrack(track, to: playlist, context: context)
  166. // Second add should be rejected (duplicate)
  167. XCTAssertEqual(playlist.trackCount, 1)
  168. XCTAssertNotNil(vm.statusMessage)
  169. }
  170. func testAddTracksMultiple() {
  171. let playlist = vm.createPlaylist(name: "Mix", context: context)
  172. let t1 = Track(title: "A", filePath: "Music/b.mp3") // intentionally out of alphabetical order
  173. let t2 = Track(title: "B", filePath: "Music/a.mp3")
  174. context.insert(t1); context.insert(t2)
  175. vm.addTracks([t1, t2], to: playlist, context: context)
  176. XCTAssertEqual(playlist.trackCount, 2)
  177. // Should be sorted by filePath
  178. let sorted = playlist.sortedEntries
  179. XCTAssertEqual(sorted[0].track?.filePath, "Music/a.mp3")
  180. XCTAssertEqual(sorted[1].track?.filePath, "Music/b.mp3")
  181. }
  182. func testAddTracksSkipsDuplicates() {
  183. let playlist = vm.createPlaylist(name: "Mix", context: context)
  184. let track = Track(title: "Song", filePath: "Music/song.mp3")
  185. context.insert(track)
  186. vm.addTrack(track, to: playlist, context: context)
  187. vm.addTracks([track], to: playlist, context: context)
  188. XCTAssertEqual(playlist.trackCount, 1)
  189. }
  190. func testRemoveEntry() {
  191. let playlist = vm.createPlaylist(name: "Mix", context: context)
  192. let track = Track(title: "Song", filePath: "Music/song.mp3")
  193. context.insert(track)
  194. vm.addTrack(track, to: playlist, context: context)
  195. let entry = playlist.sortedEntries.first!
  196. vm.removeEntry(entry, from: playlist, context: context)
  197. XCTAssertEqual(playlist.trackCount, 0)
  198. }
  199. func testMoveEntry() {
  200. let playlist = vm.createPlaylist(name: "Mix", context: context)
  201. let t1 = Track(title: "A", filePath: "Music/a.mp3")
  202. let t2 = Track(title: "B", filePath: "Music/b.mp3")
  203. let t3 = Track(title: "C", filePath: "Music/c.mp3")
  204. context.insert(t1); context.insert(t2); context.insert(t3)
  205. playlist.addTrack(t1); playlist.addTrack(t2); playlist.addTrack(t3)
  206. vm.moveEntry(in: playlist, from: 2, to: 0, context: context)
  207. let sorted = playlist.sortedEntries
  208. XCTAssertEqual(sorted[0].track?.title, "C")
  209. XCTAssertEqual(sorted[1].track?.title, "A")
  210. XCTAssertEqual(sorted[2].track?.title, "B")
  211. }
  212. // MARK: - Quick Add
  213. func testQuickAddToMixNoTarget() {
  214. let track = Track(title: "Song", filePath: "Music/song.mp3")
  215. context.insert(track)
  216. let result = vm.quickAddToMix(slot: 0, track: track, context: context)
  217. XCTAssertFalse(result)
  218. XCTAssertNotNil(vm.statusMessage)
  219. }
  220. func testQuickAddToMixSuccess() {
  221. let playlist = vm.createPlaylist(name: "Target", context: context)
  222. let track = Track(title: "Song", filePath: "Music/song.mp3")
  223. context.insert(track)
  224. vm.setMixTarget(0, playlist: playlist)
  225. let result = vm.quickAddToMix(slot: 0, track: track, context: context)
  226. XCTAssertTrue(result)
  227. XCTAssertEqual(playlist.trackCount, 1)
  228. }
  229. func testQuickAddToTargetLegacy() {
  230. let playlist = vm.createPlaylist(name: "Target", context: context)
  231. let track = Track(title: "Song", filePath: "Music/song.mp3")
  232. context.insert(track)
  233. vm.targetPlaylist = playlist
  234. let result = vm.quickAddToTarget(track: track, context: context)
  235. XCTAssertTrue(result)
  236. XCTAssertEqual(playlist.trackCount, 1)
  237. }
  238. func testQuickAddDuplicate() {
  239. let playlist = vm.createPlaylist(name: "Target", context: context)
  240. let track = Track(title: "Song", filePath: "Music/song.mp3")
  241. context.insert(track)
  242. vm.setMixTarget(0, playlist: playlist)
  243. _ = vm.quickAddToMix(slot: 0, track: track, context: context)
  244. let result2 = vm.quickAddToMix(slot: 0, track: track, context: context)
  245. XCTAssertFalse(result2)
  246. XCTAssertEqual(playlist.trackCount, 1)
  247. }
  248. // MARK: - Crossfade & Gain
  249. func testUpdateCrossfade() {
  250. let playlist = vm.createPlaylist(name: "Mix", context: context)
  251. let track = Track(title: "Song", filePath: "Music/song.mp3")
  252. context.insert(track)
  253. vm.addTrack(track, to: playlist, context: context)
  254. let entry = playlist.sortedEntries.first!
  255. vm.updateCrossfade(for: entry, duration: 5.0, context: context)
  256. XCTAssertEqual(entry.crossfadeDuration, 5.0)
  257. }
  258. func testUpdateGain() {
  259. let playlist = vm.createPlaylist(name: "Mix", context: context)
  260. let track = Track(title: "Song", filePath: "Music/song.mp3")
  261. context.insert(track)
  262. vm.addTrack(track, to: playlist, context: context)
  263. let entry = playlist.sortedEntries.first!
  264. vm.updateGain(for: entry, gain: 3.5, context: context)
  265. XCTAssertEqual(entry.gainAdjustment, 3.5)
  266. }
  267. // MARK: - Cue Points
  268. func testAddCuePoint() {
  269. let track = Track(title: "Song", filePath: "Music/song.mp3")
  270. context.insert(track)
  271. vm.addCuePoint(to: track, at: 30.0, type: .drop, name: "The Drop", context: context)
  272. XCTAssertEqual(track.cuePoints.count, 1)
  273. XCTAssertEqual(track.cuePoints.first?.name, "The Drop")
  274. XCTAssertEqual(track.cuePoints.first?.type, .drop)
  275. XCTAssertEqual(track.cuePoints.first?.timestamp, 30.0)
  276. }
  277. func testRemoveCuePoint() {
  278. let track = Track(title: "Song", filePath: "Music/song.mp3")
  279. context.insert(track)
  280. vm.addCuePoint(to: track, at: 10.0, name: "Marker", context: context)
  281. XCTAssertEqual(track.cuePoints.count, 1)
  282. let cuePoint = track.cuePoints.first!
  283. vm.removeCuePoint(cuePoint, from: track, context: context)
  284. XCTAssertEqual(track.cuePoints.count, 0)
  285. }
  286. // MARK: - Mix Duration
  287. func testMixDurationEmpty() {
  288. let playlist = Playlist(name: "Empty")
  289. XCTAssertEqual(vm.mixDuration(for: playlist), 0)
  290. }
  291. func testMixDurationSingleTrack() {
  292. let playlist = Playlist(name: "Mix")
  293. let track = Track(title: "Song", filePath: "Music/song.mp3", duration: 200)
  294. playlist.addTrack(track)
  295. XCTAssertEqual(vm.mixDuration(for: playlist), 200, accuracy: 0.01)
  296. }
  297. func testMixDurationWithCrossfades() {
  298. let playlist = Playlist(name: "Mix")
  299. let t1 = Track(title: "A", filePath: "Music/a.mp3", duration: 200)
  300. let t2 = Track(title: "B", filePath: "Music/b.mp3", duration: 180)
  301. let t3 = Track(title: "C", filePath: "Music/c.mp3", duration: 240)
  302. playlist.addTrack(t1, crossfadeDuration: 0)
  303. playlist.addTrack(t2, crossfadeDuration: 5)
  304. playlist.addTrack(t3, crossfadeDuration: 3)
  305. // total = 200 + 180 + 240 - 5 - 3 = 612
  306. XCTAssertEqual(vm.mixDuration(for: playlist), 612, accuracy: 0.01)
  307. }
  308. // MARK: - Show Status
  309. func testShowStatus() {
  310. vm.showStatus("Test message")
  311. XCTAssertEqual(vm.statusMessage, "Test message")
  312. }
  313. func testShowStatusNilInitially() {
  314. XCTAssertNil(vm.statusMessage)
  315. }
  316. // MARK: - isDuplicate
  317. func testIsDuplicateWithContext() {
  318. let playlist = vm.createPlaylist(name: "Mix", context: context)
  319. let track = Track(title: "Song", filePath: "Music/song.mp3")
  320. context.insert(track)
  321. XCTAssertFalse(vm.isDuplicate(track: track, in: playlist, context: context))
  322. vm.addTrack(track, to: playlist, context: context)
  323. XCTAssertTrue(vm.isDuplicate(track: track, in: playlist, context: context))
  324. }
  325. func testIsDuplicateWithoutContext() {
  326. let playlist = Playlist(name: "Mix")
  327. let track = Track(title: "Song", filePath: "Music/song.mp3")
  328. XCTAssertFalse(vm.isDuplicate(track: track, in: playlist))
  329. playlist.addTrack(track)
  330. XCTAssertTrue(vm.isDuplicate(track: track, in: playlist))
  331. }
  332. }