diff --git a/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpDeviceFixture.kt b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpDeviceFixture.kt index 641b07d..cea3224 100644 --- a/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpDeviceFixture.kt +++ b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpDeviceFixture.kt @@ -39,6 +39,8 @@ internal class RsvpDeviceFixture(tokens: List, config: RsvpConfig, startI @Volatile var saved: RsvpResumePoint? = null + @Volatile var position: RsvpResumePoint? = null + @Volatile var finished = false val loads = AtomicInteger() private val repository = RsvpFrameRepositoryImpl( @@ -50,20 +52,22 @@ internal class RsvpDeviceFixture(tokens: List, config: RsvpConfig, startI ) val dependencies = RsvpScreenDependencies( object : RsvpFrameRepository by repository { - override suspend fun getFrames( + override suspend fun getSeekableFrames( bookId: BookId, chapterIndex: Int, config: RsvpConfig, startIndex: Int, options: RsvpGenerationOptions, - ): RsvpFrameSet = repository.getFrames(bookId, chapterIndex, config, startIndex, options).also { loads.incrementAndGet() } + ): RsvpFrameSet = repository.getSeekableFrames(bookId, chapterIndex, config, startIndex, options).also { + loads.incrementAndGet() + } }, ) val callbacks = RsvpScreenCallbacks( bookmarks = RsvpBookmarkCallbacks({ _, _ -> }, {}), playback = RsvpPlaybackCallbacks( onFinished = { finished = true }, - onPositionChanged = {}, + onPositionChanged = { position = it }, onTempoChange = {}, onExit = { saved = it }, onFrameConsumed = { diff --git a/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpProgressDeviceTest.kt b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpProgressDeviceTest.kt new file mode 100644 index 0000000..38e3a16 --- /dev/null +++ b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpProgressDeviceTest.kt @@ -0,0 +1,167 @@ +package com.kairo.reader.ui.rsvp + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.click +import androidx.compose.ui.test.junit4.StateRestorationTester +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.kairo.reader.R +import com.kairo.reader.TestActivity +import com.kairo.reader.core.model.BlinkMode +import com.kairo.reader.core.model.RsvpConfig +import com.kairo.reader.core.model.Token +import com.kairo.reader.core.model.TokenType +import com.kairo.reader.core.model.buildWordCountByToken +import com.kairo.reader.ui.reader.ReaderProgressInput +import com.kairo.reader.ui.reader.rememberReaderProgressState +import com.kairo.reader.ui.theme.KairoTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RsvpProgressDeviceTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun midChapterProgressAndPausedPositionSurviveRestorationAndRegeneration() { + verifyProgressRestoration(landscape = false) + } + + @Test + fun landscapeProgressAndPausedPositionSurviveRestorationAndRegeneration() { + verifyProgressRestoration(landscape = true) + } + + private fun verifyProgressRestoration(landscape: Boolean) { + val fixture = RsvpDeviceFixture( + tokens = List(100) { Token("word", TokenType.WORD) }, + config = RsvpConfig(enablePhraseChunking = false, blinkMode = BlinkMode.OFF, startDelayMs = 10_000L), + startIndex = 74, + ) + fixture.state = fixture.state.copy( + book = fixture.state.book.copy(chapterIndex = 1), + initialIsPlaying = true, + ) + var isLandscape by mutableStateOf(false) + var reading by mutableStateOf(true) + val restoration = StateRestorationTester(rule) + restoration.setContent { + CompositionLocalProvider(LocalDensity provides if (isLandscape) Density(1f) else LocalDensity.current) { + Box(if (isLandscape) Modifier.size(800.dp, 360.dp) else Modifier.fillMaxSize()) { + KairoTheme { + if (reading) { + ReaderProgressFixture(fixture) { reading = false } + } else { + RsvpScreen(fixture.state, fixture.callbacks, fixture.dependencies) + } + } + } + } + } + rule.onNodeWithText("Reader: 75%").assertIsDisplayed() + rule.onNodeWithText("Open RSVP").performClick() + awaitLoaded(fixture, 1) + pause() + assertProgress(0.75f) + assertPosition(75) + click(R.string.content_desc_next) + assertPosition(76) + + rule.runOnIdle { isLandscape = landscape } + assertPosition(76) + restoration.emulateSavedInstanceStateRestore() + awaitLoaded(fixture, 2) + assertProgress(0.76f) + + rule.runOnIdle { + fixture.state = fixture.state.copy( + profile = fixture.state.profile.copy(config = fixture.state.profile.config.copy(blinkMode = BlinkMode.SUBTLE)), + ) + } + awaitLoaded(fixture, 3) + assertProgress(0.76f) + click(R.string.content_desc_close) + rule.runOnIdle { assertEquals(75, requireNotNull(fixture.saved).tokenIndex) } + } + + @Composable + private fun ReaderProgressFixture(fixture: RsvpDeviceFixture, onLaunch: () -> Unit) { + val progress = rememberReaderProgressState( + ReaderProgressInput( + safeFocusIndex = fixture.state.book.startIndex, + totalChapterWords = 100, + wordCountByToken = buildWordCountByToken(fixture.state.book.tokens), + resolvedPageIndex = -1, + pages = emptyList(), + currentPage = null, + estimatedWpm = 0, + // This source position is 75% of the chapter but only 20% of the book. + bookWordCounts = listOf(100, 100, 675), + chapterIndex = 1, + chapterCount = 3, + ), + ) + Column { + Text("Reader: ${progress.progressPercent}%") + Button(onClick = onLaunch) { Text("Open RSVP") } + } + } + + private fun assertProgress(fraction: Float) { + rule.onNode( + SemanticsMatcher.expectValue( + SemanticsProperties.ProgressBarRangeInfo, + ProgressBarRangeInfo(fraction, 0f..1f), + ), + ).assertIsDisplayed() + } + + private fun pause() { + rule.onRoot().performTouchInput { click() } + val play = rule.activity.getString(R.string.content_desc_play) + rule.waitUntil(timeoutMillis = 3000) { + rule.onAllNodesWithContentDescription(play).fetchSemanticsNodes().isNotEmpty() + } + rule.waitForIdle() + } + + private fun assertPosition(word: Int) { + rule.onNodeWithText(rule.activity.getString(R.string.rsvp_frame_progress, word, 100)).assertIsDisplayed() + } + + private fun click(stringId: Int) { + rule.onNodeWithContentDescription(rule.activity.getString(stringId)).performClick() + } + + private fun awaitLoaded(fixture: RsvpDeviceFixture, count: Int) { + rule.waitUntil(timeoutMillis = 10_000) { fixture.loads.get() >= count } + rule.waitForIdle() + } +} diff --git a/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpSweepDeviceTest.kt b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpSweepDeviceTest.kt new file mode 100644 index 0000000..20b7c74 --- /dev/null +++ b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpSweepDeviceTest.kt @@ -0,0 +1,124 @@ +package com.kairo.reader.ui.rsvp + +import android.os.SystemClock +import android.util.Log +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.junit4.StateRestorationTester +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipe +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.kairo.reader.TestActivity +import com.kairo.reader.core.model.BlinkMode +import com.kairo.reader.core.model.RsvpConfig +import com.kairo.reader.core.model.Token +import com.kairo.reader.core.model.TokenType +import com.kairo.reader.ui.theme.KairoTheme +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RsvpSweepDeviceTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun fullChapterSweepSurvivesBothOrientationChangesAndSettingsReload() { + val fixture = RsvpDeviceFixture(List(40) { word() }, config(), startIndex = 20) + fixture.state = fixture.state.copy(initialIsPlaying = false) + var landscape by mutableStateOf(false) + val restoration = StateRestorationTester(rule) + restoration.setContent { + CompositionLocalProvider(LocalDensity provides if (landscape) Density(1f) else LocalDensity.current) { + Box( + (if (landscape) Modifier.size(800.dp, 360.dp) else Modifier.fillMaxSize()) + .testTag("sweep-surface"), + ) { + KairoTheme { RsvpScreen(fixture.state, fixture.callbacks, fixture.dependencies) } + } + } + } + awaitLoaded(fixture, 1) + assertEquals(20, requireNotNull(fixture.position).tokenIndex) + sweepEntireChapter(fixture) + + for ((index, orientation) in listOf(true, false).withIndex()) { + rule.runOnIdle { landscape = orientation } + restoration.emulateSavedInstanceStateRestore() + awaitLoaded(fixture, index + 2) + assertEquals(39, requireNotNull(fixture.position).tokenIndex) + sweepEntireChapter(fixture) + } + + rule.runOnIdle { + fixture.state = fixture.state.copy( + book = fixture.state.book.copy(startIndex = 39), + profile = fixture.state.profile.copy(config = config().copy(commaPauseMs = 90L, blinkMode = BlinkMode.SUBTLE)), + ) + } + awaitLoaded(fixture, 4) + assertEquals(39, requireNotNull(fixture.position).tokenIndex) + sweepEntireChapter(fixture) + } + + @Test + fun largeChapterReloadPreparationIsMeasuredOffTheUiThread() = runBlocking { + val fixture = RsvpDeviceFixture(List(5000) { word() }, config()) + val times = mutableListOf() + for (start in listOf(2500, 1000, 4000, 2500)) { + val before = SystemClock.elapsedRealtimeNanos() + val frames = fixture.dependencies.frameRepository.getSeekableFrames( + fixture.state.book.bookId, + 0, + config(), + start, + ) + times += (SystemClock.elapsedRealtimeNanos() - before) / 1_000_000L + assertEquals(0, frames.frames.first().originalTokenIndex) + assertEquals(4999, frames.frames.last().originalTokenIndex) + assertTrue(frames.initialRampStartFrameIndex > 0) + } + Log.i("RsvpSweepBenchmark", "5000 words: cold=${times.first()} ms, cached reloads=${times.drop(1)} ms") + } + + private fun sweepEntireChapter(fixture: RsvpDeviceFixture) { + repeat(8) { sweep(forward = false) } + rule.runOnIdle { assertEquals(0, requireNotNull(fixture.position).tokenIndex) } + repeat(8) { sweep(forward = true) } + rule.runOnIdle { assertEquals(39, requireNotNull(fixture.position).tokenIndex) } + } + + private fun sweep(forward: Boolean) { + rule.onNodeWithTag("sweep-surface").performTouchInput { + val left = Offset(width * 0.15f, height * 0.4f) + val right = Offset(width * 0.85f, height * 0.4f) + swipe(if (forward) left else right, if (forward) right else left, durationMillis = 160L) + } + rule.waitForIdle() + } + + private fun awaitLoaded(fixture: RsvpDeviceFixture, count: Int) { + rule.waitUntil(timeoutMillis = 10_000) { fixture.loads.get() >= count } + rule.waitForIdle() + } + + private fun word(): Token = Token("reading", TokenType.WORD) + private fun config(): RsvpConfig = RsvpConfig(enablePhraseChunking = false, blinkMode = BlinkMode.OFF) +} diff --git a/app/src/main/java/com/kairo/reader/core/model/ChapterReadingProgress.kt b/app/src/main/java/com/kairo/reader/core/model/ChapterReadingProgress.kt new file mode 100644 index 0000000..fd764d2 --- /dev/null +++ b/app/src/main/java/com/kairo/reader/core/model/ChapterReadingProgress.kt @@ -0,0 +1,26 @@ +package com.kairo.reader.core.model + +import kotlin.math.roundToInt + +/** Progress through source words in the current chapter, shared by Reader and timed reading. */ +data class ChapterReadingProgress(val currentWord: Int, val totalWords: Int) { + val fraction: Float + get() = if (totalWords > 0) (currentWord.toFloat() / totalWords).coerceIn(0f, 1f) else 0f + val percent: Int get() = (fraction * PROGRESS_PERCENT_SCALE).roundToInt() +} + +fun chapterReadingProgress( + wordCountByToken: IntArray?, + tokenIndex: Int, + totalWords: Int, +): ChapterReadingProgress { + val safeTotal = totalWords.coerceAtLeast(0) + val currentWord = when { + wordCountByToken == null || wordCountByToken.isEmpty() || tokenIndex < 0 -> 0 + tokenIndex >= wordCountByToken.size -> safeTotal + else -> wordCountByToken[tokenIndex].coerceIn(0, safeTotal) + } + return ChapterReadingProgress(currentWord, safeTotal) +} + +private const val PROGRESS_PERCENT_SCALE = 100f diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpFramePostProcessor.kt b/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpFramePostProcessor.kt index 14936cd..072d0c4 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpFramePostProcessor.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpFramePostProcessor.kt @@ -18,8 +18,9 @@ import kotlin.math.roundToLong internal fun applyPlaybackEffects( frames: MutableList, config: RsvpConfig, + startFrameIndex: Int = 0, ) { - RsvpSessionTimingPolicy.applyInitialSessionRamps(frames = frames, config = config) + RsvpSessionTimingPolicy.applyInitialSessionRamps(frames = frames, config = config, startFrameIndex = startFrameIndex) applyBlinkSeparation(frames, config) } diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/timing/RsvpSessionTimingPolicy.kt b/app/src/main/java/com/kairo/reader/core/rsvp/timing/RsvpSessionTimingPolicy.kt index 8d1ca36..1c12004 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/timing/RsvpSessionTimingPolicy.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/timing/RsvpSessionTimingPolicy.kt @@ -9,27 +9,30 @@ internal object RsvpSessionTimingPolicy { fun applyInitialSessionRamps( frames: MutableList, config: RsvpConfig, + startFrameIndex: Int = 0, ) { if (frames.isEmpty()) return - val total = frames.size + val safeStart = startFrameIndex.coerceIn(frames.indices) + val total = frames.size - safeStart val rampUp = min(config.rampUpFrames.coerceAtLeast(0), total / 2) for (i in 0 until rampUp) { - frames[i] = - frames[i].copy( - durationMs = (frames[i].durationMs * rampUpMultiplier(i, rampUp)).toLong(), + val index = safeStart + i + frames[index] = + frames[index].copy( + durationMs = (frames[index].durationMs * rampUpMultiplier(i, rampUp)).toLong(), ) } - frames[0] = - frames[0].copy( - durationMs = addNonNegativeDelay(frames[0].durationMs, config.startDelayMs) + frames[safeStart] = + frames[safeStart].copy( + durationMs = addNonNegativeDelay(frames[safeStart].durationMs, config.startDelayMs) .coerceAtLeast(MIN_FRAME_MS), ) val rampDown = min(config.rampDownFrames.coerceAtLeast(0), total / 2) - val start = total - rampDown - for (i in start until total) { + val start = frames.size - rampDown + for (i in start until frames.size) { frames[i] = frames[i].copy( durationMs = (frames[i].durationMs * rampDownMultiplier(i - start, rampDown)).toLong(), @@ -48,9 +51,10 @@ internal object RsvpSessionTimingPolicy { frameIndex: Int, rampStartIndex: Int, preparationScale: Double = 1.0, + initialRampStartIndex: Int = 0, ): Double { val rampFrames = config.rampUpFrames - if (rampStartIndex <= 0 || rampStartIndex < rampFrames) return 1.0 + if (hasInitialRamp(config, rampStartIndex, initialRampStartIndex)) return 1.0 val offset = frameIndex - rampStartIndex if (rampFrames <= 0 || offset < 0 || offset >= rampFrames) return 1.0 return 1.0 + (rampUpMultiplier(offset, rampFrames) - 1.0) * preparationScale.coerceIn(0.0, 1.0) @@ -61,9 +65,9 @@ internal object RsvpSessionTimingPolicy { frameIndex: Int, rampStartIndex: Int, preparationScale: Double = 1.0, + initialRampStartIndex: Int = 0, ): Long { - if (rampStartIndex <= 0 || - rampStartIndex < config.rampUpFrames || + if (hasInitialRamp(config, rampStartIndex, initialRampStartIndex) || frameIndex != rampStartIndex ) { return 0L @@ -71,6 +75,10 @@ internal object RsvpSessionTimingPolicy { return (config.startDelayMs * preparationScale.coerceIn(0.0, 1.0)).toLong() } + private fun hasInitialRamp(config: RsvpConfig, rampStartIndex: Int, initialRampStartIndex: Int): Boolean = + rampStartIndex < 0 || + (rampStartIndex - initialRampStartIndex) in 0 until config.rampUpFrames.coerceAtLeast(1) + fun resumePreparationScale(pausedMs: Long): Double = ((pausedMs - BRIEF_PAUSE_MS).coerceAtLeast(0L).toDouble() / REORIENTATION_WINDOW_MS) .coerceIn(MIN_RESUME_PREPARATION, 1.0) diff --git a/app/src/main/java/com/kairo/reader/data/rsvp/RsvpFrameRepository.kt b/app/src/main/java/com/kairo/reader/data/rsvp/RsvpFrameRepository.kt index efb2ecc..a361383 100644 --- a/app/src/main/java/com/kairo/reader/data/rsvp/RsvpFrameRepository.kt +++ b/app/src/main/java/com/kairo/reader/data/rsvp/RsvpFrameRepository.kt @@ -10,9 +10,19 @@ data class RsvpFrameSet( val frames: List, val baseTempoMs: Long, val frameIndexMap: RsvpFrameIndexMap = RsvpFrameIndexMap.from(frames), + val initialRampStartFrameIndex: Int = 0, ) interface RsvpFrameRepository { + /** Full chapter navigation; startIndex selects the initial playback position, not its bounds. */ + suspend fun getSeekableFrames( + bookId: BookId, + chapterIndex: Int, + config: RsvpConfig, + startIndex: Int = 0, + options: RsvpGenerationOptions = RsvpGenerationOptions.DEFAULT, + ): RsvpFrameSet = getFrames(bookId, chapterIndex, config, startIndex = 0, options = options) + suspend fun getFrames( bookId: BookId, chapterIndex: Int, diff --git a/app/src/main/java/com/kairo/reader/data/rsvp/RsvpFrameRepositoryImpl.kt b/app/src/main/java/com/kairo/reader/data/rsvp/RsvpFrameRepositoryImpl.kt index 68fbccf..aefc9ce 100644 --- a/app/src/main/java/com/kairo/reader/data/rsvp/RsvpFrameRepositoryImpl.kt +++ b/app/src/main/java/com/kairo/reader/data/rsvp/RsvpFrameRepositoryImpl.kt @@ -63,6 +63,31 @@ class RsvpFrameRepositoryImpl( private val previewDispatcher = dispatcherProvider.default private val scope = CoroutineScope(SupervisorJob() + dispatcherProvider.default) + override suspend fun getSeekableFrames( + bookId: BookId, + chapterIndex: Int, + config: RsvpConfig, + startIndex: Int, + options: RsvpGenerationOptions, + ): RsvpFrameSet { + val baseConfig = config.normalizedForPlayback().withoutPlaybackEffects() + val key = CacheKey( + bookId.value, + chapterIndex, + baseConfig.frameTimingKey(), + 0, + CacheMode.CHAPTER_BASE, + options, + currentGeneration(bookId), + ) + val base = ensureFramesAsync(key, bookId, chapterIndex, baseConfig, 0, options).await() + val tokens = tokenRepository.getTokens(bookId, chapterIndex) + // Copying, optional boundary splitting, playback effects and indexing stay off the UI thread. + return withContext(engineDispatcher) { + buildSeekableFrameSet(tokens, base, config, startIndex, engine, options) + } + } + override suspend fun getFrames( bookId: BookId, chapterIndex: Int, @@ -308,15 +333,6 @@ class RsvpFrameRepositoryImpl( private fun RsvpFrameSet.startsBefore(startIndex: Int): Boolean = startIndex > 0 && frames.firstOrNull()?.originalTokenIndex?.let { it < startIndex } == true - private fun RsvpConfig.withoutPlaybackEffects(): RsvpConfig = - copy( - startDelayMs = 0L, - endDelayMs = 0L, - rampUpFrames = 0, - rampDownFrames = 0, - blinkMode = BlinkMode.OFF, - ) - private fun RsvpFrame.asPreviewFrame(visibleEndExclusive: Int): RsvpFrame = copy(nextOriginalTokenIndex = nextOriginalTokenIndex.coerceIn(0, visibleEndExclusive)) @@ -390,3 +406,12 @@ private fun previewLookaheadEndExclusive( } return cursor } + +internal fun RsvpConfig.withoutPlaybackEffects(): RsvpConfig = + copy( + startDelayMs = 0L, + endDelayMs = 0L, + rampUpFrames = 0, + rampDownFrames = 0, + blinkMode = BlinkMode.OFF, + ) diff --git a/app/src/main/java/com/kairo/reader/data/rsvp/RsvpSeekableFrames.kt b/app/src/main/java/com/kairo/reader/data/rsvp/RsvpSeekableFrames.kt new file mode 100644 index 0000000..72a4555 --- /dev/null +++ b/app/src/main/java/com/kairo/reader/data/rsvp/RsvpSeekableFrames.kt @@ -0,0 +1,54 @@ +package com.kairo.reader.data.rsvp + +import com.kairo.reader.core.model.RsvpConfig +import com.kairo.reader.core.model.Token +import com.kairo.reader.core.model.nearestWordIndex +import com.kairo.reader.core.rsvp.RsvpEngine +import com.kairo.reader.core.rsvp.RsvpGenerationOptions +import com.kairo.reader.core.rsvp.engine.applyPlaybackEffects +import com.kairo.reader.core.rsvp.engine.normalizedForPlayback + +/** Keep the cached chapter intact except for a reading unit bisected by the requested start. */ +internal fun buildSeekableFrameSet( + tokens: List, + base: RsvpFrameSet, + config: RsvpConfig, + startIndex: Int, + engine: RsvpEngine, + options: RsvpGenerationOptions, +): RsvpFrameSet { + if (base.frames.isEmpty() || tokens.isEmpty()) return base + val start = tokens.nearestWordIndex(startIndex.coerceIn(tokens.indices)) + val frames = base.frames.toMutableList() + val index = base.frameIndexMap.alignFrameIndex(start, frameCount = frames.size) + val boundary = frames[index] + val normalized = config.normalizedForPlayback() + if (boundary.originalTokenIndex < start && boundary.displayOriginalEndExclusive > start) { + // Only rebuild the two sides of this one unit. Re-generating the whole remaining chapter + // would make rotation/settings changes expensive when starting inside a grouped phrase. + val splitConfig = normalized.withoutPlaybackEffects() + val before = engine.generateFrames( + tokens.subList(0, start), + boundary.originalTokenIndex, + splitConfig, + options, + ) + val after = engine.generateFrames( + tokens.subList(0, boundary.nextOriginalTokenIndex.coerceAtMost(tokens.size)), + start, + splitConfig, + options, + ) + frames.removeAt(index) + frames.addAll(index, before + after) + } + val startFrameIndex = frames.indexOfFirst { it.originalTokenIndex >= start }.coerceAtLeast(0) + applyPlaybackEffects(frames, normalized, startFrameIndex = startFrameIndex) + val indexMap = RsvpFrameIndexMap.from(frames) + return RsvpFrameSet( + frames = frames, + baseTempoMs = normalized.tempoMsPerWord, + frameIndexMap = indexMap, + initialRampStartFrameIndex = indexMap.alignFrameIndex(start, frameCount = frames.size), + ) +} diff --git a/app/src/main/java/com/kairo/reader/ui/reader/ReaderProgressState.kt b/app/src/main/java/com/kairo/reader/ui/reader/ReaderProgressState.kt index 40891ca..f5cdc1b 100644 --- a/app/src/main/java/com/kairo/reader/ui/reader/ReaderProgressState.kt +++ b/app/src/main/java/com/kairo/reader/ui/reader/ReaderProgressState.kt @@ -2,15 +2,13 @@ package com.kairo.reader.ui.reader import android.content.res.Resources import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import com.kairo.reader.R +import com.kairo.reader.core.model.chapterReadingProgress import com.kairo.reader.core.model.estimateMinutesForWords import com.kairo.reader.ui.format.formatShortDurationMinutes -import kotlin.math.roundToInt internal data class ReaderProgressState( val progressPercent: Int, @@ -33,8 +31,6 @@ internal data class ReaderProgressInput( val chapterCount: Int, ) -private data class ChapterWordProgress(val currentWordIndex: Int, val progressPercent: Int,) - @Composable internal fun rememberReaderProgressState( input: ReaderProgressInput, @@ -43,13 +39,12 @@ internal fun rememberReaderProgressState( val metaSeparator = stringResource(R.string.meta_separator) val chapterProgress = remember(input.safeFocusIndex, input.totalChapterWords, input.wordCountByToken) { - calculateChapterWordProgress(input) - } - val progressFraction by remember(chapterProgress.progressPercent) { - derivedStateOf { - (chapterProgress.progressPercent / PERCENT_SCALE_FLOAT).coerceIn(0f, 1f) + chapterReadingProgress( + wordCountByToken = input.wordCountByToken, + tokenIndex = input.safeFocusIndex, + totalWords = input.totalChapterWords, + ) } - } val pageLabel = if (input.resolvedPageIndex >= 0 && input.pages.isNotEmpty()) { resources.getString( @@ -61,16 +56,16 @@ internal fun rememberReaderProgressState( null } val wordsReadInPage = - remember(input.currentPage, input.wordCountByToken, chapterProgress.currentWordIndex) { - calculateWordsReadInPage(input, chapterProgress.currentWordIndex) + remember(input.currentPage, input.wordCountByToken, chapterProgress.currentWord) { + calculateWordsReadInPage(input, chapterProgress.currentWord) } val remainingPageWords = remember(input.currentPage, wordsReadInPage) { (input.currentPage?.wordCount ?: 0).minus(wordsReadInPage).coerceAtLeast(0) } val remainingChapterWords = - remember(input.totalChapterWords, chapterProgress.currentWordIndex) { - (input.totalChapterWords - chapterProgress.currentWordIndex).coerceAtLeast(0) + remember(input.totalChapterWords, chapterProgress.currentWord) { + (input.totalChapterWords - chapterProgress.currentWord).coerceAtLeast(0) } val adjustedBookWordCounts = remember(input.bookWordCounts, input.chapterIndex, input.totalChapterWords, input.chapterCount) { @@ -85,8 +80,8 @@ internal fun rememberReaderProgressState( adjustedBookWordCounts.sum() } val wordsReadOverall = - remember(wordsBeforeChapter, chapterProgress.currentWordIndex) { - wordsBeforeChapter + chapterProgress.currentWordIndex + remember(wordsBeforeChapter, chapterProgress.currentWord) { + wordsBeforeChapter + chapterProgress.currentWord } val remainingBookWords = remember(totalBookWords, wordsReadOverall) { @@ -104,27 +99,14 @@ internal fun rememberReaderProgressState( val hasProgressMeta = pageLabel != null || etaLabel != null return ReaderProgressState( - progressPercent = chapterProgress.progressPercent, - progressFraction = progressFraction, + progressPercent = chapterProgress.percent, + progressFraction = chapterProgress.fraction, pageLabel = pageLabel, etaLabel = etaLabel, hasProgressMeta = hasProgressMeta, ) } -private fun calculateChapterWordProgress(input: ReaderProgressInput): ChapterWordProgress { - val wordCounts = input.wordCountByToken - if (input.totalChapterWords <= 0 || wordCounts == null || wordCounts.isEmpty()) { - return ChapterWordProgress(currentWordIndex = 0, progressPercent = 0) - } - val currentWordIndex = wordCounts.getOrNull(input.safeFocusIndex)?.coerceAtLeast(0) ?: 0 - val progressPercent = - ((currentWordIndex.toFloat() / input.totalChapterWords.toFloat()) * PERCENT_SCALE_FLOAT) - .roundToInt() - .coerceIn(0, PERCENT_SCALE_INT) - return ChapterWordProgress(currentWordIndex, progressPercent) -} - private fun calculateWordsReadInPage( input: ReaderProgressInput, currentWordIndex: Int, @@ -185,6 +167,3 @@ private fun etaPart( val minutes = estimateMinutesForWords(remainingWords, estimatedWpm) return resources.getString(labelRes, formatShortDurationMinutes(resources, minutes)) } - -private const val PERCENT_SCALE_INT = 100 -private const val PERCENT_SCALE_FLOAT = 100f diff --git a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpProgress.kt b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpProgress.kt new file mode 100644 index 0000000..c5ba1b9 --- /dev/null +++ b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpProgress.kt @@ -0,0 +1,37 @@ +package com.kairo.reader.ui.rsvp + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.kairo.reader.core.model.ChapterReadingProgress +import com.kairo.reader.core.model.Token +import com.kairo.reader.core.model.buildWordCountByToken +import com.kairo.reader.core.model.chapterReadingProgress + +internal typealias RsvpProgress = ChapterReadingProgress + +/** Match Reader's chapter progress, independently of the remaining playback frame list. */ +internal class RsvpProgressBasis(tokens: List) { + private val wordCountByToken = buildWordCountByToken(tokens) + + fun at(tokenIndex: Int, completed: Boolean = false): RsvpProgress = + chapterReadingProgress( + wordCountByToken = wordCountByToken, + tokenIndex = if (completed) wordCountByToken.size else tokenIndex, + totalWords = wordCountByToken.lastOrNull() ?: 0, + ) +} + +@Composable +internal fun rememberRsvpProgress(context: RsvpUiContext): RsvpProgress { + val book = context.state.book + val basis = remember(book.tokens) { + RsvpProgressBasis(book.tokens) + } + val runtime = context.runtime + val tokenIndex = resolveCurrentTokenIndex( + frames = context.frameState.frames, + frameIndex = runtime.frameIndex, + fallbackIndex = runtime.currentTokenIndex, + ) + return basis.at(tokenIndex, completed = runtime.completed && !runtime.isExiting) +} diff --git a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreen.kt b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreen.kt index 8774be6..2233ada 100644 --- a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreen.kt +++ b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreen.kt @@ -448,7 +448,7 @@ private fun rememberFrameLoadState( val computed = runCatching { - frameRepository.getFrames( + frameRepository.getSeekableFrames( book.bookId, book.chapterIndex, profile.config, @@ -492,6 +492,7 @@ private fun rememberFrameLoadState( isComplete = isComplete, loadFailed = frameLoadFailed, frameIndexMap = frameIndexMap, + initialRampStartFrameIndex = activeFrameSet?.initialRampStartFrameIndex ?: 0, ) } diff --git a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenChrome.kt b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenChrome.kt index 1d32dfd..ba651dc 100644 --- a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenChrome.kt +++ b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenChrome.kt @@ -47,7 +47,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.kairo.reader.R import com.kairo.reader.ui.rememberWindowContainerMetrics -import kotlin.math.roundToInt @Composable internal fun BoxScope.RsvpProgressBar(context: RsvpUiContext) { @@ -61,8 +60,7 @@ internal fun BoxScope.RsvpProgressBar(context: RsvpUiContext) { return } - val frames = context.frameState.frames - val progress = (runtime.frameIndex + 1).toFloat() / frames.size.toFloat() + val progress = rememberRsvpProgress(context).fraction LinearProgressIndicator( progress = { progress }, @@ -349,12 +347,7 @@ internal fun BoxScope.RsvpPositioningIndicator(context: RsvpUiContext) { @Composable internal fun BoxScope.RsvpScrubTargetIndicator(context: RsvpUiContext) { val runtime = context.runtime - val frames = context.frameState.frames - val frameCount = frames.size.coerceAtLeast(1) - val progressPercent = - (((runtime.frameIndex + 1).toFloat() / frameCount.toFloat()) * PERCENT_SCALE) - .roundToInt() - .coerceIn(0, PERCENT_SCALE.toInt()) + val progress = rememberRsvpProgress(context) val topPadding = wordAwareIndicatorTopPadding(context, SCRUB_INDICATOR_TOP_PADDING) AnimatedVisibility( @@ -378,7 +371,7 @@ internal fun BoxScope.RsvpScrubTargetIndicator(context: RsvpUiContext) { ), ) { Text( - text = "${runtime.frameIndex + 1}/$frameCount • $progressPercent%", + text = "${progress.currentWord}/${progress.totalWords} • ${progress.percent}%", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSecondaryContainer, ) diff --git a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenControls.kt b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenControls.kt index 92d785b..f9608c6 100644 --- a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenControls.kt +++ b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenControls.kt @@ -108,6 +108,7 @@ private fun RsvpDefaultBottomControls( controlsModifier: Modifier, ) { val runtime = context.runtime + val progress = rememberRsvpProgress(context) val previewContent = rememberPausedPreviewContent(context, compact = false) Column( modifier = @@ -153,8 +154,8 @@ private fun RsvpDefaultBottomControls( progressText = stringResource( R.string.rsvp_frame_progress, - runtime.frameIndex + 1, - context.frameState.frames.size, + progress.currentWord, + progress.totalWords, ), speedText = speedText, ) @@ -176,6 +177,7 @@ private fun RsvpCompactBottomControls( controlsModifier: Modifier, ) { val runtime = context.runtime + val progress = rememberRsvpProgress(context) val previewContent = rememberPausedPreviewContent(context, compact = true) Column( modifier = @@ -230,8 +232,8 @@ private fun RsvpCompactBottomControls( progressText = stringResource( R.string.rsvp_frame_progress, - runtime.frameIndex + 1, - context.frameState.frames.size, + progress.currentWord, + progress.totalWords, ), speedText = speedText, compact = true, @@ -537,9 +539,7 @@ private fun RsvpPlaybackInfoPill( @Composable private fun RsvpControlsProgress(context: RsvpUiContext) { - val runtime = context.runtime - val frames = context.frameState.frames - val progress = (runtime.frameIndex + 1).toFloat() / frames.size.coerceAtLeast(1).toFloat() + val progress = rememberRsvpProgress(context).fraction LinearProgressIndicator( progress = { progress }, diff --git a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenEffects.kt b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenEffects.kt index dd9ce5f..8bfa654 100644 --- a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenEffects.kt +++ b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenEffects.kt @@ -95,6 +95,7 @@ internal fun RsvpFrameAlignmentEffect(context: RsvpUiContext) { resumeCursor = runtime.currentResumeCursor, frameIndexMap = context.frameState.frameIndexMap, ) + runtime.rampStartFrameIndex = runtime.frameIndex syncRuntimePositionFromVisibleFrame(runtime, frames, book) } } @@ -252,6 +253,7 @@ internal fun RsvpPlaybackLoopEffect( runtime.frameIndex, runtime.rampStartFrameIndex, runtime.resumePreparationScale, + initialRampStartIndex = context.frameState.initialRampStartFrameIndex, ) val resumeDelayMs = RsvpSessionTimingPolicy.resumeDelayMs( @@ -259,6 +261,7 @@ internal fun RsvpPlaybackLoopEffect( runtime.frameIndex, runtime.rampStartFrameIndex, runtime.resumePreparationScale, + initialRampStartIndex = context.frameState.initialRampStartFrameIndex, ) val frameMs = scaledFrameDurationMs(frame, config, effectiveTempoMs, tempoScale) var scaledMs = diff --git a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenInteractions.kt b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenInteractions.kt index b85dbce..c14f821 100644 --- a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenInteractions.kt +++ b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenInteractions.kt @@ -6,6 +6,9 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.pointerInput @@ -18,11 +21,13 @@ import kotlin.math.abs import kotlin.math.roundToInt @OptIn(ExperimentalFoundationApi::class) +@Composable internal fun Modifier.rsvpGestureModifier( context: RsvpUiContext, interactionSource: MutableInteractionSource, ): Modifier { val runtime = context.runtime + val latestContext by rememberUpdatedState(context) val dragEnabled = shouldHandleGlobalRsvpDrag( showQuickSettings = runtime.showQuickSettings, @@ -31,18 +36,19 @@ internal fun Modifier.rsvpGestureModifier( val dragModifier = if (dragEnabled) { pointerInput( + runtime, context.state.profile.config.tempoMsPerWord, runtime.isPositioningMode, runtime.showQuickSettings, ) { detectDragGestures( - onDragStart = { handleDragStart(context) }, - onDragEnd = { handleDragEnd(context) }, + onDragStart = { handleDragStart(latestContext) }, + onDragEnd = { handleDragEnd(latestContext) }, // A cancelled drag must release scrub/tempo state like a normal end, // otherwise playback stays paused with isScrubbing stuck on. - onDragCancel = { handleDragEnd(context) }, + onDragCancel = { handleDragEnd(latestContext) }, onDrag = { change, dragAmount -> - handleDrag(context, dragAmount) + handleDrag(latestContext, dragAmount) change.consume() }, ) diff --git a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenRuntime.kt b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenRuntime.kt index 7cfa3eb..040aa75 100644 --- a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenRuntime.kt +++ b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenRuntime.kt @@ -19,6 +19,7 @@ internal data class RsvpFrameLoadState( val isComplete: Boolean = true, val loadFailed: Boolean = false, val frameIndexMap: RsvpFrameIndexMap = RsvpFrameIndexMap.from(frames), + val initialRampStartFrameIndex: Int = 0, ) internal data class RsvpTimingInfo(val minTempoMs: Long, val maxTempoMs: Long, val tempoScale: Double,) diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/timing/RsvpSessionTimingPolicyTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/timing/RsvpSessionTimingPolicyTest.kt new file mode 100644 index 0000000..06fc763 --- /dev/null +++ b/app/src/test/java/com/kairo/reader/core/rsvp/timing/RsvpSessionTimingPolicyTest.kt @@ -0,0 +1,31 @@ +package com.kairo.reader.core.rsvp.timing + +import com.kairo.reader.core.model.RsvpConfig +import com.kairo.reader.core.model.RsvpFrame +import com.kairo.reader.core.model.Token +import com.kairo.reader.core.model.TokenType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class RsvpSessionTimingPolicyTest { + @Test + fun initialTimingStartsAtSelectedPositionWithoutSlowingEarlierNavigation() { + val frames = MutableList(20) { RsvpFrame(listOf(Token("word", TokenType.WORD)), 100L) } + val config = RsvpConfig(startDelayMs = 200L, endDelayMs = 50L, rampUpFrames = 2, rampDownFrames = 2) + RsvpSessionTimingPolicy.applyInitialSessionRamps(frames, config, startFrameIndex = 10) + assertEquals(100L, frames[0].durationMs) + assertEquals(100L, frames[9].durationMs) + assertEquals(335L, frames[10].durationMs) + assertTrue(frames.last().durationMs > 150L) + } + + @Test + fun resumingBeforeOriginalStartGetsPreparationButOriginalStartIsNotDoubled() { + val config = RsvpConfig(startDelayMs = 200L, rampUpFrames = 2) + assertEquals(0L, RsvpSessionTimingPolicy.resumeDelayMs(config, 10, 10, initialRampStartIndex = 10)) + assertEquals(1.0, RsvpSessionTimingPolicy.resumeRampMultiplier(config, 10, 10, initialRampStartIndex = 10), 0.0) + assertEquals(200L, RsvpSessionTimingPolicy.resumeDelayMs(config, 0, 0, initialRampStartIndex = 10)) + assertTrue(RsvpSessionTimingPolicy.resumeRampMultiplier(config, 0, 0, initialRampStartIndex = 10) > 1.0) + } +} diff --git a/app/src/test/java/com/kairo/reader/data/rsvp/RsvpSeekableFramesTest.kt b/app/src/test/java/com/kairo/reader/data/rsvp/RsvpSeekableFramesTest.kt new file mode 100644 index 0000000..8a88b41 --- /dev/null +++ b/app/src/test/java/com/kairo/reader/data/rsvp/RsvpSeekableFramesTest.kt @@ -0,0 +1,104 @@ +package com.kairo.reader.data.rsvp + +import com.kairo.reader.core.dispatchers.DispatcherProvider +import com.kairo.reader.core.model.BlinkMode +import com.kairo.reader.core.model.BookId +import com.kairo.reader.core.model.Chapter +import com.kairo.reader.core.model.RsvpConfig +import com.kairo.reader.core.model.RsvpFrame +import com.kairo.reader.core.model.RsvpResumeCursor +import com.kairo.reader.core.model.Token +import com.kairo.reader.core.model.TokenType +import com.kairo.reader.core.rsvp.ComprehensionRsvpEngine +import com.kairo.reader.core.rsvp.RsvpEngine +import com.kairo.reader.core.rsvp.RsvpGenerationOptions +import com.kairo.reader.data.token.TokenRepository +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class RsvpSeekableFramesTest { + @Test + fun distantStartsAndReloadsKeepTheWholeChapterAndReuseGeneration() = runTest { + val tokens = List(1600) { Token("reading", TokenType.WORD) } + val engine = RecordingEngine() + val repository = repository(StandardTestDispatcher(testScheduler), tokens, engine) + val config = RsvpConfig(enablePhraseChunking = false, blinkMode = BlinkMode.OFF) + for (start in listOf(4, 520, 1050, 520)) { + val loaded = repository.getSeekableFrames(BookId("chapter"), 0, config, start) + assertEquals(0, loaded.frames.first().originalTokenIndex) + assertEquals(1599, loaded.frames.last().originalTokenIndex) + val index = loaded.frameIndexMap.alignFrameIndex(start, frameCount = loaded.frames.size) + assertEquals(start, loaded.frames[index].originalTokenIndex) + assertEquals(index, loaded.initialRampStartFrameIndex) + } + assertEquals(listOf(0), engine.starts) + } + + @Test + fun startingInsideAGroupedUnitPreservesEveryWordAndOnlyRebuildsThatUnit() = runTest { + val tokens = List(100) { + "They waited beside the quiet station while the morning train arrived ." + .split(' ').map { Token(it, if (it == ".") TokenType.PUNCTUATION else TokenType.WORD) } + }.flatten() + val engine = RecordingEngine() + val repository = repository(StandardTestDispatcher(testScheduler), tokens, engine) + val config = RsvpConfig(enablePhraseChunking = true, maxWordsPerUnit = 3, blinkMode = BlinkMode.OFF) + val options = RsvpGenerationOptions.fromLanguageTag("en") + val full = repository.getSeekableFrames(BookId("groups"), 0, config, options = options) + val group = full.frames.first { it.tokens.count { token -> token.type == TokenType.WORD } > 1 } + val start = (group.originalTokenIndex + 1 until group.displayOriginalEndExclusive) + .first { tokens[it].type == TokenType.WORD } + val loaded = repository.getSeekableFrames(BookId("groups"), 0, config, start, options) + val words = loaded.frames.flatMap { it.tokens }.filter { it.type == TokenType.WORD }.map { it.text } + assertEquals(tokens.filter { it.type == TokenType.WORD }.map { it.text }, words) + val index = loaded.frameIndexMap.alignFrameIndex(start, frameCount = loaded.frames.size) + assertEquals(start, loaded.frames[index].originalTokenIndex) + assertEquals(3, engine.generatedFrameCounts.size) + assertTrue(engine.generatedFrameCounts.drop(1).all { it <= 3 }) + } + + @Test + fun splitWordCursorRestoresExactlyWhileEarlierWordsRemainReachable() = runTest { + val tokens = listOf("before", "mother-in-law", "after").map { Token(it, TokenType.WORD) } + val repository = repository(StandardTestDispatcher(testScheduler), tokens, RecordingEngine()) + val loaded = repository.getSeekableFrames(BookId("split"), 0, RsvpConfig(enablePhraseChunking = false), 1) + val index = loaded.frameIndexMap.alignFrameIndex(1, RsvpResumeCursor.fromCharacterOffset(7), loaded.frames.size) + assertEquals("in-", loaded.frames[index].tokens.single().text) + assertEquals("before", loaded.frames.first().tokens.single().text) + } + + private fun repository(dispatcher: CoroutineDispatcher, tokens: List, engine: RsvpEngine): RsvpFrameRepositoryImpl = + RsvpFrameRepositoryImpl( + tokenRepository = object : TokenRepository { + override suspend fun getTokens(bookId: BookId, chapterIndex: Int, chapter: Chapter?): List = tokens + }, + engine = engine, + dispatcherProvider = object : DispatcherProvider { + override val default = dispatcher + override val io = dispatcher + }, + ) + + private class RecordingEngine : RsvpEngine { + val starts = mutableListOf() + val generatedFrameCounts = mutableListOf() + private val delegate = ComprehensionRsvpEngine() + override fun generateFrames(tokens: List, startIndex: Int, config: RsvpConfig): List = + generateFrames(tokens, startIndex, config, RsvpGenerationOptions.DEFAULT) + override fun generateFrames( + tokens: List, + startIndex: Int, + config: RsvpConfig, + options: RsvpGenerationOptions + ): List { + starts += startIndex + return delegate.generateFrames(tokens, startIndex, config, options).also { generatedFrameCounts += it.size } + } + } +} diff --git a/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpProgressTest.kt b/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpProgressTest.kt new file mode 100644 index 0000000..c89624c --- /dev/null +++ b/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpProgressTest.kt @@ -0,0 +1,60 @@ +package com.kairo.reader.ui.rsvp + +import com.kairo.reader.core.model.Token +import com.kairo.reader.core.model.TokenType +import org.junit.Assert.assertEquals +import org.junit.Test + +class RsvpProgressTest { + private val tokens = List(100) { Token("word", TokenType.WORD) } + + @Test + fun halfwayLaunchUsesTheEntireChapter() { + val progress = RsvpProgressBasis(tokens).at(49) + assertEquals(50, progress.currentWord) + assertEquals(100, progress.totalWords) + assertEquals(0.5f, progress.fraction, 0.0001f) + } + + @Test + fun readerAndRsvpShareChapterProgressAtSeventyFivePercent() { + val readerProgress = com.kairo.reader.core.model.chapterReadingProgress( + wordCountByToken = com.kairo.reader.core.model.buildWordCountByToken(tokens), + tokenIndex = 74, + totalWords = 100, + ) + val rsvpProgress = RsvpProgressBasis(tokens).at(74) + assertEquals(readerProgress, rsvpProgress) + assertEquals(75, rsvpProgress.percent) + assertEquals(RsvpProgress(100, 100), RsvpProgressBasis(tokens).at(74, completed = true)) + } + + @Test + fun punctuationAndStructuralTokensDoNotInflateWordProgress() { + val source = listOf( + Token("one", TokenType.WORD), + Token(",", TokenType.PUNCTUATION), + Token("two", TokenType.WORD), + ) + val basis = RsvpProgressBasis(source) + assertEquals(RsvpProgress(1, 2), basis.at(0)) + assertEquals(RsvpProgress(1, 2), basis.at(1)) + assertEquals(RsvpProgress(2, 2), basis.at(2)) + } + + @Test + fun rebuildingAtTheSavedSourcePositionDoesNotResetProgress() { + val beforeRotation = RsvpProgressBasis(tokens).at(74) + val afterRotation = RsvpProgressBasis(tokens.toList()).at(74) + assertEquals(beforeRotation, afterRotation) + assertEquals(RsvpProgress(75, 100), afterRotation) + } + + @Test + fun emptyAndOutOfRangePositionsRemainBounded() { + assertEquals(0f, RsvpProgressBasis(emptyList()).at(0).fraction, 0f) + val basis = RsvpProgressBasis(tokens) + assertEquals(RsvpProgress(0, 100), basis.at(-1)) + assertEquals(RsvpProgress(100, 100), basis.at(Int.MAX_VALUE)) + } +}