feat(ws): 队列满时定位并处置那条待发气泡(契约 v0.59 / 协同 G5)
WS 上行没有 REST 那样的同步回执:队列满时服务端只广播一条 pending_full,而载荷里既没有
「被拒的是哪一条」也没有任何可对齐的键,于是那条乐观气泡永远挂「发送中…」,用户不知道它
其实根本没发出去。后端 v0.59(G5)已经把这两件事补齐:pending_full 载荷新增两个可选键
clientMsgId(被拒那条消息的幂等键,未携带则整键缺席)与 messageId(仅当该消息此前已落库 /
已认领时才回填,服务端绝不在此新铸 id),并新增一条只发发送者的定向帧
error{code:"chat_busy"},与 REST 的 409 chat_busy 同码。本端把这两条接上。
data/ws/WsEvent
- PendingFull 增 clientMsgId / messageId:缺席就是 null,绝不拍成空串(否则收编路径会去
对齐一条不存在的消息)。
- Error 帧增 code / clientMsgId / messageId,并从 data.sessionId 取会话归属(定向帧的帧壳
上没有 sessionId)。code 是开放集合:未知码原样保留,不抛、也不吞掉整帧。
未给定向 error 另立事件类——它与既有 error 是同一个 type,一个 type 无法用两个类表达;
两者语义的分叉放在下面的判定函数里,保持「一个帧类型一个事件类」的既有约定。
- PendingFullData / ErrorData 同步扩键,全部可选且默认 null。
ui/chat/ChatViewModel
- 新增纯函数 queueFullOutcome(判定)与 ChatUiState.applyQueueFull / rollbackPending
(状态迁移),两者都不碰协程,逻辑不埋在协程里、可直接单测。既有 rollback 改为委托
rollbackPending,行为逐字不变。
- 判定顺序即证据强度:clientMsgId 命中时,messageId 非空 => 收编(复用 alignDuplicate:那条
其实早就落库了,不是失败、不回滚、不把正文/图片/附件退回输入栏),缺席 => 回滚那一条并把
内容还给输入栏;clientMsgId 有值但不命中 => 放手(键只属于某一次具体发送,回滚任何一条都是
误伤);clientMsgId 缺席(老服务端)=> 沿用 toolExecutionTargetIndex 的先例,只在待发恰好
一条时回滚它,两条及以上不猜、但也不装作没事,只如实提示一句。
- 广播 pending_full 与定向 error{chat_busy} 会同时到达发送者,两者共用 onQueueFull;
幂等靠状态本身——第一次已把气泡移出待发列表,第二次判定自然落到 Ignore,不会回滚两次、
也不会提示两遍。
- REST 409 chat_busy 的文案与 WS 侧合并成同一句(契约把两条入口定为同一个 code),
并点明「这条没发出去、请手动重试」:全链路没有自动重试。
测试 +30(634 -> 664,0 失败 / 0 错误)
- WsEventPendingFullTest:两个新键的有/无、老形状仍可解析、会话闸、无载荷与坏帧不抛。
- WsEventErrorFrameTest:chat_busy 定向帧全字段、可选键缺席为 null、未知 code 原样保留、
只带 code 的帧仍可解、只有 message 的老帧行为不变、坏帧不抛。
- QueueFullOutcomeTest:三种事实各自的判定与状态迁移(收编不动输入栏 / 回滚归还正文图片附件 /
不猜时一个气泡都不动),以及键对不上、空键、同一帧处理两次的幂等。
未验证:本端没有设备也没打过真后端,队列满的实机触发路径见交接报告;VM 里
「判定 -> 既有动作」的映射只由编译与纯函数单测覆盖(构造 ChatViewModel 需要 Android
环境的 AppContainer,单测里没有先例)。
This commit is contained in:
1 parent
8633ec9f06
commit
3b4a790e30
5 files changed
+736
-32
No files matched your search
@@ -0,0 +1,109 @@
|
||||
package top.yeij.cyrene.data.ws
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* 定向 `error` 帧(契约 v0.59 §3.2,协同 `G5` 的伴生帧)。
|
||||
*
|
||||
* 队列满时服务端**除广播 `pending_full` 外**,还向发送那条消息的连接单独回一条
|
||||
* `{type:"error", data:{code:"chat_busy", message, sessionId, clientMsgId?, messageId?}}`
|
||||
* ——它是 WS 路径上 REST `409 chat_busy` 的等价物(**同码**,客户端一个 code 覆盖两条入口)。
|
||||
*
|
||||
* 它**只发发送者、不广播**,因此帧壳上没有 `sessionId`(会话归属在 `data.sessionId` 里),
|
||||
* 也没有 `turnId` / `seq`。`code` 是**开放集合**:客户端不得对未知码抛异常、更不得因为
|
||||
* 认不出码就把整帧吞掉——帧里还有服务端给的文案,那是能直接给用户看的唯一东西。
|
||||
*/
|
||||
class WsEventErrorFrameTest {
|
||||
|
||||
/** 真实帧形状(服务端 `ws_route.go:notifyPendingFull`)。 */
|
||||
private val realBusyFrame = """
|
||||
{"type":"error","data":{"code":"chat_busy","message":"会话正忙,待处理消息已满,本条未入队","sessionId":"web","clientMsgId":"local-1","messageId":"m-42"}}
|
||||
""".trimIndent()
|
||||
|
||||
@Test
|
||||
fun `the directed chat_busy frame decodes with every field`() {
|
||||
val event = WsEvent.parse(realBusyFrame) as WsEvent.Error
|
||||
|
||||
assertEquals("error", event.type)
|
||||
assertEquals("chat_busy", event.code)
|
||||
assertEquals("会话正忙,待处理消息已满,本条未入队", event.message)
|
||||
// 会话归属**只在载荷里**:帧壳上没有 sessionId。
|
||||
assertEquals("web", event.sessionId)
|
||||
assertEquals("local-1", event.clientMsgId)
|
||||
assertEquals("m-42", event.messageId)
|
||||
assertNull(event.turnId)
|
||||
assertNull(event.seq)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the optional identity keys stay null when the server omits them`() {
|
||||
val frame = """{"type":"error","data":{"code":"chat_busy","message":"满","sessionId":"web"}}"""
|
||||
|
||||
val event = WsEvent.parse(frame) as WsEvent.Error
|
||||
|
||||
assertNull("未携带就该是 null,不拍成空串", event.clientMsgId)
|
||||
assertNull(event.messageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown code is kept verbatim instead of throwing or swallowing the frame`() {
|
||||
// 开放集合:服务端将来加码,老客户端**必须**还能把这句话显示出来。
|
||||
val event = WsEvent.parse(
|
||||
"""{"type":"error","data":{"code":"some_future_code","message":"以后才会有的话","sessionId":"web"}}""",
|
||||
) as WsEvent.Error
|
||||
|
||||
assertEquals("some_future_code", event.code)
|
||||
assertEquals("以后才会有的话", event.message)
|
||||
assertEquals("web", event.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a code-only error frame still decodes`() {
|
||||
// 契约里真实存在这种形状:interrupt 的 scope 非法时只回 {code:"bad_request"}。
|
||||
val event = WsEvent.parse("""{"type":"error","data":{"code":"bad_request"}}""") as WsEvent.Error
|
||||
|
||||
assertEquals("bad_request", event.code)
|
||||
assertEquals("", event.message)
|
||||
assertNull(event.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a message-only error frame keeps working exactly as before`() {
|
||||
// v0.59 之前的所有 error 帧都只有 message,这条通路一个字节都不该变。
|
||||
val event = WsEvent.parse("""{"type":"error","data":{"message":"voice unavailable"}}""") as WsEvent.Error
|
||||
|
||||
assertEquals("voice unavailable", event.message)
|
||||
assertNull(event.code)
|
||||
assertNull(event.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the shell sessionId wins over the payload one`() {
|
||||
val event = WsEvent.parse(
|
||||
"""{"type":"error","sessionId":"sess-9c3","data":{"code":"chat_busy","message":"满","sessionId":"web"}}""",
|
||||
) as WsEvent.Error
|
||||
|
||||
assertEquals("sess-9c3", event.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a broken error payload is ignored and never throws`() {
|
||||
assertNull(WsEvent.parse("""{"type":"error","data":"boom"}"""))
|
||||
assertNull(WsEvent.parse("""{"type":"error","data":{"code":42}}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an error carrying a sessionId is scoped to it`() {
|
||||
val event = WsEvent.parse(realBusyFrame)!!
|
||||
val sessionless = WsEvent.parse("""{"type":"error","data":{"message":"boom"}}""")!!
|
||||
|
||||
assertTrue(event.isForSession("web"))
|
||||
assertFalse(event.isForSession("web:other"))
|
||||
// 帧里没给会话时是「无法判定」而不是「不属于本会话」,仍放行(旧服务端 / 无归属的帧)。
|
||||
assertTrue(sessionless.isForSession("web"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package top.yeij.cyrene.data.ws
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* 下行帧 `pending_full`(契约 v0.59 / 协同 `G5`)。
|
||||
*
|
||||
* 载荷在既有的 `{sessionId, mode, max}` 上**纯增量**加了两个可选键:`clientMsgId`(被拒那条
|
||||
* 消息的幂等键)与 `messageId`(仅当它此前已落库 / 已认领)。判定这条气泡该收编还是该回滚
|
||||
* 全靠这两个键,所以「有没有键」必须能被分辨——服务端**整键缺席**,客户端也就必须区分
|
||||
* 「键不在」与「键是空串」,而不是把两者拍成同一个值。
|
||||
*/
|
||||
class WsEventPendingFullTest {
|
||||
|
||||
/** 真实帧形状(服务端 `session_reject.go:pendingFullPayload`)。 */
|
||||
private val realFrame = """
|
||||
{"type":"pending_full","sessionId":"web","data":{"sessionId":"web","mode":"thinking","max":5,"clientMsgId":"local-1","messageId":"m-42"}}
|
||||
""".trimIndent()
|
||||
|
||||
/** 队列满但那条消息**还没被认领**:只有幂等键,没有 messageId。 */
|
||||
private val unclaimedFrame = """
|
||||
{"type":"pending_full","data":{"sessionId":"web","mode":"executing","max":5,"clientMsgId":"local-2"}}
|
||||
""".trimIndent()
|
||||
|
||||
/** 老形状(v0.59 之前):两个新键一个都不在。 */
|
||||
private val legacyFrame = """
|
||||
{"type":"pending_full","sessionId":"web","data":{"sessionId":"web","mode":"thinking","max":5}}
|
||||
""".trimIndent()
|
||||
|
||||
@Test
|
||||
fun `the v059 frame carries both identity keys`() {
|
||||
val event = WsEvent.parse(realFrame) as WsEvent.PendingFull
|
||||
|
||||
assertEquals("pending_full", event.type)
|
||||
assertEquals("web", event.sessionId)
|
||||
assertEquals("thinking", event.mode)
|
||||
assertEquals(5, event.max)
|
||||
assertEquals("local-1", event.clientMsgId)
|
||||
assertEquals("m-42", event.messageId)
|
||||
// 状态机信号不认识轮次:帧壳没有这两个字段。
|
||||
assertNull(event.turnId)
|
||||
assertNull(event.seq)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a missing messageId stays null and is never fabricated`() {
|
||||
val event = WsEvent.parse(unclaimedFrame) as WsEvent.PendingFull
|
||||
|
||||
assertEquals("local-2", event.clientMsgId)
|
||||
// 缺席就是 null(不是空串):「服务端没给 id」与「服务端给了个空 id」是两件事,
|
||||
// 后者若是被拍成空串,收编路径就会去对齐一条不存在的消息。
|
||||
assertNull(event.messageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the legacy shape still parses with both keys absent`() {
|
||||
// 老服务端(或本端自己发的帧根本没带幂等键)→ 两个键都缺席。
|
||||
// 判定这一档的规则见 queueFullOutcome:只在无歧义时才落位。
|
||||
val event = WsEvent.parse(legacyFrame) as WsEvent.PendingFull
|
||||
|
||||
assertEquals("web", event.sessionId)
|
||||
assertEquals(5, event.max)
|
||||
assertNull(event.clientMsgId)
|
||||
assertNull(event.messageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the shell sessionId wins and the payload sessionId fills in`() {
|
||||
val fromShell = WsEvent.parse(
|
||||
"""{"type":"pending_full","sessionId":"web:abc","data":{"mode":"thinking","max":5}}""",
|
||||
) as WsEvent.PendingFull
|
||||
val fromPayload = WsEvent.parse(
|
||||
"""{"type":"pending_full","data":{"sessionId":"web:xyz","mode":"thinking","max":5}}""",
|
||||
) as WsEvent.PendingFull
|
||||
|
||||
assertEquals("web:abc", fromShell.sessionId)
|
||||
assertEquals("web:xyz", fromPayload.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pending_full is scoped to its own session`() {
|
||||
val event = WsEvent.parse(realFrame)!!
|
||||
|
||||
assertTrue(event.isForSession("web"))
|
||||
// 广播是发给每条连接的、不按会话订阅:别的会话的队列满绝不能去动本会话的气泡。
|
||||
assertFalse(event.isForSession("web:other"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pending_full without any payload still parses instead of being dropped`() {
|
||||
val event = WsEvent.parse("""{"type":"pending_full","sessionId":"web"}""") as WsEvent.PendingFull
|
||||
|
||||
assertEquals("web", event.sessionId)
|
||||
assertEquals("", event.mode)
|
||||
assertEquals(0, event.max)
|
||||
assertNull(event.clientMsgId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a structurally broken pending_full is ignored and never throws`() {
|
||||
// `data` 不是对象 ⇒ 解码失败 ⇒ 整帧按坏帧安全忽略(与未知 type 同一条出口)。
|
||||
assertNull(WsEvent.parse("""{"type":"pending_full","sessionId":"web","data":"oops"}"""))
|
||||
assertNull(WsEvent.parse("""{"type":"pending_full","sessionId":"web","data":{"max":"五"}}"""))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.domain.chat.DraftDocument
|
||||
|
||||
/**
|
||||
* 队列满的判定与状态迁移(契约 v0.59 / 协同 `G5`)。
|
||||
*
|
||||
* 这条链路此前是「那条气泡永远挂发送中…」:WS 上行没有同步回执,队列满时服务端只广播一条
|
||||
* `pending_full`(载荷连是哪条都没说),客户端既无法回滚也无法收编。[queueFullOutcome] 是判定
|
||||
* 本体(纯函数),[ChatUiState.applyQueueFull] 把它落到状态上——两者都不碰协程,因此可以在这里
|
||||
* 逐条钉死「什么时候动哪条气泡、什么时候一个都不许动」。
|
||||
*
|
||||
* 三个档位对应三种**不同的事实**,测试也按事实分组:收编(其实已经进去了)、回滚(确实被拒)、
|
||||
* 不猜(说不准,只能如实说一句)。
|
||||
*/
|
||||
class QueueFullOutcomeTest {
|
||||
|
||||
private fun pending(
|
||||
localId: String,
|
||||
text: String = "你好",
|
||||
imageUri: String? = null,
|
||||
documents: List<DraftDocument> = emptyList(),
|
||||
) = PendingMessage(localId = localId, text = text, imageUri = imageUri, documents = documents)
|
||||
|
||||
private val draft = DraftDocument(uri = "content://doc", displayName = "笔记.md")
|
||||
|
||||
// —— 收编:messageId 非空 = 那条其实早已落库 / 已认领 ——
|
||||
|
||||
@Test
|
||||
fun `a non-empty messageId adopts the bubble instead of rolling it back`() {
|
||||
val pending = listOf(pending("local-a"), pending("local-b"))
|
||||
|
||||
val outcome = queueFullOutcome(pending, clientMsgId = "local-b", messageId = "m-42")
|
||||
|
||||
// 重发撞上满队列:服务端只是拒绝了**第二次**,第一次的内容早就在了 ⇒ 收编,不是失败。
|
||||
assertEquals(QueueFullOutcome.Adopt("local-b", "m-42"), outcome)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adopting leaves the draft and the error untouched`() {
|
||||
val state = ChatUiState(
|
||||
pending = listOf(pending("local-a", text = "这条", imageUri = "content://img", documents = listOf(draft))),
|
||||
draft = "用户正在输入的下一条",
|
||||
draftImage = null,
|
||||
draftDocuments = emptyList(),
|
||||
error = null,
|
||||
)
|
||||
|
||||
val after = state.applyQueueFull(
|
||||
queueFullOutcome(state.pending, clientMsgId = "local-a", messageId = "m-1"),
|
||||
)
|
||||
|
||||
// 关键:收编**不能**把正文 / 图片 / 附件退回输入栏——那等于诱导用户把一条**已经发出去**
|
||||
// 的消息再发一遍(而且会覆盖他正在输入的内容)。
|
||||
assertTrue(after.pending.isEmpty())
|
||||
assertEquals("用户正在输入的下一条", after.draft)
|
||||
assertNull(after.draftImage)
|
||||
assertTrue(after.draftDocuments.isEmpty())
|
||||
assertNull(after.error)
|
||||
}
|
||||
|
||||
// —— 回滚:messageId 缺席 = 拒绝发生在幂等键认领之前 ——
|
||||
|
||||
@Test
|
||||
fun `a missing messageId rolls the identified bubble back`() {
|
||||
val pending = listOf(pending("local-a"), pending("local-b"))
|
||||
|
||||
val outcome = queueFullOutcome(pending, clientMsgId = "local-a", messageId = null)
|
||||
|
||||
assertEquals(QueueFullOutcome.Rollback("local-a", QUEUE_FULL_MESSAGE), outcome)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rolling back returns text image and documents to the composer`() {
|
||||
val state = ChatUiState(
|
||||
pending = listOf(pending("local-a", text = "被拒的正文", imageUri = "content://img", documents = listOf(draft))),
|
||||
draft = "",
|
||||
draftImage = null,
|
||||
draftDocuments = emptyList(),
|
||||
)
|
||||
|
||||
val after = state.applyQueueFull(
|
||||
queueFullOutcome(state.pending, clientMsgId = "local-a", messageId = null),
|
||||
)
|
||||
|
||||
assertTrue(after.pending.isEmpty())
|
||||
assertEquals("被拒的正文", after.draft)
|
||||
assertEquals("content://img", after.draftImage)
|
||||
assertEquals(listOf(draft), after.draftDocuments)
|
||||
assertEquals(QUEUE_FULL_MESSAGE, after.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rolling back a pure-image bubble does not clobber what the user is typing`() {
|
||||
val state = ChatUiState(
|
||||
pending = listOf(pending("local-a", text = "", imageUri = "content://img")),
|
||||
draft = "正在输入",
|
||||
)
|
||||
|
||||
val after = state.applyQueueFull(
|
||||
queueFullOutcome(state.pending, clientMsgId = "local-a", messageId = null),
|
||||
)
|
||||
|
||||
assertEquals("正在输入", after.draft)
|
||||
assertEquals("content://img", after.draftImage)
|
||||
assertEquals(QUEUE_FULL_MESSAGE, after.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rolling back a bubble that is already gone changes nothing but the reason`() {
|
||||
// 同一件事会被两条帧各报一次(广播 pending_full + 定向 error{chat_busy}),
|
||||
// 第二次到达时气泡早被摘走了:这时绝不能把输入栏再覆盖一遍。
|
||||
val state = ChatUiState(pending = emptyList(), draft = "用户后来打的字", error = null)
|
||||
|
||||
val after = state.applyQueueFull(
|
||||
queueFullOutcome(state.pending, clientMsgId = "local-a", messageId = null),
|
||||
)
|
||||
|
||||
// 待发列表本来就是空的 ⇒ 判定落到「无歧义才落位」那一档:没有待发消息 ⇒ 静默。
|
||||
assertSame(state, after)
|
||||
}
|
||||
|
||||
// —— 老路径:clientMsgId 缺席,只在无歧义时才落位 ——
|
||||
|
||||
@Test
|
||||
fun `an absent clientMsgId rolls back the only pending bubble`() {
|
||||
val pending = listOf(pending("local-a"))
|
||||
|
||||
val outcome = queueFullOutcome(pending, clientMsgId = null, messageId = null)
|
||||
|
||||
// 本会话只有这一条在飞,不可能是别的 ⇒ 无歧义。
|
||||
assertEquals(QueueFullOutcome.Rollback("local-a", QUEUE_FULL_MESSAGE), outcome)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an absent clientMsgId with several pending bubbles guesses nothing`() {
|
||||
val pending = listOf(pending("local-a"), pending("local-b"))
|
||||
|
||||
val outcome = queueFullOutcome(pending, clientMsgId = null, messageId = null)
|
||||
|
||||
// 挑一条回滚等于替用户瞎猜(被拒的可能是别的端发的那条)⇒ 不猜,但也不装作没事。
|
||||
assertEquals(QueueFullOutcome.Ambiguous(QUEUE_FULL_UNKNOWN_MESSAGE), outcome)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the ambiguous verdict moves no bubble at all`() {
|
||||
val state = ChatUiState(
|
||||
pending = listOf(pending("local-a", text = "甲"), pending("local-b", text = "乙")),
|
||||
draft = "不动的草稿",
|
||||
error = null,
|
||||
)
|
||||
|
||||
val after = state.applyQueueFull(queueFullOutcome(state.pending, null, null))
|
||||
|
||||
assertEquals(listOf("local-a", "local-b"), after.pending.map { it.localId })
|
||||
assertEquals("不动的草稿", after.draft)
|
||||
assertEquals(QUEUE_FULL_UNKNOWN_MESSAGE, after.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an absent clientMsgId with nothing pending says nothing`() {
|
||||
// 没有气泡会挂住,也就没有要说的:不能凭空弹一句提示。
|
||||
val state = ChatUiState()
|
||||
|
||||
assertSame(state, state.applyQueueFull(queueFullOutcome(emptyList(), null, null)))
|
||||
assertEquals(QueueFullOutcome.Ignore, queueFullOutcome(emptyList(), null, null))
|
||||
}
|
||||
|
||||
// —— 键对不上就放手 ——
|
||||
|
||||
@Test
|
||||
fun `an unknown clientMsgId never falls back to text matching`() {
|
||||
val pending = listOf(pending("local-a", text = "同文本"))
|
||||
|
||||
val outcome = queueFullOutcome(pending, clientMsgId = "local-other", messageId = null)
|
||||
|
||||
// 带键的帧只属于**某一次具体的发送**(别的端发的、或本端那条已被收编)。
|
||||
// 回滚任何一条都不是「对齐」,而是误伤。
|
||||
assertEquals(QueueFullOutcome.Ignore, outcome)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handling the same frame twice does not roll back twice`() {
|
||||
val state = ChatUiState(pending = listOf(pending("local-a", text = "只发一次")), draft = "", error = null)
|
||||
|
||||
val first = state.applyQueueFull(queueFullOutcome(state.pending, "local-a", null))
|
||||
// 广播帧与定向帧都会到发送者手上;第二次判定必须落到 Ignore(气泡已经不在待发列表里)。
|
||||
val outcome2 = queueFullOutcome(first.pending, "local-a", null)
|
||||
val second = first.applyQueueFull(outcome2)
|
||||
|
||||
assertEquals(QueueFullOutcome.Ignore, outcome2)
|
||||
assertEquals("只发一次", second.draft)
|
||||
assertEquals(QUEUE_FULL_MESSAGE, second.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank key counts as absent on both sides`() {
|
||||
val pending = listOf(pending("local-a"))
|
||||
|
||||
// 服务端是「整键缺席」,但真给空串时按同一件事处理(与 consumePendingFor 一致)。
|
||||
assertEquals(
|
||||
QueueFullOutcome.Rollback("local-a", QUEUE_FULL_MESSAGE),
|
||||
queueFullOutcome(pending, clientMsgId = " ", messageId = ""),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank messageId is not an adoption`() {
|
||||
val pending = listOf(pending("local-a"))
|
||||
|
||||
// 空串不是 id:必须按「没有被认领」处理,否则会去对齐一条不存在的消息。
|
||||
assertEquals(
|
||||
QueueFullOutcome.Rollback("local-a", QUEUE_FULL_MESSAGE),
|
||||
queueFullOutcome(pending, clientMsgId = "local-a", messageId = " "),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no verdict mutates the pending input`() {
|
||||
val pending = listOf(pending("local-a"), pending("local-b"))
|
||||
|
||||
queueFullOutcome(pending, clientMsgId = "local-a", messageId = "m-1")
|
||||
queueFullOutcome(pending, clientMsgId = null, messageId = null)
|
||||
queueFullOutcome(pending, clientMsgId = "local-z", messageId = null)
|
||||
|
||||
assertEquals(listOf("local-a", "local-b"), pending.map { it.localId })
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user