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) } // —— 二、横幅温度:降级告知柔和、真错误照旧红 ——