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,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