From 4aa0d0f23741f893270bb1e71d3908f04190edeb Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Sun, 4 Oct 2026 15:30:57 -0400 Subject: [PATCH] Test: the 0.32 shared vectors, loaded by the app's own code Loader tests for the three vector files flamenet-e2e docs/spec-0-32 adds, found through FLAMENET_CONTENT_KINDS_VECTORS, FLAMENET_TODAY_VECTORS and FLAMENET_HELLO_LINK_VECTORS, or in a flamenet-e2e checkout beside this one: - content kinds (app module): every written envelope parsed and written back by the engine's encoders, byte for byte; every envelope through Content.parse and the session's own gates (control traffic, and a move or an ending through ChatGames.receive), checked field by field. - Today (client module): TodayFile.verify on the sample, every case and the 512 KB edge; the sequence and If-None-Match through TodayFeed over a MockWebServer on this machine. - hello links (app module): the link cases through HelloLink.fromLink, as openHelloLink calls it, and the scan cases through prefillFor. Test files only. Co-Authored-By: Claude Opus 5.5 --- .../app/SharedVectorsContentKindsTest.kt | 221 +++++++++++++++ .../app/SharedVectorsHelloLinkTest.kt | 55 ++++ .../flamenet/client/SharedVectorsTodayTest.kt | 265 ++++++++++++++++++ 3 files changed, 541 insertions(+) create mode 100644 app/src/test/kotlin/io/flamenet/messenger/app/SharedVectorsContentKindsTest.kt create mode 100644 app/src/test/kotlin/io/flamenet/messenger/app/SharedVectorsHelloLinkTest.kt create mode 100644 client/src/test/kotlin/io/flamenet/client/SharedVectorsTodayTest.kt diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/SharedVectorsContentKindsTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/SharedVectorsContentKindsTest.kt new file mode 100644 index 0000000..190c550 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/SharedVectorsContentKindsTest.kt @@ -0,0 +1,221 @@ +package io.flamenet.messenger.app + +import io.flamenet.e2e.Content +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.fail + +/** + * The 0.32 content kinds against the shared vectors, `content-kinds-v1.json` + * from flamenet-e2e (§6.31 to §6.35, §6.12, §6.10). + * + * Two halves. Every `written` envelope is parsed and written back with this + * app's own encoders, and the bytes must be the file's. Every envelope, written + * or read, goes through `Content.parse` and the checks the session applies + * before anything is drawn or taken (control traffic, a game move's own rules + * through `ChatGames.receive`), and what comes out must be what the file says, + * for the fields the case lists. + * + * The file is found through `FLAMENET_CONTENT_KINDS_VECTORS`, or in a + * flamenet-e2e checkout beside this one. + */ +class SharedVectorsContentKindsTest { + private val file: JSONObject by lazy { + val candidates = + listOfNotNull( + System.getenv("FLAMENET_CONTENT_KINDS_VECTORS"), + "../../flamenet-e2e/test/vectors/content-kinds-v1.json", + "../../../flamenet-e2e/test/vectors/content-kinds-v1.json", + ).map(::File) + val found = + candidates.firstOrNull { it.exists() } + ?: throw AssertionError("content-kinds-v1.json not found. Looked in: ${candidates.joinToString { it.absolutePath }}") + JSONObject(found.readText()) + } + + private val dropped = mapOf("verdict" to "dropped") + + /** What this app makes of one plaintext, in the file's terms. */ + private fun read(plaintext: String): Map { + val c = Content.parse(plaintext) + if (c.raw == null) return mapOf("verdict" to "plain") + val common = + mapOf( + "mid" to c.messageId, + "gid" to c.groupId, + "forwarded" to c.isForwarded, + "ownTimer" to c.lifetimeSeconds, + ) + when (c.kind) { + Content.Kind.RSVP -> { + val r = c.rsvp ?: return dropped + return common + mapOf("verdict" to "rsvp", "rsvp" to mapOf("eid" to r.eventId, "r" to r.answer, "n" to r.n)) + } + + Content.Kind.GAME_MOVE, Content.Kind.GAME_END -> { + // As the session takes them: one to one only, then the rules' + // own refusals before anything is built. + val g = c.game ?: return dropped + if (c.groupId != null) return dropped + if (ChatGames.receive(null, c.kind, g) == ChatGames.Received.Ignored) return dropped + return common + mapOf("verdict" to c.kind, "game" to game(g, c.kind)) + } + + Content.Kind.UNPOST -> { + val u = c.unpost ?: return dropped + return common + mapOf("verdict" to "unpost", "sid" to u.storyId, "set" to u.setId) + } + + Content.Kind.EDIT -> { + val target = c.editOf + if (target != null) { + return common + mapOf("verdict" to "edit", "story" to c.editsStory, "mid" to target, "caption" to c.text) + } + } + } + if (c.isControl || !c.isRenderable) return dropped + c.card?.let { card -> + val body = mutableMapOf("d" to card.design, "msg" to card.msg) + card.to?.let { body["to"] = it } + card.from?.let { body["from"] = it } + card.open?.let { body["open"] = it } + return common + mapOf("verdict" to "card", "card" to body, "drawn" to card.shownDesign) + } + c.event?.let { e -> + val ev = mutableMapOf("id" to e.id, "rev" to e.rev, "title" to e.title, "cancel" to e.cancelled) + if (e.start != null) { + ev["start"] = e.start + e.tz?.let { ev["tz"] = it } + } else { + ev["day"] = e.day + ev["dayCount"] = e.days ?: 1 + } + e.place?.let { ev["place"] = it } + e.note?.let { ev["note"] = it } + return common + + mapOf("verdict" to "event", "ev" to ev, "allDay" to e.allDay, "endShown" to if (e.start != null) e.end else null) + } + if (c.kind == Content.Kind.GAME) { + c.game?.let { return common + mapOf("verdict" to "game", "game" to game(it, c.kind)) } + } + if (c.kind == Content.Kind.TEXT) { + val link = c.link?.let { mapOf("url" to it.url, "title" to it.title, "desc" to it.desc) } + return common + mapOf("verdict" to "text", "caption" to c.text, "lnk" to link) + } + if (c.text == "[unsupported attachment]" && c.attachment == null) return mapOf("verdict" to "unsupported") + return mapOf("verdict" to "other: ${c.kind}") + } + + private fun game( + g: Content.Game, + kind: String, + ): Map { + val out = mutableMapOf("id" to g.id, "t" to g.type, "mine" to g.mine) + if (kind != Content.Kind.GAME) g.log?.let { out["log"] = it } + if (kind == Content.Kind.GAME_END) g.ending?.let { out["r"] = it } + return out + } + + /** The bytes this app writes for what it read from [plaintext]. */ + private fun write(plaintext: String): String? { + val c = Content.parse(plaintext) + val raw = c.raw ?: return null + val groupName = raw.optString("gname").takeIf { it.isNotEmpty() } + return when (c.kind) { + Content.Kind.CARD -> { + val card = c.card ?: return null + Content.card(card, c.messageId ?: return null, c.groupId, groupName, c.isForwarded) + } + + Content.Kind.EVENT -> { + val event = c.event ?: return null + Content.event(event, c.messageId ?: return null, c.groupId, groupName, c.isForwarded) + } + + Content.Kind.RSVP -> { + c.rsvp?.let { Content.rsvp(it, c.groupId) } + } + + Content.Kind.GAME -> { + c.game?.let { Content.game(it, c.messageId ?: return null) } + } + + Content.Kind.GAME_MOVE -> { + c.game?.let { Content.gameMove(it) } + } + + Content.Kind.GAME_END -> { + c.game?.let { Content.gameEnd(it) } + } + + Content.Kind.UNPOST -> { + val u = c.unpost ?: return null + u.storyId?.let { Content.unpostStory(it) } ?: u.setId?.let { Content.unpostStorySet(it) } + } + + Content.Kind.EDIT -> { + if (c.editsStory) c.editOf?.let { Content.storyEdit(it, c.text) } else null + } + + else -> { + null + } + } + } + + /** JSON values and this app's values in one shape: whole numbers as Long, objects as maps. */ + private fun plain(value: Any?): Any? = + when (value) { + null, JSONObject.NULL -> null + is JSONObject -> value.keys().asSequence().associateWith { plain(value.get(it)) } + is JSONArray -> (0 until value.length()).map { plain(value.get(it)) } + is Map<*, *> -> value.entries.associate { (k, v) -> k.toString() to plain(v) } + is List<*> -> value.map(::plain) + // org.json reads a fraction as a BigDecimal: kept as a fraction, never cut to a whole number. + is java.math.BigDecimal -> if (value.stripTrailingZeros().scale() <= 0) value.toLong() else value.toDouble() + is Double -> if (value == Math.floor(value) && !value.isInfinite()) value.toLong() else value + is Float -> plain(value.toDouble()) + is Number -> value.toLong() + else -> value + } + + @Test + fun `every written envelope is what this app writes, byte for byte`() { + val written = file.getJSONArray("written") + assertEquals(24, written.length()) + val wrong = mutableListOf() + for (i in 0 until written.length()) { + val one = written.getJSONObject(i) + val envelope = one.getString("envelope") + val mine = runCatching { write(envelope) }.getOrElse { "threw: $it" } + if (mine != envelope) wrong += "written '${one.getString("name")}': the file has $envelope, this app writes $mine" + } + println("content-kinds written: ${written.length()} cases, ${wrong.size} differ") + if (wrong.isNotEmpty()) fail(wrong.joinToString("\n")) + } + + @Test + fun `every envelope reads the way the file says`() { + val cases = (0 until file.getJSONArray("written").length()).map { file.getJSONArray("written").getJSONObject(it) } + + (0 until file.getJSONArray("read").length()).map { file.getJSONArray("read").getJSONObject(it) } + assertEquals(132, cases.size) + val wrong = mutableListOf() + for (one in cases) { + val envelope = one.getString("envelope") + val want = one.getJSONObject("reads") + val got = read(envelope) + for (key in want.keys().asSequence().sorted()) { + val expected = plain(want.get(key)) + val actual = plain(got[key]) + if (expected != actual) { + wrong += "read '${one.getString("name")}': $key is $expected in the file, $actual here. Envelope: $envelope" + } + } + } + println("content-kinds read: ${cases.size} cases, ${wrong.size} fields differ") + if (wrong.isNotEmpty()) fail(wrong.joinToString("\n")) + } +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/SharedVectorsHelloLinkTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/SharedVectorsHelloLinkTest.kt new file mode 100644 index 0000000..861a013 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/SharedVectorsHelloLinkTest.kt @@ -0,0 +1,55 @@ +package io.flamenet.messenger.app + +import org.json.JSONObject +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.fail + +/** + * Hello links against the shared vectors, `hello-link-v1.json` from + * flamenet-e2e (§6.34). The `link` cases go through what the app does with a + * link it is opened with ([HelloLink.fromLink], as `openHelloLink` calls it), + * and the `scan` cases through what a scanned code fills the add screen with + * ([prefillFor]). The `fragment` cases are the relay's /hi page, not this app. + * + * The file is found through `FLAMENET_HELLO_LINK_VECTORS`, or in a + * flamenet-e2e checkout beside this one. + */ +class SharedVectorsHelloLinkTest { + private val file: JSONObject by lazy { + val candidates = + listOfNotNull( + System.getenv("FLAMENET_HELLO_LINK_VECTORS"), + "../../flamenet-e2e/test/vectors/hello-link-v1.json", + "../../../flamenet-e2e/test/vectors/hello-link-v1.json", + ).map(::File) + val found = + candidates.firstOrNull { it.exists() } + ?: throw AssertionError("hello-link-v1.json not found. Looked in: ${candidates.joinToString { it.absolutePath }}") + JSONObject(found.readText()) + } + + @Test + fun `every link and scan case gives the address or refusal the file says`() { + val cases = file.getJSONArray("cases") + val checked = mutableMapOf("link" to 0, "scan" to 0) + val wrong = mutableListOf() + for (i in 0 until cases.length()) { + val one = cases.getJSONObject(i) + val context = one.getString("context") + if (context != "link" && context != "scan") continue + val input = one.getString("input") + val want = if (one.isNull("expect")) null else one.getString("expect") + val got = if (context == "link") HelloLink.fromLink(input) else prefillFor(input) + if (got != want) { + wrong += "$context '${one.getString("name")}': the file says ${want ?: "refused"}, " + + "this app gives ${got ?: "refused"}. Input: ${input.take(120)}" + } + checked[context] = checked.getValue(context) + 1 + } + println("hello links: $checked checked, ${wrong.size} differ") + assertEquals(mapOf("link" to 37, "scan" to 18), checked) + if (wrong.isNotEmpty()) fail(wrong.joinToString("\n")) + } +} diff --git a/client/src/test/kotlin/io/flamenet/client/SharedVectorsTodayTest.kt b/client/src/test/kotlin/io/flamenet/client/SharedVectorsTodayTest.kt new file mode 100644 index 0000000..612ccd4 --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/SharedVectorsTodayTest.kt @@ -0,0 +1,265 @@ +package io.flamenet.client + +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.fail + +/** + * The Today file against the shared vectors, `today-v1.json` from + * flamenet-e2e (§8.12): which envelopes this app takes and why it refuses the + * rest, what a taken one holds, the 512 KB edge, which of two files the feed + * shows, and when it sends the stored ETag back. + * + * The file is found through `FLAMENET_TODAY_VECTORS`, or in a flamenet-e2e + * checkout beside this one. + */ +class SharedVectorsTodayTest { + private val file: JSONObject by lazy { + val candidates = + listOfNotNull( + System.getenv("FLAMENET_TODAY_VECTORS"), + "../../flamenet-e2e/test/vectors/today-v1.json", + "../../../flamenet-e2e/test/vectors/today-v1.json", + ).map(::File) + val found = + candidates.firstOrNull { it.exists() } + ?: throw AssertionError("today-v1.json not found. Looked in: ${candidates.joinToString { it.absolutePath }}") + JSONObject(found.readText()) + } + + private val keys: TodayFile.Keys by lazy { + val key = file.getJSONObject("key") + TodayFile.Keys(listOf(key.getString("public"))).also { + assertEquals(setOf(key.getString("kid")), it.byKid.keys) + } + } + + private fun why(refusal: TodayFile.Refusal): String = + when (refusal) { + TodayFile.Refusal.TOO_LARGE -> "too-large" + TodayFile.Refusal.MALFORMED -> "malformed" + TodayFile.Refusal.UNKNOWN_VERSION -> "unknown-version" + TodayFile.Refusal.UNSIGNED -> "unsigned" + TodayFile.Refusal.UNKNOWN_KEY -> "unknown-key" + TodayFile.Refusal.BAD_SIGNATURE -> "bad-signature" + TodayFile.Refusal.BAD_BODY -> "bad-body" + } + + /** What this app keeps at one of the file's paths. It has no `lat` or `lon` to keep. */ + private fun valueAt( + path: String, + b: TodayFile.Bundle, + ): Any? { + val p = path.split(".") + fun blank(s: String?) = s?.takeIf { it.isNotEmpty() } + val fields: Map = + when (p.first()) { + "headlines" -> b.headlines.getOrNull(p[1].toInt())?.let { mapOf("t" to it.title, "u" to it.url, "src" to blank(it.source)) } + "markets" -> + b.markets.getOrNull(p[1].toInt())?.let { + mapOf("sym" to it.symbol, "name" to it.name, "last" to it.last, "chg" to blank(it.change), "pct" to blank(it.percent)) + } + "weather" -> b.weather.getOrNull(p[1].toInt())?.let { mapOf("city" to it.name, "cc" to blank(it.country), "tz" to blank(it.zone)) } + "site" -> b.site?.let { mapOf("t" to it.title, "u" to it.url, "d" to it.description) } + "post" -> b.post?.let { mapOf("t" to it.title, "u" to it.url, "d" to it.description, "by" to it.by) } + "notes" -> return b.notes.getOrNull(p[1].toInt()) + else -> null + } ?: return null + return fields[p.last()] + } + + /** Characters as a reader counts them, which is how the file states lengths. */ + private fun drawnLength(s: String): Int { + val breaks = java.text.BreakIterator.getCharacterInstance() + breaks.setText(s) + var n = 0 + while (breaks.next() != java.text.BreakIterator.DONE) n++ + return n + } + + private fun plain(value: Any?): Any? = + when (value) { + null, JSONObject.NULL -> null + // org.json reads a fraction as a BigDecimal: kept as a fraction, never cut to a whole number. + is java.math.BigDecimal -> if (value.stripTrailingZeros().scale() <= 0) value.toLong() else value.toDouble() + is Double -> if (value == Math.floor(value) && !value.isInfinite()) value.toLong() else value + is Number -> value.toLong() + else -> value + } + + /** Where this app's answer for one envelope differs from [want], in words. */ + private fun unlike( + envelope: ByteArray, + want: JSONObject, + ): List { + val verdict = want.getString("verdict") + return when (val got = TodayFile.verify(envelope, keys)) { + is TodayFile.Verdict.Refused -> { + if (verdict != "refuse") { + listOf("refused (${why(got.why)}), the file takes it") + } else if (why(got.why) != want.optString("why")) { + listOf("refused as ${why(got.why)}, the file says ${want.optString("why")}") + } else { + emptyList() + } + } + + is TodayFile.Verdict.Good -> { + if (verdict != "accept") return listOf("taken, the file refuses it (${want.optString("why")})") + val b = got.bundle + val e = want.optJSONObject("expect") ?: JSONObject() + val wrong = mutableListOf() + if (e.has("date") && e.getString("date") != b.date) wrong += "date ${b.date}" + if (e.has("made") && e.getLong("made") != b.made) wrong += "made ${b.made}" + val counts = mapOf("headlines" to b.headlines.size, "markets" to b.markets.size, "cities" to b.weather.size, "notes" to b.notes.size) + for ((key, n) in counts) if (e.has(key) && e.getInt(key) != n) wrong += "$key $n, not ${e.getInt(key)}" + e.optJSONArray("days")?.let { days -> + val expected = (0 until days.length()).map { days.getInt(it) } + val mine = expected.indices.map { b.weather.getOrNull(it)?.days?.size ?: -1 } + if (mine != expected) wrong += "days $mine, not $expected" + } + val present = mapOf("site" to (b.site != null), "post" to (b.post != null), "siteNote" to (b.siteNote != null)) + for ((key, has) in present) if (e.has(key) && e.getBoolean(key) != has) wrong += "$key $has" + e.optJSONObject("lengths")?.let { lengths -> + for (path in lengths.keys().asSequence().sorted()) { + val n = drawnLength(valueAt(path, b) as? String ?: "") + if (n != lengths.getInt(path)) wrong += "$path drawn $n long, not ${lengths.getInt(path)}" + } + } + e.optJSONObject("fields")?.let { fields -> + for (path in fields.keys().asSequence().sorted()) { + val mine = plain(valueAt(path, b)) + val theirs = plain(fields.get(path)) + if (mine != theirs) wrong += "$path is $mine, not $theirs" + } + } + wrong + } + } + } + + @Test + fun `the sample, every case, and the 512 KB edge, as the file says`() { + val wrong = mutableListOf() + val sample = file.getJSONObject("sample") + val sampleBytes = sample.getString("envelope").toByteArray(Charsets.UTF_8) + unlike(sampleBytes, sample).forEach { wrong += "the sample: $it" } + assertEquals(sample.getString("etag"), TodayFeed.etagOf(sampleBytes)) + + val cases = file.getJSONArray("cases") + assertEquals(33, cases.length()) + for (i in 0 until cases.length()) { + val one = cases.getJSONObject(i) + unlike(one.getString("envelope").toByteArray(Charsets.UTF_8), one).forEach { wrong += "case '${one.getString("name")}': $it" } + } + + val built = file.getJSONArray("built") + val text = sample.getString("envelope") + for (i in 0 until built.length()) { + val one = built.getJSONObject(i) + val pad = one.getInt("padTo") - sampleBytes.size - ",\"pad\":\"\"".length + val bytes = (text.dropLast(1) + ",\"pad\":\"" + "x".repeat(pad) + "\"}").toByteArray(Charsets.UTF_8) + assertEquals(one.getInt("padTo"), bytes.size) + unlike(bytes, one).forEach { wrong += "built '${one.getString("name")}': $it" } + } + println("today: the sample, ${cases.length()} cases and ${built.length()} built files, ${wrong.size} differences") + if (wrong.isNotEmpty()) fail(wrong.joinToString("\n")) + } + + /** Every named file in the vectors, by name. */ + private fun byName(): Map { + val out = mutableMapOf() + val sample = file.getJSONObject("sample") + out[sample.getString("name")] = sample.getString("envelope").toByteArray(Charsets.UTF_8) + val cases = file.getJSONArray("cases") + for (i in 0 until cases.length()) { + val one = cases.getJSONObject(i) + out[one.getString("name")] = one.getString("envelope").toByteArray(Charsets.UTF_8) + } + return out + } + + /** A feed over a relay in a box on this machine, with a clock the test moves. */ + private fun withFeed(block: (MockWebServer, TodayFeed, () -> Unit) -> T): T { + val server = MockWebServer().also { it.start() } + val dir = Files.createTempDirectory("today-vectors").toFile() + var clock = 1_800_000_000_000L + try { + val feed = TodayFeed(server.url("/").toString().trimEnd('/'), dir, keys, now = { clock }) + return block(server, feed) { clock += 2 * TodayFeed.INTERVAL_MS } + } finally { + server.shutdown() + dir.deleteRecursively() + } + } + + private fun MockWebServer.serve( + bytes: ByteArray, + etag: String, + ) = enqueue( + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setHeader("ETag", etag) + .setBody(okio.Buffer().write(bytes)), + ) + + @Test + fun `an older file never replaces a newer one, through the feed`() { + val named = byName() + val made = { name: String -> (TodayFile.verify(named.getValue(name), keys) as TodayFile.Verdict.Good).bundle.made } + val steps = file.getJSONArray("sequence") + assertEquals(2, steps.length()) + val wrong = mutableListOf() + for (i in 0 until steps.length()) { + val step = steps.getJSONObject(i) + val held = named.getValue(step.getString("held")) + val then = named.getValue(step.getString("then")) + val shown = step.getString("shown") + val got = + withFeed { server, feed, later -> + server.serve(held, TodayFeed.etagOf(held)) + feed.refresh() + later() + server.serve(then, TodayFeed.etagOf(then)) + feed.refresh() + assertEquals(2, server.requestCount) + feed.current().bundle?.made + } + if (got != made(shown)) wrong += "sequence '${step.getString("name")}': shows made $got, the file says '$shown' (made ${made(shown)})" + } + if (wrong.isNotEmpty()) fail(wrong.joinToString("\n")) + } + + @Test + fun `the stored tag goes back only when it is the hash of the copy held`() { + val named = byName() + val rules = file.getJSONArray("ifNoneMatch") + assertEquals(4, rules.length()) + val wrong = mutableListOf() + for (i in 0 until rules.length()) { + val rule = rules.getJSONObject(i) + val stored = rule.getString("stored") + val send = rule.getBoolean("send") + val sent = + withFeed { server, feed, _ -> + // The relay hands this tag over with a file the phone keeps, + // or, with no copy to hold, with one it refuses. + val held = if (rule.isNull("held")) null else named.getValue(rule.getString("held")) + server.serve(held ?: "{}".toByteArray(), stored) + feed.refresh() + feed.request().header("If-None-Match") + } + if ((sent == stored) != send || (sent != null && sent != stored)) { + wrong += "ifNoneMatch '${rule.getString("name")}': sends ${sent ?: "nothing"}, the file says send=$send" + } + } + if (wrong.isNotEmpty()) fail(wrong.joinToString("\n")) + } +}