UIRevampTests.swift 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. import XCTest
  2. @testable import MixBoard
  3. /// UI-level verification tests for the MixBoard UI Revamp.
  4. /// Tests sidebar selection model, player bar layout invariants, and edge cases.
  5. final class UIRevampTests: XCTestCase {
  6. // MARK: - SidebarSection Selection Model
  7. func testSidebarSectionEquality_LibraryDestinations() {
  8. let browse1 = SidebarSection.library(.browse)
  9. let browse2 = SidebarSection.library(.browse)
  10. let albums = SidebarSection.library(.albums)
  11. XCTAssertEqual(browse1, browse2)
  12. XCTAssertNotEqual(browse1, albums)
  13. }
  14. func testSidebarSectionEquality_QueueIsSingleton() {
  15. let queue1 = SidebarSection.queue
  16. let queue2 = SidebarSection.queue
  17. XCTAssertEqual(queue1, queue2)
  18. }
  19. func testSidebarSectionEquality_QueueNotEqualToLibrary() {
  20. let queue = SidebarSection.queue
  21. let library = SidebarSection.library(.browse)
  22. XCTAssertNotEqual(queue, library)
  23. }
  24. func testLibraryDestination_AllCases() {
  25. let allCases = LibraryDestination.allCases
  26. XCTAssertEqual(allCases.count, 6)
  27. XCTAssertTrue(allCases.contains(.browse))
  28. XCTAssertTrue(allCases.contains(.albums))
  29. XCTAssertTrue(allCases.contains(.artists))
  30. XCTAssertTrue(allCases.contains(.genres))
  31. XCTAssertTrue(allCases.contains(.years))
  32. XCTAssertTrue(allCases.contains(.search))
  33. }
  34. func testLibraryDestination_InitialNavStack() {
  35. XCTAssertTrue(LibraryDestination.browse.initialNavStack.isEmpty)
  36. XCTAssertEqual(LibraryDestination.albums.initialNavStack, [.category(.album)])
  37. XCTAssertEqual(LibraryDestination.artists.initialNavStack, [.category(.artist)])
  38. XCTAssertEqual(LibraryDestination.genres.initialNavStack, [.category(.genre)])
  39. XCTAssertEqual(LibraryDestination.years.initialNavStack, [.category(.year)])
  40. XCTAssertEqual(LibraryDestination.search.initialNavStack, [.search(query: "")])
  41. }
  42. func testLibraryDestination_DisplayNames() {
  43. XCTAssertEqual(LibraryDestination.browse.displayName, "Browse")
  44. XCTAssertEqual(LibraryDestination.albums.displayName, "Albums")
  45. XCTAssertEqual(LibraryDestination.artists.displayName, "Artists")
  46. XCTAssertEqual(LibraryDestination.genres.displayName, "Genres")
  47. XCTAssertEqual(LibraryDestination.years.displayName, "Years")
  48. XCTAssertEqual(LibraryDestination.search.displayName, "Search")
  49. }
  50. func testLibraryDestination_Icons() {
  51. for dest in LibraryDestination.allCases {
  52. XCTAssertFalse(dest.icon.isEmpty, "\(dest) should have an icon")
  53. }
  54. }
  55. // MARK: - Sidebar Selection Toggle Logic (⌘B)
  56. func testCommandBToggle_FromNil_NavigatesToLibrary() {
  57. var selection: SidebarSection? = nil
  58. // Simulate ⌘B press
  59. if case .library = selection {
  60. selection = nil
  61. } else {
  62. selection = .library(.browse)
  63. }
  64. XCTAssertEqual(selection, .library(.browse))
  65. }
  66. func testCommandBToggle_FromLibrary_NavigatesToNil() {
  67. var selection: SidebarSection? = SidebarSection.library(.browse)
  68. // Simulate ⌘B press
  69. if case .library = selection {
  70. selection = nil
  71. } else {
  72. selection = .library(.browse)
  73. }
  74. XCTAssertNil(selection)
  75. }
  76. func testCommandBToggle_FromQueue_NavigatesToLibrary() {
  77. var selection: SidebarSection? = SidebarSection.queue
  78. // Simulate ⌘B press
  79. if case .library = selection {
  80. selection = nil
  81. } else {
  82. selection = .library(.browse)
  83. }
  84. XCTAssertEqual(selection, .library(.browse))
  85. }
  86. func testCommandBToggle_FromAlbumsLibrary_NavigatesToNil() {
  87. var selection: SidebarSection? = SidebarSection.library(.albums)
  88. // Simulate ⌘B press — any .library case should toggle off
  89. if case .library = selection {
  90. selection = nil
  91. } else {
  92. selection = .library(.browse)
  93. }
  94. XCTAssertNil(selection)
  95. }
  96. // MARK: - Sidebar Selection: Mutual Exclusivity
  97. func testSidebarSelection_LibraryReplacesPlaylist() {
  98. // Selecting a library item should replace any playlist selection
  99. // (single selection model ensures this automatically)
  100. var selection: SidebarSection? = nil
  101. // Select a library destination
  102. selection = .library(.albums)
  103. if case .library = selection {
  104. // OK
  105. } else {
  106. XCTFail("Should be library selection")
  107. }
  108. // Now select queue
  109. selection = .queue
  110. XCTAssertEqual(selection, .queue)
  111. if case .library = selection {
  112. XCTFail("Should no longer be library")
  113. }
  114. }
  115. // MARK: - Player ViewModel State (Player Bar Support)
  116. @MainActor
  117. func testPlayerVMDefaultState_NoTrackLoaded() {
  118. let playerVM = PlayerViewModel()
  119. // When no track is loaded, currentTrack should be nil
  120. XCTAssertNil(playerVM.currentTrack, "currentTrack should be nil when nothing is loaded")
  121. XCTAssertFalse(playerVM.isPlaying)
  122. }
  123. @MainActor
  124. func testPlayerVMShuffleToggle() {
  125. let playerVM = PlayerViewModel()
  126. let initial = playerVM.shuffleEnabled
  127. playerVM.shuffleEnabled.toggle()
  128. XCTAssertNotEqual(playerVM.shuffleEnabled, initial)
  129. playerVM.shuffleEnabled.toggle()
  130. XCTAssertEqual(playerVM.shuffleEnabled, initial)
  131. }
  132. @MainActor
  133. func testPlayerVMRepeatModeCycles() {
  134. let playerVM = PlayerViewModel()
  135. // Test repeat mode cycling: off → all → one → off
  136. XCTAssertEqual(playerVM.repeatMode, .off)
  137. playerVM.repeatMode = .all
  138. XCTAssertEqual(playerVM.repeatMode, .all)
  139. playerVM.repeatMode = .one
  140. XCTAssertEqual(playerVM.repeatMode, .one)
  141. playerVM.repeatMode = .off
  142. XCTAssertEqual(playerVM.repeatMode, .off)
  143. }
  144. @MainActor
  145. func testPlayerVMVolumeRange() {
  146. let playerVM = PlayerViewModel()
  147. // Volume should accept full range
  148. playerVM.volume = 0.0
  149. XCTAssertEqual(playerVM.volume, 0.0, accuracy: 0.001)
  150. playerVM.volume = 0.5
  151. XCTAssertEqual(playerVM.volume, 0.5, accuracy: 0.001)
  152. playerVM.volume = 1.0
  153. XCTAssertEqual(playerVM.volume, 1.0, accuracy: 0.001)
  154. }
  155. // MARK: - Player Bar: Track Info Presence
  156. @MainActor
  157. func testPlayerBar_NoTrack_ShowsNotPlaying() {
  158. let playerVM = PlayerViewModel()
  159. XCTAssertNil(playerVM.currentTrack, "No track loaded — center zone should show 'Not Playing'")
  160. XCTAssertFalse(playerVM.isPlaying)
  161. }
  162. @MainActor
  163. func testPlayerBar_WithTrack_CenterZonePopulated() async throws {
  164. let url = try TestHelpers.createTestAudioFile(name: "ui_test", duration: 1.0)
  165. let track = Track(title: "Test Track", artist: "Test Artist", filePath: url.path, duration: 1.0, fileFormat: "WAV")
  166. let playerVM = PlayerViewModel()
  167. playerVM.loadAndPlay(track)
  168. playerVM.syncForTest()
  169. XCTAssertNotNil(playerVM.currentTrack)
  170. XCTAssertEqual(playerVM.currentTrack?.title, "Test Track")
  171. XCTAssertEqual(playerVM.currentTrack?.artist, "Test Artist")
  172. playerVM.stop()
  173. }
  174. // MARK: - Player Bar: Volume Icon Mapping
  175. @MainActor
  176. func testVolumeIconMapping() {
  177. let testCases: [(volume: Double, expectedIcon: String)] = [
  178. (0.0, "speaker.slash.fill"),
  179. (0.1, "speaker.wave.1.fill"),
  180. (0.15, "speaker.wave.1.fill"),
  181. (0.32, "speaker.wave.1.fill"),
  182. (0.33, "speaker.wave.2.fill"),
  183. (0.5, "speaker.wave.2.fill"),
  184. (0.65, "speaker.wave.2.fill"),
  185. (0.66, "speaker.wave.3.fill"),
  186. (0.85, "speaker.wave.3.fill"),
  187. (0.9, "speaker.wave.3.fill"),
  188. (1.0, "speaker.wave.3.fill"),
  189. ]
  190. for testCase in testCases {
  191. let icon = volumeIcon(for: testCase.volume)
  192. XCTAssertEqual(icon, testCase.expectedIcon,
  193. "Volume \(testCase.volume) should show \(testCase.expectedIcon)")
  194. }
  195. }
  196. /// Mirror of PlayerView.volumeIcon computed property.
  197. private func volumeIcon(for volume: Double) -> String {
  198. if volume == 0 { return "speaker.slash.fill" }
  199. if volume < 0.33 { return "speaker.wave.1.fill" }
  200. if volume < 0.66 { return "speaker.wave.2.fill" }
  201. return "speaker.wave.3.fill"
  202. }
  203. }