W72 纯附件消息也有气泡外侧时间
时间原本挂在「这条消息**最后一个有正文的气泡**」外侧(W69),而纯附件消息(`documents` 或 `image` 非空、`content` 为空)解析出来的那个占位气泡 `body` 也是空,`Bubble` 见空 `body` 什么都不画 ⇒ 那条时间**整条没被画出来**(主人 2026-10-07 验收 W69 时发现的缺口)。 - 判据抽成纯函数 `messageTimeCarrier(message, bubbles)`:先找最后一个有正文的气泡(W69 原本那一档,一个字都不改);一个都没有时按「谁最后画」往回退(语音形态 → 图片 → 附件芯片排),落点用 `MessageTimeCarrier` 表示。真的空气泡(`isContentlessUserMessage`) 仍由 `ContentlessNote` 那条路自己画时间,与 W69 一致,未改。 - 附件芯片排 / 图片 / 语音形态三块改为经 `BubbleWithTime` 渲染:**不是落点时 `time = null`, 连 `Box` 都不建** ⇒ 与 W69 之前逐像素相同。贴哪一侧、宽度预扣(时间不参与 weight)、 底对齐全部复用 W69 那一个组件,没有第二份布局逻辑。 - 新增 `MessageTimeCarrierTest` 11 条:纯附件 / 纯图片 / 无转写语音 / 角色无关 / 多块时的优先序 / 有正文时 W69 口径不变 / 真空消息返回 null;`MessageTimeLabelTest` 16 条一条未动。 - 假后端补夹具:`/__control/messages` 的 `text=`(**空串** = 这条没有正文)与 `documents=` (`extracted` / `listed` / `unreadable`)。此前「纯附件消息」在本机**根本造不出来** ——工具不实现 upload 摄取链路,`PATCH` 又要求 `content` 非空——设备验收因此无从做起。 README 补 §8.3 与端点表。 验收:`assembleDebug` 通过;`testDebugUnitTest` **129 suites / 1636 tests / 0 failures / 0 errors**(基线 128 / 1625)。
This commit is contained in:
1 parent
f1e894e71f
commit
a94e310cd7
4 files changed
+436
-27
No files matched your search
@@ -148,6 +148,12 @@ fun isContentlessUserMessage(message: HistoryMessageDto): Boolean =
|
||||
* 它是与气泡下方那行角标(来源 / 六态 / 「⋯」)**并列的另一块**:那块说的是「这条从哪来、能对它
|
||||
* 做什么」,这一块说的是「它什么时候发生的」,两处一个字都不改。
|
||||
*
|
||||
* 挂在哪一块由纯函数 [messageTimeCarrier] 给:先找**最后一个有正文的气泡**(W69 原本那一档);
|
||||
* 这条**一个正文气泡都没有**时,改挂到**最后画出来的那个附件块**上(附件芯片排 / 图片 / 语音形态)
|
||||
* ——它旁边那一块就是这时唯一的位置参照物。少了这一档,一条只有附件的消息会因为
|
||||
* `bubbles` 里那个 `body` 为空的占位气泡(`Bubble` 见空 body 就什么都不画)而**整条没有时间**
|
||||
* (W72 补的就是它)。
|
||||
*
|
||||
* 只有落库消息有它:流式气泡([StreamingBubble])与待发气泡([PendingBubble],[PendingMessage]
|
||||
* 本端就没有时间戳这个概念)走的都是「不显示」那一档。
|
||||
*
|
||||
@@ -225,9 +231,11 @@ fun MessageBubble(
|
||||
createdAt = message.createdAt,
|
||||
nowMillis = System.currentTimeMillis(),
|
||||
)
|
||||
// 时间挂在**最后一个有正文的气泡**上(而不是「列表最后一项」):解析出的末段偶尔只有旁白
|
||||
// (`<action>`/`<narration>`),那一项不画正文框,挂上去时间就没有落脚点。
|
||||
val timeCarrier = bubbles.indexOfLast { it.body.isNotEmpty() }
|
||||
// 时间挂在哪一块:判据全在纯函数里([messageTimeCarrier],可单测)。原来是「最后一个有正文的
|
||||
// 气泡」(而不是「列表最后一项」——解析出的末段偶尔只有旁白,那一项不画正文框,挂上去时间就
|
||||
// 没有落脚点),于是**纯附件消息**(有 documents 或 image、`content` 为空 ⇒ 一个正文气泡都没有)
|
||||
// 那一行根本没被画出来。
|
||||
val timeCarrier = messageTimeCarrier(message, bubbles)
|
||||
|
||||
MessageGroup(
|
||||
isUser = isUser,
|
||||
@@ -240,25 +248,49 @@ fun MessageBubble(
|
||||
onOpenMenu = openMenu,
|
||||
) {
|
||||
message.thinking?.takeIf { it.isNotBlank() }?.let { ThinkingCard(it) }
|
||||
DocumentChipRow(
|
||||
chips = message.documents.map(DocumentChips::of),
|
||||
alignEnd = isUser,
|
||||
onClick = { index -> message.documents.getOrNull(index)?.let(onDocumentClick) },
|
||||
)
|
||||
// 下面三块(附件芯片排 / 图片 / 语音形态)与正文气泡共用 [BubbleWithTime]:**这条一个正文
|
||||
// 气泡都没有时**,它们中的一个就是时间的落脚点。不是那一个时 `time = null`,
|
||||
// [BubbleWithTime] 连 `Box` 都不建 ⇒ 与 W69 之前逐像素相同。
|
||||
if (message.documents.isNotEmpty()) {
|
||||
BubbleWithTime(
|
||||
time = outsideTime.takeIf { timeCarrier == MessageTimeCarrier.Attachments },
|
||||
isUser = isUser,
|
||||
) { side ->
|
||||
DocumentChipRow(
|
||||
chips = message.documents.map(DocumentChips::of),
|
||||
modifier = side,
|
||||
alignEnd = isUser,
|
||||
onClick = { index -> message.documents.getOrNull(index)?.let(onDocumentClick) },
|
||||
)
|
||||
}
|
||||
}
|
||||
message.image?.takeIf { it.isNotBlank() }?.let { ImageHostRebase.apply(it, imageBaseUrl) }
|
||||
?.let { ChatImage(it, onImageClick) }
|
||||
?.let { image ->
|
||||
BubbleWithTime(
|
||||
time = outsideTime.takeIf { timeCarrier == MessageTimeCarrier.Image },
|
||||
isUser = isUser,
|
||||
) { side ->
|
||||
ChatImage(model = image, onClick = onImageClick, modifier = side)
|
||||
}
|
||||
}
|
||||
message.voice?.let { voice ->
|
||||
// 语音形态画在正文**之外**(正文那条路一个字都不动):转写文本 / 服务端那行诚实提示
|
||||
// 照常由下面的气泡渲染。「无副本就只留文本、不画播放控件」与「durationMs 缺席就不显示
|
||||
// 时长」两条判据都在 VoiceMessageRow 里(它自己调纯函数),这里不重复判一次。
|
||||
VoiceMessageRow(
|
||||
voice = voice,
|
||||
messageId = message.id,
|
||||
playback = voicePlayback,
|
||||
note = voiceNote(voice.status, message.content),
|
||||
alignEnd = isUser,
|
||||
onToggle = { onVoiceToggle(message) },
|
||||
)
|
||||
BubbleWithTime(
|
||||
time = outsideTime.takeIf { timeCarrier == MessageTimeCarrier.Voice },
|
||||
isUser = isUser,
|
||||
) { side ->
|
||||
VoiceMessageRow(
|
||||
voice = voice,
|
||||
messageId = message.id,
|
||||
playback = voicePlayback,
|
||||
note = voiceNote(voice.status, message.content),
|
||||
alignEnd = isUser,
|
||||
onToggle = { onVoiceToggle(message) },
|
||||
modifier = side,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (isContentlessUserMessage(message)) {
|
||||
// 空气泡是最糟的一种呈现:用户完全不知道发生了什么。这里如实说一句(见 [ContentlessNote])。
|
||||
@@ -266,6 +298,9 @@ fun MessageBubble(
|
||||
ContentlessNote(onLongPress = openMenu, modifier = side)
|
||||
}
|
||||
} else {
|
||||
// 时间挂在**最后一个有正文的气泡**外侧。`null` = 这条的落点在附件块上(或这条一个字都不画),
|
||||
// 此时正文气泡一个都不带时间——时间挂在这条消息上一次,不跟着每个气泡重复。
|
||||
val carrierIndex = (timeCarrier as? MessageTimeCarrier.TextBubble)?.index
|
||||
bubbles.forEachIndexed { index, bubble ->
|
||||
Bubble(
|
||||
bubble = bubble,
|
||||
@@ -274,9 +309,7 @@ fun MessageBubble(
|
||||
showCursor = gray,
|
||||
onLongPress = openMenu,
|
||||
gray = gray,
|
||||
// 时间挂在**最后一个有正文的气泡**外侧(`-1` = 这条一个都没有,不可能发生,
|
||||
// 但真发生也只是不画时间,不会把时间画到空气泡旁边去)。
|
||||
sideTime = if (index == timeCarrier) outsideTime else null,
|
||||
sideTime = outsideTime.takeIf { index == carrierIndex },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -449,6 +482,75 @@ private val MessageDateOnlyFormat = DateTimeFormatter.ofPattern("yyyy'年'M'月'
|
||||
/** 「昨天」那两个字:只与时分连用(`昨天 09:05`),不会单独成句。 */
|
||||
private const val YESTERDAY_PREFIX = "昨天"
|
||||
|
||||
/**
|
||||
* 这条消息的时间**落在哪一块**上([messageTimeCarrier] 的返回值)。
|
||||
*
|
||||
* 之所以要给它一个名字,是因为「时间挂在正文气泡上」与「时间挂在附件块上」在 [MessageBubble] 里
|
||||
* 是**两处不同的调用点**(前者由 [Bubble] 经 `sideTime` 代画,后者各自包一层 [BubbleWithTime]),
|
||||
* 而某条消息只能落一个地方。判据本身是纯函数,所以「纯附件消息也有时间」这件事能被单测直接钉住,
|
||||
* 不必去跑一个 Compose 测试。
|
||||
*/
|
||||
sealed interface MessageTimeCarrier {
|
||||
/** 正文气泡:`bubbles` 里的下标([MessageBubble] 解析出来的第几个气泡)。 */
|
||||
data class TextBubble(val index: Int) : MessageTimeCarrier
|
||||
|
||||
/** 附件芯片排(`documents` 非空才会画)。 */
|
||||
data object Attachments : MessageTimeCarrier
|
||||
|
||||
/** 图片(`image` 非空才会画)。 */
|
||||
data object Image : MessageTimeCarrier
|
||||
|
||||
/** 语音形态(`voice` 键在才会画)。 */
|
||||
data object Voice : MessageTimeCarrier
|
||||
}
|
||||
|
||||
/**
|
||||
* 这条消息外侧那枚时间该挂在哪一块上(纯函数,可单测);`null` = 什么都不挂。
|
||||
*
|
||||
* ## 判据 = 渲染顺序里**最后一块真的画得出来**的东西
|
||||
*
|
||||
* [MessageBubble] 那一列从上到下的顺序是:思维链 → 附件芯片排 → 图片 → 语音形态 → 正文气泡。
|
||||
* 时间贴在**最后一块**旁边,读起来就是「这条消息发生在什么时候」的落款。
|
||||
*
|
||||
* 1. **先找最后一个有正文的气泡**(W69 原本那一档,一个字都不改)。为什么不是「列表最后一项」:
|
||||
* 解析出来的末段偶尔只有旁白(`<action>` / `<narration>`),那一项不画正文框,挂上去时间就
|
||||
* 没有落脚点。为什么不是第一个:一条多段消息的时间该落在它**结束**的地方。
|
||||
* 2. **一个正文气泡都没有**时,按「谁最后画」往回退:语音形态 → 图片 → 附件芯片排。这一档就是
|
||||
* **纯附件消息**(`documents` 非空、`content` 为空):`content` 空 ⇒ 解析出来的那个占位气泡
|
||||
* `body` 也是空 ⇒ [Bubble] 见空 body 什么都不画,于是 W69 的时间**整条没被画出来**
|
||||
* (主人 2026-10-07 验收时发现的缺口)。
|
||||
* 3. 三块也都没有 ⇒ `null`:这条消息确实一个可视的块都没有。
|
||||
*
|
||||
* ## 谁**不**走这一档
|
||||
* - **真的空气泡**([isContentlessUserMessage] 成立):上面三块按判据一定都不在,所以这里返回
|
||||
* `null`;但那种消息**仍然有时间**——[MessageBubble] 把 [ContentlessNote] 自己包了一层
|
||||
* [BubbleWithTime]。两处刻意分开:那块的落脚点由 `ContentlessNote` 那条路自己交代,不必让这个
|
||||
* 函数认识它(它只答「附件块和正文气泡里挂哪个」)。
|
||||
* - **思维链卡片**:它永远画在最上面,不可能是「最后一块」(有别的块时被别的块盖过,没别的块时
|
||||
* 这条消息的真问题也不是时间)。不把它算进来 ⇒ 只有思维链的助手消息与 W69 之前逐字相同。
|
||||
* - **流式 / 待发气泡**:它们根本不调这个函数(见 [BubbleWithTime] 的 KDoc)。
|
||||
*
|
||||
* 与角色无关:她侧(`assistant`)也会带附件与图,同一条规则照用。贴哪一侧由 [BubbleWithTime]
|
||||
* 按 `isUser` 定,不在这里判——这一层只回答「挂在哪一块」,不回答「挂在它的哪一边」。
|
||||
*
|
||||
* @param bubbles [MessageBubble] 已经解析好的气泡列表(本函数不去 `parse` 一遍:那一份判据在
|
||||
* `isChat` 上,重复算一次会多出「两份解析结果不一致」的可能)。
|
||||
*/
|
||||
fun messageTimeCarrier(
|
||||
message: HistoryMessageDto,
|
||||
bubbles: List<ParsedBubble>,
|
||||
): MessageTimeCarrier? {
|
||||
val lastBody = bubbles.indexOfLast { it.body.isNotEmpty() }
|
||||
if (lastBody >= 0) return MessageTimeCarrier.TextBubble(lastBody)
|
||||
// 与 [MessageBubble] 里画它们的顺序**相反**:最后画的那一块才是时间的落点。
|
||||
return when {
|
||||
message.voice != null -> MessageTimeCarrier.Voice
|
||||
!message.image.isNullOrBlank() -> MessageTimeCarrier.Image
|
||||
message.documents.isNotEmpty() -> MessageTimeCarrier.Attachments
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 乐观发送中的用户消息(还没落库)。
|
||||
*
|
||||
@@ -768,8 +870,10 @@ private fun BubbleWithTime(
|
||||
/**
|
||||
* 一条消息在气泡层渲染成一个 / 多个气泡。
|
||||
*
|
||||
* [sideTime] 只由**携带时间的那个气泡**(最后一个有正文的,见 [MessageBubble])收到,
|
||||
* 其余气泡与旁白行都不带——时间挂在这条消息上一次,不跟着每个气泡重复。
|
||||
* [sideTime] 只由**携带时间的那个气泡**收到([MessageTimeCarrier.TextBubble] 指的那一个),
|
||||
* 其余气泡与旁白行都不带——时间挂在这条消息上一次,不跟着每个气泡重复。`body` 为空的气泡
|
||||
* (比如纯附件消息解析出来的那个占位气泡)一个像素都不画,**连 [BubbleWithTime] 都不进**:
|
||||
* 这也是纯附件消息会整条没有时间的原因(判据见 [messageTimeCarrier])。
|
||||
*/
|
||||
@Composable
|
||||
private fun Bubble(
|
||||
@@ -1035,13 +1139,17 @@ private fun inlineBody(
|
||||
* 涟漪不会溢出圆角);读屏也需要知道点它会发生什么,所以带上 `contentDescription`。
|
||||
*
|
||||
* 高度不设限:图片按比例撑开,气泡那一列本来就是纵向排列。
|
||||
*
|
||||
* [modifier] 由调用方([BubbleWithTime])喂进来,装着「外侧那枚时间占掉的那一条」的 `padding`。
|
||||
* 它排在这条链的**最前面**:那一层留白在 `widthIn` / `clip` / `clickable` 之外,所以图片自己的
|
||||
* 尺寸上限、圆角与点击区都与加它之前逐像素 / 逐行为相同(与 [BubbleBody] 同一条纪律)。
|
||||
*/
|
||||
@Composable
|
||||
private fun ChatImage(model: String, onClick: (String) -> Unit) {
|
||||
private fun ChatImage(model: String, onClick: (String) -> Unit, modifier: Modifier = Modifier) {
|
||||
AsyncImage(
|
||||
model = model,
|
||||
contentDescription = "查看图片",
|
||||
modifier = Modifier
|
||||
modifier = modifier
|
||||
.widthIn(max = 240.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.clickable { onClick(model) },
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.api.dto.DocumentRefDto
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.data.api.dto.VoiceDto
|
||||
import top.yeij.cyrene.domain.parse.ParsedBubble
|
||||
|
||||
/**
|
||||
* 「这条消息外侧那枚时间挂在哪一块上」的判据(纯函数 [messageTimeCarrier])。
|
||||
*
|
||||
* **背景**:主人 2026-10-07 验收 W69 时报的缺口 —— 纯文字消息都有时间,**纯附件消息边上没有**。
|
||||
* 根因在 W69 的落脚点写法上:时间挂在「这条消息**最后一个有正文的气泡**」外侧,而纯附件消息
|
||||
* (`documents` 非空 / `image` 非空、`content` 为空)解析出来的唯一那个气泡 `body` 也是空,
|
||||
* [Bubble] 见空 `body` 什么都不画 ⇒ 那一行**根本没被画出来**。
|
||||
*
|
||||
* 这个测试要钉住三件事:
|
||||
* 1. **一个正文气泡都没有时,时间落到最后画出来的那个附件块上**(附件芯片排 / 图片 / 语音形态),
|
||||
* 而且顺序与渲染顺序一致(谁最后画谁拿)——这就是「纯附件消息也有时间」;
|
||||
* 2. **有正文气泡时一个字都不许变**(W69 的口径:最后一个有正文的气泡,末段的纯旁白气泡不算);
|
||||
* 3. **真的空气泡**([isContentlessUserMessage])在这里返回 `null`,但那种消息**仍然有时间**
|
||||
* —— 那一处由 [MessageBubble] 给 [ContentlessNote] 单独包一层 [BubbleWithTime],是另一条路。
|
||||
*
|
||||
* 「贴左还是贴右」不在这里测:那是 [BubbleWithTime] 按 `isUser` 定的,本函数只回答「挂在哪一块」。
|
||||
*/
|
||||
class MessageTimeCarrierTest {
|
||||
|
||||
private fun message(
|
||||
role: String = "user",
|
||||
content: String = "",
|
||||
image: String? = null,
|
||||
documents: List<DocumentRefDto> = emptyList(),
|
||||
thinking: String? = null,
|
||||
voice: VoiceDto? = null,
|
||||
createdAt: Long = 1_791_088_571_785L,
|
||||
) = HistoryMessageDto(
|
||||
id = "m1",
|
||||
sessionId = "web",
|
||||
role = role,
|
||||
content = content,
|
||||
image = image,
|
||||
documents = documents,
|
||||
thinking = thinking,
|
||||
voice = voice,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
|
||||
private val document = DocumentRefDto(
|
||||
name = "6c1f0a2b3d4e5f60718293a4b5c6d7e8.txt",
|
||||
mime = "text/plain",
|
||||
size = 10,
|
||||
chars = 10,
|
||||
status = "extracted",
|
||||
)
|
||||
|
||||
/** 解析出来的一个正文气泡(`plainBubble` 那份形状)。 */
|
||||
private fun body(text: String) = ParsedBubble(
|
||||
body = text,
|
||||
parts = emptyList(),
|
||||
head = emptyList(),
|
||||
tail = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* 纯附件消息的 `bubbles`:`content` 为空 ⇒ 解析器一个都没给 ⇒ `MessageBubble` 兜一个占位气泡
|
||||
* (`plainBubble("")`)。它 `body` 为空,所以 [Bubble] 一个像素都不画 —— 缺口就在这一步。
|
||||
*/
|
||||
private val placeholderBubbles = listOf(body(""))
|
||||
|
||||
private val voiceWithoutTranscript = VoiceDto(source = "asr", status = "empty")
|
||||
|
||||
// —— 一个正文气泡都没有:落到最后画出来的那个附件块上 ——
|
||||
|
||||
@Test
|
||||
fun `an attachments only message hangs the time on the chip row`() {
|
||||
val message = message(content = "", documents = listOf(document))
|
||||
|
||||
assertEquals(
|
||||
MessageTimeCarrier.Attachments,
|
||||
messageTimeCarrier(message, placeholderBubbles),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an image only message hangs the time on the image`() {
|
||||
val message = message(content = "", image = "http://host/api/v1/files/a.png")
|
||||
|
||||
assertEquals(MessageTimeCarrier.Image, messageTimeCarrier(message, placeholderBubbles))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a voice message with nothing transcribed hangs the time on the voice row`() {
|
||||
// `status = empty` ⇒ 正文为空,而这条消息**不是什么都没有**(`isContentlessUserMessage`
|
||||
// 特意把语音排除掉)。它的可视块只有语音形态,时间就该落在那里。
|
||||
val message = message(content = "", voice = voiceWithoutTranscript)
|
||||
|
||||
assertEquals(MessageTimeCarrier.Voice, messageTimeCarrier(message, placeholderBubbles))
|
||||
assertFalse(isContentlessUserMessage(message))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when several blocks are there the last one drawn wins`() {
|
||||
// 渲染顺序是「附件芯片排 → 图片 → 语音形态」,所以时间的落点也按这个顺序往回退。
|
||||
val chipsAndImage = message(
|
||||
content = "",
|
||||
documents = listOf(document),
|
||||
image = "http://host/api/v1/files/a.png",
|
||||
)
|
||||
assertEquals(MessageTimeCarrier.Image, messageTimeCarrier(chipsAndImage, placeholderBubbles))
|
||||
|
||||
val allThree = message(
|
||||
content = "",
|
||||
documents = listOf(document),
|
||||
image = "http://host/api/v1/files/a.png",
|
||||
voice = voiceWithoutTranscript,
|
||||
)
|
||||
assertEquals(MessageTimeCarrier.Voice, messageTimeCarrier(allThree, placeholderBubbles))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `her attachments get the time too`() {
|
||||
// 判据与角色无关:她也会发只有图 / 只有附件的一条。
|
||||
val message = message(role = "assistant", content = "", documents = listOf(document))
|
||||
|
||||
assertEquals(
|
||||
MessageTimeCarrier.Attachments,
|
||||
messageTimeCarrier(message, placeholderBubbles),
|
||||
)
|
||||
}
|
||||
|
||||
// —— 有正文气泡时:W69 的口径一个字都不变 ——
|
||||
|
||||
@Test
|
||||
fun `a text bubble always wins over the blocks above it`() {
|
||||
val message = message(
|
||||
content = "甲",
|
||||
documents = listOf(document),
|
||||
image = "http://host/api/v1/files/a.png",
|
||||
)
|
||||
|
||||
assertEquals(MessageTimeCarrier.TextBubble(0), messageTimeCarrier(message, listOf(body("甲"))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the last non empty bubble carries the time`() {
|
||||
val bubbles = listOf(body("甲"), body("乙"))
|
||||
|
||||
assertEquals(
|
||||
MessageTimeCarrier.TextBubble(1),
|
||||
messageTimeCarrier(message(content = "甲<p>乙</p>"), bubbles),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a trailing annotation only bubble never carries the time`() {
|
||||
// 末段只有旁白(`<action>`/`<narration>`)时不画正文框:时间退回上一个有正文的气泡。
|
||||
val bubbles = listOf(body("甲"), body(""))
|
||||
|
||||
assertEquals(
|
||||
MessageTimeCarrier.TextBubble(0),
|
||||
messageTimeCarrier(message(content = "甲<action>她抬头</action>"), bubbles),
|
||||
)
|
||||
}
|
||||
|
||||
// —— 真的什么都没有:这里返回 null,时间由 ContentlessNote 那条路自己画 ——
|
||||
|
||||
@Test
|
||||
fun `a truly empty user message has no block to hang the time on`() {
|
||||
val message = message(content = " ")
|
||||
|
||||
// 判据与 `isContentlessUserMessage` 是互补的:那条路**仍然**有时间(`BubbleWithTime`
|
||||
// 包着 `ContentlessNote`),所以这里返回 null 不等于「这条消息没有时间」。
|
||||
assertTrue(isContentlessUserMessage(message))
|
||||
assertNull(messageTimeCarrier(message, placeholderBubbles))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank image strings and empty documents are not blocks`() {
|
||||
val message = message(content = "", image = " ", documents = emptyList())
|
||||
|
||||
assertNull(messageTimeCarrier(message, placeholderBubbles))
|
||||
}
|
||||
|
||||
// —— 与时间戳缺失的关系:两件事分开判 ——
|
||||
|
||||
@Test
|
||||
fun `the carrier ignores the timestamp and the label ignores the carrier`() {
|
||||
// 「`createdAt <= 0` ⇒ 整条不画」是 [messageTimeLabel] 那一档的事:落点照旧算出来,
|
||||
// 只是文案为 null ⇒ `BubbleWithTime` 连 `Box` 都不建。两处刻意分开判,谁都不替谁兜底。
|
||||
val message = message(content = "", documents = listOf(document), createdAt = 0L)
|
||||
|
||||
assertEquals(
|
||||
MessageTimeCarrier.Attachments,
|
||||
messageTimeCarrier(message, placeholderBubbles),
|
||||
)
|
||||
assertNull(messageTimeLabel(createdAt = 0L, nowMillis = 1_791_088_571_785L))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user