Engine parity for the five features iOS regained: text stories, mentions, story reactions, story replies. This build could already receive them — legibly, because each degrades to its caption — but understood none of them as what they are. 🔴 Every envelope in the test is the shape **iOS** assembles, not this engine's own output. Two implementations that each only test against themselves agree right up to the day somebody sends a real message, and the symptom is a mention that never rings or a story that arrives as "[unsupported attachment]", with nothing in either log to say why. Two things the tests caught that reasoning would not have: ⚠️ Kotlin prints a Double in scientific notation, so this engine writes `"exp":1.8E9` where iOS writes `1800000000.0`. Both are valid JSON and both sides read either — but a story's expiry decides whether it shows at all, and "arrived already expired" is indistinguishable from "never arrived". Now pinned on both sides. Not a new risk: picture stories have carried `exp` this way all along, which is the reason it works. ⚠️ An earlier version of one test built a JSONObject from an assembled envelope and re-serialised it — which reordered the keys and moved `fnc` off the front. It failed, correctly, and is a neat demonstration of why both engines hand-assemble these strings instead of encoding a map. UI comes next; this is the layer that decides whether the two apps agree about what a message means. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
172 lines
6.7 KiB
Kotlin
172 lines
6.7 KiB
Kotlin
package io.flamenet.e2e
|
|
|
|
import org.json.JSONObject
|
|
import kotlin.test.Test
|
|
import kotlin.test.assertEquals
|
|
import kotlin.test.assertFalse
|
|
import kotlin.test.assertNotNull
|
|
import kotlin.test.assertTrue
|
|
|
|
/**
|
|
* Stories and mentions, against envelopes the **iOS** build produces.
|
|
*
|
|
* 🔴 Every string below is the shape iOS assembles, not this engine's own
|
|
* output. Two implementations that each only test against themselves agree
|
|
* right up to the day somebody sends a real message — and the symptom is a
|
|
* mention that never rings, or a story that arrives as "[unsupported
|
|
* attachment]", with nothing in either log to say why.
|
|
*/
|
|
class StoryAndMentionInteropTest {
|
|
|
|
// MARK: text stories
|
|
|
|
private val iosTextStory =
|
|
"""{"fnc":1,"kind":"story","mid":"story-1","caption":"only words","exp":1800000000.0}"""
|
|
|
|
@Test
|
|
fun `a text story from iOS parses as a story rather than an attachment`() {
|
|
val content = Content.parse(iosTextStory)
|
|
assertEquals("only words", content.text)
|
|
assertEquals(1800000000.0, content.textStoryExpires)
|
|
assertEquals(null, content.attachment)
|
|
assertEquals("story-1", content.messageId)
|
|
}
|
|
|
|
@Test
|
|
fun `this engine assembles a text story the same way`() {
|
|
val wire = Content.textStory("hello", 1800000000.0, "m-1")
|
|
// ⚠️ Asserted on the WIRE STRING, not on a JSONObject built from it.
|
|
// Round-tripping through JSONObject reorders the keys and puts `fnc`
|
|
// somewhere other than first — which is exactly the hazard that makes
|
|
// both engines hand-assemble these strings. An earlier version of this
|
|
// test did that and failed, correctly.
|
|
assertTrue(wire.startsWith("{\"fnc\":1"), wire)
|
|
assertTrue(wire.contains("\"kind\":\"story\""), wire)
|
|
assertTrue(wire.contains("\"caption\":\"hello\""), wire)
|
|
assertEquals("hello", Content.parse(wire).text)
|
|
}
|
|
|
|
/**
|
|
* ⚠️ Kotlin prints a Double in scientific notation, so this engine writes
|
|
* `"exp":1.8E9` where iOS writes `1800000000.0`. Both are valid JSON and
|
|
* both sides read either — but a story's expiry decides whether it shows
|
|
* at all, so the shape is pinned here rather than assumed. (This is not
|
|
* new: picture stories have carried `exp` this way all along.)
|
|
*/
|
|
@Test
|
|
fun `an expiry written in scientific notation reads back`() {
|
|
val wire = Content.textStory("x", 1800000000.0, "m")
|
|
assertTrue(wire.contains("\"exp\":1.8E9"), wire)
|
|
assertEquals(1800000000.0, Content.parse(wire).textStoryExpires)
|
|
}
|
|
|
|
/** 🔴 A picture story must be completely unaffected. */
|
|
@Test
|
|
fun `a picture story still parses as an attachment`() {
|
|
val ref = Attachment.Ref(
|
|
kind = Attachment.STORY, id = "a".repeat(64),
|
|
key = ByteArray(32) { 0x11 }, digest = ByteArray(32) { 0x22 },
|
|
bytes = 10, mime = "image/jpeg", width = 4, height = 3, expires = 1800000000.0,
|
|
)
|
|
val content = Content.parse(Content.attachment(ref, "look"))
|
|
assertNotNull(content.attachment)
|
|
assertEquals(null, content.textStoryExpires)
|
|
}
|
|
|
|
// MARK: mentions
|
|
|
|
@Test
|
|
fun `mentions from iOS arrive as ids`() {
|
|
val ios = """{"fnc":1,"kind":"text","caption":"morning @Ada","gid":"g1","men":[3,7]}"""
|
|
val content = Content.parse(ios)
|
|
assertEquals(listOf(3, 7), content.mentions)
|
|
assertEquals("g1", content.groupId)
|
|
}
|
|
|
|
@Test
|
|
fun `this engine emits mentions the same way`() {
|
|
val json = Content.groupText("hi @Ada", "g1", mentions = listOf(7, 3))
|
|
assertTrue(json.contains("\"men\":[3,7]"), json)
|
|
assertEquals(listOf(3, 7), Content.parse(json).mentions)
|
|
}
|
|
|
|
/** ⚠️ A message that merely spells a name carries no mention. */
|
|
@Test
|
|
fun `spelling a name is not a mention`() {
|
|
val json = Content.groupText("hello @Ada", "g1")
|
|
assertFalse(json.contains("\"men\""))
|
|
assertTrue(Content.parse(json).mentions.isEmpty())
|
|
}
|
|
|
|
// MARK: story reactions
|
|
|
|
@Test
|
|
fun `a story reaction from iOS is not mistaken for a message reaction`() {
|
|
val ios = """{"fnc":1,"kind":"react","caption":"❤️","re":{"mid":"s-1","author":42,"preview":""},"st":1}"""
|
|
val content = Content.parse(ios)
|
|
assertTrue(content.reactsToStory)
|
|
assertEquals("❤️", content.reaction)
|
|
assertEquals("s-1", content.about?.messageId)
|
|
}
|
|
|
|
/**
|
|
* 🔴 The separation that matters. Both are `kind: "react"`; without the
|
|
* flag a story reaction folds onto a message with that id — and if one
|
|
* ever matched, onto entirely the wrong thing.
|
|
*/
|
|
@Test
|
|
fun `an ordinary message reaction is not read as a story one`() {
|
|
val json = Content.reaction("👍", Content.Reference("m-1", 5, ""))
|
|
val content = Content.parse(json)
|
|
assertFalse(content.reactsToStory)
|
|
assertFalse(json.contains("\"st\""))
|
|
}
|
|
|
|
@Test
|
|
fun `this engine emits a story reaction the same way`() {
|
|
val json = Content.storyReaction("😂", "s-2", 9L)
|
|
assertTrue(json.contains("\"st\":1"), json)
|
|
val content = Content.parse(json)
|
|
assertTrue(content.reactsToStory)
|
|
assertEquals("😂", content.reaction)
|
|
}
|
|
|
|
// MARK: story replies
|
|
|
|
@Test
|
|
fun `a story reply from iOS keeps what it is about`() {
|
|
val ios = """{"fnc":1,"kind":"text","mid":"m-9","caption":"lovely","re":{"mid":"s-3","author":1,"preview":"at the beach"},"st":1}"""
|
|
val content = Content.parse(ios)
|
|
assertTrue(content.repliesToStory)
|
|
assertEquals("lovely", content.text)
|
|
assertEquals("at the beach", content.about?.preview)
|
|
}
|
|
|
|
@Test
|
|
fun `an ordinary reply is not a story reply`() {
|
|
val json = Content.text("sure", messageId = "m-1",
|
|
about = Content.Reference("other", 2, "earlier"))
|
|
assertFalse(Content.parse(json).repliesToStory)
|
|
}
|
|
|
|
@Test
|
|
fun `this engine emits a story reply the same way`() {
|
|
val json = Content.storyReply("nice", "s-4", 3L, "sunset", "m-2")
|
|
val content = Content.parse(json)
|
|
assertTrue(content.repliesToStory)
|
|
assertEquals("sunset", content.about?.preview)
|
|
assertEquals("nice", content.text)
|
|
}
|
|
|
|
/** 🔴 fnc stays first, whatever else was added. */
|
|
@Test
|
|
fun `every envelope this engine builds starts with fnc`() {
|
|
listOf(
|
|
Content.textStory("x", 1.0, "m"),
|
|
Content.storyReaction("👍", "s", 1L),
|
|
Content.storyReply("hi", "s", 1L, "p", "m"),
|
|
Content.groupText("hi", "g", mentions = listOf(1)),
|
|
).forEach { assertTrue(it.startsWith("{\"fnc\":1"), it) }
|
|
}
|
|
}
|