address the bulk download review

- Cancelling a list while its download loop was still enqueueing missed every
  song queued after the cancel looked. The loop now holds the lock for the whole
  queue, so a cancel waits for it to finish and then removes all of it. That
  needed the enqueue helper split into a locked entry point and a body the loop
  can call while already holding the lock — calling the locked one from inside
  the lock deadlocked, which is what hung the tests.
- An empty resolved list now says so instead of doing nothing.
- One song failing to enqueue no longer aborts the rest of the batch.
- The artist actions resolve through the albums the artist page lists, matching
  what is on screen, rather than through songs.artistId, which for a guest on a
  compilation enqueued nothing.
- Bulk actions are one `applyAction` on the repository, so the three view models
  and both screens stop repeating the same dispatch.
- Tests: the playlist and artist resolve paths, a Running row counted as
  downloading, the action dispatch, and the action rule moved to a plain unit
  test.
This commit is contained in:
austinried
2026-10-04 00:30:02 +00:00
parent b2b92a388e
commit bf561075a2
15 changed files with 178 additions and 99 deletions
@@ -12,6 +12,7 @@ import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.download.FakeDownloadEngine
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.DownloadList
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.ListDownloadStatus
@@ -490,6 +491,10 @@ class DownloadRepositoryTest {
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Queued, engineId = 22))
assertEquals(ListDownloadStatus(total = 2, downloaded = 1, downloading = 1), statusOf(DownloadList.Album, "al1"))
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Running, engineId = 22))
assertEquals(ListDownloadStatus(total = 2, downloaded = 1, downloading = 1), statusOf(DownloadList.Album, "al1"))
}
@Test
@@ -514,6 +519,72 @@ class DownloadRepositoryTest {
refId: String,
): ListDownloadStatus = runBlocking { repository.status(1, list, refId).first() }
@Test
fun downloadingAPlaylistQueuesEverySong() =
runTest {
seedLibrary()
runBlocking {
db.libraryDao().upsertPlaylists(listOf(Playlist(1, "pl1", "Playlist", null, null, 2, 0)))
db.libraryDao().upsertPlaylistSongs(
listOf(
PlaylistSong(1, "pl1", "s1", 0),
PlaylistSong(1, "pl1", "s2", 1),
),
)
}
runBlocking { repository.downloadAll(1, DownloadList.Playlist, "pl1") }
assertEquals(listOf("s1", "s2"), requestedSongIds())
}
@Test
fun downloadingAnArtistQueuesTheSongsOfItsAlbums() =
runTest {
seedLibrary()
// s2 is a guest on the album, so its own artistId differs: the artist page lists the
// album, and the download follows what the page shows.
runBlocking {
db.libraryDao().upsertSongs(
listOf(
Song(
sourceId = 1,
id = "s2",
albumId = "al1",
artistId = "ar2",
title = "Song 2",
album = "Album",
artist = "Guest",
duration = 100,
track = 2,
disc = 1,
starred = null,
genre = null,
),
),
)
}
runBlocking { repository.downloadAll(1, DownloadList.Artist, "ar1") }
assertEquals(listOf("s1", "s2"), requestedSongIds())
}
@Test
fun applyActionRoutesToTheMatchingBulkAction() =
runTest {
seedLibrary()
runBlocking { repository.applyAction(1, DownloadList.Album, "al1", BulkDownloadAction.Download) }
assertEquals(2, engine.requests.size)
runBlocking { repository.applyAction(1, DownloadList.Album, "al1", BulkDownloadAction.Cancel) }
assertEquals(ListDownloadStatus(total = 2), statusOf(DownloadList.Album, "al1"))
}
private fun requestedSongIds(): List<String> = engine.requests.map { it.second.path.substringAfterLast('/') }
private fun album(coverArt: String?) =
Album(
sourceId = 1,
@@ -0,0 +1,29 @@
package com.subtracks.ui.components
import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.ListDownloadStatus
import org.junit.Assert.assertEquals
import org.junit.Test
class BulkDownloadActionTest {
@Test
fun anUndownloadedListOffersDownload() {
assertEquals(BulkDownloadAction.Download, ListDownloadStatus(total = 3).action())
assertEquals(BulkDownloadAction.Download, ListDownloadStatus().action())
}
@Test
fun aPartiallyDownloadedListStillOffersDownload() {
assertEquals(BulkDownloadAction.Download, ListDownloadStatus(total = 3, downloaded = 1).action())
}
@Test
fun aListWithDownloadsInFlightOffersCancel() {
assertEquals(BulkDownloadAction.Cancel, ListDownloadStatus(total = 3, downloaded = 1, downloading = 1).action())
}
@Test
fun aFullyDownloadedListOffersDelete() {
assertEquals(BulkDownloadAction.Delete, ListDownloadStatus(total = 3, downloaded = 3).action())
}
}
@@ -7,6 +7,7 @@ import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.performClick
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.ui.theme.SubtracksTheme
import org.junit.Assert.assertEquals
@@ -21,27 +22,6 @@ class HeroDownloadTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun anUndownloadedListOffersDownload() {
assertEquals(BulkDownloadAction.Download, ListDownloadStatus(total = 3).action())
assertEquals(BulkDownloadAction.Download, ListDownloadStatus().action())
}
@Test
fun aPartiallyDownloadedListStillOffersDownload() {
assertEquals(BulkDownloadAction.Download, ListDownloadStatus(total = 3, downloaded = 1).action())
}
@Test
fun aListWithDownloadsInFlightOffersCancel() {
assertEquals(BulkDownloadAction.Cancel, ListDownloadStatus(total = 3, downloaded = 1, downloading = 1).action())
}
@Test
fun aFullyDownloadedListOffersDelete() {
assertEquals(BulkDownloadAction.Delete, ListDownloadStatus(total = 3, downloaded = 3).action())
}
@Test
fun tappingTheButtonReportsTheActionItsStateOffers() {
val actions = mutableListOf<BulkDownloadAction>()