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:
2026-10-23 01:35:59 -05:00
parent 5e28989adc
commit 3048582ce0
2 changed files with 175 additions and 0 deletions

View 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"))
}
}