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:
AskaEth committed 2026-10-04 19:41:40 +08:00
1 parent 8633ec9f06
commit 3b4a790e30
5 files changed
+736 -32

No files matched your search

@@ -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 })
}
}