fix(w79): 让「分享零声明 / 批量只第一条声明」只剩一个判据
接 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 的遗留里),本次靠「参数必填 + 判据单点 + 复刻喂入形状」三条结构约束守。
This commit is contained in:
1 parent
e94bdf6583
commit
f6eb93027d
3 files changed
+95
-16
No files matched your search
Whitespace-only changes.
@@ -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<DraftDocument>,
|
||||
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] 共用**同一次读**:
|
||||
* 本函数自己再读一次偏好就会有一个窗口——用户在两读之间切档,一条消息既打断又并行。
|
||||
|
||||
@@ -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<String> = (1..n).map { "content://img$it" }
|
||||
|
||||
/** VM 里那一段:`plan.messages.forEachIndexed { index, _ -> …declaresSendPreference(fromShare, index) }`。 */
|
||||
private fun declarations(fromShare: Boolean, plan: ImageSendPlan): List<Boolean> =
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user