Skip to content

Commit 8a1bad8

Browse files
authored
tests: improve coverage (#34)
1 parent fd2587b commit 8a1bad8

17 files changed

+3855
-2
lines changed
Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
import Foundation
2+
import Testing
3+
@testable import Kaset
4+
5+
/// Tests for ArtistDetailViewModel using mock client.
6+
@Suite("ArtistDetailViewModel", .serialized, .tags(.viewModel), .timeLimit(.minutes(1)))
7+
@MainActor
8+
struct ArtistDetailViewModelTests {
9+
var mockClient: MockYTMusicClient
10+
var viewModel: ArtistDetailViewModel
11+
12+
init() {
13+
self.mockClient = MockYTMusicClient()
14+
let artist = TestFixtures.makeArtist(id: "UC-test-artist", name: "Test Artist")
15+
self.viewModel = ArtistDetailViewModel(artist: artist, client: self.mockClient)
16+
}
17+
18+
// MARK: - Initial State Tests
19+
20+
@Test("Initial state is idle with no artist detail")
21+
func initialState() {
22+
#expect(self.viewModel.loadingState == .idle)
23+
#expect(self.viewModel.artistDetail == nil)
24+
#expect(self.viewModel.showAllSongs == false)
25+
}
26+
27+
// MARK: - Load Tests
28+
29+
@Test("Load success sets artist detail")
30+
func loadSuccess() async {
31+
let artistDetail = TestFixtures.makeArtistDetail(
32+
artist: TestFixtures.makeArtist(id: "UC-test-artist"),
33+
songCount: 10,
34+
albumCount: 3
35+
)
36+
self.mockClient.artistDetails["UC-test-artist"] = artistDetail
37+
38+
await self.viewModel.load()
39+
40+
#expect(self.mockClient.getArtistCalled == true)
41+
#expect(self.mockClient.getArtistIds.first == "UC-test-artist")
42+
#expect(self.viewModel.loadingState == .loaded)
43+
#expect(self.viewModel.artistDetail != nil)
44+
#expect(self.viewModel.artistDetail?.songs.count == 10)
45+
#expect(self.viewModel.artistDetail?.albums.count == 3)
46+
}
47+
48+
@Test("Load error sets error state")
49+
func loadError() async {
50+
self.mockClient.shouldThrowError = YTMusicError.networkError(underlying: URLError(.notConnectedToInternet))
51+
52+
await self.viewModel.load()
53+
54+
#expect(self.mockClient.getArtistCalled == true)
55+
if case let .error(error) = viewModel.loadingState {
56+
#expect(!error.message.isEmpty)
57+
#expect(error.isRetryable)
58+
} else {
59+
Issue.record("Expected error state")
60+
}
61+
#expect(self.viewModel.artistDetail == nil)
62+
}
63+
64+
@Test("Load does not duplicate when already loading")
65+
func loadDoesNotDuplicateWhenAlreadyLoading() async {
66+
let artistDetail = TestFixtures.makeArtistDetail(
67+
artist: TestFixtures.makeArtist(id: "UC-test-artist"),
68+
songCount: 5
69+
)
70+
self.mockClient.artistDetails["UC-test-artist"] = artistDetail
71+
72+
await self.viewModel.load()
73+
await self.viewModel.load()
74+
75+
#expect(self.viewModel.loadingState == .loaded)
76+
}
77+
78+
@Test("Load uses original artist info for unknown name")
79+
func loadUsesOriginalArtistInfoForUnknownName() async {
80+
// Create an artist detail with "Unknown Artist" name
81+
let unknownArtist = Artist(
82+
id: "UC-test-artist",
83+
name: "Unknown Artist",
84+
thumbnailURL: nil
85+
)
86+
let artistDetail = ArtistDetail(
87+
artist: unknownArtist,
88+
description: nil,
89+
songs: TestFixtures.makeSongs(count: 3),
90+
albums: [],
91+
thumbnailURL: nil
92+
)
93+
self.mockClient.artistDetails["UC-test-artist"] = artistDetail
94+
95+
await self.viewModel.load()
96+
97+
// Should use original artist name "Test Artist" instead of "Unknown Artist"
98+
#expect(self.viewModel.artistDetail?.name == "Test Artist")
99+
}
100+
101+
// MARK: - Refresh Tests
102+
103+
@Test("Refresh clears detail and reloads")
104+
func refreshClearsDetailAndReloads() async {
105+
let artistDetail = TestFixtures.makeArtistDetail(
106+
artist: TestFixtures.makeArtist(id: "UC-test-artist"),
107+
songCount: 5
108+
)
109+
self.mockClient.artistDetails["UC-test-artist"] = artistDetail
110+
111+
await self.viewModel.load()
112+
#expect(self.viewModel.artistDetail?.songs.count == 5)
113+
114+
// Update mock to return different song count
115+
let newArtistDetail = TestFixtures.makeArtistDetail(
116+
artist: TestFixtures.makeArtist(id: "UC-test-artist"),
117+
songCount: 8
118+
)
119+
self.mockClient.artistDetails["UC-test-artist"] = newArtistDetail
120+
121+
await self.viewModel.refresh()
122+
123+
#expect(self.viewModel.artistDetail?.songs.count == 8)
124+
#expect(self.viewModel.showAllSongs == false)
125+
}
126+
127+
// MARK: - Displayed Songs Tests
128+
129+
@Test("displayedSongs returns preview count by default")
130+
func displayedSongsReturnsPreviewCount() async {
131+
let artistDetail = TestFixtures.makeArtistDetail(
132+
artist: TestFixtures.makeArtist(id: "UC-test-artist"),
133+
songCount: 10
134+
)
135+
self.mockClient.artistDetails["UC-test-artist"] = artistDetail
136+
137+
await self.viewModel.load()
138+
139+
#expect(self.viewModel.displayedSongs.count == ArtistDetailViewModel.previewSongCount)
140+
}
141+
142+
@Test("displayedSongs returns all songs when showAllSongs is true")
143+
func displayedSongsReturnsAllWhenShowAllSongs() async {
144+
let artistDetail = TestFixtures.makeArtistDetail(
145+
artist: TestFixtures.makeArtist(id: "UC-test-artist"),
146+
songCount: 10
147+
)
148+
self.mockClient.artistDetails["UC-test-artist"] = artistDetail
149+
150+
await self.viewModel.load()
151+
self.viewModel.showAllSongs = true
152+
153+
#expect(self.viewModel.displayedSongs.count == 10)
154+
}
155+
156+
@Test("displayedSongs returns empty when no detail")
157+
func displayedSongsReturnsEmptyWhenNoDetail() {
158+
#expect(self.viewModel.displayedSongs.isEmpty)
159+
}
160+
161+
// MARK: - Has More Songs Tests
162+
163+
@Test("hasMoreSongs returns false when no detail")
164+
func hasMoreSongsReturnsFalseWhenNoDetail() {
165+
#expect(self.viewModel.hasMoreSongs == false)
166+
}
167+
168+
@Test("hasMoreSongs returns true when songs exceed preview count")
169+
func hasMoreSongsReturnsTrueWhenExceedsPreview() async {
170+
let artistDetail = TestFixtures.makeArtistDetail(
171+
artist: TestFixtures.makeArtist(id: "UC-test-artist"),
172+
songCount: 10 // More than previewSongCount
173+
)
174+
self.mockClient.artistDetails["UC-test-artist"] = artistDetail
175+
176+
await self.viewModel.load()
177+
178+
#expect(self.viewModel.hasMoreSongs == true)
179+
}
180+
181+
@Test("hasMoreSongs returns false when songs within preview count")
182+
func hasMoreSongsReturnsFalseWhenWithinPreview() async {
183+
let artistDetail = TestFixtures.makeArtistDetail(
184+
artist: TestFixtures.makeArtist(id: "UC-test-artist"),
185+
songCount: 3 // Less than previewSongCount
186+
)
187+
self.mockClient.artistDetails["UC-test-artist"] = artistDetail
188+
189+
await self.viewModel.load()
190+
191+
#expect(self.viewModel.hasMoreSongs == false)
192+
}
193+
194+
// MARK: - Subscription Tests
195+
196+
@Test("toggleSubscription does nothing without channel ID")
197+
func toggleSubscriptionNoChannelId() async {
198+
let artistDetail = ArtistDetail(
199+
artist: TestFixtures.makeArtist(id: "UC-test-artist"),
200+
description: nil,
201+
songs: [],
202+
albums: [],
203+
thumbnailURL: nil,
204+
channelId: nil // No channel ID
205+
)
206+
self.mockClient.artistDetails["UC-test-artist"] = artistDetail
207+
208+
await self.viewModel.load()
209+
await self.viewModel.toggleSubscription()
210+
211+
#expect(self.mockClient.subscribeToArtistCalled == false)
212+
#expect(self.mockClient.unsubscribeFromArtistCalled == false)
213+
}
214+
215+
@Test("toggleSubscription subscribes when not subscribed")
216+
func toggleSubscriptionSubscribes() async {
217+
let artistDetail = ArtistDetail(
218+
artist: TestFixtures.makeArtist(id: "UC-test-artist"),
219+
description: nil,
220+
songs: [],
221+
albums: [],
222+
thumbnailURL: nil,
223+
channelId: "UC-channel-123",
224+
isSubscribed: false
225+
)
226+
self.mockClient.artistDetails["UC-test-artist"] = artistDetail
227+
228+
await self.viewModel.load()
229+
await self.viewModel.toggleSubscription()
230+
231+
#expect(self.mockClient.subscribeToArtistCalled == true)
232+
#expect(self.mockClient.subscribeToArtistIds.first == "UC-channel-123")
233+
#expect(self.viewModel.artistDetail?.isSubscribed == true)
234+
}
235+
236+
@Test("toggleSubscription unsubscribes when subscribed")
237+
func toggleSubscriptionUnsubscribes() async {
238+
let artistDetail = ArtistDetail(
239+
artist: TestFixtures.makeArtist(id: "UC-test-artist"),
240+
description: nil,
241+
songs: [],
242+
albums: [],
243+
thumbnailURL: nil,
244+
channelId: "UC-channel-123",
245+
isSubscribed: true
246+
)
247+
self.mockClient.artistDetails["UC-test-artist"] = artistDetail
248+
249+
await self.viewModel.load()
250+
await self.viewModel.toggleSubscription()
251+
252+
#expect(self.mockClient.unsubscribeFromArtistCalled == true)
253+
#expect(self.mockClient.unsubscribeFromArtistIds.first == "UC-channel-123")
254+
#expect(self.viewModel.artistDetail?.isSubscribed == false)
255+
}
256+
257+
@Test("toggleSubscription sets error on failure")
258+
func toggleSubscriptionSetsErrorOnFailure() async {
259+
let artistDetail = ArtistDetail(
260+
artist: TestFixtures.makeArtist(id: "UC-test-artist"),
261+
description: nil,
262+
songs: [],
263+
albums: [],
264+
thumbnailURL: nil,
265+
channelId: "UC-channel-123",
266+
isSubscribed: false
267+
)
268+
self.mockClient.artistDetails["UC-test-artist"] = artistDetail
269+
270+
await self.viewModel.load()
271+
self.mockClient.shouldThrowError = YTMusicError.networkError(underlying: URLError(.notConnectedToInternet))
272+
273+
await self.viewModel.toggleSubscription()
274+
275+
#expect(self.viewModel.subscriptionError != nil)
276+
#expect(self.viewModel.artistDetail?.isSubscribed == false) // Unchanged
277+
}
278+
279+
// MARK: - Get All Songs Tests
280+
281+
@Test("getAllSongs returns artist detail songs when no browse ID")
282+
func getAllSongsReturnsDetailSongs() async {
283+
let artistDetail = TestFixtures.makeArtistDetail(
284+
artist: TestFixtures.makeArtist(id: "UC-test-artist"),
285+
songCount: 5
286+
)
287+
self.mockClient.artistDetails["UC-test-artist"] = artistDetail
288+
289+
await self.viewModel.load()
290+
let songs = await self.viewModel.getAllSongs()
291+
292+
#expect(songs.count == 5)
293+
#expect(self.mockClient.getArtistSongsCalled == false)
294+
}
295+
296+
@Test("getAllSongs fetches from API when browse ID available")
297+
func getAllSongsFetchesFromAPI() async {
298+
let artistDetail = ArtistDetail(
299+
artist: TestFixtures.makeArtist(id: "UC-test-artist"),
300+
description: nil,
301+
songs: TestFixtures.makeSongs(count: 5),
302+
albums: [],
303+
thumbnailURL: nil,
304+
hasMoreSongs: true,
305+
songsBrowseId: "artist-songs-browse-id"
306+
)
307+
self.mockClient.artistDetails["UC-test-artist"] = artistDetail
308+
self.mockClient.artistSongs["artist-songs-browse-id"] = TestFixtures.makeSongs(count: 20)
309+
310+
await self.viewModel.load()
311+
let songs = await self.viewModel.getAllSongs()
312+
313+
#expect(songs.count == 20)
314+
#expect(self.mockClient.getArtistSongsCalled == true)
315+
#expect(self.mockClient.getArtistSongsBrowseIds.first == "artist-songs-browse-id")
316+
}
317+
318+
@Test("getAllSongs returns cached songs on subsequent calls")
319+
func getAllSongsReturnsCached() async {
320+
let artistDetail = ArtistDetail(
321+
artist: TestFixtures.makeArtist(id: "UC-test-artist"),
322+
description: nil,
323+
songs: TestFixtures.makeSongs(count: 5),
324+
albums: [],
325+
thumbnailURL: nil,
326+
hasMoreSongs: true,
327+
songsBrowseId: "artist-songs-browse-id"
328+
)
329+
self.mockClient.artistDetails["UC-test-artist"] = artistDetail
330+
self.mockClient.artistSongs["artist-songs-browse-id"] = TestFixtures.makeSongs(count: 20)
331+
332+
await self.viewModel.load()
333+
_ = await self.viewModel.getAllSongs()
334+
_ = await self.viewModel.getAllSongs()
335+
336+
// Should only call API once
337+
#expect(self.mockClient.getArtistSongsBrowseIds.count == 1)
338+
}
339+
}

0 commit comments

Comments
 (0)