From 24f2eb8978e0098b5288017ca0d7febb6b335449 Mon Sep 17 00:00:00 2001 From: Chmouel Boudjnah Date: Tue, 13 Oct 2026 21:19:57 +0200 Subject: [PATCH] test: Cover translated selection ranges --- app/build.gradle.kts | 1 + .../chmouel/liseur/reader/PageSwapsTest.kt | 142 +++++++++++++++++- gradle/libs.versions.toml | 2 + 3 files changed, 140 insertions(+), 5 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2e0bf0f2..f4293f3a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -269,6 +269,7 @@ dependencies { implementation(libs.androidx.datastore.preferences) testImplementation(libs.junit) + testImplementation(libs.rhino) // The app uses the platform's org.json; unit tests need a real implementation // of it because android.jar's is a stub. Test-only, never shipped. testImplementation(libs.json) diff --git a/app/src/test/kotlin/com/chmouel/liseur/reader/PageSwapsTest.kt b/app/src/test/kotlin/com/chmouel/liseur/reader/PageSwapsTest.kt index 9566a51e..4a67c407 100644 --- a/app/src/test/kotlin/com/chmouel/liseur/reader/PageSwapsTest.kt +++ b/app/src/test/kotlin/com/chmouel/liseur/reader/PageSwapsTest.kt @@ -4,13 +4,12 @@ import com.chmouel.liseur.translate.PageSentence import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +import org.mozilla.javascript.Context +import org.mozilla.javascript.RhinoException /** - * The script the reader sends a translated page to ask whether a tap - * landed on the words it marks as the voice's, checked the way - * `ImageAtPointTest` checks its script: for what it asks of the - * document rather than for what the document makes of the question, - * which only a real WebView can say. + * The page scripts are checked against the generated code and the + * selection range logic runs against a small JavaScript DOM fixture. */ class PageSwapsTest { @@ -29,6 +28,132 @@ class PageSwapsTest { .substringAfter("markedAt(url, s, fx, fy) {") .substringBefore("restore() {") + private fun bodyAfter(script: String, marker: String): String { + val bodyStart = script.indexOf(marker) + marker.length + var depth = 1 + var bodyEnd = bodyStart + while (depth > 0) { + when (script[bodyEnd]) { + '{' -> depth++ + '}' -> depth-- + } + if (depth > 0) bodyEnd++ + } + return script.substring(bodyStart, bodyEnd) + } + + private fun selectionIntersects(node: String, start: Int, end: Int): Boolean { + val script = PageSwaps.selectionIntersectsAppliedSwap( + "https://book/EPUB/text/ch1.xhtml", + sentence, + ) + val functionBody = bodyAfter(script, "selectionIntersectsAppliedSwap(url, s) {") + val rangeStart = functionBody.indexOf("const range = selection.getRangeAt(0);") + val rangeLogic = functionBody.substring(rangeStart) + .replace( + "for (const [node, extent] of bounds) {", + """ + for (var boundIndex = 0; boundIndex < bounds.length; boundIndex++) { + var node = bounds[boundIndex][0]; + var extent = bounds[boundIndex][1]; + """.trimIndent(), + ) + .replace( + "for (const edit of edits.get(node) || []) {", + """ + var nodeEdits = edits.get(node) || []; + for (var editIndex = 0; editIndex < nodeEdits.length; editIndex++) { + var edit = nodeEdits[editIndex]; + """.trimIndent(), + ) + .replace(Regex("\\bconst\\b"), "var") + val shownBody = bodyAfter(script, "const shown = (node, at) => {") + .replace( + "for (const e of list) {", + """ + for (var shownIndex = 0; shownIndex < list.length; shownIndex++) { + var e = list[shownIndex]; + """.trimIndent(), + ) + .replace(Regex("\\bconst\\b"), "var") + val context = Context.enter() + context.languageVersion = Context.VERSION_ES6 + var fixture = "" + return try { + val scope = context.initStandardObjects() + fixture = """ + const translation = "La pluie"; + const text = { data: "Thus was a La pluie and stormy night.", order: 0 }; + const note = { data: "[1]", order: 19 }; + const following = { data: " after", order: note.order + note.data.length }; + const bounds = [[text, [0, ${sentence.text.length}]]]; + const edits = { + get: function() { + return [ + { from: 0, to: 2, text: "Thus" }, + { from: 9, to: 13, text: translation } + ]; + } + }; + function shown(node, at) { + $shownBody + } + function absolute(node, offset) { return node.order + offset; } + const Range = { END_TO_START: 1, START_TO_END: 2 }; + function FakeRange(startNode, startOffset, endNode, endOffset) { + this.startNode = startNode; + this.startOffset = startOffset; + this.endNode = endNode; + this.endOffset = endOffset; + } + FakeRange.prototype.setStart = function(node, offset) { + this.startNode = node; + this.startOffset = offset; + }; + FakeRange.prototype.setEnd = function(node, offset) { + this.endNode = node; + this.endOffset = offset; + }; + FakeRange.prototype.compareBoundaryPoints = function(how, other) { + if (how === Range.END_TO_START) { + return absolute(this.endNode, this.endOffset) - absolute(other.startNode, other.startOffset); + } + return absolute(this.startNode, this.startOffset) - absolute(other.endNode, other.endOffset); + }; + const selectedRange = new FakeRange(__NODE__, __START__, __NODE__, __END__); + const selection = { rangeCount: 1, isCollapsed: false, getRangeAt() { return selectedRange; } }; + const document = { createRange() { return new FakeRange(null, 0, null, 0); } }; + (function() { + $rangeLogic + })(); + """.trimIndent() + .replace("__NODE__", node) + .replace("__START__", start.toString()) + .replace("__END__", end.toString()) + val result = context.evaluateString( + scope, + fixture, + "PageSwapsTest.js", + 1, + null, + ) + Context.toBoolean(result) + } catch (e: RhinoException) { + throw AssertionError( + "${e.message}: ${e.lineSource() ?: e.lineNumber().let { line -> fixture.lines().getOrNull(line - 1) }}", + e, + ) + } finally { + Context.exit() + } + } + + private companion object { + const val TRANSLATION_NODE = "text" + const val NOTE_REFERENCE_NODE = "note" + const val FOLLOWING_TEXT_NODE = "following" + } + @Test fun `another document's page answers nothing`() { // Readium reuses web views between chapters, so every call names @@ -86,6 +211,13 @@ class PageSwapsTest { assertTrue(body.contains("if (!at) return null;")) } + @Test + fun `dictionary language follows whether selected words were translated`() { + assertTrue(selectionIntersects(TRANSLATION_NODE, 12, 15)) + assertFalse(selectionIntersects(FOLLOWING_TEXT_NODE, 0, 4)) + assertFalse(selectionIntersects(NOTE_REFERENCE_NODE, 0, 3)) + } + @Test fun `a rect with nothing in it is not a place a finger can land`() { assertTrue(body().contains("r.width > 0 && r.height > 0")) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 541057ab..efb78c24 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,6 +27,7 @@ reorderable = "3.1.0" navigation3 = "1.2.0" adaptiveNavigation3 = "1.3.0" robolectric = "4.17" +rhino = "1.9.1" # Play build only: on-device translation (see DEVELOPER.md, F-Droid readiness). mlkitTranslate = "17.0.3" junit = "4.13.2" @@ -60,6 +61,7 @@ room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } +rhino = { module = "org.mozilla:rhino", version.ref = "rhino" } room-testing = { module = "androidx.room:room-testing", version.ref = "room" } androidx-test-core = { module = "androidx.test:core", version = "1.7.0" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }