test: Cover translated selection ranges
Some checks are pending
Build / build (push) Waiting to run

This commit is contained in:
Chmouel Boudjnah 2026-10-13 21:19:57 +02:00
parent 5bf98ec353
commit 24f2eb8978
3 changed files with 140 additions and 5 deletions

View file

@ -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)

View file

@ -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"))

View file

@ -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" }