feat(chat): 图片入口改多选,N 张图按既有切分落成 N 条消息

主人 2026-10-05 定案「那肯定要多选」:图片按钮从 PickVisualMedia(单选、回一个 Uri)换成
PickMultipleVisualMedia(系统 Photo Picker 的多选形态,仍然零权限,不退回落 SAF / 存储权限)。
文件按钮那条 OpenMultipleDocuments 一个字没动。

为什么复用分享路径那一段(不另起一套发送逻辑):
分享落地(ChatViewModel.sendShared)早就把「一条消息只能带一张图 ⇒ N 张图 = N 条消息」做完了,
那段切分本来内联在它自己的 forEach 里,不是可复用的纯函数。这次把判据抽成纯函数 imageSendPlan
(ui/chat/ImageSendPlan.kt,跟 SharePayload.kt 与 SendQueue.kt 一样的写法,可单测),投递抽成
emitPlannedMessages(私有),输入栏那条路与分享落地那条路共用同一段:两处各写一份循环,迟早会在
「哪条带正文 / 哪条带附件 / 哪条清草稿 / 超过上限怎么办」上分叉。每张图各自的解码 / 缩放 / 重编码
与「内联还是上传」的取舍仍旧逐张走既有实现(deliver 与 deliverImage),一行没改。

为什么 N 张 = N 条(而不是一条带多张):
契约里 chat.image 是单图字段,一次上行只能带一张,所以多选进来的 N 张只能落成 N 条消息:
第一条带正文 + 第一张图 + 全部附件,其余每条一张图,顺序 = 用户在多选器里选的顺序。契约与服务端
一个字没动。

上限与告知沿用分享路径的口径:
上限仍是 ShareRules.MAX_IMAGES(4)。超限那几张不静默丢,如实告知;那句文案抽成
ShareRules.truncatedImagesNotice,分享入口与选图入口共用同一句(逐字未改,ShareNoticeTest 钉住)。
系统选择器自己会把可选张数卡在 4 上,只有回落成 ACTION_OPEN_DOCUMENT 的设备(它表达不了最多几张)
以及「草稿里已有几张、再选几张」这两种情形会真超,那时由入口与投递两处说出同一句。

其余口径与今天一致:
输入栏最多攒 4 张(到顶后不再显示图片入口),每张各自可移除;取消选择什么都不做;
发送时第一条带正文并清草稿(N 条是一次发送,草稿只清一次),失败的那一条只把自己那一张还给输入栏。
一次发送切出的多条里只有第一条套用「她忙时发消息」偏好——第 2 条起声明打断就是打断自己刚起的
上一轮(与分享路径不声明同一条理由),N = 1 时与今天逐字节相同。

顺带:
- ChatUiState.draftImage(String?)改为 draftImages(List<String>),rollbackPending 把失败那张补回
  待发列表(不再覆盖),输入栏渲染成每张各自带移除键的一排预览;
- QueueFullOutcomeTest 里断言单图的四处改成新事实(断言一条没删);
- 新增 ImageSendPlanTest,覆盖 N = 0 / 1 / 2 / 4 / 5(超限),并钉住附件只跟第一条、顺序不重排、
  超限那句与分享路径同一句。
This commit is contained in:
AskaEth committed 2026-10-05 14:41:21 +08:00
1 parent eb22521269
commit 5a9f52a069
7 files changed
+369 -67

No files matched your search

@@ -0,0 +1,113 @@
package top.yeij.cyrene.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.domain.chat.DraftDocument
import top.yeij.cyrene.domain.share.ShareRules
/**
* 「一次发送带 N 张图」的切分判据([imageSendPlan])。
*
* 背景(W42 主人定案:「那肯定要多选」):契约里 `chat.image` 是**单图字段**,所以 N 张图 = N 条
* 消息,第一条带正文 + 第一张图 + 全部附件,其余每条一张图。切错的两种样子用户都看得见——正文挂错
* 条(第二张图把用户打的那段字带走了)、图少发一张(选了几张、发出去几张对不上)。而它本身是纯
* 函数:不需要组合界面,就能把 N = 0 / 1 / 2 / 4(正好到顶)/ 5(超限)逐条穷举。
*
* 顺带钉住超限的那句告知:它与分享路径**同一句**([ShareRules.truncatedImagesNotice]),因为两个
* 入口的上限本来就是同一个数。
*/
class ImageSendPlanTest {
private val doc = DraftDocument(uri = "content://doc", displayName = "笔记.md")
private fun images(n: Int): List<String> = (1..n).map { "content://img$it" }
@Test
fun `no image is one plain message carrying the text and the documents`() {
val plan = imageSendPlan(text = "看这个", images = emptyList(), documents = listOf(doc))
assertEquals(1, plan.messages.size)
assertEquals("看这个", plan.messages.single().text)
assertNull(plan.messages.single().imageUri)
assertEquals(listOf(doc), plan.messages.single().documents)
assertEquals(0, plan.truncatedImages)
}
@Test
fun `an empty send still plans one message - whether there is anything to send is a different judge`() {
// N = 0 且三样都空:这条规则照旧回一条(它是同一条规则的空形态)。「有没有东西可发」由
// hasSendableContent 判——两个判据各管一件事,谁也不替谁下结论。
val plan = imageSendPlan(text = "", images = emptyList(), documents = emptyList())
assertEquals(1, plan.messages.size)
assertTrue(plan.imagesToSend.isEmpty())
assertEquals(0, plan.truncatedImages)
}
@Test
fun `one image stays one message with the text and the documents on it`() {
val plan = imageSendPlan(text = "带一张", images = images(1), documents = listOf(doc))
assertEquals(1, plan.messages.size)
assertEquals("带一张", plan.messages.single().text)
assertEquals("content://img1", plan.messages.single().imageUri)
assertEquals(listOf(doc), plan.messages.single().documents)
assertEquals(listOf("content://img1"), plan.imagesToSend)
}
@Test
fun `two images become two messages and only the first carries the text and the documents`() {
val plan = imageSendPlan(text = "两张", images = images(2), documents = listOf(doc))
assertEquals(2, plan.messages.size)
assertEquals(listOf("两张", ""), plan.messages.map { it.text })
assertEquals(listOf("content://img1", "content://img2"), plan.imagesToSend)
assertEquals(listOf(doc), plan.messages[0].documents)
// 附件只随第一条:它本来就挂在**这条**消息上(`chat.documents` 是数组,但不是每条各一份)。
assertTrue(plan.messages[1].documents.isEmpty())
assertEquals(0, plan.truncatedImages)
}
@Test
fun `the order the user picked is the order the messages go out`() {
val plan = imageSendPlan(text = "四张", images = images(4), documents = emptyList())
assertEquals(4, plan.messages.size)
assertEquals(images(4), plan.imagesToSend)
assertEquals("四张", plan.messages.first().text)
// 第二条起正文为空:不替用户多说一个字,也不把上一张的配文复制一遍。
assertTrue(plan.messages.drop(1).all { it.text.isEmpty() })
}
@Test
fun `four images is exactly the cap and nothing is dropped`() {
val plan = imageSendPlan(text = "到顶了", images = images(ShareRules.MAX_IMAGES), documents = emptyList())
assertEquals(ShareRules.MAX_IMAGES, plan.messages.size)
assertEquals(ShareRules.MAX_IMAGES, plan.imagesToSend.size)
assertEquals(0, plan.truncatedImages)
}
@Test
fun `five images keeps the first four and reports one dropped - never silently`() {
val plan = imageSendPlan(text = "超了一张", images = images(5), documents = listOf(doc))
assertEquals(ShareRules.MAX_IMAGES, plan.messages.size)
assertEquals(images(4), plan.imagesToSend)
assertEquals(1, plan.truncatedImages)
// 「只发送前 N 张」里的 N 就是真发出去的那几张(告知那句文案拿它拼)。
assertEquals(
"分享的图片超过 ${ShareRules.MAX_IMAGES} 张,只发送前 ${ShareRules.MAX_IMAGES} 张",
ShareRules.truncatedImagesNotice(plan.imagesToSend.size, plan.truncatedImages),
)
}
@Test
fun `the over-limit notice says nothing when nothing was dropped`() {
// 一张没少时说话才是噪声:这正是「分享一条正常的图什么都不说」的那条边界。
assertNull(ShareRules.truncatedImagesNotice(1, 0))
assertNull(ShareRules.truncatedImagesNotice(ShareRules.MAX_IMAGES, 0))
}
}
@@ -46,7 +46,7 @@ class QueueFullOutcomeTest {
val state = ChatUiState(
pending = listOf(pending("local-a", text = "这条", imageUri = "content://img", documents = listOf(draft))),
draft = "用户正在输入的下一条",
draftImage = null,
draftImages = emptyList(),
draftDocuments = emptyList(),
error = null,
)
@@ -59,7 +59,7 @@ class QueueFullOutcomeTest {
// 的消息再发一遍(而且会覆盖他正在输入的内容)。
assertTrue(after.pending.isEmpty())
assertEquals("用户正在输入的下一条", after.draft)
assertNull(after.draftImage)
assertTrue(after.draftImages.isEmpty())
assertTrue(after.draftDocuments.isEmpty())
assertNull(after.error)
}
@@ -80,7 +80,7 @@ class QueueFullOutcomeTest {
val state = ChatUiState(
pending = listOf(pending("local-a", text = "被拒的正文", imageUri = "content://img", documents = listOf(draft))),
draft = "",
draftImage = null,
draftImages = emptyList(),
draftDocuments = emptyList(),
)
@@ -90,7 +90,8 @@ class QueueFullOutcomeTest {
assertTrue(after.pending.isEmpty())
assertEquals("被拒的正文", after.draft)
assertEquals("content://img", after.draftImage)
// N 张图 = N 条消息之后,失败的那一条只把自己那一张还给输入栏(别的几条各自的图不归它管)。
assertEquals(listOf("content://img"), after.draftImages)
assertEquals(listOf(draft), after.draftDocuments)
assertEquals(QUEUE_FULL_MESSAGE, after.error)
}
@@ -107,7 +108,7 @@ class QueueFullOutcomeTest {
)
assertEquals("正在输入", after.draft)
assertEquals("content://img", after.draftImage)
assertEquals(listOf("content://img"), after.draftImages)
assertEquals(QUEUE_FULL_MESSAGE, after.error)
}