From d98c1efc99d8dae09b107fc2117860d0814a0341 Mon Sep 17 00:00:00 2001 From: Aatricks Date: Mon, 6 Jul 2026 15:02:40 -0400 Subject: [PATCH] Fix(reader): preserve reading progress across library round-trips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uiState restore anchors (index, element key, offset fraction) are frozen at chapter-open and never updated during scrolling — scrolling only updates the controller's live progressState. Opening the full library route disposes the reader composition; returning recomposes it and re-runs runScrollRestore, which replayed the stale anchor: it snapped back and then persisted the stale position over the good one. The in-reader drawer never hit this (it keeps the reader composed), and a seek never did (it syncs uiState), which made it intermittent. Gate restore on a monotonic restoreRequestId bumped only at genuine restore points (calculateInitialPosition, seekToProgress). runScrollRestore resolves its anchor via consumeRestoreAnchor(): a genuine load/seek replays the uiState anchor unchanged, a bare recomposition re-applies the live progressState position with the from-bottom one-shot hard-nulled. Also covers rotation and other route pushes, same recomposition surface. Also harden healLibraryItemForChapterList: it wrote a whole row via updateItem (REPLACE outside progressMutex), a lost-update window against a concurrent progress write. It now writes only totalChapters/currentChapter/ hasUpdates via targeted column UPDATEs (healChapterMetadata). --- .../easyreader/data/local/LibraryDao.kt | 3 + .../data/repository/LibraryRepository.kt | 20 +++ .../ui/screens/reader/ReaderContentArea.kt | 30 ++-- .../ui/viewmodel/ReaderProgressController.kt | 16 +++ .../ui/viewmodel/ReaderViewModel.kt | 79 ++++++++++- .../repository/LibraryRepositoryUpdateTest.kt | 40 ++++++ .../viewmodel/ReaderProgressControllerTest.kt | 24 ++++ .../ReaderViewModelNavigationTest.kt | 14 +- .../ui/viewmodel/ReaderViewModelTest.kt | 133 ++++++++++++++++++ 9 files changed, 336 insertions(+), 23 deletions(-) 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 6a9ebb42..386ebf46 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 @@ -58,6 +58,9 @@ interface LibraryDao { @Query("UPDATE library_items SET hasUpdates = 1 WHERE id = :id") suspend fun markHasUpdates(id: String) + @Query("UPDATE library_items SET currentChapter = :currentChapter WHERE id = :id") + suspend fun updateCurrentChapter(id: String, currentChapter: 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/LibraryRepository.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/LibraryRepository.kt index 1a51dabc..65b35bad 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 @@ -147,6 +147,26 @@ class LibraryRepository @Inject constructor( true } ?: false + /** + * Update only chapter-metadata columns via targeted UPDATEs, never touching progress + * columns. Used by the reader's chapter-list heal, which previously did a whole-row + * `updateItem` (REPLACE) outside `progressMutex` — a lost-update window that could revert a + * concurrent progress write. Targeted column writes make that read→write gap harmless, so + * no mutex is needed (same approach as the background-refresh path). Pass `null` to leave a + * field unchanged. + */ + suspend fun healChapterMetadata( + itemId: String, + totalChapters: Int?, + currentChapter: String?, + markHasUpdates: Boolean + ): Boolean = runRepoCatching("Failed to heal chapter metadata", false) { + totalChapters?.let { libraryDao.updateTotalChapters(itemId, it) } + currentChapter?.let { libraryDao.updateCurrentChapter(itemId, it) } + if (markHasUpdates) libraryDao.markHasUpdates(itemId) + true + } ?: false + suspend fun updateReadingMode(itemId: String, readingMode: ReadingMode): Boolean = runRepoCatching("Failed to update reading mode", false) { libraryDao.getItemById(itemId)?.let { item -> 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 c9172bf6..fd3acc96 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 @@ -431,10 +431,16 @@ private suspend fun runScrollRestore( return } - val uiState = readerViewModel.uiState.value - val targetIndex = resolveRestoreIndex(uiState, stableKeys) + // isPagedMode is a UI-mode flag, not a stale-able progress field — always read it live. + val isPagedMode = readerViewModel.uiState.value.isPagedMode + // Anchor source: a genuine load/seek replays the frozen uiState anchor (unchanged + // behavior); a bare recomposition (e.g. returning from the full library screen) re-applies + // the live progressState position, so the user is never yanked back to a stale open-time + // anchor and no stale value is later persisted. + val anchor = readerViewModel.consumeRestoreAnchor() + val targetIndex = resolveRestoreIndex(anchor.elementKey, anchor.scrollIndex, stableKeys) .coerceIn(0, content.paragraphs.lastIndex) - val targetFraction = uiState.restoreOffsetFraction + val targetFraction = anchor.offsetFraction .takeIf { it >= 0f } ?.coerceIn(0f, 1f) @@ -442,11 +448,11 @@ private suspend fun runScrollRestore( // navigation seeks the final item end. Anything else lands at the item and then chases // the intra-item fraction via the watch-until-stable loop below. val handledAsOneShot = when { - uiState.isPagedMode -> { + isPagedMode -> { runCatching { pagerState.scrollToPage(targetIndex) } true } - uiState.targetScrollPosition == 100f -> { + anchor.targetScrollPosition == 100f -> { runCatching { listState.scrollToItem(content.paragraphs.lastIndex, Int.MAX_VALUE) } true } @@ -466,12 +472,12 @@ private suspend fun runScrollRestore( // intra-item fraction — catches "landed at the wrong index but seek bar says 89%". if (!readerViewModel.userHasDragged && shouldRunPercentRestoreFallback( - isPreciseRestore = uiState.isPreciseRestore, + isPreciseRestore = anchor.isPreciseRestore, targetFraction = targetFraction ) ) { val visiblePercent = computeVisiblePercent(listState, content.paragraphs.size) - val targetPercent = uiState.scrollPosition + val targetPercent = anchor.scrollPosition if (visiblePercent != null && abs(visiblePercent - targetPercent) > RESTORE_PERCENT_TOLERANCE) { val fallbackIndex = ((targetPercent / 100f) * content.paragraphs.lastIndex).toInt() .coerceIn(0, content.paragraphs.lastIndex) @@ -549,15 +555,15 @@ private suspend fun awaitStableRestore( } private fun resolveRestoreIndex( - uiState: ReaderViewModel.ReaderUiState, + elementKey: String, + fallbackIndex: Int, stableKeys: List ): Int { - val key = uiState.restoreElementKey - if (key.isNotEmpty()) { - val idx = stableKeys.indexOf(key) + if (elementKey.isNotEmpty()) { + val idx = stableKeys.indexOf(elementKey) if (idx >= 0) return idx } - return uiState.scrollIndex + return fallbackIndex } private fun computeVisiblePercent(listState: LazyListState, totalItems: Int): Float? { diff --git a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderProgressController.kt b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderProgressController.kt index 741e7275..5d6da1f3 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderProgressController.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderProgressController.kt @@ -74,6 +74,21 @@ class ReaderProgressController( */ var restoreInProgress: Boolean = false + /** + * Monotonic restore-request counter. Bumped ONLY at genuine restore chokepoints + * (`calculateInitialPosition` = a new chapter load, and `seekToProgress` via + * `requestRestore()`). A bare recomposition — e.g. returning from the full library screen, + * which disposes and recreates the reader composition — does NOT bump it. The UI restore + * path uses this to distinguish "replay the frozen load/seek anchor" from "re-apply the + * live scrolled position", which is what stops a library round-trip from clobbering progress. + */ + var restoreRequestId: Long = 0L + private set + + fun requestRestore() { + restoreRequestId++ + } + private var lastRawScrollOffset: Float = -1f private var lastReportedIndex: Int = -1 private var lastReportedFractionMillis: Int = -1 @@ -135,6 +150,7 @@ class ReaderProgressController( fromBottom: Boolean, isExplicitNavigation: Boolean ): ReaderProgressState { + requestRestore() suppressAutoNavUntilUserInteraction = true hasUserInteractedSinceLoad = false userHasDragged = false diff --git a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModel.kt b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModel.kt index a1681dae..2c89a533 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModel.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModel.kt @@ -75,6 +75,53 @@ class ReaderViewModel @Inject constructor( progressController.beginRestore() } + // Survives recomposition because ReaderViewModel is activity-scoped. Tracks the last + // restore-request id the UI restore path already consumed, so a bare recomposition — the + // reader being disposed and recreated on a library round-trip, with no new load/seek — + // is told to re-apply the live scrolled position instead of replaying the frozen load/seek + // anchor from uiState, which is what silently clobbered progress. + private var lastConsumedRestoreId: Long = 0L + + /** + * Called once per `runScrollRestore` entry. Decides whether this restore is a GENUINE + * load/seek (replay the frozen `uiState` anchor — unchanged behavior) or a BARE + * RECOMPOSITION (re-apply the live `progressState` position), marks the request consumed, + * and returns the anchor to apply. The decision lives here — not in the composable — so it + * is unit-testable. + */ + fun consumeRestoreAnchor(): RestoreAnchor { + val pendingId = progressController.restoreRequestId + val isGenuine = pendingId != lastConsumedRestoreId + lastConsumedRestoreId = pendingId + return if (isGenuine) { + val ui = _uiState.value + RestoreAnchor( + elementKey = ui.restoreElementKey, + scrollIndex = ui.scrollIndex, + offsetFraction = ui.restoreOffsetFraction, + scrollPosition = ui.scrollPosition, + isPreciseRestore = ui.isPreciseRestore, + targetScrollPosition = ui.targetScrollPosition, + isLiveSource = false + ) + } else { + val live = progressController.progressState.value + RestoreAnchor( + elementKey = live.scrollElementKey, + scrollIndex = live.scrollIndex, + offsetFraction = live.scrollOffsetFraction, + scrollPosition = live.scrollPosition, + isPreciseRestore = live.isPreciseRestore, + // Hard-null: never replay a from-bottom / seek-to-end one-shot on a bare return. + // That one-shot is only meaningful for a genuine load/seek (which always take the + // genuine branch above). The live scrollIndex/fraction/percent already encode a + // real end-of-chapter landing if that is where the user actually is. + targetScrollPosition = null, + isLiveSource = true + ) + } + } + // Resolved intrinsic dimensions keyed by image URL, one Compose State per URL. A // ReaderImageView subscribes to its own url's State, so a write only recomposes that one // image — and an item scrolled away and back is sized correctly on its FIRST composition @@ -237,6 +284,21 @@ class ReaderViewModel @Inject constructor( val toastMessage: String? ) + /** + * The anchor `runScrollRestore` applies. `isLiveSource` distinguishes the genuine load/seek + * path (frozen `uiState` fields) from the bare-recomposition path (live `progressState`). + * See [consumeRestoreAnchor]. + */ + data class RestoreAnchor( + val elementKey: String, + val scrollIndex: Int, + val offsetFraction: Float, + val scrollPosition: Float, + val isPreciseRestore: Boolean, + val targetScrollPosition: Float?, + val isLiveSource: Boolean + ) + data class ReaderUiState( val content: ChapterContent? = null, val isLoading: Boolean = true, @@ -1225,6 +1287,9 @@ class ReaderViewModel @Inject constructor( // the restore loop triggered by seekTrigger does not later suppress saves, and // pass forcePersist=true to bypass the upstream-layout-stability gate (which // would otherwise reject seeks into chapters with unmeasured images). + // requestRestore() makes the seekTrigger-driven runScrollRestore read the fresh + // uiState seek anchor (genuine branch) rather than the live progressState. + progressController.requestRestore() progressController.markUserDragged() viewModelScope.launch { @@ -1398,12 +1463,14 @@ class ReaderViewModel @Inject constructor( markerChapterNumber != null && markerChapterNumber >= item.totalChapters.toDouble() && item.hasFinishedProgress() - libraryRepository.updateItem( - item.copy( - totalChapters = if (countChanged) newCount else item.totalChapters, - currentChapter = healedChapter ?: item.currentChapter, - hasUpdates = if (wasCaughtUp) true else item.hasUpdates - ) + // Targeted metadata write (not a whole-row REPLACE) so a concurrent progress write + // between the getItemById above and here is never clobbered. healedChapter is + // already null unless the label changed; markHasUpdates only ever sets the flag. + libraryRepository.healChapterMetadata( + itemId = id, + totalChapters = if (countChanged) newCount else null, + currentChapter = healedChapter, + markHasUpdates = wasCaughtUp ) } } 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 86e13781..09d47dbb 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 @@ -46,6 +46,46 @@ class LibraryRepositoryUpdateTest { clearInvocations(libraryDao) } + @Test + fun `healChapterMetadata writes only targeted meta columns and never a whole-row replace`() = runBlocking { + val itemId = "heal-id" + + val result = repository.healChapterMetadata( + itemId = itemId, + totalChapters = 42, + currentChapter = "Chapter 42", + markHasUpdates = true + ) + + assertTrue(result) + verify(libraryDao).updateTotalChapters(itemId, 42) + verify(libraryDao).updateCurrentChapter(itemId, "Chapter 42") + verify(libraryDao).markHasUpdates(itemId) + // The whole point of the fix: no whole-row REPLACE, so a concurrent progress write can + // never be clobbered. insertItem is the REPLACE funnel used by updateItem/progress writes. + verify(libraryDao, never()).insertItem(any()) + verify(libraryDao, never()).getItemById(any()) + Unit + } + + @Test + fun `healChapterMetadata skips null fields and the unset updates flag`() = runBlocking { + val itemId = "heal-id" + + repository.healChapterMetadata( + itemId = itemId, + totalChapters = null, + currentChapter = "Chapter 7", + markHasUpdates = false + ) + + verify(libraryDao, never()).updateTotalChapters(any(), any()) + verify(libraryDao).updateCurrentChapter(itemId, "Chapter 7") + verify(libraryDao, never()).markHasUpdates(any()) + verify(libraryDao, never()).insertItem(any()) + Unit + } + @Test fun testUpdateNovelInfo_CallsDaoUpdate() = runBlocking { val itemId = "test-id" diff --git a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderProgressControllerTest.kt b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderProgressControllerTest.kt index 532c8c24..5c6f55af 100644 --- a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderProgressControllerTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderProgressControllerTest.kt @@ -57,6 +57,30 @@ class ReaderProgressControllerTest { ) } + @Test + fun `calculateInitialPosition increments restoreRequestId`() = runTest { + val controller = ReaderProgressController(libraryRepository, this) + val content = ChapterContent( + paragraphs = List(10) { ContentElement.Text("Text $it") }, + title = "Chapter 1", + url = "http://example.com/1" + ) + val libraryItem = LibraryItem( + id = "test-id", + url = "http://example.com/1", + title = "Novel", + progress = 40, + lastScrollPosition = 40f + ) + + assertEquals(0L, controller.restoreRequestId) + controller.calculateInitialPosition(content, libraryItem, fromBottom = false, isExplicitNavigation = false) + assertEquals(1L, controller.restoreRequestId) + // The explicit-nav path (which ignores the saved item) still counts as a genuine request. + controller.calculateInitialPosition(content, libraryItem, fromBottom = false, isExplicitNavigation = true) + assertEquals(2L, controller.restoreRequestId) + } + @Test fun `calculateInitialPosition restores via element key when present`() = runTest { val controller = ReaderProgressController(libraryRepository, this) diff --git a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModelNavigationTest.kt b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModelNavigationTest.kt index 550c1634..ad92da3f 100644 --- a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModelNavigationTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModelNavigationTest.kt @@ -297,7 +297,7 @@ class ReaderViewModelNavigationTest { whenever(contentRepository.incrementChapterUrl(currentUrl)).thenReturn("http://example.com/guessed-next") whenever(contentRepository.decrementChapterUrl(currentUrl)).thenReturn("http://example.com/ch1") whenever(libraryRepository.getItemById(currentItem.id)).thenReturn(currentItem) - whenever(libraryRepository.updateItem(any())).thenReturn(true) + whenever(libraryRepository.healChapterMetadata(any(), anyOrNull(), anyOrNull(), any())).thenReturn(true) whenever(exploreRepository.getNovelDetails(baseUrl, sourceName)).thenReturn(details) viewModel.loadContent(currentUrl, currentItem.id) @@ -307,9 +307,13 @@ class ReaderViewModelNavigationTest { listOf("http://example.com/ch1", currentUrl, "http://example.com/ch3"), viewModel.uiState.value.fullChapterList.map { it.url } ) - verify(libraryRepository).updateItem(check { - assertEquals(currentItem.id, it.id) - assertEquals(3, it.totalChapters) - }) + // Heal writes only metadata via targeted UPDATEs (never a whole-row replace), so it can't + // clobber a concurrent progress write. totalChapters goes 1 -> 3; no label heal here. + verify(libraryRepository).healChapterMetadata( + eq(currentItem.id), + eq(3), + anyOrNull(), + eq(false) + ) } } diff --git a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModelTest.kt b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModelTest.kt index 883cf773..7844fc4a 100644 --- a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModelTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModelTest.kt @@ -504,6 +504,139 @@ class ReaderViewModelTest { assertEquals(0.1f, progressState.scrollOffsetFraction, 0.001f) } + @Test + fun `consumeRestoreAnchor returns frozen uiState anchor on genuine load`() = runTest { + val itemId = "web-item" + val url = "https://example.com/chapter-10" + val savedItem = LibraryItem( + id = itemId, + title = "Chapter 10", + url = url, + progress = 55, + lastReadIndex = 4, + lastReadOffsetFraction = 0.6f, + lastScrollPosition = 55f + ) + whenever(contentRepository.loadContent(url)).thenReturn( + ContentResult.Success(List(10) { ContentElement.Text("Paragraph $it") }, "Chapter 10", url) + ) + whenever(libraryRepository.getItemById(itemId)).thenReturn(savedItem) + + viewModel.loadContent(url, itemId) + advanceUntilIdle() + + val anchor = viewModel.consumeRestoreAnchor() + val ui = viewModel.uiState.value + assertFalse(anchor.isLiveSource) + assertEquals(ui.scrollIndex, anchor.scrollIndex) + assertEquals(ui.restoreElementKey, anchor.elementKey) + assertEquals(ui.restoreOffsetFraction, anchor.offsetFraction, 0.001f) + } + + @Test + fun `consumeRestoreAnchor returns live progressState anchor on bare recomposition after scroll`() = runTest { + val itemId = "item-1" + val url = "https://example.com/1" + whenever(contentRepository.loadContent(url)).thenReturn( + ContentResult.Success(listOf(ContentElement.Text("Test")), "Test", url) + ) + whenever(libraryRepository.getItemById(itemId)).thenReturn( + LibraryItem(id = itemId, title = "Test", url = url) + ) + + viewModel.loadContent(url, itemId) + advanceUntilIdle() + // First consume = the genuine restore for this load (marks the request id consumed). + viewModel.consumeRestoreAnchor() + + // onUserInteraction() first is load-bearing: it clears the restore/suppress gates so the + // scroll sample actually reaches _progressState (otherwise this would pass vacuously). + viewModel.onUserInteraction() + viewModel.updateScrollPosition( + scrollOffset = 50f, + maxScrollOffset = 100f, + viewportHeight = 10f, + index = 5, + offsetFraction = 0.1f, + elementKey = "txt:$url:5:abc", + firstVisibleItemSize = 100 + ) + + // Second consume with no intervening load/seek = a bare recomposition (the library + // round-trip). Must resolve to the LIVE scrolled position, not the frozen uiState. + val anchor = viewModel.consumeRestoreAnchor() + assertTrue(anchor.isLiveSource) + assertEquals(5, anchor.scrollIndex) + assertEquals("txt:$url:5:abc", anchor.elementKey) + assertEquals(0.1f, anchor.offsetFraction, 0.001f) + // The frozen uiState still holds the open-time top (index 0) — proves we did NOT read it. + assertEquals(0, viewModel.uiState.value.scrollIndex) + } + + @Test + fun `consumeRestoreAnchor returns genuine anchor after seek`() = runTest { + val itemId = "item-1" + val url = "https://example.com/1" + whenever(contentRepository.loadContent(url)).thenReturn( + ContentResult.Success(List(10) { ContentElement.Text("Paragraph $it") }, "Test", url) + ) + whenever(libraryRepository.getItemById(itemId)).thenReturn( + LibraryItem(id = itemId, title = "Test", url = url) + ) + + viewModel.loadContent(url, itemId) + advanceUntilIdle() + viewModel.consumeRestoreAnchor() // consume the load's request id + + viewModel.seekToProgress(42f) + + // A seek bumps the restore-request id, so the seekTrigger-driven restore is genuine and + // reads the fresh seek anchor from uiState. (Index can't discriminate the branch here, + // because seekToProgress syncs uiState and progressState to the same values.) + val anchor = viewModel.consumeRestoreAnchor() + assertFalse(anchor.isLiveSource) + } + + @Test + fun `consumeRestoreAnchor hard-nulls targetScrollPosition on bare recomposition`() = runTest { + val itemId = "web-item" + val url = "https://example.com/chapter-end" + val savedItem = LibraryItem( + id = itemId, + title = "Chapter End", + url = url, + progress = 100, + lastReadIndex = 9, + lastScrollPosition = 100f + ) + whenever(contentRepository.loadContent(url)).thenReturn( + ContentResult.Success(List(10) { ContentElement.Text("Paragraph $it") }, "Chapter End", url) + ) + whenever(libraryRepository.getItemById(itemId)).thenReturn(savedItem) + + viewModel.loadContent(url, itemId) + advanceUntilIdle() + + // Genuine branch honors the end-of-chapter one-shot. + val genuine = viewModel.consumeRestoreAnchor() + assertEquals(100f, genuine.targetScrollPosition!!, 0.001f) + + // User scrolls up, then returns from the library (bare recomposition). + viewModel.onUserInteraction() + viewModel.updateScrollPosition( + scrollOffset = 30f, + maxScrollOffset = 100f, + viewportHeight = 10f, + index = 3, + offsetFraction = 0.0f, + elementKey = "txt:$url:3:abc", + firstVisibleItemSize = 100 + ) + val bare = viewModel.consumeRestoreAnchor() + assertTrue(bare.isLiveSource) + assertNull(bare.targetScrollPosition) // never yanked back to the chapter end + } + @Test fun `persistLifecycleProgress persists untouched chapter start as true top snapshot`() = runTest { val itemId = "item-1"