fix(chat): 忙时被拒那条横幅调柔和(只它,真错误照旧红)+ 补 pending_ack 的并行降级告知

一、忙时被拒的横幅改用柔和档颜色(它不是错误,不该刺眼)
改了什么:那句告知(她正忙且队列已满,这条没发出去,请稍后手动重试)一直走 state.error 这一条通道,
ChatScreen 里那条 Banner 的调用点对所有 error 一律用 colorScheme.error(正红)。现在给 ChatUiState
加一位 errorIsQueueFullNotice(只决定配色),忙时被拒的两条入口(WS 广播 pending_full 与定向
error{chat_busy} 的队列满处置、REST 409 chat_busy 的回滚)把这一位写成 true,ChatScreen 那一处调用
据此在 onSurfaceVariantSummary(同文件里分享/附件/定位三条告知用的那档柔和色)与 error 之间选色。
为什么:主人裁决形态不变(摘乐观气泡 + 顶部横幅如实告知 + 正文/图片/附件还给输入栏 + 重发复用幂等键),
但红色不要太醒目——忙时被拒不是坏了,是真没排上,红得像故障会吓人。而它与真错误(登录失效、网络失败、
服务端 4xx、图片与附件处理失败……)共用 error 这同一条通道,直接改那个调用点的颜色会把真错误一并调柔和,
所以先分档再选色:写 error 的每一处都显式带着这一位,边界写进了 ChatUiState.errorIsQueueFullNotice
与 Banner 的 KDoc,免得后来人统一改色。

二、消费 pending_ack 的 reason,如实告知「并行没排上、已按普通排队走」
改了什么:reason 字段在解析层早就原样保留(WsEvent.kt 的 PendingAck.reason),但一直没有任何地方消费它
——收到它时界面与普通排队毫无区别。新增纯函数 parallelLimitNoticeFor(reason)(已知值给一句话,未知值 /
缺席 / 空白给 null,不为不认识的 reason 编文案)、常量 PARALLEL_LIMIT_REASON 与 PARALLEL_LIMIT_NOTICE,
以及状态位 ChatUiState.parallelLimitNotice(与 shareNotice / attachmentNotice 同一套机制:8 秒自动收走、
用柔和色、不给关闭按钮),在 WS 的 PendingAck 分支里接上。
为什么:设置页新增并行档之后,开关 ON 不等于一定起第二路——服务端 CHAT_MAX_PARALLEL_TURNS 小于 2 或
额度已满时会让它落回排队,并回 pending_ack{reason:"parallel_limit"}。用户选了并行却看不出区别就是静默,
违反项目铁律「绝不静默」;而这条不是错误、是降级,所以措辞把三件事说清(并行没排上、这条没丢、她做完
手上这句就轮到它),配色与上面那条忙时告知同一档。

测试:新增 ParallelLimitNoticeTest 8 条(reason 的三档穷举 + 横幅温度的边界:忙时被拒为柔和、真错误不
继承那份柔和)。assembleDebug 与 testDebugUnitTest 全绿:1030 个测试,0 失败 0 错误(基线 1022,新增 8)。
This commit is contained in:
AskaEth committed 2026-10-05 09:55:53 +08:00
1 parent bb4bb583d5
commit e85209eebb
3 files changed
+229 -14

No files matched your search

@@ -0,0 +1,91 @@
package top.yeij.cyrene.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 「并行没排上」的如实告知与顶部横幅的**两档配色**(契约 v0.65 §2.6)。
*
* 两件事各测各的:
*
* 1. [parallelLimitNoticeFor] 的判据(`pending_ack.reason` ⇒ 文案或 `null`):已知值一句话、
* 未知值**不编**、缺席与空白没话说。开关 ON ≠ 一定起第二路,这一句就是那个差别的全部告知。
* 2. [ChatUiState.errorIsQueueFullNotice] 那条**边界**:忙时被拒(降级告知)为 `true`,
* 真错误为 `false`。形状仍是既有的顶部横幅,变的只是颜色——所以判据必须可测,且必须
* 只对忙时被拒那一档成立。配色本身(哪一档用哪个色)在 `ChatScreen` 里,不进单测。
*/
class ParallelLimitNoticeTest {
private fun pending(localId: String) = PendingMessage(localId = localId, text = "这条")
// —— 一、reason ⇒ 文案(纯函数,穷举三档) ——
@Test
fun `the known reason says parallel did not fit and the message queued normally`() {
assertEquals(PARALLEL_LIMIT_NOTICE, parallelLimitNoticeFor("parallel_limit"))
}
@Test
fun `the known reason is tolerant of surrounding whitespace`() {
// 机器码两侧的空格只可能是传输噪声:认它比因为一个空格而**静默**更安全。
assertEquals(PARALLEL_LIMIT_NOTICE, parallelLimitNoticeFor(" parallel_limit "))
}
@Test
fun `an unknown reason invents nothing`() {
// 开放集合:将来服务端可能再加取值。不为不认识的 reason 编文案——说不出的话就不说,
// 而「这条在排队」仍由既有的排队态与队列深度如实呈现(reason 本身也没被丢掉)。
assertNull(parallelLimitNoticeFor("some_future_reason"))
assertNull(parallelLimitNoticeFor("PARALLEL_LIMIT"))
}
@Test
fun `an absent or blank reason has nothing to say`() {
assertNull(parallelLimitNoticeFor(null))
assertNull(parallelLimitNoticeFor(""))
assertNull(parallelLimitNoticeFor(" "))
}
// —— 二、横幅温度:降级告知柔和、真错误照旧红 ——
@Test
fun `a queue-full rejection is marked as a degradation notice`() {
val state = ChatUiState(pending = listOf(pending("local-a")))
val after = state.applyQueueFull(QueueFullOutcome.Rollback("local-a", QUEUE_FULL_MESSAGE))
assertEquals(QUEUE_FULL_MESSAGE, after.error)
assertTrue(after.errorIsQueueFullNotice)
}
@Test
fun `an unlocatable queue-full rejection is marked as a degradation notice too`() {
val state = ChatUiState(pending = listOf(pending("local-a"), pending("local-b")))
val after = state.applyQueueFull(QueueFullOutcome.Ambiguous(QUEUE_FULL_UNKNOWN_MESSAGE))
assertEquals(QUEUE_FULL_UNKNOWN_MESSAGE, after.error)
assertTrue(after.errorIsQueueFullNotice)
}
@Test
fun `a genuine error stays red even right after a degradation notice`() {
// 边界:柔和那份**不继承**。一条接着忙时被拒之后的真错误必须照旧红色。
val afterNotice = ChatUiState(pending = listOf(pending("local-a")))
.applyQueueFull(QueueFullOutcome.Rollback("local-a", QUEUE_FULL_MESSAGE))
val afterError = afterNotice
.applyMessageActionFailure(MessageActionKind.Edit, "m-1", "登录状态已失效,请重新登录后再编辑")
assertEquals("登录状态已失效,请重新登录后再编辑", afterError.error)
assertFalse(afterError.errorIsQueueFullNotice)
}
@Test
fun `a fresh state has no degradation marker`() {
assertFalse(ChatUiState().errorIsQueueFullNotice)
}
}