feat(chat): 普通发送未连接时进队列(不再回滚),连接恢复后按发送顺序自动补发

主人 2026-10-05 裁决:普通发送在「未连接」时进队列。

此前 deliver 拿到 sent=false 就 rollback:气泡摘掉、正文退回输入栏,外加一句「还没连上,
这条没发出去,请稍后手动重试」。内容是没丢,但把「网络还没好」说成了一次要用户自己负责的
失败,而且他还得对着输入栏再发一遍。这一批按 W38「分享落地等连接」那套语义重做普通发送:
不报错、内容不丢、连接恢复后自动补发。W38 的落地时机、PendingShareHolder.take() 的原子语义、
ChatSocket 的连接状态机一个字都没动。

① 三档处置:未就绪就排队(新纯函数 wsSendOutcome,ui/chat/SendQueue.kt)
判据的输入只有两个:sendChat 的回值,以及这一刻 socket 自己的连接状态。sent=true ⇒ Dispatched
(帧已经交给 socket,成功路径一个字没动:不打标记、不说告知、不写任何状态);sent=false 且
连接就绪 ⇒ Failed(连接是好的,那 false 只可能来自 socket 对象已空 / 已被 cancel / 序列化抛
异常,「发送失败」在这一刻是事实 ⇒ 走今天那条 rollback + SEND_FAILED_MESSAGE);sent=false 且
未就绪 ⇒ Queued(ChatSocket.send 第一行就按状态挡掉,帧连序列化都没做、服务端一个字节都收不到,
所以既不回滚也不进 error 横幅)。未就绪四档(Idle / Connecting / Reconnecting / Offline)
一视同仁:主人的原话是「未连接就进队列」,Offline 只是不会自己变好,内容仍然一个字节都不会丢,
两档的差别只在告知措辞(这一点与 W39 给分享落地定的规矩一致)。

② 队列与补发:顺序与「只发一次」的保证点
deliver 的 WS 分支只改了「未就绪」这一支:queueForConnection 把这条留在待发态
(ChatUiState.queuedForConnection,条目只有 localId 与投递那一刻定下的并行声明;正文 / 图片 /
附件仍从那条气泡上现取,同一件事不留第二份真相源,也就不存在两条真相分叉)。触发点只有一个:
socket 状态变成 Connected 那一刻(onConnectionChanged,由状态流驱动、没有任何轮询),外加
入队之后的一次竞态兜底(连接恰好在投递与入队之间就绪时,那一次补发会扑空)。补发从队头开始,
FIFO 顺序 = 用户发送的先后顺序。
「只发一次」的保证点是「队头 + 出队恰好一次」:队头只在这一步有了结论(发出去了 / 真失败 /
气泡已经不在了)之后才被 dropQueuedSend 摘掉,所以重复的触发拿到的是同一个键、不会多出第二次
出队;flushQueuedSends 是同步函数(一个挂起点都没有),两个触发点在主线程上不可能交错。
第二道保证在服务端:补发复用同一个 clientMsgId(绝不铸第二个),真重复到达也只会被回
chat_duplicate,不会落成第二条消息。补发又失败时按今天的失败路径处理(回滚 + 文案),
绝不静默吞掉。已知边界(写在 KDoc 里):打断声明不随补发重放——补发时在跑的那一轮早就不是
用户按发送时看到的那一个了,隔半分钟再去打断可能毁掉他没见过的一轮。

③ 状态表达:仍是六态,③ 里多一句更准的话
不新增第七态:等连接的这条走六态里的 ③ 排队中(phaseOf 的判据从「收到过入队回执」扩为
「收到过回执,或本端因未连接把它留住了」),两类排队的区别由气泡小标说清——新纯函数
pendingBubbleFooter 给 ③ 加四个字:「排队中 · 还没连上」。为什么挂在气泡上而不只是那句告知:
告知八秒就收走,而这是个会持续几十秒的状态,牌子撤掉之后光看「排队中」分不出是「她已经收下、
前面还排着队」还是「连都没连上、一个字节都没上路」。柔和告知走新通道 ChatUiState.sendQueueNotice
(与 shareNotice 分开:两条描述的是不同的动作,可以同时挂着),文案 SEND_QUEUED_NOTICE 是
「还没连上,这条排队等着,连上后自动发出」;Offline 那一档换 SEND_QUEUED_OFFLINE_NOTICE
「现在处于离线,需要重新登录或检查服务器地址;这条还没发出去,已排队不会丢」——不提「连上」
「自动发」,那一档没有会自动到来的连接。不刷屏:一轮「队列从空到空」只说一次(连续几条离线
消息不会每按一次发送就弹一遍),记账在 queueForConnection。

④ 未覆盖:REST 通道(长文 / 图片 / 附件)保持今天的行为
POST /api/v1/chat 那条路仍是失败回滚 + 既有网络错误文案,本批不动。两条理由:一是它给不出
「未连接」这个信号(拿到的只是一次网络错误,而请求可能已被服务端收到、也可能没有),
判据不可靠;二是要补发就得重跑图片重编码与附件上传换存储名,而本地 URI 的读授权可能已经失效。
代价:未连接时发长文 / 图片 / 附件仍要用户手动重发一次;影响面仅这一条通道,WS 那条(短文本,
日常绝大多数消息)已经覆盖。

⑤ 未连接时不再有的那句话
「还没连上,这条没发出去,请稍后手动重试」(W39 加的 SEND_NOT_CONNECTED_MESSAGE)与它的判据
sendFailureMessage 一并删除:回滚那条路现在只在连接就绪时才走,这句话已经没有出口,留着就是
一段描述旧行为的死文案。SendFailureMessageTest 的断言改成新事实(未就绪 ⇒ 排队,而不是回滚
加那句话)并连同新的队列判据并入 SendQueueTest,一条断言都没删。另把「空输入什么都不发生」
这条边界抽成纯函数 hasSendableContent,输入栏的 send 与投递口的 emitAndDeliver 共用同一条判据
(行为不变,只是原本写在两处的同一个 if 现在可单测,也不会再分叉)。

测试:新增 SendQueueTest(21 条:三档判据穷举 WsState.entries、两档告知措辞、队列顺序与出队
恰好一次、气泡小标与六态、空输入边界)。assembleDebug 成功;testDebugUnitTest 1118 条
(1101 减 4 加 21),failures 0 / errors 0。
This commit is contained in:
AskaEth committed 2026-10-05 13:57:54 +08:00
1 parent e60ae7aecc
commit df9fc85f76
8 files changed
+817 -102

No files matched your search

@@ -1,59 +0,0 @@
package top.yeij.cyrene.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.data.ws.WsState
/**
* 「`sendChat` 回了 `false`」之后该说的那句话([sendFailureMessage])。
*
* 背景:WS 那条路上 `sendChat` 有三种失败成因(连接状态不是 `Connected` / socket 对象已空或已被
* cancel / 序列化抛了异常),此前共用一句「发送失败,请重试」。2026-10-05 的实机取证(原文见
* `ShareLandingTest` 的类注释)说明代价:连接还没就绪时那条消息**一个字节都没上路**(后端零请求、
* 也没有任何 error 帧),而横幅说成「发送失败」——用户于是去怀疑网络与对方,而事实是连帧都没发出去。
*
* 本测试钉住三件事:
*
* 1. 连接**没就绪**的每一档([WsState.Idle] / [WsState.Connecting] / [WsState.Reconnecting] /
* [WsState.Offline])都说「还没连上」那句,**不是**「发送失败」;
* 2. 连接状态**仍是** `Connected` 时逐字保持今天那句(那种失败里「发送失败」是事实);
* 3. 两句文案各说各的:新那句里不许出现「发送失败」。
*/
class SendFailureMessageTest {
@Test
fun `a failure with the connection still up keeps today's wording byte for byte`() {
assertEquals("发送失败,请重试", sendFailureMessage(WsState.Connected))
assertEquals(SEND_FAILED_MESSAGE, sendFailureMessage(WsState.Connected))
}
@Test
fun `every state that is not connected says the message never left`() {
WsState.entries.filter { it != WsState.Connected }.forEach { connection ->
val said = sendFailureMessage(connection)
assertEquals("$connection 该说「还没连上」那句", SEND_NOT_CONNECTED_MESSAGE, said)
assertNotEquals("$connection 上不该说成发送失败", SEND_FAILED_MESSAGE, said)
}
}
@Test
fun `the not connected wording never claims the send failed`() {
assertTrue(SEND_NOT_CONNECTED_MESSAGE.contains("还没连上"))
// 说清**没发生什么**:这条消息压根没上路(否则用户会以为东西已经出去了)。
assertTrue(SEND_NOT_CONNECTED_MESSAGE.contains("没发出去"))
assertFalse(SEND_NOT_CONNECTED_MESSAGE.contains("发送失败"))
// 也别说成「失败了」——失败的是连接,不是这次发送。而「重试」是唯一该留给用户的动作:
// 内容已经被回滚退回输入栏,全链路没有任何自动重发。
assertFalse(SEND_NOT_CONNECTED_MESSAGE.contains("失败"))
assertTrue(SEND_NOT_CONNECTED_MESSAGE.contains("重试"))
}
@Test
fun `the two sentences are really two`() {
assertNotEquals(SEND_FAILED_MESSAGE, SEND_NOT_CONNECTED_MESSAGE)
}
}
@@ -0,0 +1,323 @@
package top.yeij.cyrene.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.data.ws.WsState
import top.yeij.cyrene.domain.chat.DraftDocument
import top.yeij.cyrene.domain.chat.TurnPhase
/**
* 「普通发送在未连接时**进队列**而不是回滚」的判据([wsSendOutcome] / [queuedSendNoticeFor] /
* 补发队列本身)。
*
* ## 背景:主人 2026-10-05 的裁决
* W39 之前,输入栏发一条消息而 WS 未就绪时,`deliver` 拿到 `sent == false` 就 `rollback(...)`:
* 气泡摘掉、正文退回输入栏,外加一句「这条没发出去,请稍后手动重试」——**内容不丢,但发送的
* 责任被推回给了用户**,而那一刻的事实只是「网络还没好」。主人的裁决是:**未连接就进队列**,
* 不报错、内容留着,连接恢复后自动补发(W38 给「分享落地」做的正是同一件事,见 `ShareLandingTest`)。
*
* ## 本测试钉住四件事
* 1. **三档判据**([wsSendOutcome]):已连接 ⇒ 真的发(`Dispatched`,连接那一格不再参与判断);
* 连接就绪却发不出去 ⇒ 今天那条失败路径(`Failed`);**未连接的每一档**(`Idle` / `Connecting` /
* `Reconnecting` / `Offline`)⇒ 排队(`Queued`),**绝不**变成 `Failed`(那是唯一会回滚 + 报
* 横幅的一档)。
* 2. **告知措辞**([queuedSendNoticeFor]):说清「还没连上、已排队、连上后自动发出」,**不说**
* 「失败」「重试」;而 `Offline` 那一档换一句**可执行**的(重新登录 / 检查服务器地址),
* 且一个字都不提「连上」「自动发」——那一档没有会自动到来的连接。
* 3. **队列本身**:有序(= 用户发送的先后顺序)、出队**恰好一次**、重复触发不重复补发、
* 补发用的仍是原来那个键(`clientMsgId`,绝不铸第二个)。
* 4. **状态表达**:等连接的这条读作 ③ 排队中(**不新增第七态**),小标比「服务端忙时排队」
* 多一句「还没连上」。
*/
class SendQueueTest {
private fun pending(
localId: String = "local-a",
text: String = "还没连上时发的那条",
imageUri: String? = null,
documents: List<DraftDocument> = emptyList(),
) = PendingMessage(localId = localId, text = text, imageUri = imageUri, documents = documents)
private fun QueuedSendStep.asSend(): QueuedSendStep.Send = this as QueuedSendStep.Send
// —— 一、这一刻该发、该排队、还是该走失败那条路 ——
@Test
fun `a frame handed to the socket keeps today's path whatever the connection column says`() {
// `sendChat` 回 true 就是「帧交给 socket 了」:连接状态那一格不再参与判断(它判的就是自己
// 那个状态),所以六档都得是 Dispatched——这里**不许**有「虽然有回值但连接不对所以算排队」
// 这种加戏。
WsState.entries.forEach { connection ->
assertEquals(
"$connection 上帧已经交出去了,不该改成别的处置",
WsSendOutcome.Dispatched,
wsSendOutcome(sent = true, connection),
)
}
}
@Test
fun `a send that failed while the connection is up stays a failure`() {
// 连接是好的,那 false 只可能来自「socket 对象已空 / 已 cancel」或「序列化抛异常」
// ⇒ 「发送失败」在这一刻是事实,照 W39 的口径回滚 + 文案。
assertEquals(WsSendOutcome.Failed, wsSendOutcome(sent = false, WsState.Connected))
}
@Test
fun `every state that is not connected queues instead of failing`() {
WsState.entries.filter { it != WsState.Connected }.forEach { connection ->
val outcome = wsSendOutcome(sent = false, connection)
assertEquals("$connection 上该排队", WsSendOutcome.Queued, outcome)
// 排队这一档**不是失败**:`Failed` 是唯一会回滚 + 报横幅的一档,未连接时那两件事
// 一件都不许发生(这正是主人这次裁决要改的地方)。
assertNotEquals("$connection 上不该走回滚那条路", WsSendOutcome.Failed, outcome)
}
}
@Test
fun `offline queues like the others, because only the wording differs`() {
// Offline 只是**不会自己变好**(终态、不自动重连),内容仍是一个字节都没发出去、也一个字
// 都不会丢 ⇒ 照主人「未连接就进队列」的原话一视同仁:队列里留着,用户重新登录之后
// 连接一旦就绪照常补发。差别只在告知措辞(下面那一组测试)。
assertEquals(WsSendOutcome.Queued, wsSendOutcome(sent = false, WsState.Offline))
}
// —— 二、该说哪一句话 ——
@Test
fun `the queued notice says not connected, queued, and automatic`() {
assertEquals(SEND_QUEUED_NOTICE, queuedSendNoticeFor(WsState.Idle))
assertEquals(SEND_QUEUED_NOTICE, queuedSendNoticeFor(WsState.Connecting))
assertEquals(SEND_QUEUED_NOTICE, queuedSendNoticeFor(WsState.Reconnecting))
assertTrue(SEND_QUEUED_NOTICE.contains("还没连上"))
assertTrue(SEND_QUEUED_NOTICE.contains("排队"))
assertTrue(SEND_QUEUED_NOTICE.contains("自动发出"))
// 「失败」「重试」这一族措辞会把用户推向「重新打一遍」——内容明明就留在气泡里等着。
assertFalse(SEND_QUEUED_NOTICE.contains("失败"))
assertFalse(SEND_QUEUED_NOTICE.contains("重试"))
}
@Test
fun `the offline wording says what the user can do and promises nothing`() {
assertEquals(SEND_QUEUED_OFFLINE_NOTICE, queuedSendNoticeFor(WsState.Offline))
assertTrue(SEND_QUEUED_OFFLINE_NOTICE.contains("离线"))
// 说清用户**能做的事**:会话过期那条路(重新登录)与配置不对那条路(服务器地址)。
assertTrue(SEND_QUEUED_OFFLINE_NOTICE.contains("重新登录"))
assertTrue(SEND_QUEUED_OFFLINE_NOTICE.contains("服务器地址"))
// 内容一个字节都没发出去、但排进了本端的队列——后一半不说清,用户会以为东西丢了、
// 重新打一遍(那就真发两遍了)。
assertTrue(SEND_QUEUED_OFFLINE_NOTICE.contains("还没发出去"))
assertTrue(SEND_QUEUED_OFFLINE_NOTICE.contains("排队"))
assertTrue(SEND_QUEUED_OFFLINE_NOTICE.contains("不会丢"))
// 「连上后自动发出」在 Offline 上永远不会兑现 ⇒ 这一档一个字都不许提。
assertFalse(SEND_QUEUED_OFFLINE_NOTICE.contains("连上"))
assertFalse(SEND_QUEUED_OFFLINE_NOTICE.contains("自动发"))
assertFalse(SEND_QUEUED_OFFLINE_NOTICE.contains("失败"))
assertNotEquals(SEND_QUEUED_NOTICE, SEND_QUEUED_OFFLINE_NOTICE)
}
@Test
fun `the connected instant has nothing to say`() {
// 连接就绪时「排队等连接」这件事根本不成立:要么发出去、要么走真失败那条路
// (见 [wsSendOutcome] 的前两档),没有第三档。
assertNull(queuedSendNoticeFor(WsState.Connected))
}
@Test
fun `every state that can queue has something to say`() {
WsState.entries.forEach { connection ->
if (connection == WsState.Connected) return@forEach
assertNotNull("$connection 上该有一句话", queuedSendNoticeFor(connection))
}
}
// —— 三、队列:顺序、出队恰好一次、补发用的还是原来那个键 ——
@Test
fun `the queue keeps the order the user sent in`() {
val state = ChatUiState(
pending = listOf(pending("local-1", "第一条"), pending("local-2", "第二条"), pending("local-3", "第三条")),
)
.withQueuedSend("local-1", parallel = false)
.withQueuedSend("local-2", parallel = true)
.withQueuedSend("local-3", parallel = false)
assertEquals(
listOf("local-1", "local-2", "local-3"),
state.queuedForConnection.map { it.localId },
)
// 补发照这个顺序走:出队头一条之后,队头就是**下一条**(不是重新排序、也不是最后一条)。
assertEquals("local-2", state.withoutQueuedSend("local-1").nextQueuedSend().asSend().localId)
}
@Test
fun `a repeated trigger steps towards the same head instead of a second dequeue`() {
val state = ChatUiState(
pending = listOf(pending("local-a", "第一条"), pending("local-b", "第二条")),
)
.withQueuedSend("local-a", parallel = false)
.withQueuedSend("local-b", parallel = false)
// 同一批消息被**多次触发**(连接抖动、重组、刚连上又断)时,每一步看到的都是同一个队头:
// 触发器重复**不会**多出一次出队,也就不会多出一次补发。
repeat(3) {
val step = state.nextQueuedSend().asSend()
assertEquals("local-a", step.localId)
assertEquals("第一条", step.text)
}
// 出队不是「步进」的一部分:nextQueuedSend 只读,队列一动不动。
assertEquals(listOf("local-a", "local-b"), state.queuedForConnection.map { it.localId })
}
@Test
fun `dequeuing the head exactly once is what stops a second send`() {
val queued = ChatUiState(
pending = listOf(pending("local-a", "第一条"), pending("local-b", "第二条")),
)
.withQueuedSend("local-a", parallel = false)
.withQueuedSend("local-b", parallel = false)
val afterFirst = queued.withoutQueuedSend("local-a")
assertEquals(listOf("local-b"), afterFirst.queuedForConnection.map { it.localId })
// 同一条再出队一次**什么都不变**(幂等),也不会顺手把第二条带走。
assertEquals(afterFirst, afterFirst.withoutQueuedSend("local-a"))
assertSame(afterFirst, afterFirst.withoutQueuedSend("local-a"))
// 补发用的键就是气泡的 `localId`(= 上行幂等键 `clientMsgId`):补发**不铸第二个键**,
// 所以就算某一帧真的被重复交出去,服务端仍按同一个键判重、不会落成第二条消息。
assertEquals("local-b", afterFirst.nextQueuedSend().asSend().localId)
}
@Test
fun `the replayed text comes from the bubble itself`() {
// 队列里只有键与并行声明,正文从那条气泡上现取 ⇒ 两者不可能分叉,也就不存在
// 「补发的是旧文案」这种第二份真相。
val state = ChatUiState(pending = listOf(pending("local-a", "改过之后的正文")))
.withQueuedSend("local-a", parallel = false)
assertEquals("改过之后的正文", state.nextQueuedSend().asSend().text)
}
@Test
fun `the parallel declaration travels with the message`() {
// 并行这一位在**投递那一刻**按偏好定下,随消息一起排队:补发不重读偏好(见 QueuedSend)。
val state = ChatUiState(pending = listOf(pending("local-a", "并行档发出去的")))
.withQueuedSend("local-a", parallel = true)
assertEquals(QueuedSendStep.Send("local-a", "并行档发出去的", true), state.nextQueuedSend())
}
@Test
fun `a queued key whose bubble is gone is dropped, not sent`() {
// 回滚 / 落库归一都会把气泡带走(回滚那一处也顺手出队,这里是兜底那一层):
// 气泡不在,就没有任何要补发的东西——凭空发一条才是真的多出来。
val state = ChatUiState().withQueuedSend("local-a", parallel = false)
assertEquals(QueuedSendStep.Drop("local-a"), state.nextQueuedSend())
}
@Test
fun `a queued bubble that is not plain text is never sent as text only`() {
// WS 那条路只走纯文本(有图 / 有附件一律 REST,见 ChatSendRouting.channelFor)。今天不可能
// 有这种条目进队列;这道闸是给将来路由变化留的:宁可回滚让用户看见(内容一条不丢、
// 图与附件一起退回输入栏),也不要发出一条缺了图的消息。
val withImage = ChatUiState(pending = listOf(pending("local-a", "带图的", imageUri = "content://img")))
.withQueuedSend("local-a", parallel = false)
val withDocument = ChatUiState(
pending = listOf(
pending("local-b", "带附件的", documents = listOf(DraftDocument("content://doc", "a.txt"))),
),
).withQueuedSend("local-b", parallel = false)
assertEquals(QueuedSendStep.Failed("local-a"), withImage.nextQueuedSend())
assertEquals(QueuedSendStep.Failed("local-b"), withDocument.nextQueuedSend())
}
@Test
fun `an empty queue has nothing to do`() {
assertEquals(QueuedSendStep.Idle, ChatUiState().nextQueuedSend())
}
@Test
fun `only the keys in the queue count as waiting for the connection`() {
val state = ChatUiState(pending = listOf(pending("local-a"), pending("local-b")))
.withQueuedSend("local-a", parallel = false)
assertTrue(state.isAwaitingConnection("local-a"))
assertFalse(state.isAwaitingConnection("local-b"))
// 空白键与 null 一律不算(与队列里其余判据一致:空白等同缺席)。
assertFalse(state.isAwaitingConnection(null))
assertFalse(state.isAwaitingConnection(" "))
}
// —— 四、状态表达:仍是六态,只是 ③ 里多一句更准的话 ——
@Test
fun `a message waiting for the connection reads as queued plus not connected`() {
val state = ChatUiState(pending = listOf(pending("local-a"), pending("local-b")))
.withQueuedSend("local-a", parallel = false)
// 等连接的这条走 ③ 排队中——**不新增第七态**(六态是 W30 定下的口径,见 ChatUiState.phaseOf)。
assertEquals(TurnPhase.Queued, state.phaseOf(state.pending[0]))
assertEquals(
"排队中 · 还没连上",
pendingBubbleFooter(state.phaseOf(state.pending[0]), state.isAwaitingConnection("local-a")),
)
// 没进队列的(真的交出去了、还没等到回执)仍是 ① 发送中,一个字都没变。
assertEquals(TurnPhase.Sending, state.phaseOf(state.pending[1]))
assertEquals(
"发送中…",
pendingBubbleFooter(state.phaseOf(state.pending[1]), state.isAwaitingConnection("local-b")),
)
}
@Test
fun `the two kinds of queueing look different on the bubble`() {
// ③ 的两个来源:服务端入队回执(她忙、这条已受理)与**本端留住**(一个字节都没上路)。
// 两者都是「在排队」,但用户要能分辨——差别就挂在小标上。
val told = ChatUiState(pending = listOf(pending("local-a")), queuedKeys = setOf("local-a"))
val waiting = ChatUiState(pending = listOf(pending("local-a")))
.withQueuedSend("local-a", parallel = false)
assertEquals(TurnPhase.Queued, told.phaseOf(told.pending.single()))
assertEquals(TurnPhase.Queued, waiting.phaseOf(waiting.pending.single()))
assertEquals(
"排队中",
pendingBubbleFooter(told.phaseOf(told.pending.single()), told.isAwaitingConnection("local-a")),
)
assertEquals(
"排队中 · 还没连上",
pendingBubbleFooter(waiting.phaseOf(waiting.pending.single()), waiting.isAwaitingConnection("local-a")),
)
}
// —— 五、空输入的边界(与今天一致:什么都不发生) ——
@Test
fun `an empty input has nothing to send in any shape of empty`() {
assertFalse(hasSendableContent("", null, emptyList()))
// 调用方喂进来的本来就是 trim 过的:空白串与空串是同一件事。
assertFalse(hasSendableContent(" ", null, emptyList()))
assertFalse(hasSendableContent("\n\t ", null, emptyList()))
}
@Test
fun `any one of the three things counts as content`() {
val document = DraftDocument(uri = "content://doc", displayName = "a.txt")
assertTrue(hasSendableContent("你好", null, emptyList()))
assertTrue(hasSendableContent("", "content://img", emptyList()))
assertTrue(hasSendableContent("", null, listOf(document)))
// 判据是「任意一样在」,不是「三样都得在」。
assertTrue(hasSendableContent("你好", "content://img", listOf(document)))
}
}