import Testing import Foundation @testable import Mobile_Music_Assistant // MARK: - Pagination Threshold @Suite("MALibraryManager – Pagination Threshold") struct PaginationThresholdTests { /// Replicates the "load more if needed" threshold logic. /// Returns true if a load should be triggered for the given currentIndex. private func shouldLoadMore(currentIndex: Int, totalLoaded: Int, threshold: Int = 10) -> Bool { guard totalLoaded > 0 else { return false } return currentIndex >= totalLoaded - threshold } @Test("Triggers load when item is within 10 of the end") func triggersNearEnd() { #expect(shouldLoadMore(currentIndex: 91, totalLoaded: 100)) #expect(shouldLoadMore(currentIndex: 95, totalLoaded: 100)) #expect(shouldLoadMore(currentIndex: 99, totalLoaded: 100)) } @Test("Does not trigger load when item is far from the end") func noTriggerFarFromEnd() { #expect(!shouldLoadMore(currentIndex: 50, totalLoaded: 100)) #expect(!shouldLoadMore(currentIndex: 0, totalLoaded: 100)) #expect(!shouldLoadMore(currentIndex: 89, totalLoaded: 100)) } @Test("Boundary: triggers exactly at threshold position") func triggersAtExactThreshold() { // With 100 items and threshold 10, position 90 is the boundary (100 - 10 = 90) #expect(shouldLoadMore(currentIndex: 90, totalLoaded: 100)) #expect(!shouldLoadMore(currentIndex: 89, totalLoaded: 100)) } @Test("Does not trigger with empty list") func noTriggerWithEmptyList() { #expect(!shouldLoadMore(currentIndex: 0, totalLoaded: 0)) } @Test("Always triggers with a single-item list") func alwaysTriggersForSingleItem() { #expect(shouldLoadMore(currentIndex: 0, totalLoaded: 1)) } } // MARK: - Page hasMore Detection @Suite("MALibraryManager – hasMore Detection") struct HasMoreDetectionTests { /// Returns false (no more pages) if returned count < pageSize. private func hasMorePages(returned: Int, pageSize: Int) -> Bool { returned >= pageSize } @Test("No more pages when returned count equals pageSize") func exactPageSizeHasMore() { #expect(hasMorePages(returned: 50, pageSize: 50)) } @Test("No more pages when returned count is less than pageSize") func partialPageMeansNoMore() { #expect(!hasMorePages(returned: 25, pageSize: 50)) #expect(!hasMorePages(returned: 0, pageSize: 50)) #expect(!hasMorePages(returned: 49, pageSize: 50)) } @Test("Has more pages when returned count is greater than pageSize (e.g. over-fetch)") func overFetchHasMore() { #expect(hasMorePages(returned: 51, pageSize: 50)) } } // MARK: - Favorite URI Collection @Suite("MALibraryManager – Favorite URI Collection") struct FavoriteURICollectionTests { @Test("Collects URIs of items marked as favorite") func collectsFavoriteURIs() throws { let items: [MAMediaItem] = try [ #"{"uri":"spotify://1","name":"A","favorite":true}"#, #"{"uri":"spotify://2","name":"B","favorite":false}"#, #"{"uri":"spotify://3","name":"C","favorite":true}"#, ].map { try JSONDecoder().decode(MAMediaItem.self, from: Data($0.utf8)) } let favorites = Set(items.filter(\.favorite).map(\.uri)) #expect(favorites == Set(["spotify://1", "spotify://3"])) } @Test("Returns empty set when no items are favorite") func emptyWhenNoFavorites() throws { let items: [MAMediaItem] = try [ #"{"uri":"x://1","name":"A","favorite":false}"#, #"{"uri":"x://2","name":"B"}"#, ].map { try JSONDecoder().decode(MAMediaItem.self, from: Data($0.utf8)) } let favorites = Set(items.filter(\.favorite).map(\.uri)) #expect(favorites.isEmpty) } @Test("isFavorite check on MALibraryManager respects favoriteURIs set") func isFavoriteCheck() { let manager = MALibraryManager(service: nil) // Initially no favorites #expect(manager.isFavorite(uri: "spotify://track/1") == false) } } // MARK: - Genre Deduplication @Suite("MALibraryManager – Genre Deduplication") struct GenreDeduplicationTests { private func uniqueByName(_ genres: [MAGenre]) -> [MAGenre] { var seen = Set() return genres.filter { seen.insert($0.name.lowercased()).inserted } } private func genre(_ name: String, provider: String) -> MAGenre { MAGenre(uri: "genre://\(name.lowercased())@\(provider)", name: name, metadata: nil) } @Test("Single genre per provider is unchanged") func singleProviderUnchanged() { let genres = [genre("Rock", provider: "spotify"), genre("Pop", provider: "spotify")] #expect(uniqueByName(genres).count == 2) } @Test("Duplicate names across providers collapse to one entry") func duplicatesCollapse() { let genres = [ genre("Rock", provider: "spotify"), genre("Rock", provider: "local"), genre("Rock", provider: "tidal"), ] let unique = uniqueByName(genres) #expect(unique.count == 1) #expect(unique[0].name == "Rock") } @Test("Mixed duplicates and unique names are handled correctly") func mixedList() { let genres = [ genre("Jazz", provider: "spotify"), genre("Rock", provider: "spotify"), genre("Rock", provider: "local"), genre("Blues", provider: "local"), ] let unique = uniqueByName(genres) #expect(unique.count == 3) #expect(unique.map(\.name).sorted() == ["Blues", "Jazz", "Rock"]) } @Test("Case-insensitive deduplication treats rock and Rock as the same") func caseInsensitive() { let genres = [genre("rock", provider: "local"), genre("Rock", provider: "spotify")] #expect(uniqueByName(genres).count == 1) } @Test("browseGenresByName on manager with no genres returns empty without service crash") func emptyGenresNoService() { let manager = MALibraryManager(service: nil) #expect(manager.genres.isEmpty) // Without genres loaded, matching set is empty — no service call is attempted. } } // MARK: - Genre URI Parsing @Suite("MALibraryManager – Genre URI Parsing") struct GenreURIParsingTests { /// Replicates the ID extraction used in browseGenresByName and filterNonEmptyGenres. private func genreId(from uri: String) -> Int? { Int(uri.components(separatedBy: "/").last ?? "") } @Test("Standard library genre URI yields correct integer ID") func standardURI() { #expect(genreId(from: "library://genre/26") == 26) } @Test("Small genre ID parses correctly") func smallId() { #expect(genreId(from: "library://genre/1") == 1) } @Test("Large genre ID parses correctly") func largeId() { #expect(genreId(from: "library://genre/99999") == 99999) } @Test("Non-integer last path component returns nil") func nonIntegerComponent() { #expect(genreId(from: "genre://Rock@local") == nil) } @Test("URI with trailing slash returns nil for empty last component") func trailingSlash() { #expect(genreId(from: "library://genre/") == nil) } @Test("Empty URI string returns nil") func emptyURI() { #expect(genreId(from: "") == nil) } } // MARK: - Genre ID Aggregation @Suite("MALibraryManager – Genre ID Aggregation") struct GenreIDAggregationTests { /// Replicates the ID collection used in browseGenresByName and filterNonEmptyGenres. private func idsForName(_ name: String, in genres: [MAGenre]) -> [Int] { genres .filter { $0.name.caseInsensitiveCompare(name) == .orderedSame } .compactMap { Int($0.uri.components(separatedBy: "/").last ?? "") } } private func genre(_ name: String, id: Int) -> MAGenre { MAGenre(uri: "library://genre/\(id)", name: name, metadata: nil) } @Test("Single genre yields its ID") func singleGenreId() { let genres = [genre("Rock", id: 26)] #expect(idsForName("Rock", in: genres) == [26]) } @Test("Multiple providers for the same genre name aggregate all IDs") func duplicateNamesAggregateIds() { let genres = [ genre("Rock", id: 26), genre("Rock", id: 7), genre("Rock", id: 14), ] let ids = idsForName("Rock", in: genres) #expect(Set(ids) == Set([26, 7, 14])) #expect(ids.count == 3) } @Test("Different genre names yield independent ID sets") func separateNames() { let genres = [genre("Rock", id: 26), genre("Jazz", id: 5)] #expect(idsForName("Rock", in: genres) == [26]) #expect(idsForName("Jazz", in: genres) == [5]) } @Test("Name matching is case-insensitive") func caseInsensitiveMatch() { let genres = [genre("rock", id: 1), genre("Rock", id: 2), genre("ROCK", id: 3)] #expect(Set(idsForName("Rock", in: genres)) == Set([1, 2, 3])) } @Test("Genre with non-integer URI is skipped by compactMap") func invalidURISkipped() { let bad = MAGenre(uri: "genre://Rock@local", name: "Rock", metadata: nil) let good = genre("Rock", id: 26) #expect(idsForName("Rock", in: [bad, good]) == [26]) } @Test("No matching genre name yields empty ID list") func noMatchReturnsEmpty() { let genres = [genre("Jazz", id: 5)] #expect(idsForName("Rock", in: genres).isEmpty) } } // MARK: - Display Genres State @Suite("MALibraryManager – Display Genres") struct DisplayGenresTests { @Test("displayGenres starts empty before any load") func displayGenresStartEmpty() { #expect(MALibraryManager(service: nil).displayGenres.isEmpty) } @Test("displayGenres and genres are independent collections") func displayGenresIsSeparate() { let manager = MALibraryManager(service: nil) #expect(manager.genres.isEmpty) #expect(manager.displayGenres.isEmpty) } } // MARK: - MALibraryManager Initial State @Suite("MALibraryManager – Initial State") struct LibraryManagerInitialStateTests { @Test("Artists collection starts empty") func artistsStartEmpty() { #expect(MALibraryManager(service: nil).artists.isEmpty) } @Test("Albums collection starts empty") func albumsStartEmpty() { #expect(MALibraryManager(service: nil).albums.isEmpty) } @Test("Playlists collection starts empty") func playlistsStartEmpty() { #expect(MALibraryManager(service: nil).playlists.isEmpty) } @Test("Loading flags start as false") func loadingFlagsStartFalse() { let m = MALibraryManager(service: nil) #expect(m.isLoadingArtists == false) #expect(m.isLoadingAlbums == false) #expect(m.isLoadingPlaylists == false) } @Test("favoriteURIs starts empty") func favoriteURIsStartEmpty() { #expect(MALibraryManager(service: nil).favoriteURIs.isEmpty) } }