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 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-10-04 15:30:57 -04:00
co-authored by Claude Opus 5.5
parent 067e6fcf51
commit 4aa0d0f237
3 changed files with 541 additions and 0 deletions
@@ -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<String, Any?> {
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<String, Any?>("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<String, Any?>("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<String, Any?> {
val out = mutableMapOf<String, Any?>("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<String>()
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<String>()
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"))
}
}
@@ -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<String>()
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"))
}
}
@@ -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<String, Any?> =
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<String> {
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<String>()
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<String>()
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<String, ByteArray> {
val out = mutableMapOf<String, ByteArray>()
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 <T> 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<String>()
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<String>()
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"))
}
}