feat(strings): the app's words for every game (M20) #58

Merged
whitlocktech merged 5 commits from feat/app-vocabulary into edge 2026-10-23 01:50:51 -05:00
2 changed files with 175 additions and 0 deletions
Showing only changes of commit 3048582ce0 - Show all commits

View File

@@ -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<Test>().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

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