Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ internal class RsvpDeviceFixture(tokens: List<Token>, config: RsvpConfig, startI

@Volatile var saved: RsvpResumePoint? = null

@Volatile var position: RsvpResumePoint? = null

@Volatile var finished = false
val loads = AtomicInteger()
private val repository = RsvpFrameRepositoryImpl(
Expand All @@ -50,20 +52,22 @@ internal class RsvpDeviceFixture(tokens: List<Token>, 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 = {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<TestActivity>()

@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()
}
}
Original file line number Diff line number Diff line change
@@ -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<TestActivity>()

@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<Unit> {
val fixture = RsvpDeviceFixture(List(5000) { word() }, config())
val times = mutableListOf<Long>()
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)
}
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ import kotlin.math.roundToLong
internal fun applyPlaybackEffects(
frames: MutableList<RsvpFrame>,
config: RsvpConfig,
startFrameIndex: Int = 0,
) {
RsvpSessionTimingPolicy.applyInitialSessionRamps(frames = frames, config = config)
RsvpSessionTimingPolicy.applyInitialSessionRamps(frames = frames, config = config, startFrameIndex = startFrameIndex)
applyBlinkSeparation(frames, config)
}

Expand Down
Loading