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 716360c..8bb5825 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 @@ -1626,7 +1626,7 @@ class ChatViewModel( emitPlannedMessages( plan = imageSendPlan(text, images, documents), clearDraft = true, - applySendPreference = true, + fromShare = false, localIdPrefix = LOCAL_CLIENT_MSG_ID_PREFIX, ) } @@ -1954,7 +1954,7 @@ class ChatViewModel( * - **空内容也照常处理**:[notice] 该说就说,静默什么都不发生比报一句更糟; * - **一次都不套用「她忙时发消息」那个偏好**:见下面「分享不声明」一段。 * - * ## 分享不声明(`applySendPreference` 恒为 false) + * ## 分享不声明(`fromShare = true` ⇒ [ShareRules.declaresSendPreference] 恒为 false) * 分享是**一次把东西倒进去**的批量动作:N 张图 = N 条消息,[emitPlannedMessages] 会逐条投递, * 而「她忙时发消息」那个偏好(协同 A2 + 忙时并行规范 §2.2)在**每条**投递前都会声明点什么。 * 两种声明在这里**都**是错的: @@ -2015,7 +2015,7 @@ class ChatViewModel( emitPlannedMessages( plan = imageSendPlan(text, share.images, files), clearDraft = false, - applySendPreference = false, + fromShare = true, localIdPrefix = SHARE_CLIENT_MSG_ID_PREFIX, ) } @@ -2179,16 +2179,20 @@ class ChatViewModel( * 落在第一条上:用户按的那一下、想打断/并行的意图照样生效一次,其后几条按编排器的正常续跑规则 * 依次排队。N = 1 时**逐字节等于今天**(第一条就是唯一那条)。 * + * 判据**只有一处**:[ShareRules.declaresSendPreference](纯函数,穷举整批的测试在 + * `ShareSendDeclarationTest`)。这里只把两条入口的事实([fromShare])与位置([index])喂给它, + * 不在本函数里再写一遍 `&& index == 0`——那种写法正是 W76 之前两处各半个判据的由来。 + * * @param clearDraft 只有用户按发送键那条路为 true,且**只清第一条**:N 条是**一次**发送, * 草稿也只该清一次;后面几条若也清,用户在投递途中(每条之间都让出了线程)新打的字会被抹掉。 - * @param applySendPreference 这一次发送要不要套用「她忙时发消息」那个偏好(见上)。分享落地的 - * 那条传 false(分享不是用户按的发送键,从不声明)。 + * @param fromShare 这一批是不是分享进来的:分享落地那条传 true([sendShared]),输入栏那条传 + * false([send])。它是 [ShareRules.declaresSendPreference] 的入参之一——分享从不声明。 * @param localIdPrefix 见 [emitAndDeliver]:分享那条传 [SHARE_CLIENT_MSG_ID_PREFIX](角标的唯一判据)。 */ private suspend fun emitPlannedMessages( plan: ImageSendPlan, clearDraft: Boolean, - applySendPreference: Boolean, + fromShare: Boolean, localIdPrefix: String, ) { ShareRules.truncatedImagesNotice(plan.imagesToSend.size, plan.truncatedImages) @@ -2199,7 +2203,7 @@ class ChatViewModel( imageUri = message.imageUri, documents = message.documents, clearDraft = clearDraft && index == 0, - applySendPreference = applySendPreference && index == 0, + applySendPreference = ShareRules.declaresSendPreference(fromShare, index), localIdPrefix = localIdPrefix, ) } @@ -2211,9 +2215,9 @@ class ChatViewModel( * @param clearDraft 只有「用户按了发送键」才清草稿——那时草稿正是刚发出去的这一条; * 分享落地是替用户发一条他自己录进来的内容,草稿不是它。 * @param applySendPreference 这条投递要不要套用「她忙时发消息」那个偏好(两种声明: - * `interrupt` 与 `parallel`)。**默认 true = 用户按发送键的既有口径**;两处传 false: - * [sendShared](分享从不声明,理由见它那一段)与 [emitPlannedMessages] 里第一条之后的那些 - * ——一次发送切出来的第 2 条起声明打断,就是在**打断自己刚起的上一轮**。 + * `interrupt` 与 `parallel`)。它是**已经算好的结果**,由 [emitPlannedMessages] 按 + * [ShareRules.declaresSendPreference] 逐条判出来(唯一调用方就是它)——本函数不再自己推导, + * 也没有默认值:判据只能有一个出处,给个 `= true` 的默认值等于把口径又抄了一遍。 * @param localIdPrefix 幂等键的前缀:手打那条路是 [LOCAL_CLIENT_MSG_ID_PREFIX](默认), * [sendShared] 传 [SHARE_CLIENT_MSG_ID_PREFIX]。它把「这条是怎么进来的」写进了这个本来就在 * 上行的字段,好让渲染层据此画那枚分享角标([SHARED_MESSAGE_BADGE])——**正文因此一个字节都不用动** @@ -2226,7 +2230,7 @@ class ChatViewModel( imageUri: String?, documents: List, clearDraft: Boolean, - applySendPreference: Boolean = true, + applySendPreference: Boolean, localIdPrefix: String = LOCAL_CLIENT_MSG_ID_PREFIX, ) { // 与输入栏那条入口**同一个判据**([hasSendableContent]):空内容不上屏、不排队、不发帧。 @@ -2306,10 +2310,10 @@ class ChatViewModel( // 之间切档就会让一条消息既打断又并行——那正是这两档的互斥语义要排除的组合(见 [InterruptMode])。 // 每次投递只经过这里一次 ⇒ 打断帧**最多一条**,下面的通道分支与任何后续重试都不会再发。 // - // 只有 [applySendPreference] 为 true 才套用偏好(= 用户按发送键那条路,默认档)。 - // [ChatViewModel.sendShared] 传 false:它是**批量**投递,两种声明在那儿都是错的(理由与取舍 - // 见 [sendShared] 的「分享不声明」一段)。一次发送切出的多条里也只有第一条是 true - // (见 [emitPlannedMessages]:第 2 条起声明打断就是在打断自己刚起的上一轮)。 + // 只有 [applySendPreference] 为 true 才套用偏好(= 用户按发送键那条路,且是这一批的第一条)。 + // 这个值由 [emitPlannedMessages] 按 [ShareRules.declaresSendPreference] 逐条算好后传进来—— + // 分享那条入口([ChatViewModel.sendShared])因此一次都不声明:它是**批量**投递,两种声明在那儿 + // 都是错的(理由与取舍见 [sendShared] 的「分享不声明」一段)。 val mode = currentInterruptMode() val busy = _state.value.busyMode != null if (applySendPreference) interruptRunningTurn(mode, busy) @@ -2730,7 +2734,7 @@ class ChatViewModel( * 判据本身是纯函数 [interruptScopeForSend](默认档与「并行」档恒不发、忙且选了「打断」才发 * `scope:"current"`,命中空闲会话不发),这里只做一件事:把帧发出去。它由 [deliver] 在**通道分支 * 之前**调用,所以一次投递最多一条打断帧——且**只在 [deliver] 的 `applySendPreference` 为 true 时** - * 才调(分享那条批量路径传 false,见 [sendShared])。 + * 才调(那个值出自 [ShareRules.declaresSendPreference],分享那条批量路径恒为 false,见 [sendShared])。 * * [mode] / [busy] 由 [deliver] 读一次偏好后传进来,与 [parallelForSend] 共用**同一次读**: * 本函数自己再读一次偏好就会有一个窗口——用户在两读之间切档,一条消息既打断又并行。 diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareDeclarationWiringTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareDeclarationWiringTest.kt new file mode 100644 index 0000000..4d067ff --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareDeclarationWiringTest.kt @@ -0,0 +1,75 @@ +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 +import top.yeij.cyrene.domain.share.ShareRules + +/** + * 接线:`ChatViewModel.emitPlannedMessages` 逐条投递时喂给 [ShareRules.declaresSendPreference] 的 + * 那两个入参(`fromShare` 与 `index`)。 + * + * ## 判据在 `ShareSendDeclarationTest`,这里钉的是「喂进去的是什么」 + * 判据本身(`!fromShare && index == 0`)已被 `ShareSendDeclarationTest` 在裸整数上穷举过。本测试补的 + * 是另一半:VM 那个 `forEachIndexed` 的 `index` **是 [ImageSendPlan.messages] 里的位置**,不是选图 + * 顺序、也不是 `imagesToSend` 的下标。两者在超限截断时会不一样——`images` 给了 7 张而 `messages` 只有 + * [ShareRules.MAX_IMAGES] 条,若哪天把声明算在「原始选图位置」上,就会有一条落在这次根本不发的消息上。 + * + * ## 为什么不是「直接调 VM」 + * `ChatViewModel` 要一个 [top.yeij.cyrene.app.AppContainer],本项目的单元测试没有 fake container / + * fake SessionsApi 这套基建(登记在 `05` 的遗留里),所以 VM 里的编排穿不过去。这里退一步钉住**它喂进去 + * 的形状**;调用点自身靠结构守:`fromShare` 是必填参数(没有默认值)、判据只有 [ShareRules] 一处。 + */ +class ShareDeclarationWiringTest { + + private fun images(n: Int): List = (1..n).map { "content://img$it" } + + /** VM 里那一段:`plan.messages.forEachIndexed { index, _ -> …declaresSendPreference(fromShare, index) }`。 */ + private fun declarations(fromShare: Boolean, plan: ImageSendPlan): List = + plan.messages.indices.map { index -> ShareRules.declaresSendPreference(fromShare, index) } + + @Test + fun `a share batch declares nothing at any position of the plan`() { + // 0 = 只有正文的分享(也走这条路,同样不声明);1..MAX_IMAGES 逐档穷举。 + (0..ShareRules.MAX_IMAGES).forEach { n -> + val plan = imageSendPlan(text = "看这个", images = images(n), documents = emptyList()) + assertEquals("N=$n 的切分至少一条消息", n.coerceAtLeast(1), plan.messages.size) + assertFalse( + "分享 N=$n 的任何一条都不许声明(声明就会打断同一批里的上一条)", + declarations(fromShare = true, plan).any { it }, + ) + } + } + + @Test + fun `the typed send declares on the first planned message only`() { + (0..ShareRules.MAX_IMAGES).forEach { n -> + val plan = imageSendPlan(text = "看这个", images = images(n), documents = emptyList()) + assertEquals( + "手打 N=$n:只有第一条按「她忙时发消息」偏好投递", + List(plan.messages.size) { index -> index == 0 }, + declarations(fromShare = false, plan), + ) + } + } + + @Test + fun `truncated images never shift the declaration onto a message outside the batch`() { + val plan = imageSendPlan( + text = "看这个", + images = images(ShareRules.MAX_IMAGES + 3), + documents = emptyList(), + ) + // 超限那几张本来就不发(见 truncatedImagesNotice),声明只按真正发出去的条数算。 + assertEquals(ShareRules.MAX_IMAGES, plan.messages.size) + assertEquals(3, plan.truncatedImages) + + assertEquals(ShareRules.MAX_IMAGES, declarations(fromShare = true, plan).size) + assertFalse(declarations(fromShare = true, plan).any { it }) + assertEquals( + List(ShareRules.MAX_IMAGES) { index -> index == 0 }, + declarations(fromShare = false, plan), + ) + } +}