feat(chat): 分享进来的消息加一枚「用户分享」渲染层角标
背景:ACTION_SEND 分享进来的内容在会话里与手打的完全看不出区别,主人要求「可识别」。 与后端定过的红线(devdocs/02 的 F4、ShareRules 的「为什么正文一个字都不加」)要求人类可见 标记只能放在客户端渲染层,所以本次正文、content 与任何上行字段一个字节都没动。 改了什么: - ChatViewModel 的 emitAndDeliver 多一个 localIdPrefix 参数(默认 local-,手打那条路逐字不变), sendShared 的每一条投递都传 share-:多张图会发多条消息,它们都是分享进来的,所以每条都带。 前缀只加在幂等键前面,后面仍是随机 UUID,唯一性不受影响。 - MessageSource.kt 新增两个常量与两个纯函数:isSharedClientMsgId 只认 share- 且要求后面还有尾巴 (恰好 share- 一律 false:本端铸键永远是 share-uuid,没有尾巴的键不是本端铸的,不能替一条 来路不明的消息声明「这是你分享的」);isSharedMessage 在它之上加一道只认 user 侧的闸。 - MessageBubble 的 bubbleFooter 多一个 shared 参数,角标 [用户分享] 紧挨来源角标拼在角标行里, 沿用这行既有的字级与颜色,不新造样式、不用 Material3,也只对用户侧气泡出现。 - 新增 SharedMessageBadgeTest:判据的六类边界取值与角标行里的落位。 为什么用幂等键而不是正文标记:clientMsgId 由服务端原样回填,是客户端读得回来的既有字段, 「这条怎么进来的」写在它上面不用新增任何协议,也不污染用户正文。
This commit is contained in:
1 parent
5d4888ab71
commit
6e00697110
4 files changed
+221
-7
No files matched your search
@@ -0,0 +1,117 @@
|
||||
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.HistoryMessageDto
|
||||
import top.yeij.cyrene.domain.chat.TurnPhase
|
||||
|
||||
/**
|
||||
* 分享进来的消息那枚角标:判据([isSharedClientMsgId] / [isSharedMessage])与落位([bubbleFooter])。
|
||||
*
|
||||
* **背景**:`ACTION_SEND` 分享进来的消息在今天的历史里与手打的一模一样,主人要求「可识别」。
|
||||
* 识别的依据只能是幂等键 `share-<uuid>`(服务端原样回填),**不是**正文里的任何标记——
|
||||
* 往正文里拼「[用户分享]」是与后端定过的红线(`devdocs/02` 的 F4)。
|
||||
*
|
||||
* 这个测试要钉住两件事:
|
||||
* 1. 判据只认本端铸出来的那种键(`share-` 后面还有尾巴),其余一律不认;
|
||||
* 2. 角标只出现在该出现的地方:用户侧、且拼进角标行时位置固定(挨着来源角标)。
|
||||
*/
|
||||
class SharedMessageBadgeTest {
|
||||
|
||||
private fun message(role: String = "user", clientMsgId: String? = null) = HistoryMessageDto(
|
||||
id = "m1",
|
||||
sessionId = "web",
|
||||
role = role,
|
||||
content = "分享过来的正文",
|
||||
clientMsgId = clientMsgId,
|
||||
createdAt = 1_791_088_571_785L,
|
||||
)
|
||||
|
||||
// —— 判据:认的只有本端铸出来的那种键 ——
|
||||
|
||||
@Test
|
||||
fun `a share prefixed key is recognised`() {
|
||||
assertTrue(isSharedClientMsgId("share-8f14e45f-ceea-467f-a1b2-9d5d0e3c7a41"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a locally minted key is not a share`() {
|
||||
// 手打那条路铸的是 `local-`,它一个字都不变(前缀只是加在前面,后半仍是同一个 UUID)。
|
||||
assertFalse(isSharedClientMsgId("local-8f14e45f-ceea-467f-a1b2-9d5d0e3c7a41"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an absent key is not a share`() {
|
||||
// 没有键 = 没有证据,不猜(老客户端发的、IM 镜像进来的、v0.56 之前的历史行都是 null)。
|
||||
assertFalse(isSharedClientMsgId(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank keys are not a share`() {
|
||||
assertFalse(isSharedClientMsgId(""))
|
||||
assertFalse(isSharedClientMsgId(" "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bare prefix without a uuid tail is not a share`() {
|
||||
// 只有前缀、没有尾巴的键**不是**本端铸出来的(本端永远是 `share-<uuid>`),
|
||||
// 认下它等于替一条来路不明的消息声明「这是你分享的」——宁可少画一枚角标。
|
||||
assertFalse(isSharedClientMsgId(SHARE_CLIENT_MSG_ID_PREFIX))
|
||||
assertFalse(isSharedClientMsgId("share- "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the prefix must be the very beginning and case sensitive`() {
|
||||
// 键是机器生成的字面量、服务端原样回填,不是给人读的输入:不 trim、不忽略大小写。
|
||||
assertFalse(isSharedClientMsgId(" share-abc"))
|
||||
assertFalse(isSharedClientMsgId("Share-abc"))
|
||||
assertFalse(isSharedClientMsgId("xshare-abc"))
|
||||
// 前缀出现在中间也不算:判据是 startsWith。
|
||||
assertFalse(isSharedClientMsgId("local-share-abc"))
|
||||
}
|
||||
|
||||
// —— 判据之上还有一道「只认用户侧」的闸 ——
|
||||
|
||||
@Test
|
||||
fun `only user side messages get the badge`() {
|
||||
assertTrue(isSharedMessage(message(clientMsgId = "share-abc")))
|
||||
// 助手的消息不会有这种键;万一有一行的 role 与键对不上,也不在她的气泡上写「用户分享」。
|
||||
assertFalse(isSharedMessage(message(role = "assistant", clientMsgId = "share-abc")))
|
||||
assertFalse(isSharedMessage(message(role = "system", clientMsgId = "share-abc")))
|
||||
assertFalse(isSharedMessage(message(clientMsgId = "local-abc")))
|
||||
assertFalse(isSharedMessage(message()))
|
||||
}
|
||||
|
||||
// —— 落位:拼进角标行,紧挨来源角标 ——
|
||||
|
||||
@Test
|
||||
fun `the badge sits next to the source label`() {
|
||||
assertEquals("[用户分享] · 来自 安卓", bubbleFooter("来自 安卓", TurnPhase.Sent, null, shared = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `without the flag the footer is byte for byte what it was`() {
|
||||
assertEquals("来自 安卓", bubbleFooter("来自 安卓", TurnPhase.Sent, null))
|
||||
assertEquals("排队中 · 来自 安卓", bubbleFooter("来自 安卓", TurnPhase.Queued, null, shared = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the badge survives a message with no source at all`() {
|
||||
// 来源判不出来(老服务端 / 表里没有的端)时角标照样在:它说的是「怎么进来的」,
|
||||
// 与「从哪一端」是两件事,不该被后者拖着一起消失。
|
||||
assertEquals("[用户分享]", bubbleFooter(null, TurnPhase.Sent, null, shared = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parallel leg and phase keep their places around the badge`() {
|
||||
assertEquals(
|
||||
"并行路 2 · 排队中 · [用户分享] · 来自 安卓",
|
||||
bubbleFooter("来自 安卓", TurnPhase.Queued, "并行路 2", shared = true),
|
||||
)
|
||||
// 一条既没有来源也没有分享角标、且不在 ③④ 的消息,仍然什么都不显示。
|
||||
assertNull(bubbleFooter(null, TurnPhase.Answered, null))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user