From d3fc0c8005b7ff5fce2bac0e438aa684d35acd07 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 21:11:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=E9=87=8D=E8=AF=95=E8=AE=B0?= =?UTF-8?q?=E5=BF=86=E6=94=B9=E5=A4=9A=E6=A7=BD=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E6=AF=8F=E6=9D=A1=E5=A4=B1=E8=B4=A5=E5=90=84=E7=95=99=E8=87=AA?= =?UTF-8?q?=E5=B7=B1=E7=9A=84=E5=B9=82=E7=AD=89=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RetryKeyMemory 从单槽改成多槽(slots: List,上限 MAX_SLOTS = 8,FIFO 淘汰最旧)。 单槽只记得最近一次失败那条,而用户的重试顺序不必和失败顺序一致:他重试 A 又失败、再回头去重试 更早失败的 B 时,B 的槽早被顶掉,那次重发只能铸新键 —— 而 B 那次失败完全可能是「服务端已经收下、 客户端没看见」,于是又落成第二条消息,正是本机制要消灭的重复。 - remember:同指纹先摘掉旧的、再追加到末尾(同一条又失败一次是更新它的键,不是并列两条), 超过上限从头部丢最旧的,且刚写进去的那条一定留得住。 - forget:按 localId 清;一条都对不上就原样返回同一个实例;同 localId 万一有多条则一并清掉。 - keyFor:命中只消费那一条、其余槽全部保留;未命中铸新键且一条槽也不清 —— 单槽时代「未命中也要 清空」在多槽下会把别的失败记录一起抹掉,这条已显式写进 KDoc 以防后人照着旧注释改回去。 - ChatViewModel:代码逻辑一个字节没动(三个调用点的签名都没变),只把提到「单槽」「下一次发送就 被消费」的 KDoc 改成与新实现一致;值比较指纹、正文不归一、晚到的 message_persisted 刻意不清键 这几条 W15 纪律原样保留。 - 测试:RetryKeyMemoryTest 由 10 条重写为 16 条。删掉「未命中即清空记忆」那两条旧断言(新语义下 不再成立),新增:两条失败各自原样重试都落回自己的键(两种重试顺序各一条)、重试 A 不抹掉 B 的槽、 同指纹重复 remember 只留一条且键是最新的、超上限 FIFO 淘汰最旧且刚写的仍在、未命中时全部槽保留、 命中消费后其余槽保留。 验证:assembleDebug 与 testDebugUnitTest 均通过(--rerun-tasks 全量重跑):760 → 766 测试 / 0 失败 / 0 错误。 --- .../yeij/cyrene/domain/chat/RetryKeyMemory.kt | 83 +++++-- .../top/yeij/cyrene/ui/chat/ChatViewModel.kt | 13 +- .../cyrene/domain/chat/RetryKeyMemoryTest.kt | 235 +++++++++++++----- 3 files changed, 231 insertions(+), 100 deletions(-) diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/RetryKeyMemory.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/RetryKeyMemory.kt index 94db291..2173ef6 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/RetryKeyMemory.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/RetryKeyMemory.kt @@ -6,13 +6,13 @@ data class RetryKeySlot(val fingerprint: MessageFingerprint, val localId: String /** * [RetryKeyMemory.keyFor] 的判定结果:这一次发送该用哪个幂等键,以及记忆变成什么样。 * - * 两半都是判定的一部分,所以一起回:记忆**无论走哪一支都要被消费掉**(见 [RetryKeyMemory.keyFor]), + * 两半都是判定的一部分,所以一起回:**命中的那一条**会被消费掉(见 [RetryKeyMemory.keyFor]), * 把它放进返回值,调用方就没有「用完了忘了清」的机会。 */ data class RetryKeyDecision(val localId: String, val memory: RetryKeyMemory) /** - * 「上一次没发出去的那条」的记忆——**单槽**、纯值、可单测。 + * 「没发出去的那几条」的记忆——**多槽**、纯值、可单测。 * * ## 它存在的理由 * 幂等键要想真的挡住重复,就必须在**同一条消息**的两次发送之间保持不变。而「某次失败」并不 @@ -25,45 +25,74 @@ data class RetryKeyDecision(val localId: String, val memory: RetryKeyMemory) * ## 三条纪律 * 1. **只在回滚时写**([remember]:发送失败 / 队列满被拒 / 附件与图片上传失败 / 图片处理失败…): * 被受理的那条没有任何要记的东西。 - * 2. **在下一次发送时被消费**([keyFor]):指纹相等 ⇒ 复用那个键;不等 ⇒ 铸新键。**不等这一支 - * 同样消费**——用户改过字就是**另一条**消息,那条旧记录再留着,只会让「下一条恰好和上一条 - * 失败的一模一样」这种巧合偷偷复用别人的键。 - * 3. **按 `localId` 清**([forget]),不是无条件清空:两条同时在飞时,一条被受理(它的键与槽里 - * 记的那条对不上)不该把另一条刚记下的重试键抹掉。 + * 2. **在用户下一次发送时按指纹取用**([keyFor]):指纹相等 ⇒ 复用那个键、**且只消费那一条**; + * 不等 ⇒ 铸新键、**一条槽也不清**(多槽下「不等也清」是错的,见 [keyFor] 的注释)。 + * 3. **按 `localId` 清**([forget]),不是无条件清空:几条同时在飞时,一条被受理(它的键与别的槽 + * 里记的那条对不上)不该把别条刚记下的重试键抹掉。 * - * ## 为什么单槽够 - * 记忆只对「用户下一次按发送」有意义,而那一次只可能是**最近一次失败**的那条——回滚已经把正文 / - * 图片 / 附件还回输入栏了,用户改的就是它。同时在飞的多条各有自己的 `localId`,与重试语义无关。 + * ## 为什么是「多槽」:单槽的边界 + * 单槽只记得**最近一次**失败那条,而用户的重试顺序不必和失败顺序一致:他重试 A(又失败),再回头 + * 去重试更早失败的 B —— B 的槽早被 A 顶掉了,那次重发只能铸新键([keyFor] 未命中),于是 + * 「服务端其实已经收下」的那条 B 落成**第二条**消息,正是本机制要消灭的重复。多槽让每条失败各留 + * 一个槽,用户重试哪一条就落回哪一条的键,与他的操作顺序无关。 + * + * ## 上限与淘汰:FIFO,不是 LRU + * 一条记录只在「用户可能还要重试」的窗口里有意义:失败后正文 / 图片 / 附件都还回了输入栏,他要么 + * 马上原样重发、要么早就改了内容(那就成了另一条消息,记录永远命中不了)。窗口外留下的只是垃圾, + * 所以给一个小上界 [MAX_SLOTS],满了丢**最旧**的([remember] 里 FIFO)。刻意**不**做 LRU:命中 + * 并消费是**轮询**而不是访问 —— [keyFor] 把命中的那条整个摘走,与它有多旧无关,谈「最近使用」没有 + * 意义;也正因如此,剩下的槽天然还是写入顺序。 * * ## 与「绝不自动重试」不矛盾 * 这里一个字节都不自动重发:复用键只发生在**用户自己又按了一次发送**、且内容一字不差的时候。 * 它做的唯一一件事是把那次重发变成幂等的。 */ -data class RetryKeyMemory(val slot: RetryKeySlot? = null) { +data class RetryKeyMemory(val slots: List = emptyList()) { - /** 记下「这一条没发出去」(回滚时调用)。同一时刻只需要记住一条,后写的覆盖先写的。 */ - fun remember(fingerprint: MessageFingerprint, localId: String): RetryKeyMemory = - RetryKeyMemory(RetryKeySlot(fingerprint, localId)) + /** + * 记下「这一条没发出去」(回滚时调用)。 + * + * 同一个指纹**先摘掉旧的、再把新的追加到末尾**:同一条又失败一次是**更新**它的键,而不是并列 + * 两条 —— 两条同指纹时 [keyFor] 只会命中更早那条,用户的重试反而用不上刚铸的键。超过 + * [MAX_SLOTS] 就从头部丢最旧的:本函数最多让**末尾**那条变新,淘汰因此绝不会伤到刚写的这条。 + */ + fun remember(fingerprint: MessageFingerprint, localId: String): RetryKeyMemory { + val kept = slots.filterNot { it.fingerprint == fingerprint } + RetryKeySlot(fingerprint, localId) + return RetryKeyMemory(if (kept.size > MAX_SLOTS) kept.takeLast(MAX_SLOTS) else kept) + } /** * 忘掉 [localId] 那条——受理成功 / 撞上 `duplicate` / 气泡被收编时调用。 * - * 键对不上就**原样返回同一个实例**(不清):槽里记的可能是另一条刚回滚的消息,顺手清掉它 - * 会让那条的重试退回「铸新键」。 + * 一条都对不上就**原样返回同一个实例**(一条也不清):槽里躺的多半是别的刚回滚的消息,顺手清掉 + * 它们会让那几条的重试退回「铸新键」。同一个 `localId` 万一有多条([remember] 保证不会有), + * 一并清掉:留着一条键已被服务端受理过的记录,只会让将来的某次重发被当成重复吞掉。 */ - fun forget(localId: String): RetryKeyMemory = - if (slot?.localId == localId) RetryKeyMemory() else this + fun forget(localId: String): RetryKeyMemory { + val kept = slots.filterNot { it.localId == localId } + return if (kept.size == slots.size) this else RetryKeyMemory(kept) + } /** - * 这一次发送该用哪个幂等键:内容与记下的那条一致 ⇒ **复用**它的键;否则铸一个新的 - * ([mint] 由调用方给,测试才能喂确定性的键,也才能断言「该不该铸」)。 + * 这一次发送该用哪个幂等键:内容与某条记录一致 ⇒ **复用**它的键,并**只消费那一条**(其余槽 + * 原样保留 —— 它们对应的是别的失败消息,正等着被各自的重试捡起来);一条都对不上 ⇒ 铸一个新的 + * ([mint] 由调用方给,测试才能喂确定性的键,也才能断言「该不该铸」),**且一条槽也不清**。 * - * 无论走哪一支,返回的 [RetryKeyDecision.memory] 都是空的——消费掉记录是本函数的定义的一部分 - * (见类注释第 2 条),而不是调用方的义务。 + * 最后这半句是与单槽时代最本质的差别,**别照着旧注释改回去**:那时记忆只有一条,「未命中就清空」 + * 与「保留」是同一件事;多槽下清空会把用户**别的**失败记录一并抹掉(他重试 A 时铸了新键,接着去 + * 重试 B,B 的槽已经没了 ⇒ 那次重发退化成非幂等)。未命中只说明「这次的正文没有任何失败记录对得 + * 上」,不是「记忆都作废了」。 */ - fun keyFor(fingerprint: MessageFingerprint, mint: () -> String): RetryKeyDecision = - when (val remembered = slot?.takeIf { it.fingerprint == fingerprint }) { - null -> RetryKeyDecision(mint(), RetryKeyMemory()) - else -> RetryKeyDecision(remembered.localId, RetryKeyMemory()) - } + fun keyFor(fingerprint: MessageFingerprint, mint: () -> String): RetryKeyDecision { + val hitIndex = slots.indexOfFirst { it.fingerprint == fingerprint } + if (hitIndex < 0) return RetryKeyDecision(mint(), this) + + val consumed = RetryKeyMemory(slots.filterIndexed { index, _ -> index != hitIndex }) + return RetryKeyDecision(slots[hitIndex].localId, consumed) + } + + companion object { + /** 同时保留的失败记录条数上限;淘汰策略见类注释「上限与淘汰」。 */ + const val MAX_SLOTS = 8 + } } 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 72074f4..f82a659 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 @@ -58,7 +58,7 @@ private const val DEVICE_ID = "voiceassist:cyrene:v1" * [localId] 同时是**上行幂等键** `clientMsgId`(契约 v0.56 §3.13):同一会话内服务端只承认 * 第一个非空值,重复到达回 `chat_duplicate`(WS)或 `{duplicate:true,messageId}`(REST)而 * **不再落库、不再起回合**。所以**同一条内容**的手动重试可以**复用**同一个 id:回滚时按 - * [MessageFingerprint] 把它记进 [ChatViewModel] 的单槽记忆,下一次发送指纹相等就接着用—— + * [MessageFingerprint] 把它记进 [ChatViewModel] 的失败记忆,下一次发送指纹相等就接着用—— * 「服务端其实已经收下、客户端判成失败」那种失败(队列满 / 超时 / 回执丢失)因此不会在 * 重试时落成第二条消息。指纹**不相等**(用户改过字 / 换了图 / 动了附件)则铸新键: * 那是**另一条**消息,绝不能顶替。它必须足够唯一:服务端的判重窗口是**整个会话历史** @@ -436,14 +436,15 @@ class ChatViewModel( private var streamParser: StreamingContentParser? = null /** - * 「上一次没发出去的那条」的记忆:**回滚**时按内容指纹记下它用的幂等键,**下一次发送** - * 指纹相等就复用那个键(见 [RetryKeyMemory])。 + * 「没发出去的那几条」的记忆(**多槽**,见 [RetryKeyMemory]):**回滚**时按内容指纹记下那条用的 + * 幂等键,**下一次发送**指纹相等就复用那个键。复用时**只消费命中的那一条**,别的失败记录原样 + * 留着 —— 用户重试的顺序不必和失败顺序一致,抹掉它们就等于让那几条的重发退回「铸新键」。 * * 这就是「手动重试同一个键 = 幂等」的落地处:**没有任何自动重试**,键的复用只在用户自己 * 又按了一次发送、且内容一字不差时发生([emitAndDeliver] 是唯一的消费者)。所有回滚路径都 * 汇到 [rollback],所以只有那一个写入口。 * - * 受理那一半由 [onAck] / [alignDuplicate] / [onQueueFull] 按 `localId` 清(正常那一路其实在 + * 受理那一半由 [onAck] / [alignDuplicate] / [onQueueFull] 按 `localId` 清(命中那一条其实在 * 发送时就已经被消费掉了,那几步是兜底)。但**落库消息到达时([onPersisted] / [reconcilePending]) * 刻意不清**:一条晚到的 `message_persisted` 恰恰是「服务端其实收下了、客户端当时判成失败」 * 的证据,这时候把键留着,用户接下来的重试才会被判重而不是落成第二条消息 —— 那正是本机制 @@ -606,11 +607,11 @@ class ChatViewModel( ) { if (text.isEmpty() && imageUri == null && documents.isEmpty()) return - // `localId` 即幂等键。它与「上一次没发出去的那条」内容一致时**复用当时的键**: + // `localId` 即幂等键。它与「某条没发出去的记录」内容一致时**复用当时的键**: // 那次失败可能是「服务端已经收下、客户端判成失败」(队列满 / 超时 / 回执丢失), // 复用键会让服务端判重并回 `chat_duplicate` / `{duplicate:true}`,而不是落成第二条消息。 // 内容不同(改过字 / 换图 / 动了附件)则铸新键——用户的编辑是一条**新**消息。 - // 记忆无论走哪一支都在这里被消费掉,绝不会被下一次发送再捡起来。 + // 消费只发生在命中那一支:命中的那条记录在这里被摘走,别的失败记录原样留着(见 [RetryKeyMemory])。 val decision = retryKey.keyFor(MessageFingerprint.of(text, imageUri, documents)) { "local-${UUID.randomUUID()}" } diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/chat/RetryKeyMemoryTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/RetryKeyMemoryTest.kt index 663f402..80bf7be 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/domain/chat/RetryKeyMemoryTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/RetryKeyMemoryTest.kt @@ -1,7 +1,6 @@ package top.yeij.cyrene.domain.chat import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Test @@ -12,16 +11,19 @@ import top.yeij.cyrene.ui.chat.QueueFullOutcome import top.yeij.cyrene.ui.chat.applyQueueFull /** - * 重试记忆:幂等键什么时候**复用**、什么时候**重铸**。 + * 重试记忆:幂等键什么时候**复用**、什么时候**重铸**、什么时候**被清**。 * * 这是本模块要修的那条「文档与实现不符」的判据所在:`PendingMessage` 的 KDoc 一直写着「重试复用 * 同一个 id」,而实现每次发送都铸新键 —— 于是「服务端其实已经收下、客户端判成失败」(队列满 / * 超时 / 回执丢失)之后,用户的手动重试会落成**第二条**消息。 * - * 这里把三条纪律逐条钉死,它们**缺一不可**: + * 记忆是**多槽**的,下面这几条纪律**缺一不可**: * 1. 指纹相等 ⇒ 复用;不相等 ⇒ 重铸(否则用户的编辑会被服务端判重丢掉); - * 2. 记下的键**用掉就没了**(否则他后来又打了一遍同样的话,会被无声吞掉); - * 3. 只有这一条能清的清得掉、别的条清不动(否则两条同时在飞时会互相踩)。 + * 2. 复用时**只消费命中的那一条**,其余槽原样保留;未命中则**一条槽也不清** —— 单槽时代「未命中 + * 也要清空」是对的(那时只有一条),多槽下照抄会把用户**别的**失败记录一起抹掉,那些重发就 + * 退化成非幂等; + * 3. 只有这一条能清的清得掉、别的条清不动(否则几条同时在飞时会互相踩); + * 4. 满了按 FIFO 丢最旧的,**且绝不丢刚写进去的那条**。 */ class RetryKeyMemoryTest { @@ -37,9 +39,17 @@ class RetryKeyMemoryTest { private val edited = MessageFingerprint.of("你好!", null, emptyList()) + private val other = MessageFingerprint.of("另一个问题", null, emptyList()) + private fun doc(uri: String) = DraftDocument(uri = uri, displayName = "a.txt", sizeBytes = 1L) - // —— 铸新键:没有记录时 —— + /** 按失败发生的先后逐条 `remember`(与 `ChatViewModel.rollback` 的调用顺序一致)。 */ + private fun failed(vararg records: Pair): RetryKeyMemory = + records.fold(RetryKeyMemory()) { memory, (fingerprint, localId) -> + memory.remember(fingerprint, localId) + } + + // —— 铸新键:没有对得上的记录时 —— @Test fun `an empty memory mints a brand new key`() { @@ -49,7 +59,21 @@ class RetryKeyMemoryTest { assertEquals("new-1", decision.localId) assertEquals(1, minter.count) - assertNull(decision.memory.slot) + assertTrue(decision.memory.slots.isEmpty()) + } + + @Test + fun `a brand new message mints a key without touching any slot`() { + val memory = failed(text to "local-K1", other to "local-K2") + val minter = KeyMinter() + + val decision = memory.keyFor(edited, minter::next) + + // 未命中 ≠ 记忆作废:这里是「另一条消息」,不是「那两条记录都不算数了」。 + assertEquals("new-1", decision.localId) + assertEquals(1, minter.count) + assertSame(memory, decision.memory) + assertEquals(listOf(RetryKeySlot(text, "local-K1"), RetryKeySlot(other, "local-K2")), decision.memory.slots) } // —— 复用:指纹一字不差 —— @@ -66,47 +90,6 @@ class RetryKeyMemoryTest { assertEquals(0, minter.count) } - @Test - fun `using the remembered key consumes it`() { - val remembered = RetryKeyMemory().remember(text, "local-K1") - - val afterUse = remembered.keyFor(text) { "local-K1" }.memory - assertNull(afterUse.slot) - - // 用户后来**又打了一遍**同样的话(上一条早就发出去了):那是真的第二条消息, - // 必须铸新键 —— 记录要是留在这儿,这句话会被服务端当重复吞掉,屏幕上什么都不出现。 - val minter = KeyMinter() - assertEquals("new-1", afterUse.keyFor(text, minter::next).localId) - assertEquals(1, minter.count) - } - - // —— 重铸:用户改过 —— - - @Test - fun `editing the text mints a new key and drops the record`() { - val remembered = RetryKeyMemory().remember(text, "local-K1") - val minter = KeyMinter() - - val decision = remembered.keyFor(edited, minter::next) - - // 改过字就是**另一条**消息:复用旧键会让服务端判重,用户这次的编辑一个字都发不出去。 - assertEquals("new-1", decision.localId) - assertEquals(1, minter.count) - assertNull(decision.memory.slot) - } - - @Test - fun `an edit followed by re-sending the original text mints yet another key`() { - val remembered = RetryKeyMemory().remember(text, "local-K1") - - // 「改了再发」这一步把记录消费掉了 ⇒ 之后发回原来那句话也拿不回旧键。 - val afterEdit = remembered.keyFor(edited) { "new-1" }.memory - - val minter = KeyMinter() - assertEquals("new-1", afterEdit.keyFor(text, minter::next).localId) - assertEquals(1, minter.count) - } - @Test fun `only a fingerprint that matches exactly reuses the key`() { val original = MessageFingerprint.of("你好", "content://img/1", listOf(doc("content://doc/a"))) @@ -134,35 +117,153 @@ class RetryKeyMemoryTest { assertEquals("local-K1", remembered.keyFor(original) { "new-x" }.localId) } + @Test + fun `the retry that used the key consumes exactly that record`() { + val remembered = RetryKeyMemory().remember(text, "local-K1") + + val afterUse = remembered.keyFor(text) { "local-K1" }.memory + assertTrue(afterUse.slots.isEmpty()) + + // 用户后来**又打了一遍**同样的话(上一条早就发出去了):那是真的第二条消息, + // 必须铸新键 —— 记录要是留在这儿,这句话会被服务端当重复吞掉,屏幕上什么都不出现。 + val minter = KeyMinter() + assertEquals("new-1", afterUse.keyFor(text, minter::next).localId) + assertEquals(1, minter.count) + } + + @Test + fun `consuming the hit leaves every other slot in place`() { + val memory = failed(text to "local-K1", other to "local-K2", edited to "local-K3") + + val afterHit = memory.keyFor(other) { "new-x" }.memory + + assertEquals(listOf(RetryKeySlot(text, "local-K1"), RetryKeySlot(edited, "local-K3")), afterHit.slots) + } + + // —— 多槽:各自失败、各自复用(本次改动的核心) —— + + @Test + fun `two failures each keep their own key however the user retries them`() { + val memory = failed(text to "local-K1", other to "local-K2") + val minter = KeyMinter() + + // 用户的重试顺序不必和失败顺序一致:先重试更早失败的 `text`,同样落回**它自己**的键。 + val afterText = memory.keyFor(text, minter::next) + assertEquals("local-K1", afterText.localId) + + val afterOther = afterText.memory.keyFor(other, minter::next) + assertEquals("local-K2", afterOther.localId) + assertEquals(0, minter.count) + } + + @Test + fun `retrying the later failure first still finds the earlier one`() { + val memory = failed(text to "local-K1", other to "local-K2") + val minter = KeyMinter() + + val afterOther = memory.keyFor(other, minter::next) + assertEquals("local-K2", afterOther.localId) + + assertEquals("local-K1", afterOther.memory.keyFor(text, minter::next).localId) + assertEquals(0, minter.count) + } + + @Test + fun `retrying one failed message does not wipe the other slot`() { + val memory = failed(text to "local-K1", other to "local-K2") + + val afterRetry = memory.keyFor(text) { "new-1" }.memory + + // 单槽时代这一步会清空整份记忆 ⇒ 之后重试 B 只能铸新键,而 B 那次失败完全可能是 + // 「服务端其实已经收下、客户端判成失败」:那次重发就落成了第二条消息,正是本机制要消灭的重复。 + assertEquals(listOf(RetryKeySlot(other, "local-K2")), afterRetry.slots) + assertEquals("local-K2", afterRetry.keyFor(other, KeyMinter()::next).localId) + } + + @Test + fun `remembering the same content again updates its key instead of adding a second slot`() { + // 同一条(指纹相同)又失败一次:键换成新的,槽还是那一个(并列两条只会命中更早那条, + // 用户的重试反而用不上刚铸的键)。 + val memory = failed(text to "local-K1", other to "local-K2").remember(text, "local-K1b") + + assertEquals(listOf(RetryKeySlot(other, "local-K2"), RetryKeySlot(text, "local-K1b")), memory.slots) + assertEquals("local-K1b", memory.keyFor(text, KeyMinter()::next).localId) + } + + // —— 编辑:重铸但不迁怒别的槽 —— + + @Test + fun `an edit mints a new key and keeps the failed record`() { + val remembered = RetryKeyMemory().remember(text, "local-K1") + val minter = KeyMinter() + + val decision = remembered.keyFor(edited, minter::next) + + // 改过字就是**另一条**消息:复用旧键会让服务端判重,用户这次的编辑一个字都发不出去。 + assertEquals("new-1", decision.localId) + assertEquals(1, minter.count) + // 但记忆不因此作废 —— 未命中只说明这次的内容没有记录对得上(见类注释第 2 条)。 + assertSame(remembered, decision.memory) + } + + @Test + fun `an edit does not cost the original text its key`() { + val remembered = RetryKeyMemory().remember(text, "local-K1") + + // 「改了再发」这一步不清槽 ⇒ 之后发回原来那句话仍然拿得回旧键。这是「未命中不清」的代价: + // 单槽时代的测试在这里断言的是「铸新键」,多槽下那条断言**不再成立**,也不是我们想要的。 + val afterEdit = remembered.keyFor(edited) { "new-1" }.memory + + val minter = KeyMinter() + assertEquals("local-K1", afterEdit.keyFor(text, minter::next).localId) + assertEquals(0, minter.count) + } + + // —— 上限:FIFO 丢最旧,刚写的必须留 —— + + @Test + fun `more than MAX_SLOTS failures drop the oldest and keep the newest`() { + val count = RetryKeyMemory.MAX_SLOTS + 3 + val records = List(count) { index -> + MessageFingerprint.of("第 $index 条", null, emptyList()) to "local-K$index" + } + + val memory = records.fold(RetryKeyMemory()) { acc, (fingerprint, localId) -> + acc.remember(fingerprint, localId) + } + + assertEquals(RetryKeyMemory.MAX_SLOTS, memory.slots.size) + assertEquals( + records.takeLast(RetryKeyMemory.MAX_SLOTS).map { RetryKeySlot(it.first, it.second) }, + memory.slots, + ) + + // 刚写进去的那条一定在(淘汰只可能从头开始),还认得出自己的键。 + val minter = KeyMinter() + assertEquals("local-K${count - 1}", memory.keyFor(records.last().first, minter::next).localId) + assertEquals(0, minter.count) + + // 被挤出去的那条就只剩「铸新键」了:它的键确实已经不在记忆里。 + assertEquals("new-1", memory.keyFor(records.first().first, minter::next).localId) + } + // —— 清:只在无歧义时清得动 —— @Test fun `forget clears exactly the message that settled`() { - val remembered = RetryKeyMemory().remember(text, "local-K1") + val memory = failed(text to "local-K1", other to "local-K2") - assertNull(remembered.forget("local-K1").slot) + assertEquals(listOf(RetryKeySlot(other, "local-K2")), memory.forget("local-K1").slots) } @Test fun `forget leaves another message's key alone`() { - val remembered = RetryKeyMemory().remember(text, "local-K1") + val memory = failed(text to "local-K1", other to "local-K2") - // 两条同时在飞时,一条被受理(这里键是 local-other)不该把另一条刚回滚的记忆抹掉: - // 抹掉它,那条的重试就退回「铸新键」,正是本模块要消灭的重复。 - assertSame(remembered, remembered.forget("local-other")) - assertEquals(RetryKeySlot(text, "local-K1"), remembered.forget("local-other").slot) - } - - @Test - fun `remembering a later failure replaces the earlier one`() { - // 单槽:记忆只对「用户下一次按发送」有意义,而那一次只可能是最近一次失败的那条。 - val memory = RetryKeyMemory() - .remember(text, "local-K1") - .remember(edited, "local-K2") - - assertEquals(RetryKeySlot(edited, "local-K2"), memory.slot) - // 更早那条已经不在输入栏里了,它的键也就跟着作废。 - assertEquals("new-1", memory.keyFor(text) { "new-1" }.localId) + // 几条同时在飞时,一条被受理(这里键是 local-other)不该把别条刚回滚的记忆抹掉: + // 抹掉它们,那几条的重试就退回「铸新键」,正是本模块要消灭的重复。 + assertSame(memory, memory.forget("local-other")) + assertEquals(2, memory.forget("local-other").slots.size) } // —— 与气泡对齐的衔接:重试真的撞上原来那条 ——