diff --git a/android/app/src/test/java/com/fabledsword/minstrel/theme/BundledFontsTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/theme/BundledFontsTest.kt new file mode 100644 index 00000000..e061ba24 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/theme/BundledFontsTest.kt @@ -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 + } +}