diff --git a/app/src/main/java/io/aatricks/easyreader/data/local/LibraryDao.kt b/app/src/main/java/io/aatricks/easyreader/data/local/LibraryDao.kt index 601bde86..6a9ebb42 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/local/LibraryDao.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/local/LibraryDao.kt @@ -52,6 +52,12 @@ interface LibraryDao { @Query("UPDATE library_items SET isDownloaded = :downloaded, downloadedAt = :timestamp WHERE id = :id") suspend fun setDownloaded(id: String, downloaded: Boolean, timestamp: Long?) + @Query("UPDATE library_items SET totalChapters = :totalChapters WHERE id = :id") + suspend fun updateTotalChapters(id: String, totalChapters: Int) + + @Query("UPDATE library_items SET hasUpdates = 1 WHERE id = :id") + suspend fun markHasUpdates(id: String) + @Query("SELECT * FROM library_items WHERE isDownloaded = 1") suspend fun getDownloadedItems(): List diff --git a/app/src/main/java/io/aatricks/easyreader/data/repository/ChapterListCache.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/ChapterListCache.kt index 5beb2713..c7015c8d 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/repository/ChapterListCache.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/repository/ChapterListCache.kt @@ -52,7 +52,15 @@ class ChapterListCache @Inject constructor( baseNovelUrl = baseNovelUrl, sourceName = sourceName ) - file.writeText(json.encodeToString(Entry.serializer(), entry)) + val temp = File.createTempFile("${file.name}.", ".tmp", file.parentFile) + try { + temp.writeText(json.encodeToString(Entry.serializer(), entry)) + if (!temp.renameTo(file)) { + temp.copyTo(file, overwrite = true) + } + } finally { + temp.delete() + } } } diff --git a/app/src/main/java/io/aatricks/easyreader/data/repository/ContentRepository.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/ContentRepository.kt index de2b58e3..d4d95850 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/repository/ContentRepository.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/repository/ContentRepository.kt @@ -6,6 +6,7 @@ import io.aatricks.easyreader.data.model.* import io.aatricks.easyreader.data.repository.content.* import io.aatricks.easyreader.data.repository.content.StorageTier import io.aatricks.easyreader.util.FileSizeUtils +import io.aatricks.easyreader.util.rethrowCancellation import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.CancellationException @@ -235,7 +236,7 @@ class ContentRepository @Inject constructor( isRetryable = false ) } - }.getOrElse { + }.rethrowCancellation().getOrElse { PrefetchResult(url, htmlCached = false, totalImages = 0, cachedImages = 0, isComplete = false, isRetryable = true) } trimCachesInternal(force = true) @@ -285,7 +286,7 @@ class ContentRepository @Inject constructor( isPersistentDownload = true ) } - }.getOrElse { + }.rethrowCancellation().getOrElse { PrefetchResult( url, htmlCached = false, @@ -328,7 +329,7 @@ class ContentRepository @Inject constructor( isPersistentDownload = true ) } - }.getOrElse { + }.rethrowCancellation().getOrElse { PrefetchResult( url, htmlCached = false, @@ -363,7 +364,7 @@ class ContentRepository @Inject constructor( isRetryable = false ) } - }.getOrElse { + }.rethrowCancellation().getOrElse { PrefetchResult(url, htmlCached = false, totalImages = 0, cachedImages = 0, isComplete = false, isRetryable = true) } diff --git a/app/src/main/java/io/aatricks/easyreader/data/repository/LibraryRepository.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/LibraryRepository.kt index 89eb250b..1a51dabc 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/repository/LibraryRepository.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/repository/LibraryRepository.kt @@ -2,6 +2,7 @@ package io.aatricks.easyreader.data.repository import io.aatricks.easyreader.util.TextUtils import io.aatricks.easyreader.util.normalizeChapterList +import io.aatricks.easyreader.util.rethrowCancellation import io.aatricks.easyreader.data.local.PreferencesManager import io.aatricks.easyreader.data.local.LibraryDao import io.aatricks.easyreader.data.model.LibraryItem @@ -82,6 +83,7 @@ class LibraryRepository @Inject constructor( block: suspend () -> T ): T? = withContext(Dispatchers.IO) { kotlin.runCatching { block() } + .rethrowCancellation() .onFailure { e -> Log.e(TAG, errorMessage, e) } .getOrDefault(fallback) } @@ -369,7 +371,11 @@ class LibraryRepository @Inject constructor( ): Unit = io { runRepoCatching("Refresh updates failed") { val allItems = libraryDao.getAllItems().firstOrNull() ?: emptyList() - val groupedItems = getGroupedByTitle(allItems) + val groupedItems = allItems.groupBy { item -> + Pair(item.baseTitle.ifBlank { item.title }, item.sourceName) + }.mapValues { (_, group) -> + sortChapters(group) + } val semaphore = Semaphore(5) // Only check for updates on novels that have been read recently or were added recently. @@ -385,19 +391,20 @@ class LibraryRepository @Inject constructor( }) } - val channel = Channel>>(Channel.UNLIMITED) + val channel = Channel, List>>(Channel.UNLIMITED) activeGroups.forEach { channel.trySend(it.key to it.value) } channel.close() val allUpdates = coroutineScope { val workers = (1..5).map { async { - val localUpdates = mutableListOf() - for ((baseTitle, items) in channel) { + val localUpdates = mutableListOf() + for ((key, items) in channel) { + val (baseTitle, _) = key if (items.isNotEmpty()) { val latestInLibrary = items.last() if (latestInLibrary.baseNovelUrl.isNotBlank() && latestInLibrary.sourceName.isNotBlank()) { - val newUpdates = runRepoCatching("Failed to refresh updates for $baseTitle", emptyList()) { + val newUpdates = runRepoCatching("Failed to refresh updates for $baseTitle", emptyList()) { val details = withTimeoutOrNull(REFRESH_PER_SOURCE_TIMEOUT_MS) { exploreRepository.getNovelDetails( latestInLibrary.baseNovelUrl, @@ -415,17 +422,20 @@ class LibraryRepository @Inject constructor( markerChapterNumber >= previousTotalChapters.toDouble() && itemToMark.hasFinishedProgress() items.map { item -> - var newItem = item.copy(totalChapters = sourceChapterCount) - if (wasCaughtUp && item.id == itemToMark.id && !item.hasUpdates) { - newItem = newItem.copy(hasUpdates = true) - } - newItem + val markUpdate = wasCaughtUp && + item.id == itemToMark.id && + !item.hasUpdates + LibraryUpdate( + itemId = item.id, + newTotalChapters = sourceChapterCount, + markHasUpdates = markUpdate + ) } } else { - emptyList() + emptyList() } } else { - emptyList() + emptyList() } } ?: emptyList() localUpdates.addAll(newUpdates) @@ -439,7 +449,12 @@ class LibraryRepository @Inject constructor( } if (allUpdates.isNotEmpty()) { - libraryDao.insertItems(allUpdates) + allUpdates.forEach { update -> + libraryDao.updateTotalChapters(update.itemId, update.newTotalChapters) + if (update.markHasUpdates) { + libraryDao.markHasUpdates(update.itemId) + } + } } } } @@ -473,4 +488,9 @@ class LibraryRepository @Inject constructor( } ?: false } ?: false + private data class LibraryUpdate( + val itemId: String, + val newTotalChapters: Int, + val markHasUpdates: Boolean + ) } diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentArea.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentArea.kt index 7329b275..34b446cc 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentArea.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentArea.kt @@ -58,6 +58,7 @@ import io.aatricks.easyreader.ui.components.applyReaderEdgeBlur import io.aatricks.easyreader.ui.components.supportsReaderEdgeBlur import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlin.math.abs @@ -134,15 +135,18 @@ internal fun ContentArea( if (uiState.showControls) edgeBlurCaptureGeneration++ } - LaunchedEffect( - uiState.showControls, - listState.firstVisibleItemIndex, - listState.firstVisibleItemScrollOffset, - pagerState.currentPage - ) { + LaunchedEffect(uiState.showControls, content.url) { if (!uiState.showControls) return@LaunchedEffect - kotlinx.coroutines.delay(EDGE_BLUR_RECAPTURE_DEBOUNCE_MS) - edgeBlurCaptureGeneration++ + snapshotFlow { + Triple( + listState.firstVisibleItemIndex, + listState.firstVisibleItemScrollOffset, + pagerState.currentPage + ) + }.collectLatest { + kotlinx.coroutines.delay(EDGE_BLUR_RECAPTURE_DEBOUNCE_MS) + edgeBlurCaptureGeneration++ + } } LaunchedEffect(listState.firstVisibleItemIndex, pagerState.currentPage, content.url) { diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderScreen.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderScreen.kt index d8873c76..ee720c44 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderScreen.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.material.icons.automirrored.filled.* import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate @@ -77,11 +78,11 @@ fun ReaderScreen( val scope = rememberCoroutineScope() val context = LocalContext.current - var showCloudflareWebView by remember { mutableStateOf(false) } - var cloudflareUrl by remember { mutableStateOf("") } + var showCloudflareWebView by rememberSaveable { mutableStateOf(false) } + var cloudflareUrl by rememberSaveable { mutableStateOf("") } - var showChapterList by remember { mutableStateOf(false) } - var showSettings by remember { mutableStateOf(false) } + var showChapterList by rememberSaveable { mutableStateOf(false) } + var showSettings by rememberSaveable { mutableStateOf(false) } val bottomSheetState = rememberModalBottomSheetState() val settingsSheetState = rememberModalBottomSheetState() diff --git a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/BaseViewModel.kt b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/BaseViewModel.kt index efddaa87..c81efa44 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/BaseViewModel.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/BaseViewModel.kt @@ -2,6 +2,7 @@ package io.aatricks.easyreader.ui.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import io.aatricks.easyreader.util.rethrowCancellation import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -29,6 +30,7 @@ abstract class BaseViewModel(initialState: S) : ViewModel() { if (handleError) updateState { errorState(it, null) } runCatching { block() } + .rethrowCancellation() .onFailure { e -> if (handleError) updateState { errorState(it, e.message ?: "An unknown error occurred") } } diff --git a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManager.kt b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManager.kt index 8ef5ca97..e50a2bc8 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManager.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ImageDimensionManager.kt @@ -6,6 +6,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import android.util.Log +import io.aatricks.easyreader.util.rethrowCancellation /** * Owns the resolved-image-dimension pipeline extracted from ReaderViewModel. @@ -32,6 +34,7 @@ class ImageDimensionManager( private var contentDimApplyJob: Job? = null companion object { + private const val TAG = "ImageDimensionManager" private const val IMAGE_DIMENSION_FLUSH_DELAY_MS = 100L private const val CONTENT_DIM_APPLY_DEBOUNCE_MS = 350L } @@ -78,9 +81,13 @@ class ImageDimensionManager( val updates = pendingImageDimensions.toMap() pendingImageDimensions.clear() if (updates.isEmpty()) return - imageDimensionCache.persistAll(updates.map { (url, dimensions) -> - Triple(url, dimensions.first, dimensions.second) - }) + runCatching { + imageDimensionCache.persistAll(updates.map { (url, dimensions) -> + Triple(url, dimensions.first, dimensions.second) + }) + }.rethrowCancellation().onFailure { e -> + Log.e(TAG, "Failed to flush pending image dimensions", e) + } } // Matches the reader's resetState: drop the queued in-memory rebuild and the resolved map. diff --git a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModel.kt b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModel.kt index f0079374..42d15384 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModel.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModel.kt @@ -19,6 +19,8 @@ import io.aatricks.easyreader.work.ChapterDownloadQueue import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import kotlinx.coroutines.Job +import io.aatricks.easyreader.util.rethrowCancellation import javax.inject.Inject import android.util.Log @@ -347,6 +349,8 @@ class LibraryViewModel @Inject constructor( return true } + private var openNewChapterJob: Job? = null + private suspend fun addUnresolvedItem(url: String, contentType: ContentType) { val fetchedTitle = runCatching { contentRepository.fetchTitle(url) }.getOrNull() ?: url val fullTitle = fetchedTitle.trim().ifBlank { url } @@ -368,7 +372,8 @@ class LibraryViewModel @Inject constructor( sourceName: String, onChapterLoaded: (String, String) -> Unit ): Unit { - viewModelScope.launch { + if (openNewChapterJob?.isActive == true) return + openNewChapterJob = viewModelScope.launch { runCatching { updateState { it.copy(isLoading = true) } val details = exploreRepository.getNovelDetails(baseNovelUrl, sourceName) @@ -401,7 +406,7 @@ class LibraryViewModel @Inject constructor( repository.clearUpdateIndicator(item.id) onChapterLoaded(item.url, item.id) updateState { it.copy(isLoading = false) } - }.onFailure { e -> + }.rethrowCancellation().onFailure { e -> Log.e(TAG, "Failed to open new chapter", e) updateState { it.copy(isLoading = false, error = "Failed to load new chapter: ${e.message}") } } diff --git a/app/src/main/java/io/aatricks/easyreader/util/ResultExtensions.kt b/app/src/main/java/io/aatricks/easyreader/util/ResultExtensions.kt new file mode 100644 index 00000000..0afe0fa6 --- /dev/null +++ b/app/src/main/java/io/aatricks/easyreader/util/ResultExtensions.kt @@ -0,0 +1,12 @@ +package io.aatricks.easyreader.util + +import kotlinx.coroutines.CancellationException + +/** + * Rethrows kotlinx.coroutines.CancellationException if present in the Result. + */ +fun Result.rethrowCancellation(): Result { + val e = exceptionOrNull() + if (e is CancellationException) throw e + return this +} diff --git a/app/src/test/java/io/aatricks/easyreader/data/repository/ChapterListCacheTest.kt b/app/src/test/java/io/aatricks/easyreader/data/repository/ChapterListCacheTest.kt new file mode 100644 index 00000000..e4875508 --- /dev/null +++ b/app/src/test/java/io/aatricks/easyreader/data/repository/ChapterListCacheTest.kt @@ -0,0 +1,66 @@ +package io.aatricks.easyreader.data.repository + +import io.aatricks.easyreader.data.model.ChapterInfo +import kotlinx.serialization.json.Json +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Test +import java.io.File +import java.nio.file.Files + +class ChapterListCacheTest { + + private lateinit var tempDir: File + private lateinit var cache: ChapterListCache + private val json = Json { ignoreUnknownKeys = true } + + @Before + fun setup() { + tempDir = Files.createTempDirectory("chapter_list_cache_test").toFile() + cache = ChapterListCache(tempDir, json) + } + + @After + fun teardown() { + tempDir.deleteRecursively() + } + + @Test + fun `save and load round trip`() { + val chapters = listOf( + ChapterInfo("Ch 1", "url1"), + ChapterInfo("Ch 2", "url2") + ) + + cache.save("novel1", "Source1", chapters) + + val loaded = cache.load("novel1", "Source1") + assertNotNull(loaded) + assertEquals(chapters, loaded?.chapters) + assertEquals("novel1", loaded?.baseNovelUrl) + assertEquals("Source1", loaded?.sourceName) + } + + @Test + fun `save over existing entry round trip`() { + val chapters1 = listOf(ChapterInfo("Ch 1", "url1")) + val chapters2 = listOf( + ChapterInfo("Ch 1", "url1"), + ChapterInfo("Ch 2", "url2") + ) + + // First save + cache.save("novel1", "Source1", chapters1) + val loaded1 = cache.load("novel1", "Source1") + assertNotNull(loaded1) + assertEquals(chapters1, loaded1?.chapters) + + // Save over existing + cache.save("novel1", "Source1", chapters2) + val loaded2 = cache.load("novel1", "Source1") + assertNotNull(loaded2) + assertEquals(chapters2, loaded2?.chapters) + } +} diff --git a/app/src/test/java/io/aatricks/easyreader/data/repository/LibraryRepositoryUpdateTest.kt b/app/src/test/java/io/aatricks/easyreader/data/repository/LibraryRepositoryUpdateTest.kt index 9e93d35e..86e13781 100644 --- a/app/src/test/java/io/aatricks/easyreader/data/repository/LibraryRepositoryUpdateTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/data/repository/LibraryRepositoryUpdateTest.kt @@ -5,10 +5,13 @@ import io.aatricks.easyreader.data.local.PreferencesManager import io.aatricks.easyreader.data.model.ChapterInfo import io.aatricks.easyreader.data.model.ExploreItem import io.aatricks.easyreader.data.model.LibraryItem +import io.aatricks.easyreader.util.FieldUpdate import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.launch import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue +import org.junit.Assert.assertFalse import org.junit.Before import org.junit.Test import org.mockito.Mock @@ -35,6 +38,7 @@ class LibraryRepositoryUpdateTest { fun setup() { MockitoAnnotations.openMocks(this) whenever(preferencesManager.loadLibraryItems()).thenReturn(emptyList()) + whenever(preferencesManager.loadCollapsedSources()).thenReturn(emptySet()) whenever(libraryDao.getAllItems()).thenReturn(flowOf(emptyList())) repository = LibraryRepository(libraryDao, preferencesManager) @@ -118,8 +122,11 @@ class LibraryRepositoryUpdateTest { // Execute repository.refreshLibraryUpdates(exploreRepository) - // Verify behavior: Optimized behavior calls insertItems once with all updates. - verify(libraryDao, times(1)).insertItems(any()) + // Verify behavior: Optimized behavior calls updateTotalChapters. + verify(libraryDao).updateTotalChapters("1", 15) + verify(libraryDao).updateTotalChapters("2", 25) + verify(libraryDao).updateTotalChapters("3", 35) + verify(libraryDao, never()).insertItems(any()) } @Test @@ -142,9 +149,9 @@ class LibraryRepositoryUpdateTest { repository.refreshLibraryUpdates(exploreRepository) - verify(libraryDao).insertItems(argThat { - size == 1 && first().totalChapters == 11 && !first().hasUpdates - }) + verify(libraryDao).updateTotalChapters("unfinished", 11) + verify(libraryDao, never()).markHasUpdates(any()) + verify(libraryDao, never()).insertItems(any()) } @Test @@ -167,9 +174,9 @@ class LibraryRepositoryUpdateTest { repository.refreshLibraryUpdates(exploreRepository) - verify(libraryDao).insertItems(argThat { - size == 1 && first().totalChapters == 11 && first().hasUpdates - }) + verify(libraryDao).updateTotalChapters("caught-up", 11) + verify(libraryDao).markHasUpdates("caught-up") + verify(libraryDao, never()).insertItems(any()) } @Test @@ -189,9 +196,9 @@ class LibraryRepositoryUpdateTest { repository.refreshLibraryUpdates(exploreRepository) // Verify behavior: It should still update the successful one - verify(libraryDao, times(1)).insertItems(argThat { - size == 1 && first().id == "1" && first().totalChapters == 15 - }) + verify(libraryDao).updateTotalChapters("1", 15) + verify(libraryDao, never()).updateTotalChapters(eq("2"), any()) + verify(libraryDao, never()).insertItems(any()) } @Test @@ -230,9 +237,9 @@ class LibraryRepositoryUpdateTest { verify(exploreRepository, never()).getNovelDetails("novel_old", "Source1") // Verify that only recent items were updated in DB - verify(libraryDao).insertItems(argThat { - size == 1 && first().id == "recent" && first().totalChapters == 15 - }) + verify(libraryDao).updateTotalChapters("recent", 15) + verify(libraryDao, never()).updateTotalChapters(eq("old"), any()) + verify(libraryDao, never()).insertItems(any()) } @Test @@ -263,9 +270,8 @@ class LibraryRepositoryUpdateTest { verify(exploreRepository).getNovelDetails("novel_old_reading", "Source1") // Verify that it was updated - verify(libraryDao).insertItems(argThat { - size == 1 && first().id == "old_but_reading" && first().totalChapters == 15 - }) + verify(libraryDao).updateTotalChapters("old_but_reading", 15) + verify(libraryDao, never()).insertItems(any()) } @Test @@ -297,8 +303,156 @@ class LibraryRepositoryUpdateTest { repository.refreshLibraryUpdates(exploreRepository, ignoreActivityThreshold = true) verify(exploreRepository).getNovelDetails("novel_imported", "Source1") - verify(libraryDao).insertItems(argThat { - size == 1 && first().id == "imported" && first().totalChapters == 15 && first().hasUpdates - }) + verify(libraryDao).updateTotalChapters("imported", 15) + verify(libraryDao).markHasUpdates("imported") + verify(libraryDao, never()).insertItems(any()) + } + + @Test + fun `refresh preserves progress written during the refresh window`() = runBlocking { + val item = LibraryItem( + id = "1", title = "Novel 1", url = "url1", + baseTitle = "Novel 1", baseNovelUrl = "novel1", sourceName = "Source1", + currentChapter = "Ch 10", + totalChapters = 10, + progress = 50, + lastReadElementKey = "oldKey" + ) + val dbItems = mutableMapOf("1" to item) + whenever(libraryDao.getAllItems()).thenAnswer { flowOf(dbItems.values.toList()) } + whenever(libraryDao.getItemById("1")).thenAnswer { dbItems["1"] } + whenever(libraryDao.insertItem(any())).thenAnswer { inv -> + val itm = inv.arguments[0] as LibraryItem + dbItems[itm.id] = itm + Unit + } + whenever(libraryDao.updateTotalChapters(any(), any())).thenAnswer { inv -> + val id = inv.arguments[0] as String + val chapters = inv.arguments[1] as Int + dbItems[id]?.let { + dbItems[id] = it.copy(totalChapters = chapters) + } + Unit + } + + // We want getNovelDetails to suspend, allowing us to update progress concurrently + val deferredNovelDetails = kotlinx.coroutines.CompletableDeferred() + whenever(exploreRepository.getNovelDetails("novel1", "Source1")).thenAnswer { + runBlocking { deferredNovelDetails.await() } + } + + val refreshJob = launch { + repository.refreshLibraryUpdates(exploreRepository) + } + + // yield to let refreshLibraryUpdates start and wait on getNovelDetails + kotlinx.coroutines.yield() + + // updateProgressExplicit concurrently + repository.updateProgressExplicit( + itemId = "1", + progress = FieldUpdate.Set(80), + lastReadElementKey = FieldUpdate.Set("newKey") + ) + + // Now resume getNovelDetails with more chapters + val chapters = chapterList(15, "novel1") + deferredNovelDetails.complete(ExploreItem("Novel 1", "novel1", source = "Source1", chapters = chapters)) + + refreshJob.join() + + val finalItem = dbItems["1"]!! + assertEquals(80, finalItem.progress) + assertEquals("newKey", finalItem.lastReadElementKey) + assertEquals(15, finalItem.totalChapters) + } + + @Test + fun `refresh keeps per-source chapter counts independent`() = runBlocking { + val item1 = LibraryItem( + id = "1", title = "Novel", url = "url1", + baseTitle = "Novel", baseNovelUrl = "novel_src1", sourceName = "Source1", + totalChapters = 10 + ) + val item2 = LibraryItem( + id = "2", title = "Novel", url = "url2", + baseTitle = "Novel", baseNovelUrl = "novel_src2", sourceName = "Source2", + totalChapters = 10 + ) + val dbItems = mutableMapOf("1" to item1, "2" to item2) + whenever(libraryDao.getAllItems()).thenAnswer { flowOf(dbItems.values.toList()) } + whenever(libraryDao.updateTotalChapters(any(), any())).thenAnswer { inv -> + val id = inv.arguments[0] as String + val chapters = inv.arguments[1] as Int + dbItems[id]?.let { + dbItems[id] = it.copy(totalChapters = chapters) + } + Unit + } + + whenever(exploreRepository.getNovelDetails("novel_src1", "Source1")) + .thenReturn(ExploreItem("Novel", "novel_src1", source = "Source1", chapters = chapterList(15, "novel_src1"))) + whenever(exploreRepository.getNovelDetails("novel_src2", "Source2")) + .thenReturn(ExploreItem("Novel", "novel_src2", source = "Source2", chapters = chapterList(20, "novel_src2"))) + + repository.refreshLibraryUpdates(exploreRepository) + + assertEquals(15, dbItems["1"]?.totalChapters) + assertEquals(20, dbItems["2"]?.totalChapters) + } + + @Test + fun `refresh marks hasUpdates only on the caught-up marker item`() = runBlocking { + // Group of 3 items under same baseTitle, same sourceName. + // One is currently reading (item2), one is older (item1), one is newer but not read (item3). + val item1 = LibraryItem( + id = "1", title = "Novel Ch 5", url = "url1", + baseTitle = "Novel", baseNovelUrl = "novel", sourceName = "Source1", + currentChapter = "Ch 5", totalChapters = 10, progress = 100, hasUpdates = false + ) + val item2 = LibraryItem( + id = "2", title = "Novel Ch 10", url = "url10", + baseTitle = "Novel", baseNovelUrl = "novel", sourceName = "Source1", + currentChapter = "Ch 10", totalChapters = 10, progress = 90, hasUpdates = false, + isCurrentlyReading = true + ) + val item3 = LibraryItem( + id = "3", title = "Novel Ch 10", url = "url3", + baseTitle = "Novel", baseNovelUrl = "novel", sourceName = "Source1", + currentChapter = "Ch 10", totalChapters = 10, progress = 0, hasUpdates = false + ) + val dbItems = mutableMapOf("1" to item1, "2" to item2, "3" to item3) + whenever(libraryDao.getAllItems()).thenAnswer { flowOf(dbItems.values.toList()) } + whenever(libraryDao.updateTotalChapters(any(), any())).thenAnswer { inv -> + val id = inv.arguments[0] as String + val chapters = inv.arguments[1] as Int + dbItems[id]?.let { + dbItems[id] = it.copy(totalChapters = chapters) + } + Unit + } + whenever(libraryDao.markHasUpdates(any())).thenAnswer { inv -> + val id = inv.arguments[0] as String + dbItems[id]?.let { + dbItems[id] = it.copy(hasUpdates = true) + } + Unit + } + + whenever(exploreRepository.getNovelDetails("novel", "Source1")) + .thenReturn(ExploreItem("Novel", "novel", source = "Source1", chapters = chapterList(12, "novel"))) + + repository.refreshLibraryUpdates(exploreRepository) + + // item2 is currently reading and caught up (progress 90 >= 90% of totalChapters 10). + // It should be marked with hasUpdates. + // item1 and item3 should NOT be marked. + assertTrue(dbItems["2"]!!.hasUpdates) + assertFalse(dbItems["1"]!!.hasUpdates) + assertFalse(dbItems["3"]!!.hasUpdates) + + assertEquals(12, dbItems["1"]!!.totalChapters) + assertEquals(12, dbItems["2"]!!.totalChapters) + assertEquals(12, dbItems["3"]!!.totalChapters) } } diff --git a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/BaseViewModelTest.kt b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/BaseViewModelTest.kt new file mode 100644 index 00000000..060ae459 --- /dev/null +++ b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/BaseViewModelTest.kt @@ -0,0 +1,75 @@ +package io.aatricks.easyreader.ui.viewmodel + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class BaseViewModelTest { + + private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher() + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun teardown() { + Dispatchers.resetMain() + } + + data class TestState( + val isLoading: Boolean = false, + val error: String? = null + ) + + class TestViewModel : BaseViewModel(TestState()) { + fun runWithStatus( + handleLoading: Boolean = true, + handleError: Boolean = true, + block: suspend kotlinx.coroutines.CoroutineScope.() -> Unit + ) { + launchWithStatus( + handleLoading = handleLoading, + handleError = handleError, + loadingState = { state, loading -> state.copy(isLoading = loading) }, + errorState = { state, err -> state.copy(error = err) }, + block = block + ) + } + } + + @Test + fun `cancelled launchWithStatus does not set error state`() = runTest { + val viewModel = TestViewModel() + viewModel.runWithStatus { + throw CancellationException("Cancelled") + } + + // Assert error is not set because of cancellation + assertNull(viewModel.uiState.value.error) + } + + @Test + fun `failing launchWithStatus sets error state`() = runTest { + val viewModel = TestViewModel() + viewModel.runWithStatus { + throw RuntimeException("Real error") + } + + // Assert error state is set on real failure + assertEquals("Real error", viewModel.uiState.value.error) + assertEquals(false, viewModel.uiState.value.isLoading) + } +}