ModelTests.swift 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493
  1. import XCTest
  2. import SwiftData
  3. @testable import MixBoard
  4. /// Tests for the Track model.
  5. final class TrackModelTests: XCTestCase {
  6. func testTrackCreation() {
  7. let track = Track(
  8. title: "Test Song",
  9. artist: "Test Artist",
  10. album: "Test Album",
  11. genre: "Rock",
  12. filePath: "/tmp/test.mp3",
  13. duration: 180,
  14. sampleRate: 44100,
  15. bitDepth: 16,
  16. channels: 2,
  17. fileFormat: "MP3",
  18. fileSizeBytes: 5_000_000
  19. )
  20. XCTAssertEqual(track.title, "Test Song")
  21. XCTAssertEqual(track.artist, "Test Artist")
  22. XCTAssertEqual(track.album, "Test Album")
  23. XCTAssertEqual(track.genre, "Rock")
  24. XCTAssertEqual(track.duration, 180)
  25. XCTAssertEqual(track.sampleRate, 44100)
  26. XCTAssertEqual(track.bitDepth, 16)
  27. XCTAssertEqual(track.channels, 2)
  28. XCTAssertEqual(track.fileFormat, "MP3")
  29. XCTAssertEqual(track.fileSizeBytes, 5_000_000)
  30. XCTAssertEqual(track.rating, 0)
  31. XCTAssertEqual(track.playCount, 0)
  32. XCTAssertFalse(track.isAnalyzed)
  33. XCTAssertNil(track.bpm)
  34. XCTAssertNil(track.musicalKey)
  35. XCTAssertTrue(track.cuePoints.isEmpty)
  36. }
  37. func testFormattedDuration() {
  38. let track = Track(title: "T", filePath: "/t", duration: 185)
  39. XCTAssertEqual(track.formattedDuration, "3:05")
  40. let track2 = Track(title: "T", filePath: "/t", duration: 60)
  41. XCTAssertEqual(track2.formattedDuration, "1:00")
  42. let track3 = Track(title: "T", filePath: "/t", duration: 0)
  43. XCTAssertEqual(track3.formattedDuration, "0:00")
  44. }
  45. func testFormattedBPM() {
  46. let track = Track(title: "T", filePath: "/t")
  47. XCTAssertEqual(track.formattedBPM, "—")
  48. track.bpm = 128.5
  49. XCTAssertEqual(track.formattedBPM, "128.5")
  50. }
  51. func testFileURL() {
  52. let track = Track(title: "T", filePath: "/Users/test/music/song.mp3")
  53. XCTAssertEqual(track.fileURL.path, "/Users/test/music/song.mp3")
  54. }
  55. func testFormattedFileSize() {
  56. let track = Track(title: "T", filePath: "/t", fileSizeBytes: 1_048_576)
  57. let formatted = track.formattedFileSize
  58. // Should be approximately "1 MB"
  59. XCTAssertTrue(formatted.contains("MB") || formatted.contains("1"))
  60. }
  61. }
  62. /// Tests for the CuePoint model.
  63. final class CuePointModelTests: XCTestCase {
  64. func testCuePointCreation() {
  65. let cue = CuePoint(name: "Drop", timestamp: 30.5, type: .drop)
  66. XCTAssertEqual(cue.name, "Drop")
  67. XCTAssertEqual(cue.timestamp, 30.5)
  68. XCTAssertEqual(cue.type, .drop)
  69. XCTAssertNil(cue.endTimestamp)
  70. XCTAssertFalse(cue.isRegion)
  71. }
  72. func testCuePointRegion() {
  73. let cue = CuePoint(name: "Verse", timestamp: 10, endTimestamp: 40, type: .verse)
  74. XCTAssertTrue(cue.isRegion)
  75. XCTAssertEqual(cue.endTimestamp, 40)
  76. }
  77. func testCuePointComparable() {
  78. let c1 = CuePoint(timestamp: 10)
  79. let c2 = CuePoint(timestamp: 20)
  80. let c3 = CuePoint(timestamp: 5)
  81. let sorted = [c1, c2, c3].sorted()
  82. XCTAssertEqual(sorted[0].timestamp, 5)
  83. XCTAssertEqual(sorted[1].timestamp, 10)
  84. XCTAssertEqual(sorted[2].timestamp, 20)
  85. }
  86. func testFormattedTimestamp() {
  87. let cue = CuePoint(timestamp: 65.123)
  88. XCTAssertEqual(cue.formattedTimestamp, "01:05.123")
  89. }
  90. func testAllCuePointTypes() {
  91. XCTAssertEqual(CuePointType.allCases.count, 11)
  92. XCTAssertEqual(CuePointType.marker.rawValue, "Marker")
  93. XCTAssertEqual(CuePointType.drop.rawValue, "Drop")
  94. XCTAssertEqual(CuePointType.fadeOut.rawValue, "Fade Out")
  95. }
  96. }
  97. /// Tests for the Playlist model.
  98. final class PlaylistModelTests: XCTestCase {
  99. func testPlaylistCreation() {
  100. let pl = Playlist(name: "My Mix")
  101. XCTAssertEqual(pl.name, "My Mix")
  102. XCTAssertTrue(pl.entries.isEmpty)
  103. XCTAssertEqual(pl.trackCount, 0)
  104. XCTAssertEqual(pl.totalDuration, 0)
  105. XCTAssertNil(pl.targetBPM)
  106. }
  107. func testAddTrack() {
  108. let pl = Playlist(name: "Mix")
  109. let track = Track(title: "Song", filePath: "/t", duration: 120)
  110. pl.addTrack(track, crossfadeDuration: 2.0)
  111. XCTAssertEqual(pl.trackCount, 1)
  112. XCTAssertEqual(pl.entries.count, 1)
  113. XCTAssertEqual(pl.entries.first?.track?.title, "Song")
  114. XCTAssertEqual(pl.entries.first?.crossfadeDuration, 2.0)
  115. XCTAssertEqual(pl.entries.first?.position, 0)
  116. }
  117. func testSortedEntries() {
  118. let pl = Playlist(name: "Mix")
  119. let t1 = Track(title: "First", filePath: "/1", duration: 60)
  120. let t2 = Track(title: "Second", filePath: "/2", duration: 90)
  121. let t3 = Track(title: "Third", filePath: "/3", duration: 45)
  122. pl.addTrack(t1)
  123. pl.addTrack(t2)
  124. pl.addTrack(t3)
  125. let sorted = pl.sortedEntries
  126. XCTAssertEqual(sorted.count, 3)
  127. XCTAssertEqual(sorted[0].track?.title, "First")
  128. XCTAssertEqual(sorted[1].track?.title, "Second")
  129. XCTAssertEqual(sorted[2].track?.title, "Third")
  130. }
  131. func testFormattedTotalDuration() {
  132. let pl = Playlist(name: "Mix")
  133. pl.addTrack(Track(title: "A", filePath: "/a", duration: 3661))
  134. let formatted = pl.formattedTotalDuration
  135. XCTAssertEqual(formatted, "1:01:01")
  136. }
  137. func testFormattedTotalDurationShort() {
  138. let pl = Playlist(name: "Mix")
  139. pl.addTrack(Track(title: "A", filePath: "/a", duration: 125))
  140. XCTAssertEqual(pl.formattedTotalDuration, "2:05")
  141. }
  142. }
  143. /// Tests for PlaylistFolder model.
  144. final class PlaylistFolderTests: XCTestCase {
  145. func testFolderCreation() {
  146. let folder = PlaylistFolder(name: "Hip-Hop")
  147. XCTAssertEqual(folder.name, "Hip-Hop")
  148. XCTAssertTrue(folder.playlists.isEmpty)
  149. XCTAssertTrue(folder.isExpanded)
  150. XCTAssertEqual(folder.totalTrackCount, 0)
  151. }
  152. func testFolderWithPlaylists() {
  153. let folder = PlaylistFolder(name: "Mixes")
  154. let pl1 = Playlist(name: "Mix 1")
  155. let pl2 = Playlist(name: "Mix 2")
  156. pl1.addTrack(Track(title: "A", filePath: "/a", duration: 60))
  157. pl2.addTrack(Track(title: "B", filePath: "/b", duration: 90))
  158. pl2.addTrack(Track(title: "C", filePath: "/c", duration: 45))
  159. pl1.folder = folder
  160. pl2.folder = folder
  161. folder.playlists = [pl1, pl2]
  162. XCTAssertEqual(folder.playlists.count, 2)
  163. XCTAssertEqual(folder.totalTrackCount, 3)
  164. }
  165. func testPlaylistFolderAssignment() {
  166. let folder = PlaylistFolder(name: "Test")
  167. let pl = Playlist(name: "My Playlist")
  168. XCTAssertNil(pl.folder)
  169. pl.folder = folder
  170. XCTAssertNotNil(pl.folder)
  171. XCTAssertEqual(pl.folder?.name, "Test")
  172. // Remove from folder
  173. pl.folder = nil
  174. XCTAssertNil(pl.folder)
  175. }
  176. func testFolderExpanded() {
  177. let folder = PlaylistFolder(name: "F")
  178. XCTAssertTrue(folder.isExpanded)
  179. folder.isExpanded = false
  180. XCTAssertFalse(folder.isExpanded)
  181. }
  182. }
  183. /// Tests for PlaylistEntry model.
  184. final class PlaylistEntryModelTests: XCTestCase {
  185. func testEffectiveDuration() {
  186. let track = Track(title: "T", filePath: "/t", duration: 180)
  187. let entry = PlaylistEntry(position: 0, track: track, startOffset: 10, endOffset: 170)
  188. XCTAssertEqual(entry.effectiveDuration, 160)
  189. }
  190. func testEffectiveDurationNoOffsets() {
  191. let track = Track(title: "T", filePath: "/t", duration: 180)
  192. let entry = PlaylistEntry(position: 0, track: track)
  193. XCTAssertEqual(entry.effectiveDuration, 180)
  194. }
  195. func testGainDefaults() {
  196. let entry = PlaylistEntry(position: 0, track: nil)
  197. XCTAssertEqual(entry.gainAdjustment, 0)
  198. XCTAssertEqual(entry.crossfadeDuration, 0)
  199. }
  200. }
  201. /// Tests for PlaylistViewConfig.
  202. final class PlaylistViewConfigTests: XCTestCase {
  203. func testDefaultColumns() {
  204. let defaultCols = PlaylistViewConfig.defaultColumns
  205. XCTAssertTrue(defaultCols.contains(.title))
  206. XCTAssertTrue(defaultCols.contains(.artist))
  207. XCTAssertTrue(defaultCols.contains(.duration))
  208. }
  209. func testToggleColumn() {
  210. let config = PlaylistViewConfig()
  211. let initialCount = config.visibleColumns.count
  212. // Toggle off a column that exists
  213. if config.isColumnVisible(.title) {
  214. config.toggleColumn(.title)
  215. XCTAssertFalse(config.isColumnVisible(.title))
  216. XCTAssertEqual(config.visibleColumns.count, initialCount - 1)
  217. }
  218. // Toggle it back on
  219. config.toggleColumn(.title)
  220. XCTAssertTrue(config.isColumnVisible(.title))
  221. }
  222. func testResetToDefaults() {
  223. let config = PlaylistViewConfig()
  224. config.visibleColumns = [.title]
  225. config.showArtwork = false
  226. config.resetToDefaults()
  227. XCTAssertEqual(config.visibleColumns, PlaylistViewConfig.defaultColumns)
  228. XCTAssertTrue(config.showArtwork)
  229. }
  230. func testArtworkSizes() {
  231. XCTAssertEqual(PlaylistViewConfig.ArtworkSize.small.points, 32)
  232. XCTAssertEqual(PlaylistViewConfig.ArtworkSize.medium.points, 48)
  233. XCTAssertEqual(PlaylistViewConfig.ArtworkSize.large.points, 64)
  234. }
  235. }
  236. /// Tests for GroupTemplateResolver.
  237. final class GroupTemplateResolverTests: XCTestCase {
  238. func testEmptyTemplateReturnsEmpty() {
  239. let track = Track(title: "Test", artist: "Artist", album: "Album", filePath: "/music/test.mp3")
  240. let result = GroupTemplateResolver.resolve(template: "", for: track)
  241. XCTAssertEqual(result, "")
  242. }
  243. func testAlbumDateTemplate() {
  244. let track = Track(title: "Test", artist: "Artist", album: "My Album", filePath: "/music/test.mp3")
  245. track.year = 1995
  246. let result = GroupTemplateResolver.resolve(template: "{Album} ({Date})", for: track)
  247. XCTAssertEqual(result, "My Album (1995)")
  248. }
  249. func testAlbumDateTemplateNoYear() {
  250. let track = Track(title: "Test", artist: "Artist", album: "My Album", filePath: "/music/test.mp3")
  251. let result = GroupTemplateResolver.resolve(template: "{Album} ({Date})", for: track)
  252. // No year → empty brackets cleaned up
  253. XCTAssertEqual(result, "My Album")
  254. }
  255. func testArtistAlbumTemplate() {
  256. let track = Track(title: "Song", artist: "Fagner", album: "Manera Fru Fru", filePath: "/music/test.mp3")
  257. let result = GroupTemplateResolver.resolve(template: "{Artist} — {Album}", for: track)
  258. XCTAssertEqual(result, "Fagner — Manera Fru Fru")
  259. }
  260. func testUnknownFallbacks() {
  261. let track = Track(title: "Test", filePath: "/music/test.mp3")
  262. let result = GroupTemplateResolver.resolve(template: "{Artist}", for: track)
  263. XCTAssertEqual(result, "Unknown Artist")
  264. }
  265. func testFolderPlaceholder() {
  266. let track = Track(title: "Test", filePath: "/music/batch1/test.mp3")
  267. let result = GroupTemplateResolver.resolve(template: "{Folder}", for: track)
  268. XCTAssertEqual(result, "batch1")
  269. }
  270. func testPresetsExist() {
  271. XCTAssertTrue(GroupTemplateResolver.presets.count >= 9)
  272. XCTAssertEqual(GroupTemplateResolver.presets.first?.template, "")
  273. }
  274. }
  275. /// Tests for AppTheme.
  276. final class AppThemeTests: XCTestCase {
  277. private var savedSkinRaw: String?
  278. override func setUp() {
  279. super.setUp()
  280. // Save current skin so tests don't pollute user preferences
  281. savedSkinRaw = UserDefaults.standard.string(forKey: "appThemeSkin")
  282. }
  283. override func tearDown() {
  284. // Restore original skin
  285. if let raw = savedSkinRaw {
  286. UserDefaults.standard.set(raw, forKey: "appThemeSkin")
  287. } else {
  288. UserDefaults.standard.removeObject(forKey: "appThemeSkin")
  289. }
  290. super.tearDown()
  291. }
  292. func testDefaultSkin() {
  293. let theme = AppTheme()
  294. XCTAssertTrue(AppTheme.Skin.allCases.contains(theme.currentSkin))
  295. }
  296. func testSkinSwitch() {
  297. let theme = AppTheme()
  298. theme.currentSkin = .ocean
  299. XCTAssertEqual(theme.currentSkin, .ocean)
  300. theme.currentSkin = .warm
  301. XCTAssertEqual(theme.currentSkin, .warm)
  302. theme.currentSkin = .winampClassic
  303. XCTAssertEqual(theme.currentSkin, .winampClassic)
  304. theme.currentSkin = .foobarDark
  305. XCTAssertEqual(theme.currentSkin, .foobarDark)
  306. theme.currentSkin = .foobarLight
  307. XCTAssertEqual(theme.currentSkin, .foobarLight)
  308. theme.currentSkin = .win95
  309. XCTAssertEqual(theme.currentSkin, .win95)
  310. }
  311. func testAllSkinsAvailable() {
  312. // 6 modern + 8 retro = 14
  313. XCTAssertEqual(AppTheme.Skin.allCases.count, 14)
  314. }
  315. func testAllSkinsApplyWithoutCrash() {
  316. let theme = AppTheme()
  317. for skin in AppTheme.Skin.allCases {
  318. theme.currentSkin = skin
  319. // Verify key properties are set to reasonable values
  320. XCTAssertGreaterThan(theme.seekbarHeight, 0, "Seekbar height should be > 0 for \(skin.rawValue)")
  321. XCTAssertGreaterThan(theme.dataFontSize, 0, "Font size should be > 0 for \(skin.rawValue)")
  322. XCTAssertGreaterThan(theme.rowHeight, 0, "Row height should be > 0 for \(skin.rawValue)")
  323. }
  324. }
  325. func testRetroSkinsHaveDistinctColors() {
  326. let theme = AppTheme()
  327. // Winamp Classic should have green text
  328. theme.currentSkin = .winampClassic
  329. // Just verify it doesn't crash and has accent set
  330. XCTAssertEqual(theme.currentSkin, .winampClassic)
  331. // foobar2000 should be light/system-like
  332. theme.currentSkin = .foobarLight
  333. XCTAssertEqual(theme.rowHeight, 18) // foobar has tighter rows
  334. // Win95 should have thicker seekbar
  335. theme.currentSkin = .win95
  336. XCTAssertEqual(theme.seekbarHeight, 10)
  337. // XP Luna
  338. theme.currentSkin = .xpLuna
  339. XCTAssertEqual(theme.seekbarHeight, 10)
  340. // Mac OS 9
  341. theme.currentSkin = .macOSClassic
  342. XCTAssertEqual(theme.dataFontSize, 12) // slightly larger like classic Mac
  343. }
  344. func testSeekbarHeight() {
  345. let theme = AppTheme()
  346. theme.currentSkin = .dark
  347. XCTAssertEqual(theme.seekbarHeight, 8)
  348. }
  349. }
  350. /// Tests for AppState persistence.
  351. final class AppStateTests: XCTestCase {
  352. private func clearState() {
  353. UserDefaults.standard.removeObject(forKey: "appState.lastPlaylistID")
  354. UserDefaults.standard.removeObject(forKey: "appState.lastEntryID")
  355. UserDefaults.standard.removeObject(forKey: "appState.lastTrackFilePath")
  356. UserDefaults.standard.removeObject(forKey: "appState.lastPlaybackTime")
  357. }
  358. override func setUp() {
  359. super.setUp()
  360. clearState()
  361. }
  362. override func tearDown() {
  363. clearState()
  364. super.tearDown()
  365. }
  366. func testSaveAndLoadPlaylistID() {
  367. let id = UUID()
  368. AppState.saveLastPlaylist(id: id)
  369. XCTAssertEqual(AppState.lastPlaylistID, id)
  370. }
  371. func testSaveAndLoadEntryID() {
  372. let id = UUID()
  373. AppState.saveLastEntry(id: id)
  374. XCTAssertEqual(AppState.lastEntryID, id)
  375. }
  376. func testSaveAndLoadTrackPath() {
  377. AppState.saveLastTrack(filePath: "/music/test.mp3")
  378. XCTAssertEqual(AppState.lastTrackFilePath, "/music/test.mp3")
  379. }
  380. func testSaveAndLoadPlaybackTime() {
  381. AppState.savePlaybackTime(123.456)
  382. XCTAssertEqual(AppState.lastPlaybackTime, 123.456, accuracy: 0.001)
  383. }
  384. func testSavePlaybackStateAll() {
  385. let plID = UUID()
  386. let entryID = UUID()
  387. AppState.savePlaybackState(
  388. playlistID: plID,
  389. entryID: entryID,
  390. trackFilePath: "/test.wav",
  391. playbackTime: 42.5
  392. )
  393. XCTAssertEqual(AppState.lastPlaylistID, plID)
  394. XCTAssertEqual(AppState.lastEntryID, entryID)
  395. XCTAssertEqual(AppState.lastTrackFilePath, "/test.wav")
  396. XCTAssertEqual(AppState.lastPlaybackTime, 42.5, accuracy: 0.001)
  397. }
  398. func testDefaultsAreNil() {
  399. XCTAssertNil(AppState.lastPlaylistID)
  400. XCTAssertNil(AppState.lastEntryID)
  401. }
  402. }