test(strings): guard each strings file against another game's words
PLAN.md M20 step 3. StringVocabularyTest reads strings.xml, strings_uo.xml and strings_rust.xml in every values* directory, so translations are checked too. It fails, naming the key and the word, when the core file holds either game's word, the UO file holds a Rust word, or the Rust file holds a UO word. The word list is runicgateway.com's checkTerms.mjs list, word for word. The app never shows Discord's "guild ID", so that allowlist entry isn't carried over. Three more tests keep the guard honest: - a fourth strings file with no owner is refused, because Android would read it and the guard would skip it; - the three default files must exist and hold strings, so a wrong path can't pass on nothing; - the matcher is checked against known hits and misses. Run against the core file as it was before the rewording, it names exactly the 14 strings that commit changed. build.gradle.kts declares src/main/res as an input of the Test tasks. A reworded string changes no class, so without that input Gradle left the test task UP-TO-DATE and the guard did not run again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
168
app/src/test/java/com/runicgateway/app/StringVocabularyTest.kt
Normal file
168
app/src/test/java/com/runicgateway/app/StringVocabularyTest.kt
Normal file
@@ -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<Term> = 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<Pair<Term, String>> =
|
||||
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<File>
|
||||
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<Pair<String, String>> {
|
||||
val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file)
|
||||
val children = doc.documentElement.childNodes
|
||||
return (0 until children.length)
|
||||
.map { children.item(it) }
|
||||
.filterIsInstance<Element>()
|
||||
// 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<String>()
|
||||
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<String>(), 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<String>(), words(Owner.UO, "Shard online"))
|
||||
assertEquals(emptyList<String>(), words(Owner.RUST, "Clan mate"))
|
||||
// Word boundaries: not a prefix, not an identifier.
|
||||
assertEquals(emptyList<String>(), words(Owner.CORE, "wiped %1\$s"))
|
||||
assertEquals(emptyList<String>(), words(Owner.CORE, "Guildmaster"))
|
||||
assertEquals(emptyList<String>(), words(Owner.CORE, "SHARD_VISIBILITY"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user