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