LyricsTests.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. import XCTest
  2. @testable import MixBoard
  3. // MARK: - LyricsParser Tests
  4. final class LyricsParserTests: XCTestCase {
  5. // MARK: - parseSynced: Basic LRC Format
  6. func testParseSyncedBasic() {
  7. let lrc = """
  8. [00:12.34] First line
  9. [00:24.56] Second line
  10. [00:36.78] Third line
  11. """
  12. let lines = LyricsParser.parseSynced(lrc)
  13. XCTAssertEqual(lines.count, 3)
  14. XCTAssertEqual(lines[0].text, "First line")
  15. XCTAssertEqual(lines[0].timestamp, 12.34, accuracy: 0.01)
  16. XCTAssertEqual(lines[1].text, "Second line")
  17. XCTAssertEqual(lines[1].timestamp, 24.56, accuracy: 0.01)
  18. XCTAssertEqual(lines[2].text, "Third line")
  19. XCTAssertEqual(lines[2].timestamp, 36.78, accuracy: 0.01)
  20. }
  21. func testParseSyncedSortedByTimestamp() {
  22. let lrc = """
  23. [01:00.00] Late line
  24. [00:10.00] Early line
  25. [00:30.00] Middle line
  26. """
  27. let lines = LyricsParser.parseSynced(lrc)
  28. XCTAssertEqual(lines.count, 3)
  29. XCTAssertEqual(lines[0].text, "Early line")
  30. XCTAssertEqual(lines[1].text, "Middle line")
  31. XCTAssertEqual(lines[2].text, "Late line")
  32. }
  33. func testParseSyncedWithMilliseconds() {
  34. let lrc = "[02:05.456] Line with ms"
  35. let lines = LyricsParser.parseSynced(lrc)
  36. XCTAssertEqual(lines.count, 1)
  37. XCTAssertEqual(lines[0].timestamp, 125.456, accuracy: 0.001)
  38. XCTAssertEqual(lines[0].text, "Line with ms")
  39. }
  40. func testParseSyncedWithCentiseconds() {
  41. let lrc = "[01:30.50] Half second"
  42. let lines = LyricsParser.parseSynced(lrc)
  43. XCTAssertEqual(lines.count, 1)
  44. XCTAssertEqual(lines[0].timestamp, 90.50, accuracy: 0.01)
  45. }
  46. func testParseSyncedWithColonSeparator() {
  47. // Some LRC files use : instead of . for fractional seconds
  48. let lrc = "[01:30:50] Colon style"
  49. let lines = LyricsParser.parseSynced(lrc)
  50. XCTAssertEqual(lines.count, 1)
  51. XCTAssertEqual(lines[0].timestamp, 90.50, accuracy: 0.01)
  52. }
  53. func testParseSyncedWithCommaSeparator() {
  54. let lrc = "[01:30,50] Comma style"
  55. let lines = LyricsParser.parseSynced(lrc)
  56. XCTAssertEqual(lines.count, 1)
  57. XCTAssertEqual(lines[0].timestamp, 90.50, accuracy: 0.01)
  58. }
  59. func testParseSyncedMultipleTimestampsPerLine() {
  60. let lrc = "[00:12.00][01:24.00] Repeated chorus line"
  61. let lines = LyricsParser.parseSynced(lrc)
  62. XCTAssertEqual(lines.count, 2)
  63. XCTAssertEqual(lines[0].timestamp, 12.0, accuracy: 0.01)
  64. XCTAssertEqual(lines[0].text, "Repeated chorus line")
  65. XCTAssertEqual(lines[1].timestamp, 84.0, accuracy: 0.01)
  66. XCTAssertEqual(lines[1].text, "Repeated chorus line")
  67. }
  68. func testParseSyncedEmptyTextLine() {
  69. let lrc = """
  70. [00:10.00] Has text
  71. [00:20.00]
  72. [00:30.00] After break
  73. """
  74. let lines = LyricsParser.parseSynced(lrc)
  75. XCTAssertEqual(lines.count, 3)
  76. XCTAssertEqual(lines[1].text, "")
  77. }
  78. func testParseSyncedEmptyInput() {
  79. let lines = LyricsParser.parseSynced("")
  80. XCTAssertTrue(lines.isEmpty)
  81. }
  82. func testParseSyncedNoTimestamps() {
  83. let lrc = """
  84. Just plain text
  85. No timestamps here
  86. """
  87. let lines = LyricsParser.parseSynced(lrc)
  88. XCTAssertTrue(lines.isEmpty)
  89. }
  90. func testParseSyncedThreeDigitMinutes() {
  91. let lrc = "[100:00.00] Very long track"
  92. let lines = LyricsParser.parseSynced(lrc)
  93. XCTAssertEqual(lines.count, 1)
  94. XCTAssertEqual(lines[0].timestamp, 6000.0, accuracy: 0.01)
  95. }
  96. func testParseSyncedSkipsBlankLines() {
  97. let lrc = """
  98. [00:10.00] Line one
  99. [00:20.00] Line two
  100. """
  101. let lines = LyricsParser.parseSynced(lrc)
  102. XCTAssertEqual(lines.count, 2)
  103. }
  104. func testParseSyncedZeroTimestamp() {
  105. let lrc = "[00:00.00] Very start"
  106. let lines = LyricsParser.parseSynced(lrc)
  107. XCTAssertEqual(lines.count, 1)
  108. XCTAssertEqual(lines[0].timestamp, 0.0, accuracy: 0.001)
  109. }
  110. // MARK: - parsePlain
  111. func testParsePlainBasic() {
  112. let text = """
  113. First line
  114. Second line
  115. Third line
  116. """
  117. let lines = LyricsParser.parsePlain(text)
  118. XCTAssertEqual(lines.count, 3)
  119. XCTAssertEqual(lines[0].text, "First line")
  120. XCTAssertEqual(lines[1].text, "Second line")
  121. XCTAssertEqual(lines[2].text, "Third line")
  122. }
  123. func testParsePlainTimestampsAreLineIndices() {
  124. let text = "Line A\nLine B\nLine C"
  125. let lines = LyricsParser.parsePlain(text)
  126. XCTAssertEqual(lines[0].timestamp, 0)
  127. XCTAssertEqual(lines[1].timestamp, 1)
  128. XCTAssertEqual(lines[2].timestamp, 2)
  129. }
  130. func testParsePlainEmptyInput() {
  131. let lines = LyricsParser.parsePlain("")
  132. XCTAssertEqual(lines.count, 1)
  133. XCTAssertEqual(lines[0].text, "")
  134. }
  135. func testParsePlainWithBlankLines() {
  136. let text = "Verse 1\n\nVerse 2"
  137. let lines = LyricsParser.parsePlain(text)
  138. XCTAssertEqual(lines.count, 3)
  139. XCTAssertEqual(lines[1].text, "")
  140. }
  141. // MARK: - currentLineIndex
  142. func testCurrentLineIndexBasic() {
  143. let lines = [
  144. LyricsParser.LyricLine(timestamp: 10, text: "A"),
  145. LyricsParser.LyricLine(timestamp: 20, text: "B"),
  146. LyricsParser.LyricLine(timestamp: 30, text: "C"),
  147. ]
  148. XCTAssertEqual(LyricsParser.currentLineIndex(in: lines, at: 15), 0)
  149. XCTAssertEqual(LyricsParser.currentLineIndex(in: lines, at: 25), 1)
  150. XCTAssertEqual(LyricsParser.currentLineIndex(in: lines, at: 35), 2)
  151. }
  152. func testCurrentLineIndexExactMatch() {
  153. let lines = [
  154. LyricsParser.LyricLine(timestamp: 10, text: "A"),
  155. LyricsParser.LyricLine(timestamp: 20, text: "B"),
  156. ]
  157. XCTAssertEqual(LyricsParser.currentLineIndex(in: lines, at: 10), 0)
  158. XCTAssertEqual(LyricsParser.currentLineIndex(in: lines, at: 20), 1)
  159. }
  160. func testCurrentLineIndexBeforeFirstLine() {
  161. let lines = [
  162. LyricsParser.LyricLine(timestamp: 10, text: "A"),
  163. ]
  164. XCTAssertNil(LyricsParser.currentLineIndex(in: lines, at: 5))
  165. }
  166. func testCurrentLineIndexEmpty() {
  167. XCTAssertNil(LyricsParser.currentLineIndex(in: [], at: 10))
  168. }
  169. func testCurrentLineIndexZeroTime() {
  170. let lines = [
  171. LyricsParser.LyricLine(timestamp: 0, text: "Start"),
  172. LyricsParser.LyricLine(timestamp: 10, text: "Next"),
  173. ]
  174. XCTAssertEqual(LyricsParser.currentLineIndex(in: lines, at: 0), 0)
  175. }
  176. func testCurrentLineIndexAfterLastLine() {
  177. let lines = [
  178. LyricsParser.LyricLine(timestamp: 10, text: "A"),
  179. LyricsParser.LyricLine(timestamp: 20, text: "B"),
  180. ]
  181. XCTAssertEqual(LyricsParser.currentLineIndex(in: lines, at: 9999), 1)
  182. }
  183. // MARK: - LyricLine.formattedTime
  184. func testFormattedTimeZero() {
  185. let line = LyricsParser.LyricLine(timestamp: 0, text: "")
  186. XCTAssertEqual(line.formattedTime, "0:00")
  187. }
  188. func testFormattedTimeMinutesAndSeconds() {
  189. let line = LyricsParser.LyricLine(timestamp: 125.456, text: "")
  190. XCTAssertEqual(line.formattedTime, "2:05")
  191. }
  192. func testFormattedTimeLargeValue() {
  193. let line = LyricsParser.LyricLine(timestamp: 3661, text: "")
  194. XCTAssertEqual(line.formattedTime, "61:01")
  195. }
  196. // MARK: - LyricLine.Equatable
  197. func testLyricLineEquatable() {
  198. let a = LyricsParser.LyricLine(timestamp: 10, text: "Hello")
  199. let b = LyricsParser.LyricLine(timestamp: 10, text: "Hello")
  200. // Different UUIDs, so they're not equal
  201. XCTAssertNotEqual(a, b)
  202. // Same instance should be equal
  203. XCTAssertEqual(a, a)
  204. }
  205. }
  206. // MARK: - LyricsResult Tests
  207. final class LyricsResultTests: XCTestCase {
  208. func testHasSyncedLyricsTrue() {
  209. let result = LyricsResult(syncedLyrics: "[00:10.00] Hello")
  210. XCTAssertTrue(result.hasSyncedLyrics)
  211. }
  212. func testHasSyncedLyricsFalseNil() {
  213. let result = LyricsResult(syncedLyrics: nil)
  214. XCTAssertFalse(result.hasSyncedLyrics)
  215. }
  216. func testHasSyncedLyricsFalseEmpty() {
  217. let result = LyricsResult(syncedLyrics: "")
  218. XCTAssertFalse(result.hasSyncedLyrics)
  219. }
  220. func testHasLyricsWithSynced() {
  221. let result = LyricsResult(syncedLyrics: "[00:10.00] Hello")
  222. XCTAssertTrue(result.hasLyrics)
  223. }
  224. func testHasLyricsWithPlain() {
  225. let result = LyricsResult(plainLyrics: "Hello world")
  226. XCTAssertTrue(result.hasLyrics)
  227. }
  228. func testHasLyricsFalse() {
  229. let result = LyricsResult()
  230. XCTAssertFalse(result.hasLyrics)
  231. }
  232. func testIsInstrumental() {
  233. let result = LyricsResult(isInstrumental: true)
  234. XCTAssertTrue(result.isInstrumental)
  235. }
  236. func testDefaultValues() {
  237. let result = LyricsResult()
  238. XCTAssertEqual(result.trackName, "")
  239. XCTAssertEqual(result.artistName, "")
  240. XCTAssertEqual(result.albumName, "")
  241. XCTAssertFalse(result.isInstrumental)
  242. XCTAssertNil(result.plainLyrics)
  243. XCTAssertNil(result.syncedLyrics)
  244. }
  245. }
  246. // MARK: - LyricsError Tests
  247. final class LyricsErrorTests: XCTestCase {
  248. func testInvalidURLDescription() {
  249. let error = LyricsError.invalidURL
  250. XCTAssertEqual(error.errorDescription, "Invalid lyrics search URL")
  251. }
  252. func testNotFoundDescription() {
  253. let error = LyricsError.notFound
  254. XCTAssertEqual(error.errorDescription, "No lyrics found")
  255. }
  256. func testNetworkErrorDescription() {
  257. let error = LyricsError.networkError
  258. XCTAssertEqual(error.errorDescription, "Network error fetching lyrics")
  259. }
  260. func testHTTPErrorDescription() {
  261. let error = LyricsError.httpError(404)
  262. XCTAssertEqual(error.errorDescription, "HTTP error 404")
  263. }
  264. func testHTTPErrorDescription500() {
  265. let error = LyricsError.httpError(500)
  266. XCTAssertEqual(error.errorDescription, "HTTP error 500")
  267. }
  268. }
  269. // MARK: - LRCLIBService Tests
  270. final class LRCLIBServiceTests: XCTestCase {
  271. func testCacheIsInitiallyEmpty() async {
  272. let service = LRCLIBService()
  273. await service.clearCache()
  274. // No crash
  275. }
  276. func testFetchLyricsEmptyArtist() async {
  277. let service = LRCLIBService()
  278. do {
  279. _ = try await service.fetchLyrics(artist: "", title: "")
  280. // May succeed or throw — depends on network
  281. } catch {
  282. // Expected — either notFound or networkError
  283. XCTAssertTrue(error is LyricsError)
  284. }
  285. }
  286. func testFetchLyricsWellKnownSong() async throws {
  287. // Use a well-known song that's very likely in LRCLIB
  288. let service = LRCLIBService()
  289. do {
  290. let result = try await service.fetchLyrics(
  291. artist: "Queen",
  292. title: "Bohemian Rhapsody"
  293. )
  294. XCTAssertTrue(result.hasLyrics)
  295. } catch {
  296. // Network may be unavailable in CI — don't fail
  297. print("Network test skipped: \(error)")
  298. }
  299. }
  300. func testFetchLyricsNonExistentSong() async {
  301. let service = LRCLIBService()
  302. do {
  303. _ = try await service.fetchLyrics(
  304. artist: "zzz_nonexistent_artist_12345",
  305. title: "zzz_nonexistent_title_12345"
  306. )
  307. XCTFail("Expected LyricsError.notFound")
  308. } catch LyricsError.notFound {
  309. // Expected
  310. } catch {
  311. // Network error is also acceptable
  312. }
  313. }
  314. func testCachingWorks() async throws {
  315. let service = LRCLIBService()
  316. do {
  317. let result1 = try await service.fetchLyrics(
  318. artist: "Queen",
  319. title: "Bohemian Rhapsody"
  320. )
  321. // Second call should hit cache (much faster)
  322. let result2 = try await service.fetchLyrics(
  323. artist: "Queen",
  324. title: "Bohemian Rhapsody"
  325. )
  326. XCTAssertEqual(result1.trackName, result2.trackName)
  327. } catch {
  328. // Network may be unavailable — don't fail
  329. print("Network test skipped: \(error)")
  330. }
  331. }
  332. func testClearCache() async {
  333. let service = LRCLIBService()
  334. await service.clearCache()
  335. // Should not crash and cache should be empty
  336. }
  337. }