diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0df18df..4594d20 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -181,6 +181,13 @@ dependencies { androidTestImplementation(libs.androidx.compose.ui.test.junit4) } +// StringVocabularyTest (PLAN.md M20) reads the string resources from disk. A reworded +// string changes no class, so without this input the test task would stay UP-TO-DATE +// and the guard would not run again. +tasks.withType().configureEach { + inputs.dir("src/main/res").withPathSensitivity(PathSensitivity.RELATIVE) +} + // JaCoCo XML coverage from the JVM unit tests, consumed by SonarQube (§12.1). Generated, // DI (Hilt), and Compose-scaffold classes are excluded so they don't dilute the number; // pure-@Composable UI is excluded on the Sonar side (sonar.coverage.exclusions) because diff --git a/app/src/test/java/com/runicgateway/app/StringVocabularyTest.kt b/app/src/test/java/com/runicgateway/app/StringVocabularyTest.kt new file mode 100644 index 0000000..dc60bd3 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/StringVocabularyTest.kt @@ -0,0 +1,168 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import org.w3c.dom.Element +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory + +/** + * The guard on the app's words (PLAN.md M20). A game's words stay on that game's + * screens, and the screens every deployment shows use core's words. This is the + * app's half of the rule the website docs and runicgateway.com already follow + * (runicgateway.com PLAN.md §16, D63/D64). + * + * The strings are split by owner into three resource files that Android merges as + * one `R.string`: `strings.xml` (core), `strings_uo.xml` and `strings_rust.xml`. + * A string's owner is the screens that use it, not its key: a string any screen a + * Rust deployment can reach is core, even when its key says `shard_`. This test + * reads the three files and fails, naming the key and the word, when + * - the core file holds either game's word; + * - the UO file holds a Rust word; + * - the Rust file holds a UO word. + * + * It checks every `values*` directory, so a translation is held to the same rule. + * Comments are not read, because nobody using the app sees them. + */ +class StringVocabularyTest { + + private enum class Owner(val file: String, val label: String) { + CORE("strings.xml", "core"), + UO("strings_uo.xml", "Ultima Online"), + RUST("strings_rust.xml", "Rust"), + } + + private class Term(val game: Owner, val word: String, pattern: String) { + val regex = Regex(pattern, RegexOption.IGNORE_CASE) + } + + /** + * runicgateway.com's `scripts/checkTerms.mjs` list, word for word. Each word is a game's + * name for something core names differently (shard → game server, guild and clan → + * Team), or the name of one game's component. Game NAMES (Ultima Online, Rust) are not + * on it, and neither is Steam, which is a core sign-in provider. Matching is on word + * boundaries, so "wipe" does not match "wiped" and "shard" does not match `SHARD_X`. + */ + private val terms = listOf( + Term(Owner.UO, "shard", """\bshards?\b"""), + Term(Owner.UO, "ServUO", """\bservuo\b"""), + Term(Owner.UO, "uo-link", """\buo-?link\b"""), + Term(Owner.UO, "Module-uo", """\bmodule-uo\b"""), + Term(Owner.UO, "Bridge.cfg", """\bbridge\.cfg\b"""), + Term(Owner.UO, "cliloc", """\bclilocs?\b"""), + Term(Owner.UO, "guild", """\bguilds?\b"""), + Term(Owner.UO, "IDOC", """\bidocs?\b"""), + Term(Owner.RUST, "rust-link", """\brust-?link\b"""), + Term(Owner.RUST, "Rust-Plugins", """\brust-plugins\b"""), + Term(Owner.RUST, "Module-Rust", """\bmodule-rust\b"""), + Term(Owner.RUST, "Oxide", """\boxide\b"""), + Term(Owner.RUST, "Carbon", """\bcarbon\b"""), + Term(Owner.RUST, "uMod", """\bumod\b"""), + Term(Owner.RUST, "clan", """\bclans?\b"""), + Term(Owner.RUST, "wipe", """\bwipes?\b"""), + ) + + /** The words [owner]'s file may not use. */ + private fun forbiddenIn(owner: Owner): List = when (owner) { + Owner.CORE -> terms + Owner.UO -> terms.filter { it.game == Owner.RUST } + Owner.RUST -> terms.filter { it.game == Owner.UO } + } + + /** The words in [text] that [owner]'s file may not use. */ + private fun hits(owner: Owner, text: String): List> = + forbiddenIn(owner).flatMap { term -> term.regex.findAll(text).map { term to it.value }.toList() } + + /** + * The module's `res`, wherever the test runs from: Gradle runs unit tests in the + * module directory, and an IDE may run them from the repository root. + */ + private val res: File = listOf(File("src/main/res"), File("app/src/main/res")) + .firstOrNull { it.isDirectory } + ?: error("Can't find src/main/res from ${File(".").absolutePath}") + + private val valuesDirs: List + get() = res.listFiles { f -> f.isDirectory && f.name.startsWith("values") }!!.sortedBy { it.name } + + /** Every resource in [file] that the app shows: key → its visible text. */ + private fun visibleText(file: File): List> { + val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file) + val children = doc.documentElement.childNodes + return (0 until children.length) + .map { children.item(it) } + .filterIsInstance() + // A plural's items arrive as one text, which is all a word scan needs. + .map { it.getAttribute("name") to it.textContent } + } + + @Test fun eachFileUsesOnlyItsOwnGamesWords() { + val failures = mutableListOf() + for (dir in valuesDirs) { + for (owner in Owner.entries) { + val file = File(dir, owner.file) + if (!file.exists()) continue + for ((key, text) in visibleText(file)) { + for ((term, found) in hits(owner, text)) { + failures += "${dir.name}/${owner.file}: $key uses \"$found\", " + + "${term.game.label} vocabulary, in the ${owner.label} file" + } + } + } + } + if (failures.isNotEmpty()) { + fail( + failures.joinToString( + separator = "\n ", + prefix = "${failures.size} game word(s) outside their game's file:\n ", + postfix = "\n\nPLAN.md M20: a string any deployment's screen shows is core, and uses " + + "core's words (site, community, game server, Team). Reword it, or, if only one " + + "game's screens use it, move it to that game's file.", + ), + ) + } + } + + /** + * A fourth strings file would be read by Android and skipped by this guard, so it + * is refused. A new game gets its own file and its own row in [Owner]. + */ + @Test fun everyStringsFileHasAnOwner() { + val known = Owner.entries.map { it.file }.toSet() + val strays = valuesDirs.flatMap { dir -> + dir.listFiles { f -> f.name.startsWith("strings") && f.name !in known }!!.map { "${dir.name}/${it.name}" } + } + assertEquals("Strings files with no owner in StringVocabularyTest", emptyList(), strays) + } + + /** Without this a wrong path would let the guard pass on nothing. */ + @Test fun theDefaultFilesAreAllThere() { + for (owner in Owner.entries) { + val file = File(res, "values/${owner.file}") + assertTrue("${file.path} is missing", file.exists()) + assertTrue("${file.path} holds no strings", visibleText(file).isNotEmpty()) + } + } + + /** The matcher itself, so a broken pattern can't make every file look clean. */ + @Test fun theWordListCatchesWhatItShould() { + fun words(owner: Owner, text: String) = hits(owner, text).map { it.first.word } + + assertEquals(listOf("shard"), words(Owner.CORE, "Connect to your shard")) + assertEquals(listOf("guild"), words(Owner.CORE, "Guilds")) + assertEquals(listOf("clan"), words(Owner.CORE, "You and your clan")) + assertEquals(listOf("wipe"), words(Owner.UO, "Next wipe %1\$s")) + assertEquals(listOf("IDOC"), words(Owner.RUST, "Your house entered IDOC")) + // Each game's own words are fine in its own file. + assertEquals(emptyList(), words(Owner.UO, "Shard online")) + assertEquals(emptyList(), words(Owner.RUST, "Clan mate")) + // Word boundaries: not a prefix, not an identifier. + assertEquals(emptyList(), words(Owner.CORE, "wiped %1\$s")) + assertEquals(emptyList(), words(Owner.CORE, "Guildmaster")) + assertEquals(emptyList(), words(Owner.CORE, "SHARD_VISIBILITY")) + } +}