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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ md-backup/
/docs/performance-hindrances.md
/docs/release-branch-strategy.md
/docs/large-file-code-smell-review.md
/docs/rsvp-expressive-reading-review.md

# Android
*.apk
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.kairo.reader.ui.rsvp

import android.os.SystemClock
import androidx.activity.compose.setContent
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.kairo.reader.TestActivity
import com.kairo.reader.core.model.Chapter
import com.kairo.reader.core.model.RsvpProfile
import com.kairo.reader.core.model.TokenType
import com.kairo.reader.core.model.defaultConfig
import com.kairo.reader.core.rsvp.ComprehensionRsvpEngine
import com.kairo.reader.core.rsvp.RsvpGenerationOptions
import com.kairo.reader.core.rsvp.RsvpLanguagePolicy
import com.kairo.reader.core.tokenization.Tokenizer
import com.kairo.reader.ui.theme.KairoTheme
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith

/** Exercises authored HTML through the repository and real grouped Narrative playback. */
@RunWith(AndroidJUnit4::class)
class RsvpExpressivePlaybackDeviceTest {
@Test
fun authoredStressAndContextualThoughtsReachTheDisplayWithoutLosingWords() {
val text = "I want that one. I said Tuesday, then waited before opening the door."
val tokens = Tokenizer().tokenize(Chapter(0, null, "<p>${text.replace("Tuesday", "<em>Tuesday</em>")}</p>", text))
val options = RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH)
val config = RsvpProfile.NARRATIVE.defaultConfig().copy(
maxWordsPerUnit = 3,
maxCharsPerUnit = 32,
startDelayMs = 0L,
endDelayMs = 0L,
rampUpFrames = 0,
rampDownFrames = 0,
)
val fixture = RsvpDeviceFixture(tokens, config)
fixture.state = fixture.state.copy(book = fixture.state.book.copy(generationOptions = options))
ActivityScenario.launch(TestActivity::class.java).use { scenario ->
scenario.onActivity { activity ->
activity.setContent { KairoTheme { RsvpScreen(fixture.state, fixture.callbacks, fixture.dependencies) } }
}
val deadline = SystemClock.elapsedRealtime() + 20_000L
while (!fixture.finished && SystemClock.elapsedRealtime() < deadline) SystemClock.sleep(20L)
assertTrue("Narrative playback finishes", fixture.finished)
val played = fixture.consumed.filter { frame -> frame.tokens.any { it.type == TokenType.WORD } }
assertTrue("Fixture exercises grouping", played.any { frame -> frame.tokens.count { it.type == TokenType.WORD } > 1 })
assertEquals(
tokens.indices.filter { tokens[it].type == TokenType.WORD },
played.flatMap { frame ->
(frame.displayOriginalStartIndex until frame.displayOriginalEndExclusive).filter { tokens[it].type == TokenType.WORD }
}.distinct(),
)
val that = played.first { frame -> frame.tokens.any { it.text == "that" } }
assertEquals(0, that.phraseStartTokenIndex)
val before = played.first { frame -> frame.tokens.any { it.text == "before" } }
assertEquals(tokens.indexOfFirst { it.text == "before" }, before.phraseStartTokenIndex)
val accent = played.first { frame -> frame.tokens.any { it.authorEmphasis } }
val neutral = ComprehensionRsvpEngine().generateFrames(tokens.map { it.copy(authorEmphasis = false) }, 0, config, options)
.first { it.originalTokenIndex == accent.originalTokenIndex }
assertTrue("Authored word retains extra exposure in playback", accent.durationMs > neutral.durationMs)
val consumedIndex = fixture.consumed.indexOf(accent)
assertTrue(consumedIndex > 0)
assertTrue(
"The scheduler preserves that exposure",
fixture.consumedAtMs[consumedIndex] - fixture.consumedAtMs[consumedIndex - 1] >= accent.durationMs,
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
package com.kairo.reader.ui.rsvp

import android.os.SystemClock
import android.util.Log
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.click
import androidx.compose.ui.test.doubleClick
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipe
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.BookId
import com.kairo.reader.core.model.RsvpConfig
import com.kairo.reader.core.model.RsvpProfile
import com.kairo.reader.core.model.Token
import com.kairo.reader.core.model.TokenType
import com.kairo.reader.core.model.defaultConfig
import com.kairo.reader.core.rsvp.RsvpGenerationOptions
import com.kairo.reader.core.tokenization.Tokenizer
import com.kairo.reader.data.rsvp.RsvpFrameRepository
import com.kairo.reader.data.rsvp.RsvpFrameSet
import com.kairo.reader.sample.SampleBooks
import com.kairo.reader.ui.theme.KairoTheme
import kotlinx.coroutines.CompletableDeferred
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 RsvpStartupDeviceTest {
@get:Rule
val composeRule = createAndroidComposeRule<TestActivity>()

@Test
fun slowPreparationKeepsPreviewStillThenReadsFromSelectedWordInOrder() {
val fixture = fixture(startDelayMs = 0L)
val releaseFrames = showWithBlockedPreparation(fixture)
composeRule.onNodeWithText("Start", useUnmergedTree = true).assertIsDisplayed()
// Keep preparation blocked beyond the preview's reading interval.
composeRule.mainClock.advanceTimeBy(800L)
composeRule.runOnIdle {
assertTrue("The loading preview must not be consumed", fixture.consumed.isEmpty())
}
composeRule.onNodeWithText("Start", useUnmergedTree = true).assertIsDisplayed()
releaseFrames.complete(Unit)
composeRule.waitUntil(timeoutMillis = 10_000L) { fixture.finished }
assertEquals(listOf(2, 3, 4), fixture.consumed.map { it.originalTokenIndex })
}

@Test
fun coldPreparationOverlapsStartPauseWithoutSkippingTheFirstWord() {
val fixture = fixture(startDelayMs = 1000L)
val releaseFrames = showWithBlockedPreparation(fixture)
composeRule.onNodeWithText("Start", useUnmergedTree = true).assertIsDisplayed()
composeRule.mainClock.autoAdvance = false
// The app measures real preparation time; playback uses Compose's test clock.
SystemClock.sleep(1200L)
releaseFrames.complete(Unit)
composeRule.waitUntil(timeoutMillis = 10_000L) {
composeRule.mainClock.advanceTimeByFrame()
fixture.loads.get() > 0
}
composeRule.mainClock.advanceTimeByFrame()
composeRule.mainClock.advanceTimeByFrame()
assertTrue("The first word still needs its reading interval", fixture.consumed.isEmpty())
composeRule.mainClock.advanceTimeBy(400L)
composeRule.runOnIdle {
assertTrue("Do not add another full startup pause after loading", fixture.consumed.isNotEmpty())
assertEquals(2, fixture.consumed.first().originalTokenIndex)
}
composeRule.mainClock.autoAdvance = true
composeRule.waitUntil(timeoutMillis = 10_000L) { fixture.finished }
assertEquals(listOf(2, 3, 4), fixture.consumed.map { it.originalTokenIndex })
}

@Test
fun preparedOpeningPlaysBeforeChapterArrivesAndWaitsAtItsBoundaryWithoutRepeating() {
val fixture = fixture(startDelayMs = 0L, tokens = List(120) { Token("reading", TokenType.WORD) })
val releaseFrames = showWithBlockedPreparation(fixture)
composeRule.waitUntil(timeoutMillis = 15_000L) { fixture.consumed.size >= 64 }
composeRule.mainClock.advanceTimeBy(1000L)
composeRule.runOnIdle {
assertEquals("Playback must start before full-chapter preparation completes", 0, fixture.loads.get())
assertEquals((2 until 66).toList(), fixture.consumed.map { it.originalTokenIndex })
assertTrue(!fixture.finished)
}
releaseFrames.complete(Unit)
composeRule.waitUntil(timeoutMillis = 20_000L) { fixture.finished }
assertEquals((2 until 120).toList(), fixture.consumed.map { it.originalTokenIndex })
}

@Test
fun pausingTheOpeningKeepsTheSameWordWhenFullChapterArrives() {
val fixture = fixture(startDelayMs = 0L, tokens = List(120) { Token("word$it", TokenType.WORD) })
val releaseFrames = showWithBlockedPreparation(fixture)
composeRule.waitUntil(timeoutMillis = 10_000L) { fixture.consumed.size >= 2 }
composeRule.onRoot().performTouchInput { click() }
val play = composeRule.activity.getString(R.string.content_desc_play)
composeRule.waitUntil(timeoutMillis = 3000L) {
composeRule.onAllNodesWithContentDescription(play).fetchSemanticsNodes().isNotEmpty()
}
val focus = composeRule.onNodeWithTag("rsvp-focus-word", useUnmergedTree = true)
val pausedText = focus.fetchSemanticsNode().config[SemanticsProperties.Text].single().text
releaseFrames.complete(Unit)
composeRule.waitUntil(timeoutMillis = 10_000L) { fixture.loads.get() >= 2 }
focus.assertTextEquals(pausedText)
}

@Test
fun startingNearTheEndDoesNotWaitForOrReplayTheWholeChapter() {
val fixture = fixture(startDelayMs = 0L, tokens = List(120) { Token("reading", TokenType.WORD) })
fixture.state = fixture.state.copy(book = fixture.state.book.copy(startIndex = 115, sessionStartIndex = 115))
val releaseFrames = showWithBlockedPreparation(fixture)
composeRule.waitUntil(timeoutMillis = 10_000L) { fixture.finished }
assertEquals(0, fixture.loads.get())
assertEquals((115 until 120).toList(), fixture.consumed.map { it.originalTokenIndex })
releaseFrames.complete(Unit)
composeRule.waitUntil(timeoutMillis = 10_000L) { fixture.loads.get() > 0 }
composeRule.mainClock.advanceTimeBy(1000L)
assertEquals((115 until 120).toList(), fixture.consumed.map { it.originalTokenIndex })
}

@Test
fun measureFirstPlaybackOnAColdLargeEnglishChapter() {
val paragraph = Tokenizer().tokenize(SampleBooks.starterBook().chapters[2])
val fixture = RsvpDeviceFixture(List(8000) { paragraph[it % paragraph.size] }, RsvpProfile.BALANCED.defaultConfig())
fixture.state = fixture.state.copy(book = fixture.state.book.copy(generationOptions = RsvpGenerationOptions.fromLanguageTag("en")))
val startedAt = SystemClock.elapsedRealtime()
composeRule.setContent {
KairoTheme { RsvpScreen(fixture.state, fixture.callbacks, fixture.dependencies) }
}
composeRule.waitUntil(timeoutMillis = 15_000L) { fixture.consumed.isNotEmpty() }
Log.i(
"RsvpStartupBenchmark",
"8000-token UI: first consumed=${fixture.consumedAtMs.first() - startedAt} ms, chapter loads=${fixture.loads.get()}",
)
composeRule.waitUntil(timeoutMillis = 15_000L) { fixture.loads.get() > 0 }
}

@Test
fun replayDuringOpeningCanReachBeforeTheLaunchPositionOnceChapterIsReady() {
val fixture = startBufferedMidChapter()

composeRule.onRoot().performTouchInput { doubleClick() }

composeRule.waitUntil(timeoutMillis = 3000L) { (fixture.position?.tokenIndex ?: 80) < 80 }
assertTrue("Replay must reach text before the opening buffer", requireNotNull(fixture.position).tokenIndex < 80)
}

@Test
fun backwardSwipeDuringOpeningCanReachBeforeTheLaunchPositionOnceChapterIsReady() {
val fixture = startBufferedMidChapter()

composeRule.onRoot().performTouchInput {
swipe(Offset(width * 0.85f, height * 0.4f), Offset(width * 0.15f, height * 0.4f), durationMillis = 160L)
}

composeRule.waitUntil(timeoutMillis = 3000L) { (fixture.position?.tokenIndex ?: 80) < 80 }
assertTrue("Swipe must reach text before the opening buffer", requireNotNull(fixture.position).tokenIndex < 80)
}

private fun startBufferedMidChapter(): RsvpDeviceFixture {
val fixture = fixture(startDelayMs = 0L, tokens = List(200) { Token("word$it", TokenType.WORD) })
fixture.state = fixture.state.copy(
book = fixture.state.book.copy(startIndex = 80, sessionStartIndex = 80),
profile = fixture.state.profile.copy(config = fixture.state.profile.config.copy(tempoMsPerWord = 600L)),
)
val releaseFrames = showWithBlockedPreparation(fixture)
composeRule.waitUntil(timeoutMillis = 10_000L) { fixture.consumed.isNotEmpty() }
assertEquals(0, fixture.loads.get())
releaseFrames.complete(Unit)
composeRule.waitUntil(timeoutMillis = 10_000L) { fixture.loads.get() > 0 }
composeRule.waitForIdle()
assertTrue("Exercise navigation before the opening has been consumed", fixture.consumed.size < 64)
return fixture
}

private fun fixture(
startDelayMs: Long,
tokens: List<Token> = listOf("Earlier", "Before", "Start", "Next", "Last").map { Token(it, TokenType.WORD) },
): RsvpDeviceFixture =
RsvpDeviceFixture(
tokens,
RsvpConfig(
tempoMsPerWord = 100L,
enablePhraseChunking = false,
blinkMode = BlinkMode.OFF,
startDelayMs = startDelayMs,
endDelayMs = 0L,
rampUpFrames = 0,
rampDownFrames = 0,
),
startIndex = 2,
)

private fun showWithBlockedPreparation(fixture: RsvpDeviceFixture): CompletableDeferred<Unit> {
val releaseFrames = CompletableDeferred<Unit>()
val repository = fixture.dependencies.frameRepository
val delayedRepository = object : RsvpFrameRepository by repository {
override suspend fun getSeekableFrames(
bookId: BookId,
chapterIndex: Int,
config: RsvpConfig,
startIndex: Int,
options: RsvpGenerationOptions,
): RsvpFrameSet {
releaseFrames.await()
return repository.getSeekableFrames(bookId, chapterIndex, config, startIndex, options)
}
}
composeRule.setContent {
KairoTheme {
RsvpScreen(fixture.state, fixture.callbacks, RsvpScreenDependencies(delayedRepository))
}
}
return releaseFrames
}
}
38 changes: 10 additions & 28 deletions app/src/main/java/com/kairo/reader/core/linguistics/WordAnalyzer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -739,33 +739,14 @@ object ClauseDetector {
): Double {
if (nextWord == null) return 0.0
val lower = word.lowercase()
return when {
lower in setOf("a", "an", "the") -> 0.9
lower in setOf("my", "your", "his", "her", "its", "our", "their") -> 0.85
lower in phraseLeaders -> 0.75
lower in setOf(
"of",
"to",
"in",
"on",
"at",
"by",
"for",
"with",
"from",
"into",
"onto",
"upon",
"about",
"through",
"between",
"among",
"against",
"toward",
"towards",
) -> 0.7
lower in setOf("very", "quite", "rather", "too", "so", "really", "almost", "nearly") -> 0.8
lower in setOf("will", "would", "can", "could", "should", "must", "may", "might") -> 0.65
return when (lower) {
"a", "an", "the" -> 0.9
"my", "your", "his", "her", "its", "our", "their" -> 0.85
in phraseLeaders -> 0.75
"of", "to", "in", "on", "at", "by", "for", "with", "from",
"into", "onto", "upon", "about", "through", "between", "among", "against", "toward", "towards" -> 0.7
"very", "quite", "rather", "too", "so", "really", "almost", "nearly" -> 0.8
"will", "would", "can", "could", "should", "must", "may", "might" -> 0.65
else -> 0.0
}
}
Expand Down Expand Up @@ -816,10 +797,11 @@ object DialogueAnalyzer {

val lower = words.map { it.lowercase() }
val hasVerb = lower.any { it in speakerVerbs }
if (!hasVerb) return false
val text = lower.joinToString(" ")
val matchesPattern = speakerPatterns.any { it.containsMatchIn(text) }

return hasVerb && matchesPattern
return matchesPattern
}

fun isSpeakerVerb(word: String): Boolean {
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/com/kairo/reader/core/model/Models.kt
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ data class Token(
val isSubwordChunk: Boolean = false,
/** If this token is inside a link, the target chapter index */
val linkChapterIndex: Int? = null,
/** Local semantic emphasis from source markup; independent of difficulty and display styling. */
val authorEmphasis: Boolean = false,
)

data class RsvpConfig(
Expand Down
Loading
Loading