diff --git a/.gitignore b/.gitignore index 42414bb3..445ae763 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpExpressivePlaybackDeviceTest.kt b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpExpressivePlaybackDeviceTest.kt new file mode 100644 index 00000000..f04e556a --- /dev/null +++ b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpExpressivePlaybackDeviceTest.kt @@ -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, "
${text.replace("Tuesday", "Tuesday")}
", 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, + ) + } + } +} diff --git a/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpStartupDeviceTest.kt b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpStartupDeviceTest.kt new file mode 100644 index 00000000..f6ad270c --- /dev/null +++ b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpStartupDeviceTest.kt @@ -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 = createAndroidComposeRuleI said Tuesday
", "I said Tuesday")) + val config = stableConfig.copy(useFocalStress = false, useAnticipatoryLanding = false) + val before = engine.generateFrames(neutral, 0, config, english) + val after = engine.generateFrames(marked, 0, config, english) + assertEquals(before.take(2).map { it.durationMs }, after.take(2).map { it.durationMs }) + assertTrue(after.last().durationMs > before.last().durationMs) + val disabled = config.copy(useProsodyPacing = false, useFocalStress = true) + assertEquals( + engine.generateFrames(neutral, 0, disabled, english).map { it.durationMs }, + engine.generateFrames(marked, 0, disabled, english).map { it.durationMs }, + ) + } + + @Test + fun clearPhraseFocusDoesNotDefaultToTheLongestWord() { + val analysis = analyze("She photographed the cat") + assertEquals(setOf(3), analysis.focalWordIndices) + val ambiguous = analyze("Mysterious visitors arrived quietly") + assertEquals(setOf(0, 1, 2, 3), ambiguous.focalWordIndices) + } + + @Test + fun authoredShortWordKeepsFocusAndSplitWordResumeCursorsRemainDistinct() { + val tokens = tokenize("I said representatives").map { it.copy(authorEmphasis = it.text == "I" || it.text == "representatives") } + val config = stableConfig.copy(maxChunkLength = 6) + val frames = engine.generateFrames(tokens, 0, config, english) + val chunks = frames.filter { it.originalTokenIndex == 2 } + assertTrue(chunks.size > 1) + assertEquals(chunks.size, chunks.map { it.resumeCursor }.distinct().size) + assertTrue(chunks.all { frame -> frame.tokens.filter { it.type == TokenType.WORD }.all { it.authorEmphasis } }) + assertEquals(listOf(0, 1, 2), frames.map { it.originalTokenIndex }.distinct()) + } + + private fun tokenize(text: String) = Tokenizer().tokenize(Chapter(0, null, "", text)) + + private fun frames(text: String) = engine.generateFrames(tokenize(text), 0, stableConfig, english) + + private fun analyze(text: String) = analyzeExpandedTokens( + tokenize(text).mapIndexed { index, token -> ExpandedToken(token, index, index, 0, token.text.length) }, + stableConfig, + RsvpLanguagePolicy.ENGLISH, + ) +} diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/timing/RsvpGroupedExpressionTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/timing/RsvpGroupedExpressionTest.kt new file mode 100644 index 00000000..311f54ee --- /dev/null +++ b/app/src/test/java/com/kairo/reader/core/rsvp/timing/RsvpGroupedExpressionTest.kt @@ -0,0 +1,89 @@ +package com.kairo.reader.core.rsvp.timing + +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.rsvp.ComprehensionRsvpEngine +import com.kairo.reader.core.rsvp.RsvpGenerationOptions +import com.kairo.reader.core.rsvp.RsvpLanguagePolicy +import com.kairo.reader.core.rsvp.analysis.multiWordPenalty +import com.kairo.reader.core.rsvp.engine.BoundaryBefore +import com.kairo.reader.core.rsvp.engine.ContextSnapshot +import com.kairo.reader.core.rsvp.engine.PhraseContour +import com.kairo.reader.core.rsvp.engine.RhythmState +import com.kairo.reader.core.rsvp.engine.RsvpWordExpression +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class RsvpGroupedExpressionTest { + private val config = RsvpConfig( + tempoMsPerWord = 200L, + minWordMs = 1L, + difficultWordSupport = 0.0, + useAdaptiveTiming = false, + useClausePausing = false, + useDialogueDetection = false, + usePunctuationLandingHold = false, + useFocalStress = false, + startDelayMs = 0L, + endDelayMs = 0L, + rampUpFrames = 0, + rampDownFrames = 0, + ) + + @Test + fun anAccentOnTheSecondOrThirdWordIsNeitherLostNorAppliedToEveryWord() { + val accent = RsvpWordExpression(anticipatoryLanding = 1.1) + val singleDelta = duration(listOf(accent)) - duration(listOf(RsvpWordExpression())) + assertTrue(singleDelta > 0) + for (width in 2..3) { + val neutral = List(width) { RsvpWordExpression() } + val baseline = duration(neutral) + val first = duration(neutral.mapIndexed { index, cue -> if (index == 0) accent else cue }) + val last = duration(neutral.mapIndexed { index, cue -> if (index == width - 1) accent else cue }) + assertEquals(first, last) + assertEquals(singleDelta * multiWordPenalty(width), (last - baseline).toDouble(), 2.0) + } + } + + @Test + fun tailContoursBelongToTheirOwnWordAndStillObeyTheProsodyToggle() { + val neutral = List(3) { RsvpWordExpression() } + val shaped = neutral.dropLast(1) + RsvpWordExpression(phraseContour = PhraseContour(0.2, 0.0)) + assertTrue(duration(shaped) > duration(neutral)) + assertEquals(duration(neutral, false), duration(shaped, false)) + } + + @Test + fun engineCarriesALandingThatFallsInsideAGroup() { + val tokens = listOf(word("in"), word("the"), word("room"), Token(".", TokenType.PUNCTUATION)) + val engine = ComprehensionRsvpEngine() + val options = RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH) + val grouped = config.copy(enablePhraseChunking = true, maxWordsPerUnit = 3, maxCharsPerUnit = 32) + val withLanding = engine.generateFrames(tokens, 0, grouped.copy(useAnticipatoryLanding = true), options) + val withoutLanding = engine.generateFrames(tokens, 0, grouped.copy(useAnticipatoryLanding = false), options) + val index = withLanding.indexOfFirst { it.tokens.any { token -> token.text == "the" } } + val frame = withLanding[index] + assertTrue(frame.tokens.count { it.type == TokenType.WORD } > 1) + assertTrue(frame.originalTokenIndex < 1) + assertTrue(frame.durationMs > withoutLanding[index].durationMs) + } + + private fun duration(expressions: ListI said Tuesday, not Tuesday again.
", "I said Tuesday, not Tuesday again.") + assertEquals(listOf(false, true), tokens.filter { it.text == "Tuesday" }.map { it.authorEmphasis }) + } + + @Test + fun nestedMarkupEntitiesAndQuotesKeepSourcePositionsAndLinks() { + val html = "She said "you can" & left.
" + val tokens = tokenize(html, "She said \"you can\" & left.") + val neutral = tokenize(html.replace("", "").replace("", ""), "She said \"you can\" & left.") + assertEquals(listOf("you", "can"), tokens.filter { it.authorEmphasis }.map { it.text }) + assertEquals(neutral, tokens.map { it.copy(authorEmphasis = false) }) + assertTrue(tokens.filter { it.authorEmphasis }.all { it.linkChapterIndex == 3 }) + } + + @Test + fun adjacentMarkupDoesNotManufactureWordsOrAccentPartialWords() { + val tokens = tokenize("An unbelievable result!
", "An unbelievable result!") + assertEquals(listOf("An", "unbelievable", "result"), tokens.filter { it.type == TokenType.WORD }.map { it.text }) + assertEquals(listOf("result"), tokens.filter { it.authorEmphasis }.map { it.text }) + } + + @Test + fun decorativeWholeBlockAndLongEmphasisStayNeutral() { + val examples = listOf( + "A foreign word.
" to "A foreign word.", + "A whole paragraph.
" to "A whole paragraph.", + "A book title here.
" to "A book title here.", + "A rather long run of five words here.
" to "A rather long run of five words here.", + ) + examples.forEach { (html, plain) -> assertFalse(html, tokenize(html, plain).any { it.authorEmphasis }) } + } + + @Test + fun mismatchedHtmlCannotAccentAnotherOccurrence() { + val tokens = tokenize("Tuesday and Tuesday.
", "Tuesday.") + assertFalse(tokens.any { it.authorEmphasis }) + assertEquals(listOf("Tuesday", "."), tokens.map { it.text }) + } + + @Test + fun semanticEmphasisWorksAcrossTokenizerFamilies() { + for ((language, plain, accented) in listOf( + Triple("fr", "Je veux ceci.", "ceci"), + Triple("ar", "أنا أريد هذا.", "هذا"), + Triple("zh", "今天晴天。", "晴天"), + )) { + val html = "${plain.replace(accented, "$accented")}
" + val tokens = tokenize(html, plain, language) + assertTrue(language, tokens.any { it.authorEmphasis }) + assertEquals(language, accented, tokens.filter { it.authorEmphasis }.joinToString("") { it.text }) + } + } + + @Test + fun paragraphBoundariesAndNumberNormalizationDoNotMoveEmphasis() { + val html = "First.
I meant 50 %, not 20 %.
" + val plain = "First.\n\nI meant 50 %, not 20 %." + val tokens = tokenize(html, plain) + assertEquals(listOf("50%"), tokens.filter { it.authorEmphasis }.map { it.text }) + assertEquals(1, tokens.count { it.type == TokenType.PARAGRAPH_BREAK }) + assertEquals(0L, tokens.first { it.type == TokenType.PARAGRAPH_BREAK }.pauseAfterMs) + } + + @Test + fun manyAccentsRemainOrderedAndDoNotChangeTokenCount() { + val html = "" + List(500) { "word again. " }.joinToString("") + "
" + val tokens = tokenize(html, List(500) { "word again. " }.joinToString("")) + assertEquals(1500, tokens.size) + assertEquals(500, tokens.count { it.authorEmphasis }) + } + + @Test + fun importedPageMarkersDoNotDiscardEmphasisOrChangeReadingTokens() { + for (attribute in listOf("epub:type='pagebreak'", "role='doc-pagebreak'", "class='page-break'")) { + val html = "Tuesday came.
12I meant Tuesday again.
" + for (language in listOf("en", "fr", "ar", "zh")) { + val tokens = tokenizeImportedHtml(html, language) + assertEquals(listOf(false, true), tokens.filter { it.text == "Tuesday" }.map { it.authorEmphasis }) + assertEquals( + tokenizeImportedHtml(html.replace("", "").replace("", ""), language), + tokens.map { it.copy(authorEmphasis = false) }, + ) + } + } + } + + @Test + fun importedSceneSeparatorsDoNotDiscardEmphasis() { + for (separator in listOf("— —", "– –", "* * *", "---", "• • •")) { + val html = "Tuesday came.
$separator
I meant Tuesday again.
" + val tokens = tokenizeImportedHtml(html) + assertEquals(separator, listOf(false, true), tokens.filter { it.text == "Tuesday" }.map { it.authorEmphasis }) + assertTrue(separator, tokens.any { it.type == TokenType.PAGE_BREAK }) + } + } + + @Test + fun normalizationAcrossEmphasisEdgesKeepsUnrelatedAccents() { + for (number in listOf("50 %", "20 º C", "− 35")) { + val html = "I meant $number, then Tuesday.
" + val tokens = tokenizeImportedHtml(html) + assertEquals(number, listOf("Tuesday"), tokens.filter { it.authorEmphasis }.map { it.text }) + assertEquals( + tokenizeImportedHtml(html.replace("", "").replace("", "")), + tokens.map { it.copy(authorEmphasis = false) }, + ) + } + } + + @Test + fun numericMarkupKeepsFollowingEmphasisAcrossTokenizerFamilies() { + val html = "I meant 50 %, then Tuesday.
" + for (language in listOf("en", "fr", "ar", "zh")) { + assertTrue(language, tokenizeImportedHtml(html, language).any { it.text == "Tuesday" && it.authorEmphasis }) + } + } + + private fun tokenizeImportedHtml(html: String, language: String = "en"): List