From f2fbdff9a7c5e98b4be9b2c1af7f9467d91e7c91 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 5 Oct 2026 10:12:41 +0800 Subject: [PATCH] =?UTF-8?q?W33=EF=BC=9A=E5=B9=B6=E8=A1=8C=E8=90=BD?= =?UTF-8?q?=E5=9B=9E=E6=8E=92=E9=98=9F=E7=9A=84=E5=91=8A=E7=9F=A5=E5=8A=A0?= =?UTF-8?q?=E4=B8=80=E9=81=93=E6=9C=AC=E7=AB=AF=E6=A1=A3=E4=BD=8D=E9=97=B8?= =?UTF-8?q?=20+=20=E5=BF=99=E6=97=B6=E8=A2=AB=E6=8B=92=E9=82=A3=E6=9D=A1?= =?UTF-8?q?=E6=A8=AA=E5=B9=85=E7=9A=84=E5=8F=AA=E8=AF=BB=E6=9F=A5=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、改了什么 加一道闸: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 错误。 --- .../top/yeij/cyrene/ui/chat/ChatViewModel.kt | 36 +++++++++++++-- .../cyrene/ui/chat/ParallelLimitNoticeTest.kt | 44 ++++++++++++++----- 2 files changed, 66 insertions(+), 14 deletions(-) diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt index 854e84a..eb3782b 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt @@ -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) { diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ParallelLimitNoticeTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ParallelLimitNoticeTest.kt index 312dd80..d74aae2 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ParallelLimitNoticeTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ParallelLimitNoticeTest.kt @@ -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) } // —— 二、横幅温度:降级告知柔和、真错误照旧红 ——