From e94bdf6583a533b10db8d1b87eeb9b0aa2bab4e6 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 26 Oct 2026 23:59:21 +0800 Subject: [PATCH 1/2] tower: snapshot of uncommitted base checkout changes (worktree wt-6) --- .cyrene-build.lock | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 .cyrene-build.lock diff --git a/.cyrene-build.lock b/.cyrene-build.lock new file mode 100644 index 0000000..e69de29 From f6eb93027dbbb0d138dca3d4ec708822adab92ef Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 27 Oct 2026 00:21:20 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(w79):=20=E8=AE=A9=E3=80=8C=E5=88=86?= =?UTF-8?q?=E4=BA=AB=E9=9B=B6=E5=A3=B0=E6=98=8E=20/=20=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E5=8F=AA=E7=AC=AC=E4=B8=80=E6=9D=A1=E5=A3=B0=E6=98=8E=E3=80=8D?= =?UTF-8?q?=E5=8F=AA=E5=89=A9=E4=B8=80=E4=B8=AA=E5=88=A4=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接 W76 的 finding:M2 把口径提成纯函数 ShareRules.declaresSendPreference 并加了穷举测试, 但接线那一行落在 M2 scope 外,于是判据仍是两处、每处各半个——分享路径写死 applySendPreference = false,批量那条写 applySendPreference && index == 0,两处都单测不到, 而纯函数没有调用方(dead code)。 改法是把入口的事实交回入口:emitPlannedMessages 的参数从 applySendPreference: Boolean 改成 fromShare: Boolean(sendShared 传 true、输入栏 send 传 false),函数体只写 applySendPreference = ShareRules.declaresSendPreference(fromShare, index)。于是「谁声明」 这个问题的答案只剩 ShareRules 一处,VM 只负责把 (fromShare, index) 喂进去。 emitAndDeliver 的 applySendPreference: Boolean = true 默认值一并去掉(改必填)。唯一调用方 就是 emitPlannedMessages,留着默认值等于把口径又抄了一遍:任何新调用点不写这个参数就会 静默拿到「声明」,而这正是本次要消灭的那种第二出处。deliver 里 if (applySendPreference) 与 parallel = applySendPreference && … 一字未改,只是值的来源变了。 行为零变化(逐入口核对):分享路径 fromShare = true ⇒ declaresSendPreference 恒 false ⇒ 零 interrupt 帧、零 parallel,与原先写死 false 等价;手打路径 fromShare = false ⇒ 只有 index == 0 声明,与原先 && index == 0 等价。 测试:新增 ShareDeclarationWiringTest 3 条,钉的是「喂进去的是什么」——判据本身已被 ShareSendDeclarationTest 在裸整数上穷举,这里补另一半:VM 那个 forEachIndexed 的 index 是 plan.messages 的位置,不是选图顺序、也不是 imagesToSend 的下标,两者在超限截断时会分叉。 所以用 imageSendPlan + plan.messages.indices 复刻 VM 的形状,覆盖分享 N=0..MAX_IMAGES 恒不声明、手打只第一条声明、7 张图截断到 4 条时声明不落到这次不发的消息上。 ShareSendDeclarationTest 不动:裸整数判据与 plan 形状互补,合并会丢掉一层的分辨力。 全量 testDebugUnitTest --rerun-tasks:142 suites / 1755 tests / 0 failures / 0 errors / 0 skipped(基线 141/1752,+1 suite / +3 tests)。 顺手 git rm --cached .cyrene-build.lock:快照 e94bdf6 把一个空的构建锁带进了分支根目录, 锁的实际位置在仓库之外(D:/Project/Code/Uni/.cyrene-build.lock),这个 0 字节副本是误入。 仍未验:ChatViewModel 的编排本身仍没有测试穿过(项目缺 fake AppContainer / SessionsApi 基建,已登记在 05 的遗留里),本次靠「参数必填 + 判据单点 + 复刻喂入形状」三条结构约束守。 --- .cyrene-build.lock | 0 .../top/yeij/cyrene/ui/chat/ChatViewModel.kt | 36 +++++---- .../ui/chat/ShareDeclarationWiringTest.kt | 75 +++++++++++++++++++ 3 files changed, 95 insertions(+), 16 deletions(-) delete mode 100644 .cyrene-build.lock create mode 100644 app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareDeclarationWiringTest.kt diff --git a/.cyrene-build.lock b/.cyrene-build.lock deleted file mode 100644 index e69de29..0000000 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), + ) + } +}