From 5556573986cee9a580426e8a710dd8a5e1dac204 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 5 Oct 2026 15:21:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20W43=20=E8=B6=85=E9=99=90=E5=91=8A?= =?UTF-8?q?=E7=9F=A5=E6=94=B9=E5=85=A5=E5=8F=A3=E4=B8=AD=E6=80=A7=E6=8E=AA?= =?UTF-8?q?=E8=BE=9E=EF=BC=8C=E8=A1=A5=E5=8F=91=E5=8F=8D=E5=A4=8D=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E5=B0=B1=E9=9D=99=E9=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ① 超限告知(ShareRules.truncatedImagesNotice)改成**入口中性**措辞 原句以「分享的图片」开头,那是一句只对其中一条路成立的限定:W42 起这个函数同时被图片入口 用着(图片按钮已改多选),而图片入口里根本没有「分享」这件事。所以句子不再指认动作的来处, 只说两条路都成立的三件事:**有上限(4 张)、超出的没发、发出去的是前 N 张**。新句逐字: 「一次最多 4 张,超出的没发,只发送前 N 张」,N 就是真发出去的张数。 刻意不写「失败」「错误」:被上限截掉不是一次错误,是上限本身,那族措辞会让用户以为丢了东西。 两条路仍然共用同一句(只有一个出处),没有为此分裂成两份文案。 ② 补发「多次重试失败就静默」(主人 2026-10-06 定案) W40/W41 之后未连接时普通发送会进补发队列并给一句柔和告知,连接恢复后自动补发;主人新加的 规矩是**不要一直刷屏**:同一条(或同一批)反复重试都失败时,停止再弹告知,静静地等。 - 判据抽成纯函数 queuedSendNoticeAllowed(toldThisRound, failedResends):**静默闸优先**,其次是 既有的「一轮从空到空只说一次」(W40 那条一字未改)。它只回答该不该说,从不碰队列。 - 阈值 QUEUED_RESEND_SILENCE_THRESHOLD = 3。理由:不许取 1~2(那等于根本没告知);第 1 次失败 就是「刚进队列」那一刻,必须说;第 2、3 次仍允许开口(连接抖动后还是没连上,用户需要重新 知道);第 3 次仍未成功说明已在反复失败里,再说只是把同一件事刷第二遍,从此停下。 - 记账口径:本会话的一个**补发批次**(= 队列从空到非空的那一段),与告知本身的记账同口径, 不新造第二个概念;按单条记账会漏掉「每条只失败一两次、整批却一直没发出去」这种要静默的情形。 落在 ChatViewModel 的实例字段上:VM 是按会话建的,所以天然按会话,也不引入全局单例——别的 会话的反复失败不该把本会话的告知掐掉。 - 计什么:只计「补发没能上路、内容仍留在队列里」那种失败(它是唯一会反复发生的一种);已被 服务端裁决 / 已回滚的那两类是一次性了结(内容退回输入栏并给了明确原因),不计数。 - 清零时机:**补发真的成功一次**就归零(WS 帧交出去、或回落 REST 被服务端收下)⇒「静默之后 连接恢复并补发成功」之后,下一次离线**又能重新告知**,不会永久闭嘴。 - 静默不等于丢弃:这一个数只影响说不说,队列内容与补发的下一步一个字节都没动,连接一好照旧 按序补发(W38/W40/W41 那条硬要求照旧成立);气泡上「排队中 · 还没连上」的小标也一直挂着。 既有的两句队列告知(排队中那一句 / 离线那一句)一字未改。 测试:ShareNoticeTest 与 ImageSendPlanTest 的超限断言改成新事实(不删断言);新增 QueuedSendSilenceTest 钉住那条规则的四个边(第一次失败告知、阈值前仍告知、阈值后静默、成功后 清零)以及「阈值不许取 1~2」「静默不动队列」两条性质。 assembleDebug 成功;testDebugUnitTest 1150 测试 / 0 失败 / 0 错误(基线 1143,本批新增 7)。 --- .../yeij/cyrene/domain/share/SharePayload.kt | 9 +- .../top/yeij/cyrene/ui/chat/ChatViewModel.kt | 62 +++++++++-- .../top/yeij/cyrene/ui/chat/SendQueue.kt | 36 ++++++ .../yeij/cyrene/ui/chat/ImageSendPlanTest.kt | 5 +- .../cyrene/ui/chat/QueuedSendSilenceTest.kt | 104 ++++++++++++++++++ .../yeij/cyrene/ui/chat/ShareNoticeTest.kt | 22 +++- 6 files changed, 222 insertions(+), 16 deletions(-) create mode 100644 app/src/test/kotlin/top/yeij/cyrene/ui/chat/QueuedSendSilenceTest.kt diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/share/SharePayload.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/share/SharePayload.kt index 0cd049b..7371743 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/domain/share/SharePayload.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/share/SharePayload.kt @@ -157,11 +157,18 @@ object ShareRules { * 两个入口的上限本来就是同一个数([MAX_IMAGES])⇒ 告知也必须是同一句:各写一句,迟早会出现 * 「分享说只发前 4 张、选图说只发前 4 张(措辞不同)」这种同一件事的两种说法。 * + * ## 措辞**入口中性**(主人 2026-10-06 拍板) + * W42 把图片按钮改成多选之后,这一句不止在分享那条路上出现——图片入口里根本没有「分享」 + * 这件事。原句以「**分享的**图片」开头,那是一句只对其中一条路成立的限定(在图片入口里读着 + * 像在说别人的事),所以句子不再指认动作的来处:只说三件两条路都成立的事实——**有上限** + * ([MAX_IMAGES] 张)、**超出的没发**、**发出去的是前 N 张**。也刻意不写「失败」「错误」: + * 被上限截掉不是一次错误,是上限本身。 + * * @param kept 这次真正发出去的张数(`SharedContent.images` 的长度 / `ImageSendPlan.imagesToSend` 的长度) * @param dropped 被上限截掉的张数;0 ⇒ `null`(一张没少就没什么要说的,说话才是噪声) */ fun truncatedImagesNotice(kept: Int, dropped: Int): String? = - if (dropped > 0) "分享的图片超过 $MAX_IMAGES 张,只发送前 $kept 张" else null + if (dropped > 0) "一次最多 $MAX_IMAGES 张,超出的没发,只发送前 $kept 张" else null /** * 从分享 Intent 的原始字段提取内容。 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 3c4046d..45c6675 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 @@ -653,7 +653,9 @@ data class ChatUiState( * (输入栏发的这条 / 分享进来的那条),可以同时挂着,各自到期消失([SEND_QUEUED_NOTICE_MILLIS])。 * * **不刷屏**:一轮「队列从空到空」只说一次,连续几条离线消息不会每按一次发送就弹一遍 - * (记账在 [ChatViewModel.queueForConnection],不是按时间窗判的)。 + * (记账在 [ChatViewModel.queueForConnection],不是按时间窗判的)。W43 起在那之上再加一道 + * **静默闸**(主人 2026-10-06 定案):同一批**反复补发失败**到 [QUEUED_RESEND_SILENCE_THRESHOLD] + * 次之后,这一块牌子不再挂新的告知,静静地等——判据是纯函数 [queuedSendNoticeAllowed]。 */ val sendQueueNotice: String? = null, val connection: WsState = WsState.Idle, @@ -866,6 +868,30 @@ class ChatViewModel( */ private var queuedNoticeTold = false + /** + * 同一批补发**已经失败过几次**(W43 主人 2026-10-06 定案:多次重试失败就静默)。 + * + * ## 按什么记账:本会话的一个「补发批次」 + * 记账口径是**队列从空到非空的那一段**(= 一批等着补发的消息),与上面那一格 [queuedNoticeTold] + * 同一个口径。理由是不新造第二个概念:告知本来就是按一批说的,静默也该按同一批算;而主人那句 + * 「同一条(或同一批)」正是这个意思——一条反复失败与一批反复失败,对用户是同一件事(都是 + * 「这条会话还有东西没发出去」)。按单条消息记账会漏掉「每条都只失败一两次、整批却一直没发 + * 出去」这种情形,那正是要静默的那一种。 + * + * 落在 VM 实例字段上:VM 是按会话建的(`sessionId` 是构造参数),所以这一格天然**按会话**, + * 切走会话就随 VM 一起没了。**不做全局单例**:「这条会话的这批消息连着几次没发出去」与别的 + * 会话毫无关系,共用一个计数器只会让一个会话的反复失败把另一个会话的告知也掐掉。 + * + * ## 计数与清零 + * - `+1`:补发**没能上路、内容仍留在队列里**([drainQueuedSends])——只有这种会反复发生。 + * 被服务端裁决 / 已回滚的那两类是一次性了结(内容退回输入栏并给了明确原因),不计数。 + * - 归零:**补发真的成功一次**([dropQueuedSend] 的 `sent = true`)。「静默之后连接恢复并补发 + * 成功 ⇒ 下一次离线又能重新告知」,靠的就是这一句。 + * + * 它只回答「还说不说」,**从不**影响队列内容本身(判据见 [queuedSendNoticeAllowed])。 + */ + private var queuedResendFailures = 0 + /** * 有补发协程在跑(**单飞闸**,见 [flushQueuedSends])。 * @@ -1562,7 +1588,9 @@ class ChatViewModel( when (wsSendOutcome(sent, state)) { // 成功路径**一个字都没变**:帧交给 socket 之后这里什么都不做(补发那条顺手出队)。 - WsSendOutcome.Dispatched -> if (fromQueue) dropQueuedSend(localId) + // 补发出去的那条顺手把「反复失败」的记账归零([queuedResendFailures]):静默之后连接 + // 恢复并补发成功 ⇒ 下一次离线又能重新告知,不会永久闭嘴。 + WsSendOutcome.Dispatched -> if (fromQueue) dropQueuedSend(localId, sent = true) // 连接就绪却仍然发不出去:只剩「socket 对象已空 / 已被 cancel」或「toJson() / send() // 抛异常」两种成因 ⇒ 那是事实上的失败,走今天那条回滚 + 文案。 @@ -1660,7 +1688,8 @@ class ChatViewModel( // 补发这一条**必须出队**:服务端已经收下 ⇒ 这条了结了。不出队的话它会一直挂在 // 「排队中 · 还没连上」的小标上(界面上那句话就成了假话),下一次重连还会再补发 // 一遍——服务端虽然按同一个 `clientMsgId` 判重、不会落两条,但那是一次白发的请求。 - if (fromQueue) dropQueuedSend(localId) + // 这也是「补发真的成功」的另一种形态 ⇒ 顺手把反复失败的记账归零(同 Dispatched 那支)。 + if (fromQueue) dropQueuedSend(localId, sent = true) } onAck(localId, received) }.onFailure { e -> @@ -1719,8 +1748,10 @@ class ChatViewModel( * 只是多一个「排队中 · 还没连上」的小标([ChatUiState.isAwaitingConnection])。**不回滚、 * 不报错误横幅**:`error` 的语义是「出错了」,而这条消息一次都没上过路。 * 2. **该说就说一句**([queuedSendNoticeFor] 定哪句、走 [showQueuedSendNotice] 这条柔和通道): - * **一轮「队列从空到空」只说一次**([queuedNoticeTold])——连续发几条离线消息只说一次, - * 不是每按一次发送就弹一遍。 + * 说与不说的**唯一判据是纯函数 [queuedSendNoticeAllowed]**——**一轮「队列从空到空」只说一次** + * ([queuedNoticeTold]),且同一批**反复补发失败到 [QUEUED_RESEND_SILENCE_THRESHOLD] 次之后 + * 连这一句也停下**([queuedResendFailures],W43 主人 2026-10-06 定案:多次重试失败就静默)。 + * 静默只是不说:内容照旧留在队列里,连接一好照旧补发。 * 3. **竞态兜底**:投递那一刻读到的「未就绪」与现在可能已经不是同一帧(连接恰在前面几百微秒里 * 就绪,而那时这条还没进队列、[onConnectionChanged] 那次补发正好扑空)⇒ 入队之后再看一眼 * socket 自己的状态,已经就绪就立刻补一次。这**不是轮询**:只在队列刚刚多出一条时做一次。 @@ -1737,7 +1768,9 @@ class ChatViewModel( */ private fun queueForConnection(localId: String, parallel: Boolean) { _state.update { it.withQueuedSend(localId, parallel) } - if (!queuedNoticeTold) { + // 静默那一档**不置 [queuedNoticeTold]**:这一轮确实一个字都没说,往后有了一次成功的补发 + // ([queuedResendFailures] 归零)之后,用户本来就该重新知道这件事。 + if (queuedSendNoticeAllowed(queuedNoticeTold, queuedResendFailures)) { queuedNoticeTold = true queuedSendNoticeFor(socket?.state?.value ?: WsState.Idle)?.let(::showQueuedSendNotice) } @@ -1813,7 +1846,14 @@ class ChatViewModel( // 还挂在队列里 ⇒ 这一步没把它送出去(连接又断了 / 回落也没成)⇒ 停手, // 顺序一动不动地等下一次 Connected。这一刻**不再说一遍告知**:气泡上的小标 // 一直挂着,刷第二遍只是噪声。 - if (_state.value.isAwaitingConnection(step.localId)) return + if (_state.value.isAwaitingConnection(step.localId)) { + // 这是「反复重试」里的一次(补发又没上路、内容仍留在队列里)⇒ 记一次。 + // 到了 [QUEUED_RESEND_SILENCE_THRESHOLD] 之后本批不再多说一句 + // (判据 [queuedSendNoticeAllowed]),但**队列一个字节都没动**:连接一好 + // 照旧按序补发,静默从来不是丢弃。 + queuedResendFailures++ + return + } } } } @@ -1824,8 +1864,14 @@ class ChatViewModel( * * 队列空了就把「这一轮还没连上」的告知记账归零([forgetQueuedNoticeIfDrained]):下一轮 * 断线时用户确实需要重新知道一次这件事。 + * + * @param sent true = 这一次是**真的补发出去了**(服务端收下 / 帧交给 socket)。它顺手把 + * 「同一批反复失败」的记账归零([queuedResendFailures]):静默之后连接恢复并补发成功 ⇒ + * 下一次离线又**能**重新告知。回滚 / 气泡被别路收编那两种出队**不归零**——它们不是 + * 「补发成功」,这条(批)并没有因为一次成功的补发而了结。 */ - private fun dropQueuedSend(localId: String) { + private fun dropQueuedSend(localId: String, sent: Boolean = false) { + if (sent) queuedResendFailures = 0 _state.update { it.withoutQueuedSend(localId) } forgetQueuedNoticeIfDrained() } diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SendQueue.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SendQueue.kt index ed7c21b..c93b033 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SendQueue.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SendQueue.kt @@ -207,3 +207,39 @@ fun queuedSendNoticeFor(connection: WsState): String? = when (connection) { WsState.Connected -> null WsState.Idle, WsState.Connecting, WsState.Reconnecting -> SEND_QUEUED_NOTICE } + +/** + * 同一条(或同一批)补发反复失败多少次之后**不再多说**(主人 2026-10-06 定案:多次重试失败就静默)。 + * + * 取 3 的理由(**不许**取 1 或 2——那等于根本没告知): + * - 第 1 次失败就是「刚进队列」那一刻,那一句必须说:它是「已经排上队了、不用重打一遍」的全部 + * 信息量所在; + * - 第 2、3 次仍允许开口:连接抖动之后这条又没能上路,用户确实需要重新知道「还是没连上」, + * 而不是以为早就发出去了; + * - 到第 3 次仍未成功,这条(批)已经明显处在「反复失败」里了,再说一遍只是把同一件事刷第二遍 + * ——从此停下告知,静静地等(等的那件事没变:连接一好就自动补发)。 + * + * 静默**不等于丢弃**:这个数只回答「还说不说」,一个字都不动补发队列本身——W38/W40/W41 那条 + * 「内容一个字节都不丢」的硬要求照旧成立。 + */ +const val QUEUED_RESEND_SILENCE_THRESHOLD = 3 + +/** + * 这一刻「这条(批)排队等连接」还该不该再说一句(**纯函数**,`false` = 静默)。 + * + * ## 判据与优先级(两个条件同时成立才说) + * 1. **静默闸优先**:[failedResends] 达到 [QUEUED_RESEND_SILENCE_THRESHOLD] ⇒ `false`,与 + * [toldThisRound] 是什么无关。这是主人 2026-10-06 的定案:反复重试都失败时停止刷屏。 + * 2. 轮内去重:[toldThisRound] 为真 ⇒ `false`(一轮「队列从空到空」只说一次,连续发几条离线 + * 消息不会每按一次发送就弹一遍——W40 既有的记账,一字不改)。 + * 3. 其余(这一轮还没说过、失败次数还没到阈值)⇒ `true`。 + * + * [failedResends] 是**这条(批)已经失败过的补发次数**,只把「补发没能上路、内容仍留在队列里」 + * 那种失败计进来(见 `ChatViewModel.drainQueuedSends`):它是唯一会**反复发生**的一种;已被服务端 + * 裁决 / 已回滚的那两类是一次性了结(内容退回输入栏并给了明确原因),不在这条线上。 + * + * 计数归零由调用方在做:**补发真的成功一次**就清零(`ChatViewModel.dropQueuedSend` 的 + * `sent = true`)⇒「静默之后连接恢复、补发成功」之后,下一次离线**又能重新告知**,不会永久闭嘴。 + */ +fun queuedSendNoticeAllowed(toldThisRound: Boolean, failedResends: Int): Boolean = + !toldThisRound && failedResends < QUEUED_RESEND_SILENCE_THRESHOLD diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ImageSendPlanTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ImageSendPlanTest.kt index ce4d8ef..c39ca7a 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ImageSendPlanTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ImageSendPlanTest.kt @@ -97,9 +97,10 @@ class ImageSendPlanTest { assertEquals(ShareRules.MAX_IMAGES, plan.messages.size) assertEquals(images(4), plan.imagesToSend) assertEquals(1, plan.truncatedImages) - // 「只发送前 N 张」里的 N 就是真发出去的那几张(告知那句文案拿它拼)。 + // 「只发送前 N 张」里的 N 就是真发出去的那几张(告知那句文案拿它拼)。W43 起这句措辞改 + // **入口中性**(与分享路径仍是同一句,只是不含「分享」这个只对一条路成立的限定)。 assertEquals( - "分享的图片超过 ${ShareRules.MAX_IMAGES} 张,只发送前 ${ShareRules.MAX_IMAGES} 张", + "一次最多 ${ShareRules.MAX_IMAGES} 张,超出的没发,只发送前 ${ShareRules.MAX_IMAGES} 张", ShareRules.truncatedImagesNotice(plan.imagesToSend.size, plan.truncatedImages), ) } diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/QueuedSendSilenceTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/QueuedSendSilenceTest.kt new file mode 100644 index 0000000..2c2b9d5 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/QueuedSendSilenceTest.kt @@ -0,0 +1,104 @@ +package top.yeij.cyrene.ui.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 「同一条(或同一批)反复补发失败就**静默**」的判据([queuedSendNoticeAllowed])—— + * 主人 2026-10-06 定案。 + * + * ## 背景 + * W40/W41 之后,未连接时普通发送会进补发队列([ChatUiState.queuedForConnection]),并给**一句** + * 柔和的告知([SEND_QUEUED_NOTICE]:还没连上、已排队、连上后自动发出);连接恢复后自动补发。 + * 主人新加的规矩是**不要一直刷屏**:同一条(或同一批)反复重试都失败时,**停止再弹告知**,静静地等。 + * + * ## 本测试钉住四件事(就是那条规则的四个边) + * 1. 第一次失败 ⇒ 告知; + * 2. 到阈值之前(1、2 次)⇒ 仍然允许告知; + * 3. 到阈值([QUEUED_RESEND_SILENCE_THRESHOLD] = 3)之后 ⇒ **静默**; + * 4. **静默之后连接恢复并补发成功 ⇒ 计数清零**,下一次离线又能重新告知(不能永久闭嘴)。 + * + * 另外钉住两条性质:阈值不许取 1 或 2(那等于根本没告知);静默**只是不说**——队列内容与补发的 + * 下一步一个字节都不受影响(静默 ≠ 丢弃)。 + */ +class QueuedSendSilenceTest { + + // —— 一、那条规则的四个边 —— + + @Test + fun `the first failure is always told`() { + // 刚进队列那一刻(还没失败过):这一句必须说,它是「已经排上队了、不用重打一遍」的全部 + // 信息量所在。 + assertTrue(queuedSendNoticeAllowed(toldThisRound = false, failedResends = 0)) + } + + @Test + fun `the retries before the threshold may still speak`() { + (1 until QUEUED_RESEND_SILENCE_THRESHOLD).forEach { failures -> + assertTrue( + "第 $failures 次失败还在阈值以内,不该静默", + queuedSendNoticeAllowed(toldThisRound = false, failedResends = failures), + ) + } + } + + @Test + fun `from the threshold on it goes quiet`() { + (QUEUED_RESEND_SILENCE_THRESHOLD..QUEUED_RESEND_SILENCE_THRESHOLD + 3).forEach { failures -> + assertFalse( + "第 $failures 次失败之后不该再说", + queuedSendNoticeAllowed(toldThisRound = false, failedResends = failures), + ) + } + } + + @Test + fun `a successful resend clears the count so the next offline tells again`() { + // 补发成功时 `ChatViewModel.dropQueuedSend(sent = true)` 把计数归零 ⇒ 又回到「第一次失败」。 + assertFalse(queuedSendNoticeAllowed(toldThisRound = false, failedResends = QUEUED_RESEND_SILENCE_THRESHOLD)) + val afterSuccess = 0 + assertTrue(queuedSendNoticeAllowed(toldThisRound = false, failedResends = afterSuccess)) + // 再连着失败又要静默一次:静默不是一次性的,也不是永久闭嘴。 + assertFalse(queuedSendNoticeAllowed(toldThisRound = false, failedResends = QUEUED_RESEND_SILENCE_THRESHOLD)) + } + + // —— 二、阈值本身与既有的「一轮只说一次」 —— + + @Test + fun `the threshold is not one or two - silencing there would be no notice at all`() { + assertTrue( + "阈值取 1 或 2 时,用户还没被告知就已经听不到了", + QUEUED_RESEND_SILENCE_THRESHOLD >= 3, + ) + } + + @Test + fun `the round dedup and the silence gate both have to allow it`() { + // 静默闸**优先**:就算这一轮还没说过,到了阈值也一个字都不说。 + assertFalse(queuedSendNoticeAllowed(toldThisRound = true, failedResends = QUEUED_RESEND_SILENCE_THRESHOLD)) + // 没到阈值、但这一轮已经说过 ⇒ 照旧不说(W40 的「一轮从空到空只说一次」,一字不改)。 + assertFalse(queuedSendNoticeAllowed(toldThisRound = true, failedResends = 0)) + // 两个闸都放行才说。 + assertTrue(queuedSendNoticeAllowed(toldThisRound = false, failedResends = 0)) + } + + // —— 三、静默不等于丢弃 —— + + @Test + fun `silence never touches the queue - what is queued stays queued`() { + // 判据只回答「还说不说」:静默那一刻队列一动不动,补发的下一步照样是「发这条」。 + assertFalse(queuedSendNoticeAllowed(toldThisRound = false, failedResends = QUEUED_RESEND_SILENCE_THRESHOLD)) + + val state = ChatUiState( + pending = listOf(PendingMessage(localId = "local-a", text = "还没连上时发的那条")), + ).withQueuedSend("local-a", parallel = false) + + assertEquals(1, state.queuedForConnection.size) + assertEquals("local-a", (state.nextQueuedSend() as QueuedSendStep.Send).localId) + // 补发用的还是原来那个键(= 上行幂等键 `clientMsgId`):静默期里补发过的那些,服务端 + // 照旧按同一个键判重,不会落成第二条消息。 + assertEquals("local-a", state.queuedForConnection.single().localId) + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareNoticeTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareNoticeTest.kt index 47f0a74..3b7c355 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareNoticeTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareNoticeTest.kt @@ -60,11 +60,23 @@ class ShareNoticeTest { } @Test - fun `the existing wording for a truncated share is unchanged`() { - assertEquals( - "分享的图片超过 ${ShareRules.MAX_IMAGES} 张,只发送前 1 张", - shareNoticeFor(SharedContent(images = listOf("file:///cache/share-1.png"), truncatedImages = 3)), - ) + fun `a truncated share says the cap, the dropped ones, and what went out`() { + // W43 起这句改**入口中性**(主人 2026-10-06 拍板):同一个函数也被图片入口用着,而图片 + // 入口里没有「分享」这件事 ⇒ 句子不再指认动作的来处,只说两条路都成立的三件事。 + val said = shareNoticeFor( + SharedContent(images = listOf("file:///cache/share-1.png"), truncatedImages = 3), + )!! + + assertEquals("一次最多 ${ShareRules.MAX_IMAGES} 张,超出的没发,只发送前 1 张", said) + // 三件事逐条钉住:有上限 / 超出的没发 / 发出去的是前 N 张。 + assertTrue(said.contains("最多 ${ShareRules.MAX_IMAGES} 张")) + assertTrue(said.contains("超出的没发")) + assertTrue(said.contains("只发送前 1 张")) + // 被上限截掉**不是错误**,是上限本身 ⇒ 那一族措辞不许出现。 + assertFalse(said.contains("失败")) + assertFalse(said.contains("错误")) + // 入口中性:两条路里都不该再出现只对其中一条成立的「分享」这个限定。 + assertFalse(said.contains("分享")) } @Test