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:
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)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user