feat(chat): W43 超限告知改入口中性措辞,补发反复失败就静默

① 超限告知(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)。
This commit is contained in:
AskaEth committed 2026-10-05 15:21:24 +08:00
1 parent f3ee2e0d7b
commit 5556573986
6 files changed
+222 -16

No files matched your search

@@ -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),
)
}
@@ -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)
}
}
@@ -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