chore(release): cut edge over to main — the Rust screens and the app's words (v0.6.0) #59
@@ -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
|
||||
|
||||
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