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:
co-authored by
Claude Opus 5.5
parent
067e6fcf51
commit
4aa0d0f237
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user