329 lines
11 KiB
Swift
329 lines
11 KiB
Swift
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<String>()
|
||
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)
|
||
}
|
||
}
|