Recognise an edit envelope so it is not drawn as a duplicate (§6.15)
Without this branch an edit falls through parse to the last case, which returns the caption as text — so an iPhone correcting a typo appears on Android as a SECOND bubble beside the original, with nothing to say either replaced the other. That is the bug this fixes today, before any of the applying half exists. isRenderable excludes it for the same reason it excludes a reaction, and it is NOT in Kind.CONTROL: a build that swallowed it as generic control would drop every correction silently, which is worse than the duplicate because nobody would notice. ⚠️ Codec only. Session.Line records no mid, so nothing in an Android transcript can be named by an edit and the app cannot apply one. That is the same gap reactions have been sitting behind (PARITY.md "codec done, no UI") and one transcript change unlocks replies, reactions and edits together — filed separately. Refs paulhitt/flamenet-messenger-ios#26 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
9a2ff570e9
commit
4b295c2d73
@@ -0,0 +1,151 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Replacing the wording of a message already sent (§6.15).
|
||||
*
|
||||
* 🔴 **Without this branch an edit renders as a duplicate message.** An unknown
|
||||
* kind with a caption and no attachment falls through to the last case in
|
||||
* `parse`, which returns the caption as text — so an iPhone correcting a typo
|
||||
* would appear on Android as a second bubble beside the original, with no
|
||||
* indication that either replaced the other. That is the bug this fixes today,
|
||||
* before any of the applying half exists.
|
||||
*
|
||||
* ⚠️ **This is the codec only.** `Session.Line` carries no `mid`, so nothing in
|
||||
* an Android transcript can be named by an edit and the app swallows them for
|
||||
* now — the same place reactions have been sitting (PARITY.md: "codec done, no
|
||||
* UI"). One transcript change unlocks replies, reactions and edits together.
|
||||
*/
|
||||
class EditEnvelopeTest {
|
||||
|
||||
@Test
|
||||
fun `an edit names the message it replaces and carries the new wording`() {
|
||||
val wire = Content.edit("meet at 9", of = "abc")
|
||||
val content = Content.parse(wire)
|
||||
|
||||
assertEquals("abc", content.editOf)
|
||||
assertEquals("meet at 9", content.text)
|
||||
assertEquals(Content.Kind.EDIT, content.kind)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 The bug this exists to prevent. Before the branch, an edit fell
|
||||
* through to the attachment path and came back as renderable text — a
|
||||
* second bubble the sender never sent.
|
||||
*/
|
||||
@Test
|
||||
fun `an edit is not a renderable message`() {
|
||||
val content = Content.parse(Content.edit("meet at 9", of = "abc"))
|
||||
assertNull(content.attachment, "an edit came back carrying an attachment")
|
||||
assertNull(content.reaction)
|
||||
assertNull(content.sticker)
|
||||
assertTrue(content.editOf != null,
|
||||
"an edit that is not recognisable as one will be drawn as a message")
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ It must not be swallowed as generic control traffic either. A build
|
||||
* that consumed it silently would look like it worked while dropping every
|
||||
* correction — which is worse than the duplicate bubble, because nobody
|
||||
* would notice.
|
||||
*/
|
||||
@Test
|
||||
fun `an edit is a known kind, not unknown control`() {
|
||||
assertTrue(Content.Kind.EDIT in Content.Kind.KNOWN)
|
||||
assertTrue(Content.Kind.EDIT !in Content.Kind.CONTROL,
|
||||
"an edit swallowed as control would drop corrections silently")
|
||||
}
|
||||
|
||||
/** Group routing survives, for when a group thread can name a message. */
|
||||
@Test
|
||||
fun `a group edit keeps its routing`() {
|
||||
val content = Content.parse(Content.edit("meet at 9", of = "abc", groupId = "g1"))
|
||||
assertEquals("g1", content.groupId)
|
||||
assertEquals("abc", content.editOf)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 `re.author` is a claim, not evidence — anyone can put any user id
|
||||
* there. The codec must not present it as authorship, and the encoder must
|
||||
* not fill it in with something that looks like one.
|
||||
*/
|
||||
@Test
|
||||
fun `the envelope makes no claim about who wrote the original`() {
|
||||
val json = JSONObject(Content.edit("meet at 9", of = "abc"))
|
||||
assertEquals(0L, json.getJSONObject("re").getLong("author") as Long,
|
||||
"the encoder asserted an author, which a receiver might believe")
|
||||
|
||||
// And a forged one parses identically, because nothing downstream reads it.
|
||||
val forged = Content.edit("meet at 9", of = "abc")
|
||||
.replace("\"author\":0", "\"author\":99")
|
||||
assertEquals("abc", Content.parse(forged).editOf)
|
||||
}
|
||||
|
||||
/** ⚠️ `fnc` first, like every other envelope — the receive side's marker
|
||||
* check matches the serialized prefix. */
|
||||
@Test
|
||||
fun `the envelope starts with the marker`() {
|
||||
assertTrue(Content.edit("hi", of = "abc").startsWith("{\"fnc\":1,"))
|
||||
}
|
||||
|
||||
/**
|
||||
* An edit with no target is not an edit. It must fall out of the branch
|
||||
* rather than become one naming nothing.
|
||||
*/
|
||||
@Test
|
||||
fun `an edit with no target is not treated as an edit`() {
|
||||
val malformed = """{"fnc":1,"kind":"edit","caption":"meet at 9"}"""
|
||||
assertNull(Content.parse(malformed).editOf)
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ Both clients must agree byte-for-byte on the shape, since one encodes
|
||||
* what the other parses. This is the shape iOS emits.
|
||||
*/
|
||||
@Test
|
||||
fun `the wire shape matches what iOS sends`() {
|
||||
val json = JSONObject(Content.edit("meet at 9", of = "abc"))
|
||||
assertEquals(1, json.getInt("fnc"))
|
||||
assertEquals("edit", json.getString("kind"))
|
||||
assertEquals("abc", json.getJSONObject("re").getString("mid"))
|
||||
assertEquals("meet at 9", json.getString("caption"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 The single decision that keeps an edit out of the transcript.
|
||||
*
|
||||
* `Session` filters inbound envelopes on `isRenderable`, so this property is
|
||||
* the whole of what stops a correction being filed as a new message. It is
|
||||
* tested apart from the codec because it is the part that would still be wrong
|
||||
* if the parse branch were right.
|
||||
*/
|
||||
class EditIsNotRenderableTest {
|
||||
|
||||
@Test
|
||||
fun `an edit is not filed as a message`() {
|
||||
val content = Content.parse(Content.edit("meet at 9", of = "abc"))
|
||||
assertTrue(!content.isRenderable,
|
||||
"a correction would be filed as a second bubble beside the original")
|
||||
}
|
||||
|
||||
/** ⚠️ And ordinary text still is, or this fix has broken conversation. */
|
||||
@Test
|
||||
fun `ordinary text is still a message`() {
|
||||
val content = Content.parse(Content.text("meet at 6"))
|
||||
assertTrue(content.isRenderable)
|
||||
}
|
||||
|
||||
/** A reaction and a call signal stay excluded for their own reasons. */
|
||||
@Test
|
||||
fun `the other non-messages are still excluded`() {
|
||||
val reaction = Content.parse(
|
||||
Content.reaction("👍", about = Content.Reference("abc", 7L, "")))
|
||||
assertTrue(!reaction.isRenderable)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user