feat(sessions): 接上 D4 未读 —— 抽屉角标 + 已读游标(进入即读、看着就点已读)
后端 v0.37 起 `GET /sessions` 每条会话恒下发 `unreadCount`、`POST /sessions/{id}/read`
可推进**账号级**已读游标(只前进,`MAX(read_at, before)`)、成功后广播 `session_read`,
客户端一直没接。本次补齐:列表能看见未读、看完真的会消、别的端读了本端也消。
- data/api/dto/SessionDto.kt:`SessionSummaryDto.unreadCount`(**默认 0**——
`SessionPreferences` 把整份列表原样持久化,非默认值会让升级后第一次启动整份快照
解码失败、列表先空一下;默认 0 只会让角标少显示一会儿);
新增 `ReadSessionRequest`(`before` 缺席 = 现在,`explicitNulls=false` 保证序列化成
`{}`,正是服务端接受的形状)与 `ReadSessionResponse`
- data/api/SessionsApi.kt + data/repository/SessionRepository.kt:`markRead(sessionId, before)`
—— 不传 0(服务端对 0/负数回 400),null 即「现在」
- data/ws/WsEvent.kt:新增 `SessionRead`(`{type:"session_read", sessionId, data:{readAt}}`)。
会话归属**只在帧壳**上(data 里没有 sessionId);`readAt` 缺席回落 0,而 0 在上层是
「无从判断」——坏帧因此天然退化成什么都不改
- ui/sessions/SessionsViewModel.kt:`SessionItem` 多出 `unreadCount` 与 `lastMessageAt`
(后者只用于游标比对);两条调和规则方向相反且都写成纯函数——
`withReadCursor(readAt)` **有依据**地清(游标只前进,本地最新消息不比游标新才算真已读;
若有更新的消息则保留旧计数,不能把真实未读藏起来),`markRead(id)` 无条件清(用户就在这一行)
- 已读策略:① `select()`(点开 = 进入)以「现在」推进——聊天页紧接着会加载并显示最新一页,
比推本地可能陈旧的 `lastMessage.createdAt` 更诚实,且先就地清零,角标不等网络往返;
② `message_persisted` 且会话是**当前打开**的那一个时,以该消息 `createdAt` 推进
(只标它及其之前,同时落库的更晚消息仍算未读),否则「自己正看着的会话」会攒出角标。
不该标的绝不乱标:别的会话攒未读是正确行为,本端不做「替服务端数未读」(增量会漂移,
抽屉每次打开都会静默重拉,以服务端为准)。失败**静默**——服务端游标是唯一真源
- `session_read` 帧按帧里的会话 id **逐行**定位(刻意不用 `isForSession`:那道闸是给
「一个 VM 只关心一个会话」的 ChatViewModel 用的,本 VM 持有整个列表,按 activeSessionId
过滤会把「别的会话被读了」这一帧丢掉,那正是最该处理的一半)
- 另记一份本地已知游标(只取服务端口径:read 响应的 readAt / 帧的 readAt / 落库消息的
createdAt),在整表替换时把「本地已知没有更新消息」的行压回 0——否则一次竞态中的
GET(快照早于本端刚发起的 read)会把角标闪回来;登出时随快照一起清(游标是账号级的)
- ui/sessions/SessionDrawerContent.kt:角标用 Miuix 自带 `Badge`(错误色药丸 + onError 文字),
且**放在标题行里**而不是 `endActions`:抽屉固定 300dp,卡片与内边距之后只剩约 244dp,
`endActions` 已有三个 40dp 图标(120dp)且被库内 60% 硬上限卡着,再塞角标会把图标挤到裁剪。
改成标题行的 `[标题][角标]` 后角标不占额外列宽;标题/摘要同时收紧为 `maxLines = 1` +
省略号(长标题不再在约 116dp 里折成两三行把卡片撑高);`99+` 收敛、`0` 不画
测试:305 全绿(272 → +33):角标文本(0/负数不画、99 精确、100 与 1234 收敛 `99+`)、
两条调和规则的各种边界(游标等于/新于/旧于最新消息、无消息会话、坏帧 readAt=0、
按会话逐行定位、遮罩只压记过游标的行)、`toItem` 映射、`session_read` 帧解码
(生产者逐字取自后端 `gateway/session_read.go:broadcastSessionRead`)、DTO 真载荷
(`GET /sessions` 抓包里主会话 unreadCount=6、次级=0)与**旧快照无该键仍解得开**、
read 请求体序列化(`{}` / `{"before":…}`)。
未验证(无模拟器,也不许碰 adb,只有构建 + 单测):
- 角标与标题的**实际观感**没截图。宽度是按 Miuix 0.9.3 源码算的(抽屉 300dp − 卡片边距 24
− 组件内边距 32 = 244dp;endActions 120dp < 60% 上限,标题列约 116dp,角标约 16~32dp)。
LazyColumn 里角标与标题的具体落位(紧贴标题还是行尾)取决于 Row 的权重分配细节,两种都成立、
都不遮蔽文字,但真机上可能想微调间距
- 「进入即读」在 UI 上没端到端跑过:POST /sessions/{id}/read 是否 200、`session_read` 帧
是否真的清掉另一端的角标,只有单测与契约(后端源码)支撑,没有真机往返
- 已知近似:判断「正在看这个会话」只用 `activeSessionId`,而它是**活动页**的会话 id——
用户切到设置 / 管理页时 ChatScreen 已卸载,此时落库的消息也会被标记已读(他会回来看到,
但角标已经消了)。信息不足,如实记在这里
架构代价(需主人裁决):`session_read` / `message_persisted` 只在 WS 上,而 `AppContainer`
只提供 `createChatSocket()`(每次新建,**没有进程级共享连接**),`ChatSocket` / `ChatViewModel`
/ `AppContainer` 都在别人的改动范围里 —— 本 VM 因此自持一条连接(登录后连、登出断开、
销毁断开)。这是既有遗留问题「每 VM 一条 socket、旧 socket 不关」之上再加一条;服务端支持
同账号多连接(多端本来如此),收不到帧也只是退回「下次 GET /sessions 收敛」。共享一条
app 级连接是更好的终局,但那要动 AppContainer / ChatSocket。
This commit is contained in:
1 parent
feeb49ebeb
commit
f413f8db55
9 files changed
+754
-11
No files matched your search
@@ -22,7 +22,7 @@ import top.yeij.cyrene.data.api.ApiEnvelope
|
||||
* **置顶的次级会话合法地排在主会话前面**(不能假设下标 0 是主会话);
|
||||
* - 还没有用户消息的会话 `title` 键**整个缺席**(自动命名只在首条用户消息时写入);
|
||||
* - 没有任何消息的会话 `lastMessage` 键整个缺席;
|
||||
* - `pinned` 恒下发(主会话上是显式 `false`),`unreadCount` 无关键照收。
|
||||
* - `pinned` 恒下发(主会话上是显式 `false`)、`unreadCount`(D4)恒下发——两者都必须读进模型。
|
||||
*/
|
||||
class SessionDtoRealPayloadTest {
|
||||
|
||||
@@ -164,4 +164,69 @@ class SessionDtoRealPayloadTest {
|
||||
// 服务端对 `{}` 回 400 `title or pinned is required`;调用方必须保证至少带一个字段。
|
||||
assertEquals("{}", body)
|
||||
}
|
||||
|
||||
// —— D4 未读 / 已读游标(契约 v0.37) ——
|
||||
|
||||
@Test
|
||||
fun `the unread count of the live payload is read off the wire`() {
|
||||
val sessions = decode(realSessionList)
|
||||
|
||||
// 这份线上载荷里两个值都有:已读过的次级会话是显式 0,主会话有 6 条没看。
|
||||
assertEquals(0, sessions[0].unreadCount)
|
||||
assertEquals(6, sessions[1].unreadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a session row without the unread key decodes to zero`() {
|
||||
// 旧快照:本版之前写下的一份(那时 DTO 里还没有这个字段),升级后必须照样解得开——
|
||||
// 否则第一次启动整份快照解码失败、列表先空一下,正是最不该出现的表现。
|
||||
val legacy = """{"id":"web","type":"main","createdAt":1788724031196,"pinned":false}"""
|
||||
|
||||
assertEquals(0, json.decodeFromString(SessionSummaryDto.serializer(), legacy).unreadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a snapshot round trip keeps the unread count`() {
|
||||
// `SessionPreferences` 存快照用的就是这份 Json + 这个 ListSerializer。
|
||||
val before = decode(realSessionList)
|
||||
|
||||
val restored = json.decodeFromString(sessionList, json.encodeToString(sessionList, before))
|
||||
|
||||
assertEquals(before, restored)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a read request without a timestamp omits the key entirely`() {
|
||||
val body = json.encodeToString(ReadSessionRequest.serializer(), ReadSessionRequest())
|
||||
|
||||
// 「缺省 = 现在」:服务端接受的正是空对象 `{}`;而 `before` 为 0 / 负数 / 非数字一律 400,
|
||||
// 所以「没有时间戳」只能表达成**整键缺席**,绝不能是 `{"before":null}` 或 `{"before":0}`。
|
||||
assertEquals("{}", body)
|
||||
assertFalse(body, body.contains("before"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a read request with a timestamp sends it as millis`() {
|
||||
val body = json.encodeToString(
|
||||
ReadSessionRequest.serializer(),
|
||||
ReadSessionRequest(before = 1_753_000_000_000L),
|
||||
)
|
||||
|
||||
assertEquals("""{"before":1753000000000}""", body)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the documented read response decodes to the advanced cursor`() {
|
||||
// 形状取自 api-reference §3.6b 的 curl 示例,与后端 `handleMarkSessionRead` 的
|
||||
// `writeJSON`(`{"data": …}`)一致。**这是文档示例,不是线上抓取**(不调线上端点)。
|
||||
val response = json.decodeFromString(
|
||||
ApiEnvelope.serializer(ReadSessionResponse.serializer()),
|
||||
"""{"data":{"sessionId":"web","readAt":1753000000000}}""",
|
||||
)
|
||||
|
||||
val data = requireNotNull(response.data)
|
||||
|
||||
assertEquals("web", data.sessionId)
|
||||
assertEquals(1_753_000_000_000L, data.readAt)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package top.yeij.cyrene.data.ws
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* D4 下行帧 `session_read` 的解码(契约 v0.37,api-client §3.9)。
|
||||
*
|
||||
* 帧形状不是手写猜的,取自**后端生产者逐字**:
|
||||
* `internal/services/gateway/session_read.go:broadcastSessionRead` ——
|
||||
*
|
||||
* ```go
|
||||
* h.broadcast(wsEvent{Type: "session_read", SessionID: sessionID, Data: map[string]any{"readAt": readAt}})
|
||||
* ```
|
||||
*
|
||||
* 三个要点都在这里钉死:
|
||||
* 1. 会话归属**只在帧壳**上(`data` 里没有 sessionId),游标是 `data.readAt`(毫秒整数);
|
||||
* 2. 它是那 7 个「状态机信号」之一,发给所有已认证连接,因此同样要按 [isForSession] 收敛;
|
||||
* 3. 坏帧(缺 data)要安全降级:`readAt` 解成 0,而上层把 0 当「无从判断」——正好什么都不改。
|
||||
*/
|
||||
class WsEventSessionReadTest {
|
||||
|
||||
private fun parse(raw: String): WsEvent =
|
||||
requireNotNull(WsEvent.parse(raw)) { "帧没解出来:$raw" }
|
||||
|
||||
@Test
|
||||
fun `the produced frame decodes with its session and cursor`() {
|
||||
val ev = parse("""{"type":"session_read","sessionId":"web","data":{"readAt":1753000000000}}""")
|
||||
as WsEvent.SessionRead
|
||||
|
||||
assertEquals("session_read", ev.type)
|
||||
assertEquals("web", ev.sessionId)
|
||||
assertEquals(1_753_000_000_000L, ev.readAt)
|
||||
// 状态机信号不带轮次 / 序号:hub 直接广播,不经过回合。
|
||||
assertNull(ev.turnId)
|
||||
assertNull(ev.seq)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the frame carries the session of a sub session too`() {
|
||||
// 读的是哪个会话就广播哪个——不是「总是主会话」。
|
||||
val ev = parse(
|
||||
"""{"type":"session_read","sessionId":"web:299bbf0b","data":{"readAt":1791090628688}}""",
|
||||
) as WsEvent.SessionRead
|
||||
|
||||
assertEquals("web:299bbf0b", ev.sessionId)
|
||||
assertEquals(1_791_090_628_688L, ev.readAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a frame without data degrades to a zero cursor instead of failing`() {
|
||||
// 上层把 readAt = 0 当「无从判断」:有消息的行原样保留计数,绝不会误清成已读。
|
||||
val ev = parse("""{"type":"session_read","sessionId":"web"}""") as WsEvent.SessionRead
|
||||
|
||||
assertEquals(0L, ev.readAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a frame whose shell omits the session gets a blank session, never a null`() {
|
||||
// 没有会话 id 的这一帧无法归属任何一行;上层据此直接跳过(而不是当成「属于当前会话」)。
|
||||
val ev = parse("""{"type":"session_read","data":{"readAt":1753000000000}}""")
|
||||
as WsEvent.SessionRead
|
||||
|
||||
assertEquals("", ev.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an explicit null session on the shell also decodes to a blank session`() {
|
||||
val ev = parse("""{"type":"session_read","sessionId":null,"data":{"readAt":1}}""")
|
||||
as WsEvent.SessionRead
|
||||
|
||||
assertEquals("", ev.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown keys inside data are ignored`() {
|
||||
// 后端将来在 data 里加字段不能被这一层挡掉(`ignoreUnknownKeys`)。
|
||||
val ev = parse(
|
||||
"""{"type":"session_read","sessionId":"web","data":{"readAt":42,"unread":0}}""",
|
||||
) as WsEvent.SessionRead
|
||||
|
||||
assertEquals(42L, ev.readAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a read frame follows the same per session rule as the other frames`() {
|
||||
val ev = parse("""{"type":"session_read","sessionId":"web","data":{"readAt":1}}""")
|
||||
|
||||
assertTrue(ev.isForSession("web"))
|
||||
assertFalse(ev.isForSession("web:other"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank session on a read frame is unknown, so every session accepts it`() {
|
||||
val ev = parse("""{"type":"session_read","data":{"readAt":1}}""")
|
||||
|
||||
// 空 = 「无法判定」而不是「不属于本会话」——与其它帧同一口径。
|
||||
assertTrue(ev.isForSession("web"))
|
||||
assertTrue(ev.isForSession("web:other"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a sub session id on the shell is kept verbatim`() {
|
||||
// 帧壳上的 sessionId 只是字符串,本层不做格式裁决(`web:<uuid>` / `sess-xxx` 都合法)。
|
||||
val ev = parse("""{"type":"session_read","sessionId":"sess-9c3","data":{"readAt":7}}""")
|
||||
as WsEvent.SessionRead
|
||||
|
||||
assertEquals("sess-9c3", ev.sessionId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package top.yeij.cyrene.ui.sessions
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.api.dto.SessionLastMessageDto
|
||||
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
|
||||
|
||||
/**
|
||||
* D4 未读的两块纯逻辑:角标文本,以及两条**方向相反**的本地调和规则。
|
||||
*
|
||||
* 调和规则为什么要分两条:
|
||||
* - [withReadCursor](`session_read` 帧 / 本地已知游标)是**有依据**的清零——游标只前进,
|
||||
* 所以「本地最新一条消息不比游标新」就等于没有未读;反过来若本地明明有更新的消息
|
||||
* (读完以后新到的),清成 0 就是把真实未读藏起来,必须保留旧计数。
|
||||
* - [markRead] 是**无条件**清零——调用方(`select()` / 正在看的会话落库)已经知道
|
||||
* 这一行就是屏幕上的那一行,不需要游标依据。
|
||||
*/
|
||||
class SessionUnreadTest {
|
||||
|
||||
private fun item(
|
||||
id: String = "web",
|
||||
lastMessageAt: Long? = null,
|
||||
unreadCount: Int = 0,
|
||||
) = SessionItem(
|
||||
id = id,
|
||||
title = "标题",
|
||||
isMain = id == "web",
|
||||
pinned = false,
|
||||
lastMessage = if (lastMessageAt == null) null else "预览",
|
||||
lastMessageAt = lastMessageAt,
|
||||
unreadCount = unreadCount,
|
||||
)
|
||||
|
||||
// —— 角标文本 ——
|
||||
|
||||
@Test
|
||||
fun `no unread means no badge at all`() {
|
||||
assertNull(unreadBadgeText(0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a negative count is a data error, not a badge`() {
|
||||
// 服务端恒下发非负整数;真出现负数说明数据坏了,绝不画一个「-1」出来。
|
||||
assertNull(unreadBadgeText(-1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `small counts are shown verbatim`() {
|
||||
assertEquals("1", unreadBadgeText(1))
|
||||
assertEquals("9", unreadBadgeText(9))
|
||||
assertEquals("10", unreadBadgeText(10))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the boundary at 99 is still exact and 100 overflows`() {
|
||||
assertEquals("99", unreadBadgeText(99))
|
||||
// 三位数会把 300dp 抽屉里的标题挤没,而精确值几乎没有信息增量。
|
||||
assertEquals("99+", unreadBadgeText(100))
|
||||
assertEquals("99+", unreadBadgeText(1234))
|
||||
}
|
||||
|
||||
// —— 按游标清零(有依据) ——
|
||||
|
||||
@Test
|
||||
fun `a cursor newer than the last message clears the badge`() {
|
||||
val cleared = item(lastMessageAt = 1_000L, unreadCount = 3).withReadCursor(2_000L)
|
||||
|
||||
assertEquals(0, cleared.unreadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cursor exactly on the last message clears the badge`() {
|
||||
// 服务端口径是 `createdAt <= readAt` 算已读、只数 `createdAt > readAt` 的,所以相等即已读。
|
||||
val cleared = item(lastMessageAt = 1_000L, unreadCount = 1).withReadCursor(1_000L)
|
||||
|
||||
assertEquals(0, cleared.unreadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a message newer than the cursor keeps the badge`() {
|
||||
// 本地已知有比游标更新的消息 ⇒ 真的还有未读(游标只前进,这只能是读完以后新到的)。
|
||||
// 精确条数本地算不出来(没有逐条时间线),保留旧计数比清零更接近真相。
|
||||
val kept = item(lastMessageAt = 3_000L, unreadCount = 2).withReadCursor(1_000L)
|
||||
|
||||
assertEquals(2, kept.unreadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a session without any message has nothing to be unread`() {
|
||||
val cleared = item(lastMessageAt = null, unreadCount = 1).withReadCursor(0L)
|
||||
|
||||
assertEquals(0, cleared.unreadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bad frame with readAt zero changes nothing it cannot judge`() {
|
||||
// 帧里没有 readAt 时解出来是 0(真实游标不可能是 0):有消息的行**原样保留**。
|
||||
assertEquals(5, item(lastMessageAt = 10L, unreadCount = 5).withReadCursor(0L).unreadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a zero count stays zero`() {
|
||||
assertEquals(0, item(lastMessageAt = 10L, unreadCount = 0).withReadCursor(1L).unreadCount)
|
||||
}
|
||||
|
||||
// —— 整表替换时的游标遮罩 ——
|
||||
|
||||
@Test
|
||||
fun `only the rows with a known cursor are masked`() {
|
||||
val sessions = listOf(
|
||||
item(id = "web", lastMessageAt = 1_000L, unreadCount = 4),
|
||||
item(id = "web:other", lastMessageAt = 1_000L, unreadCount = 7),
|
||||
)
|
||||
|
||||
val masked = sessions.withReadCursors(mapOf("web" to 2_000L))
|
||||
|
||||
assertEquals(0, masked[0].unreadCount)
|
||||
// 没记过游标的会话保持服务端的值——遮罩只压「本地已知已读」的行。
|
||||
assertEquals(7, masked[1].unreadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cursor older than the row keeps the server count`() {
|
||||
// 服务端说 4 条未读,而本地游标只推到「上一条消息」:这不是矛盾,是真的还有未读。
|
||||
val masked = listOf(item(id = "web", lastMessageAt = 5_000L, unreadCount = 4))
|
||||
.withReadCursors(mapOf("web" to 1_000L))
|
||||
|
||||
assertEquals(4, masked[0].unreadCount)
|
||||
}
|
||||
|
||||
// —— 按会话定位(帧里带的是**那个**会话的 id,不是当前会话) ——
|
||||
|
||||
@Test
|
||||
fun `a read frame only touches its own row`() {
|
||||
val sessions = listOf(
|
||||
item(id = "web", lastMessageAt = 1_000L, unreadCount = 2),
|
||||
item(id = "web:other", lastMessageAt = 1_000L, unreadCount = 5),
|
||||
)
|
||||
|
||||
val updated = sessions.applyReadCursor("web:other", 2_000L)
|
||||
|
||||
// 别的端读的是 web:other——这正是本端要处理的一半,不能按 activeSessionId 过滤掉。
|
||||
assertEquals(2, updated[0].unreadCount)
|
||||
assertEquals(0, updated[1].unreadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a read frame for an unknown session is a no-op`() {
|
||||
val sessions = listOf(item(id = "web", lastMessageAt = 1_000L, unreadCount = 2))
|
||||
|
||||
assertEquals(sessions, sessions.applyReadCursor("web:gone", 9_999L))
|
||||
}
|
||||
|
||||
// —— 无条件清零(本端刚看到) ——
|
||||
|
||||
@Test
|
||||
fun `marking the active session read clears it regardless of the cursor`() {
|
||||
// 用户就在这个会话里:即使本地已知有更新的消息,也必须清(那正是他屏幕上那条)。
|
||||
val sessions = listOf(
|
||||
item(id = "web", lastMessageAt = 9_999L, unreadCount = 3),
|
||||
item(id = "web:other", lastMessageAt = 9_999L, unreadCount = 3),
|
||||
)
|
||||
|
||||
val updated = sessions.markRead("web")
|
||||
|
||||
assertEquals(0, updated[0].unreadCount)
|
||||
assertEquals(3, updated[1].unreadCount)
|
||||
}
|
||||
|
||||
// —— DTO → 行模型的映射 ——
|
||||
|
||||
@Test
|
||||
fun `toItem carries the unread count and the last message time`() {
|
||||
val dto = SessionSummaryDto(
|
||||
id = "web:1",
|
||||
type = SessionSummaryDto.TYPE_SUB,
|
||||
title = "临时小事",
|
||||
createdAt = 1L,
|
||||
unreadCount = 6,
|
||||
lastMessage = SessionLastMessageDto(role = "assistant", content = "在呢", createdAt = 1_791_090_628_688L),
|
||||
)
|
||||
|
||||
val item = dto.toItem()
|
||||
|
||||
assertEquals(6, item.unreadCount)
|
||||
assertEquals(1_791_090_628_688L, item.lastMessageAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toItem without a last message has no time and no unread`() {
|
||||
val dto = SessionSummaryDto(
|
||||
id = "web:1",
|
||||
type = SessionSummaryDto.TYPE_SUB,
|
||||
createdAt = 1L,
|
||||
)
|
||||
|
||||
val item = dto.toItem()
|
||||
|
||||
assertNull(item.lastMessageAt)
|
||||
assertEquals(0, item.unreadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unread count without a last message still reaches the row`() {
|
||||
// 理论上不会发生(有未读就一定有消息),但服务端字段是独立的:不能因为没预览就丢掉计数,
|
||||
// 否则渲染层拿不到它的唯一依据。
|
||||
val dto = SessionSummaryDto(
|
||||
id = "web:1",
|
||||
type = SessionSummaryDto.TYPE_SUB,
|
||||
createdAt = 1L,
|
||||
unreadCount = 3,
|
||||
)
|
||||
|
||||
assertEquals(3, dto.toItem().unreadCount)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user