Files
MobileMusicAssistant/MobileMusicAssistantTests/MALibraryManagerTests.swift
T
2026-04-20 13:02:51 +02:00

329 lines
11 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}