W33:并行落回排队的告知加一道本端档位闸 + 忙时被拒那条横幅的只读查证

一、改了什么
加一道闸:pending_ack{reason:"parallel_limit"} 那句降级告知(「她正忙,这条没能另起
一路…」),只在本端当前的「她忙时发消息」档位确实处于「并行」档(InterruptMode.Parallel)
时才给。判据扩了既有纯函数 parallelLimitNoticeFor 的签名(reason + mode 一起判),
本端不在并行档时一律返回 null;调用点在 onEvent 的 PendingAck 分支,档位沿用
currentInterruptMode() 现读(偏好是用户在设置页当场切的东西,缓存住就留一个静默不生效
的窗口),因为 onEvent 不是 suspend 函数,这一句起一个协程去读(与 ensureMessageKnown
同一形态),不起常驻订阅。

二、为什么
pending_ack 是广播帧,每条已认证连接都收得到。极端情况下「别的端在同一会话声明并行
又落回排队、而本端并未选并行」时,本端也会收到这一帧并说那句话 —— 这就是误报。
可那件事与本端无关:用户没选并行时,他这条消息本来就是普通排队、他预期的就是排队
⇒ 落回排队不是意外 ⇒ 不说并不构成静默(与「绝不静默」不冲突)。反过来,在那个时刻
说一句「这条没能另起一路」会让用户以为是自己刚发的那条出了岔子;误报会教用户不信任
这条提示,代价比少说一句大。

三、只读查证(主人点名:忙时被拒那条横幅到底会不会出现)
结论:不缺,pending_full 不带 clientMsgId 时共三档落位,其中两档都写 error,横幅照常
渲染 ——
  · 恰好一条待发气泡 ⇒ 回滚档,error = QUEUE_FULL_MESSAGE(她正忙且队列已满,这条没
    发出去,请稍后手动重试);
  · 两条及以上 ⇒ 说不准档,error = QUEUE_FULL_UNKNOWN_MESSAGE(她忙不过来,这条可能
    没发出去),两个气泡一个都不动;
  · 两档都带 errorIsQueueFullNotice = true(柔和档配色),横幅由 ChatScreen 同一条
    error 通道渲染。
只有「零条待发气泡」那一档是 Ignore,按设计什么都不写:没有气泡会挂住,也就没有要说
的,不能凭空弹一句 —— 理由在 queueFullOutcome 的 KDoc 与 QueueFullOutcomeTest 的两条
测试里(既有,非本次新增)。即「没带 clientMsgId」不等于「说不准档」,本次查证没有
发现需要改代码的地方。

四、测试
新增 3 条(ParallelLimitNoticeTest):追加队列档 + 该 reason ⇒ 不提示;打断档 + 该
reason ⇒ 不提示;再加一条把三档一起摆出来的汇总断言(只有并行档给文案)。「并行档 +
该 reason ⇒ 提示」由既有的那条测试覆盖(现在显式传 InterruptMode.Parallel)。总测试
1030 → 1033,0 失败 0 错误。
This commit is contained in:
AskaEth committed 2026-10-05 10:12:41 +08:00
1 parent 79fcd803a0
commit f2fbdff9a7
2 files changed
+66 -14

No files matched your search

@@ -157,6 +157,9 @@ const val PARALLEL_LIMIT_REASON = "parallel_limit"
* 措辞刻意不吓人:这条消息服务端**收下了**、也照常会回,只是没能另起一路。
* 三件事都要说清——(1) 没排上并行、(2) 这条并没丢(落回普通排队)、(3) 什么时候轮到它
* (前面那句做完就轮到)。少了第 (2)(3) 句,用户会把「降级」读成「失败」。
*
* 本端**不在「并行」档**时这一句根本不出现(判据见 [parallelLimitNoticeFor])——那不是静默,
* 理由写在那边的判据表里。
*/
const val PARALLEL_LIMIT_NOTICE = "她正忙,这条没能另起一路,已按普通排队排上;她做完手上这句就轮到它"
@@ -164,17 +167,30 @@ const val PARALLEL_LIMIT_NOTICE = "她正忙,这条没能另起一路,已按
* `pending_ack.reason` ⇒ 该对用户说的那句降级告知(**纯函数**,`null` = 没什么要说的)。
*
* ## 判据(穷举)
* - [PARALLEL_LIMIT_REASON] ⇒ [PARALLEL_LIMIT_NOTICE]:声明了并行但服务端额度已满,落回排队。
* - [PARALLEL_LIMIT_REASON] **且本端当前就在** [InterruptMode.Parallel] 档 ⇒ [PARALLEL_LIMIT_NOTICE]:
* 本端声明了并行、服务端额度已满,这一条落回排队。
* - [PARALLEL_LIMIT_REASON] 但本端**不在**并行档 ⇒ `null`。`pending_ack` 是**广播帧**(每条已
* 认证连接都收得到),别的端在同一会话声明并行又落回排队时本端**也会**收到这一帧——可那件事
* 与本端无关:用户没选并行时,他这条消息**本来就是**普通排队、他预期的就是排队 ⇒
* 落回排队**不是意外** ⇒ 不说**并不构成静默**。反过来,在那个时刻说一句「这条没能另起一路」
* 会让用户以为是自己刚发的那条出了岔子;**误报会教用户不信任这条提示**,代价比少说一句大。
* - 其它任何字符串 ⇒ `null`:**开放集合**(服务端明写「已知取值 `"parallel_limit"`」,将来可能
* 再加),本端**不为不认识的 reason 编文案**——说不出的话就不说。这不是静默:
* `reason` 本身在解析层**原样保留**(`WsEvent.PendingAck.reason`,未知值不丢也不改写),
* 而「这条在排队」这件事照旧由既有的排队态与队列深度如实呈现。
* - `null` / 空白 ⇒ `null`:「整键缺席」= 服务端没给理由(绝大部分情况),本来就没话说。
*
* 档位由调用方**现读**了喂进来(`ChatViewModel.currentInterruptMode` 那条纪律:偏好是用户在设置页
* 当场切的东西,缓存住就留一个静默不生效的窗口);本函数只做判断,不读偏好、不碰协程。
*
* 不做 trim 之外的归一:reason 是**机器可读码**,大小写与拼写都以服务端为准,本端不猜同义词。
*/
fun parallelLimitNoticeFor(reason: String?): String? =
if (reason?.trim() == PARALLEL_LIMIT_REASON) PARALLEL_LIMIT_NOTICE else null
fun parallelLimitNoticeFor(reason: String?, mode: InterruptMode): String? =
if (mode == InterruptMode.Parallel && reason?.trim() == PARALLEL_LIMIT_REASON) {
PARALLEL_LIMIT_NOTICE
} else {
null
}
/**
* 一帧「队列满」该对本地乐观气泡做什么(纯函数的结果,见 [queueFullOutcome])。
@@ -1120,6 +1136,9 @@ class ChatViewModel(
*
* 它描述的是**刚刚发生的那一次发送**([WsEvent.PendingAck] 到达的那一刻),所以到点自己收走;
* 它**不是错误**,不给关闭按钮、也不进 `error` 通道。
*
* 要不要说的判据不在这里,在 [parallelLimitNoticeFor]:本端不在「并行」档时那一句根本不出现
* (这一帧是广播,落在别的端上的并行降级与本端无关)。
*/
private fun showParallelLimitNotice(text: String) {
_state.update { it.copy(parallelLimitNotice = text) }
@@ -1852,7 +1871,16 @@ class ChatViewModel(
// 让它落回排队,界面上与普通排队**毫无区别**。用户选了「并行」却看不出区别就是
// 静默,所以这里如实说一句。判据与文案都是纯函数 [parallelLimitNoticeFor]
// (未知 reason = `null`,不编文案);这条**不是错误**,配色用告知档。
parallelLimitNoticeFor(event.reason)?.let(::showParallelLimitNotice)
//
// **闸**:这一帧是**广播**,别的端在同一会话声明并行又落回排队时本端也收得到
// ⇒ 只有本端当前确实在「并行」档才说(判据表与理由见 [parallelLimitNoticeFor])。
// 档位必须**现读**(同 [currentInterruptMode] 那条纪律),而 `onEvent` 不是
// suspend 函数 ⇒ 这一句得起一个协程(与 [ensureMessageKnown] 同一形态):它只读
// 一次偏好,而 `pending_ack` 是低频的状态帧,不值一个常驻订阅。
viewModelScope.launch {
parallelLimitNoticeFor(event.reason, currentInterruptMode())
?.let(::showParallelLimitNotice)
}
}
is WsEvent.PendingDrain -> if (event.sessionId == sessionId) {
@@ -5,14 +5,16 @@ import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.core.prefs.InterruptMode
/**
* 「并行没排上」的如实告知与顶部横幅的**两档配色**(契约 v0.65 §2.6)。
*
* 两件事各测各的:
*
* 1. [parallelLimitNoticeFor] 的判据(`pending_ack.reason` ⇒ 文案或 `null`):已知值一句话、
* 未知值**不编**、缺席与空白没话说。开关 ON ≠ 一定起第二路,这一句就是那个差别的全部告知。
* 1. [parallelLimitNoticeFor] 的判据(`pending_ack.reason` + **本端档位** ⇒ 文案或 `null`):
* 已知值一句话、未知值**不编**、缺席与空白没话说,而**非并行档一律不说**——`pending_ack` 是
* 广播帧,别的端的并行降级落在本端就是一条误报。
* 2. [ChatUiState.errorIsQueueFullNotice] 那条**边界**:忙时被拒(降级告知)为 `true`,
* 真错误为 `false`。形状仍是既有的顶部横幅,变的只是颜色——所以判据必须可测,且必须
* 只对忙时被拒那一档成立。配色本身(哪一档用哪个色)在 `ChatScreen` 里,不进单测。
@@ -21,32 +23,54 @@ class ParallelLimitNoticeTest {
private fun pending(localId: String) = PendingMessage(localId = localId, text = "这条")
// —— 一、reason ⇒ 文案(纯函数,穷举三档) ——
// —— 一、reason + 档位 ⇒ 文案(纯函数,穷举) ——
@Test
fun `the known reason says parallel did not fit and the message queued normally`() {
assertEquals(PARALLEL_LIMIT_NOTICE, parallelLimitNoticeFor("parallel_limit"))
assertEquals(PARALLEL_LIMIT_NOTICE, parallelLimitNoticeFor("parallel_limit", InterruptMode.Parallel))
}
@Test
fun `the known reason is tolerant of surrounding whitespace`() {
// 机器码两侧的空格只可能是传输噪声:认它比因为一个空格而**静默**更安全。
assertEquals(PARALLEL_LIMIT_NOTICE, parallelLimitNoticeFor(" parallel_limit "))
assertEquals(PARALLEL_LIMIT_NOTICE, parallelLimitNoticeFor(" parallel_limit ", InterruptMode.Parallel))
}
@Test
fun `an unknown reason invents nothing`() {
// 开放集合:将来服务端可能再加取值。不为不认识的 reason 编文案——说不出的话就不说,
// 而「这条在排队」仍由既有的排队态与队列深度如实呈现(reason 本身也没被丢掉)。
assertNull(parallelLimitNoticeFor("some_future_reason"))
assertNull(parallelLimitNoticeFor("PARALLEL_LIMIT"))
assertNull(parallelLimitNoticeFor("some_future_reason", InterruptMode.Parallel))
assertNull(parallelLimitNoticeFor("PARALLEL_LIMIT", InterruptMode.Parallel))
}
@Test
fun `an absent or blank reason has nothing to say`() {
assertNull(parallelLimitNoticeFor(null))
assertNull(parallelLimitNoticeFor(""))
assertNull(parallelLimitNoticeFor(" "))
assertNull(parallelLimitNoticeFor(null, InterruptMode.Parallel))
assertNull(parallelLimitNoticeFor("", InterruptMode.Parallel))
assertNull(parallelLimitNoticeFor(" ", InterruptMode.Parallel))
}
@Test
fun `the default append-queue mode says nothing about a parallel limit`() {
// 用户没选并行 ⇒ 他这条消息本来就是普通排队、他预期的就是排队 ⇒ 落回排队不是意外
// ⇒ 不说并不构成静默。而那一帧是**广播**(别的端声明并行也会让本端收到),
// 在默认档上说一句就是纯误报——误报会教用户不信任这条提示。
assertNull(parallelLimitNoticeFor("parallel_limit", InterruptMode.AppendQueue))
}
@Test
fun `the interrupt mode says nothing about a parallel limit`() {
// 打断档同理(它与并行互斥):这一条本来就轮不到「另起一路」这件事。
assertNull(parallelLimitNoticeFor("parallel_limit", InterruptMode.Interrupt))
}
@Test
fun `only the parallel mode turns the reason into a notice`() {
// 三档一起摆出来:判据是一位档位开关,不是一个「不等于某档」的近似。
val said = InterruptMode.entries.map { parallelLimitNoticeFor("parallel_limit", it) }
assertEquals(listOf(null, null, PARALLEL_LIMIT_NOTICE), said)
}
// —— 二、横幅温度:降级告知柔和、真错误照旧红 ——