test(android): guard that the typefaces stay bundled
android / Build + lint + test (push) Successful in 5m17s
android / Build + lint + test (push) Successful in 5m17s
The web side has no-external-assets.test.ts; Android had nothing, so the font provider could come back with no test noticing. This is the Android half. Expectations are read out of Typography.kt rather than hardcoded, which is what makes it a structural pin instead of a list that rots: the guard extracts every Font(R.font.X, FontWeight.WN) declaration and checks that X.ttf exists, is really TrueType, and reports N as its OS/2 usWeightClass. Add a face without vendoring it and this fails; change a declared weight without refetching the matching static instance and it fails too. usWeightClass is the check worth having. css2 silently collapses a multi-weight request to 400 for legacy clients, so Medium comes back as Regular — a valid TrueType file that renders at the wrong weight everywhere, and the only field that distinguishes it. Comments are stripped before the absence check, so the KDoc explaining why there is no GoogleFont reference cannot satisfy the assertion that forbids it. Falsified by mirroring every predicate and byte offset against the real files: it passes on what is committed, and trips on HEAD~1's Typography.kt via both the forbidden-symbol check and the no-declarations-found check. A 400 file asserted against a declared 500 fails, so the weight comparison is not vacuous. Compilation itself is unverified locally — no Gradle run here — so CI is the first thing to actually build this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
This commit is contained in:
@@ -0,0 +1,125 @@
|
|||||||
|
package com.fabledsword.minstrel.theme
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guards that the typefaces ship inside the APK instead of being fetched at
|
||||||
|
* runtime.
|
||||||
|
*
|
||||||
|
* Until 2026-09-09 these were resolved through the Play Services font
|
||||||
|
* provider. That needs a network the deployed app is not guaranteed, and a
|
||||||
|
* provider that devices without Play Services do not have at all. Both
|
||||||
|
* failures are silent — text just renders in the platform default, which
|
||||||
|
* reads as a styling regression rather than a missing dependency.
|
||||||
|
*
|
||||||
|
* Expectations are read out of Typography.kt itself rather than hardcoded, so
|
||||||
|
* this cannot drift away from what the app actually declares: adding a face
|
||||||
|
* without vendoring its file fails here, and so does changing a declared
|
||||||
|
* weight without refetching the matching static instance.
|
||||||
|
*/
|
||||||
|
class BundledFontsTest {
|
||||||
|
@Test
|
||||||
|
fun `typography builds its families from bundled resources`() {
|
||||||
|
val source = typographySource()
|
||||||
|
assertTrue(
|
||||||
|
source.contains("R.font."),
|
||||||
|
"Typography.kt should build its families from res/font resources",
|
||||||
|
)
|
||||||
|
FORBIDDEN.forEach { symbol ->
|
||||||
|
assertTrue(
|
||||||
|
!source.contains(symbol),
|
||||||
|
"Typography.kt must not reference $symbol — fonts are bundled, not fetched",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every declared face is vendored as TrueType at its declared weight`() {
|
||||||
|
val declared = FACE_PATTERN.findAll(typographySource()).toList()
|
||||||
|
assertTrue(
|
||||||
|
declared.isNotEmpty(),
|
||||||
|
"no Font(R.font.…, FontWeight.W…) declarations found — the guard would pass vacuously",
|
||||||
|
)
|
||||||
|
|
||||||
|
declared.forEach { match ->
|
||||||
|
val (name, weight) = match.destructured
|
||||||
|
val file = File(appDir(), "src/main/res/font/$name.ttf")
|
||||||
|
assertTrue(file.isFile, "res/font/$name.ttf is missing — run tools/vendor-fonts.py")
|
||||||
|
|
||||||
|
val bytes = file.readBytes()
|
||||||
|
assertTrue(
|
||||||
|
bytes.copyOfRange(0, TTF_MAGIC.size).contentEquals(TTF_MAGIC),
|
||||||
|
"$name.ttf is not TrueType — res/font cannot load a woff2 or an eot",
|
||||||
|
)
|
||||||
|
// The decisive check. Google's css2 endpoint silently collapses a
|
||||||
|
// multi-weight request to 400 for legacy clients, so Medium can
|
||||||
|
// come back as Regular: a valid TrueType file that renders at the
|
||||||
|
// wrong weight everywhere. usWeightClass is the only field that
|
||||||
|
// tells the two apart.
|
||||||
|
assertEquals(
|
||||||
|
weight.toInt(),
|
||||||
|
weightClass(bytes),
|
||||||
|
"$name.ttf carries a different OS/2 usWeightClass than the FontWeight declared beside it",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Typography.kt with comments removed, so prose naming the forbidden
|
||||||
|
* symbols cannot satisfy — or trip — the absence check above. */
|
||||||
|
private fun typographySource(): String =
|
||||||
|
File(appDir(), TYPOGRAPHY)
|
||||||
|
.readText()
|
||||||
|
.replace(BLOCK_COMMENT, "")
|
||||||
|
.replace(LINE_COMMENT, "")
|
||||||
|
|
||||||
|
/** Gradle's working directory for tests is the module dir, but don't rely
|
||||||
|
* on it: walk up until the module is found, and say so if it isn't. */
|
||||||
|
private fun appDir(): File {
|
||||||
|
var dir: File? = File("").absoluteFile
|
||||||
|
while (dir != null) {
|
||||||
|
if (File(dir, TYPOGRAPHY).isFile) return dir
|
||||||
|
if (File(dir, "app/$TYPOGRAPHY").isFile) return File(dir, "app")
|
||||||
|
dir = dir.parentFile
|
||||||
|
}
|
||||||
|
error("could not locate the app module from ${File("").absolutePath}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun weightClass(bytes: ByteArray): Int {
|
||||||
|
val tables = readU16(bytes, NUM_TABLES)
|
||||||
|
for (i in 0 until tables) {
|
||||||
|
val record = TABLE_DIRECTORY + i * TABLE_RECORD
|
||||||
|
if (String(bytes, record, TAG_LENGTH, Charsets.US_ASCII) == "OS/2") {
|
||||||
|
return readU16(bytes, readU32(bytes, record + OFFSET_FIELD) + WEIGHT_FIELD)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
error("no OS/2 table in the font")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readU16(bytes: ByteArray, at: Int): Int =
|
||||||
|
((bytes[at].toInt() and BYTE_MASK) shl Byte.SIZE_BITS) or (bytes[at + 1].toInt() and BYTE_MASK)
|
||||||
|
|
||||||
|
private fun readU32(bytes: ByteArray, at: Int): Int =
|
||||||
|
(readU16(bytes, at) shl Short.SIZE_BITS) or readU16(bytes, at + 2)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TYPOGRAPHY = "src/main/java/com/fabledsword/minstrel/theme/Typography.kt"
|
||||||
|
|
||||||
|
val FORBIDDEN = listOf("GoogleFont", "googlefonts")
|
||||||
|
val FACE_PATTERN = Regex("""R\.font\.(\w+)\s*,\s*FontWeight\.W(\d+)""")
|
||||||
|
val BLOCK_COMMENT = Regex("""/\*[\s\S]*?\*/""")
|
||||||
|
val LINE_COMMENT = Regex("""//.*""")
|
||||||
|
val TTF_MAGIC = byteArrayOf(0x00, 0x01, 0x00, 0x00)
|
||||||
|
|
||||||
|
// Offsets into the TrueType table directory, per the OpenType spec.
|
||||||
|
const val NUM_TABLES = 4
|
||||||
|
const val TABLE_DIRECTORY = 12
|
||||||
|
const val TABLE_RECORD = 16
|
||||||
|
const val TAG_LENGTH = 4
|
||||||
|
const val OFFSET_FIELD = 8
|
||||||
|
const val WEIGHT_FIELD = 4
|
||||||
|
const val BYTE_MASK = 0xFF
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user