From f413f8db55a1840a31bbb1a5c7724707e7f5472b Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 17:42:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(sessions):=20=E6=8E=A5=E4=B8=8A=20D4=20?= =?UTF-8?q?=E6=9C=AA=E8=AF=BB=20=E2=80=94=E2=80=94=20=E6=8A=BD=E5=B1=89?= =?UTF-8?q?=E8=A7=92=E6=A0=87=20+=20=E5=B7=B2=E8=AF=BB=E6=B8=B8=E6=A0=87?= =?UTF-8?q?=EF=BC=88=E8=BF=9B=E5=85=A5=E5=8D=B3=E8=AF=BB=E3=80=81=E7=9C=8B?= =?UTF-8?q?=E7=9D=80=E5=B0=B1=E7=82=B9=E5=B7=B2=E8=AF=BB=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 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。 --- .../top/yeij/cyrene/data/api/SessionsApi.kt | 15 ++ .../yeij/cyrene/data/api/dto/SessionDto.kt | 33 +++ .../data/repository/SessionRepository.kt | 16 ++ .../kotlin/top/yeij/cyrene/data/ws/WsEvent.kt | 27 +++ .../ui/sessions/SessionDrawerContent.kt | 70 +++++- .../cyrene/ui/sessions/SessionsViewModel.kt | 207 ++++++++++++++++- .../data/api/dto/SessionDtoRealPayloadTest.kt | 67 +++++- .../cyrene/data/ws/WsEventSessionReadTest.kt | 113 +++++++++ .../cyrene/ui/sessions/SessionUnreadTest.kt | 217 ++++++++++++++++++ 9 files changed, 754 insertions(+), 11 deletions(-) create mode 100644 app/src/test/kotlin/top/yeij/cyrene/data/ws/WsEventSessionReadTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/ui/sessions/SessionUnreadTest.kt diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/SessionsApi.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/SessionsApi.kt index 9257737..75cb2d9 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/api/SessionsApi.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/SessionsApi.kt @@ -3,6 +3,8 @@ package top.yeij.cyrene.data.api import top.yeij.cyrene.data.api.dto.CreateSessionResponse import top.yeij.cyrene.data.api.dto.DeleteSessionResponse import top.yeij.cyrene.data.api.dto.HistoryMessageDto +import top.yeij.cyrene.data.api.dto.ReadSessionRequest +import top.yeij.cyrene.data.api.dto.ReadSessionResponse import top.yeij.cyrene.data.api.dto.SessionSummaryDto import top.yeij.cyrene.data.api.dto.UpdateSessionRequest import top.yeij.cyrene.data.api.dto.UpdateSessionResponse @@ -60,4 +62,17 @@ interface SessionsApi { @Query("before") before: Long? = null, @Query("platform") platform: String? = null, ): Response>> + + /** + * 推进已读游标(D4,契约 v0.37 `api-reference §3.6b`)。 + * + * `before` 缺席 = 「现在」;游标**只前进**,所以重复 / 乱序 / 重试都幂等安全。 + * 错误面:`before` 非法 → `400`、未知会话(含隔离 `sess-xxx`)→ `404`、 + * 未注入 HistoryStore → `501`。成功后服务端会广播 `session_read`(§3.9)。 + */ + @POST("api/v1/sessions/{id}/read") + suspend fun markRead( + @Path("id") sessionId: String, + @Body body: ReadSessionRequest, + ): Response> } diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/SessionDto.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/SessionDto.kt index f0ce62a..9e6341b 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/SessionDto.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/SessionDto.kt @@ -21,6 +21,16 @@ data class SessionSummaryDto( val pinned: Boolean = false, /** 会话还没有消息时**整个键缺席**。 */ val lastMessage: SessionLastMessageDto? = null, + /** + * 未读条数(D4,契约 v0.37 起**恒下发**):`createdAt >` 该会话已读游标的消息条数, + * `0` = 无未读。 + * + * 有默认值是为了**旧快照仍能解码**:[top.yeij.cyrene.core.prefs.SessionPreferences] + * 把整个列表原样持久化成 JSON,非默认值会让升级后第一次启动的整份快照解码失败, + * 临时失效(列表先空、等 REST 回来)——那正是本应用最不该出现的表现。 + * 默认 `0` 的口径也是安全的:它只会让角标少显示一会儿,`Get /sessions` 一到就被纠正。 + */ + val unreadCount: Int = 0, ) { val isMain: Boolean get() = type == TYPE_MAIN || id == MAIN_SESSION_ID @@ -82,3 +92,26 @@ data class UpdateSessionResponse( data class DeleteSessionResponse( val deleted: String, ) + +/** + * `POST /api/v1/sessions/{id}/read`(D4)的请求体:可选地把已读游标推进到 `before`。 + * + * - **键缺席 = 「现在」**(「我看到的都算已读」)。生产 `Json` 的 `explicitNulls = false` + * 保证 `before = null` 时整个键缺席,序列化出来是 `{}`——服务端明确接受空对象 + * (`api-reference §3.6b`:**空 body / `{}` = 现在**)。 + * - 服务端对 `0` / 负数 / 非数字一律 `400`,所以**绝不能**把 `0` 发上去:调用方拿到 + * 非正的「没有时间戳」时必须折成缺席(null),而不是原样透传。 + * - 游标**只前进**(SQL `MAX(read_at, before)`),重复 / 乱序 / 重试都幂等安全。 + */ +@Serializable +data class ReadSessionRequest( + val before: Long? = null, +) + +/** `POST /api/v1/sessions/{id}/read` → `200 {"data":{"sessionId":"","readAt":<毫秒>}}`。 */ +@Serializable +data class ReadSessionResponse( + val sessionId: String = "", + /** 推进**后**的游标(毫秒 Unix 时间戳,与该会话消息的 `createdAt` 同单位)。 */ + val readAt: Long = 0L, +) diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/repository/SessionRepository.kt b/app/src/main/kotlin/top/yeij/cyrene/data/repository/SessionRepository.kt index 7046819..50fa180 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/repository/SessionRepository.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/repository/SessionRepository.kt @@ -2,6 +2,8 @@ package top.yeij.cyrene.data.repository import top.yeij.cyrene.data.api.SessionsApi import top.yeij.cyrene.data.api.apiCall +import top.yeij.cyrene.data.api.dto.ReadSessionRequest +import top.yeij.cyrene.data.api.dto.ReadSessionResponse import top.yeij.cyrene.data.api.dto.SessionSummaryDto import top.yeij.cyrene.data.api.dto.UpdateSessionRequest import top.yeij.cyrene.data.api.dto.UpdateSessionResponse @@ -49,4 +51,18 @@ class SessionRepository( suspend fun deleteSession(sessionId: String): Result = withContext(dispatcher) { apiCall { api.deleteSession(sessionId) }.map { it.deleted } } + + /** + * `POST /sessions/{id}/read`(D4):推进该会话的已读游标,成功返回**推进后**的游标。 + * + * @param before 毫秒时间戳;**null = 现在**(「我看到的都算已读」)。服务端对 `0` / 负数 + * 一律 `400`,所以调用方必须把「没有时间戳」表达成 null 而不是 0。 + * + * 这是**幂等**的(服务端 `MAX(read_at, before)`,只前进),失败也没有副作用—— + * 唯一真源仍是服务端游标,漏标一次会在下一次 `GET /sessions` 自然暴露、再被纠正。 + */ + suspend fun markRead(sessionId: String, before: Long? = null): Result = + withContext(dispatcher) { + apiCall { api.markRead(sessionId, ReadSessionRequest(before = before)) } + } } diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsEvent.kt b/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsEvent.kt index bf09ccd..80eca0f 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsEvent.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsEvent.kt @@ -309,6 +309,26 @@ sealed class WsEvent { override val type: String get() = "chat_duplicate" } + /** + * 已读游标推进(D4,契约 v0.37,api-client §3.9): + * `{type:"session_read", sessionId, data:{readAt}}`——**载荷里没有会话 id**, + * 会话归属**只**来自帧壳(服务端 `POST /sessions/{id}/read` 成功后广播)。 + * + * 它是那 7 个「不含内容的状态机信号」之一(发给**所有**已认证连接,含 `viewer`), + * 与 `message_persisted` 同一条全连接广播路径,故同样要按 [isForSession] 收敛。 + * [readAt] 是**推进后**的游标(毫秒,与该会话消息的 `createdAt` 同单位); + * data 缺席时回落 `0`——游标实际不可能为 0,于是「坏帧」天然退化成**什么也不改** + * (见 `SessionItem.withReadCursor`:只有 `lastMessageAt <= readAt` 才清零)。 + */ + data class SessionRead( + override val sessionId: String, + val readAt: Long, + override val turnId: String? = null, + override val seq: Long? = null, + ) : WsEvent() { + override val type: String get() = "session_read" + } + /** 主会话(`web` / `web:`)消息落库后广播。 */ data class MessagePersisted( val message: HistoryMessageDto?, @@ -459,6 +479,9 @@ sealed class WsEvent { seq, ) } + // 已读游标(D4 §3.9):会话归属只在帧壳上(data 只有 readAt)。 + "session_read" -> decode(data) + .let { SessionRead(resolveSessionId(sessionId, ""), it.readAt, turnId, seq) } "message_persisted" -> decode(data) .let { MessagePersisted(it.message, turnId, seq, sessionId ?: it.message?.sessionId) } "channel_persisted" -> decode(data) @@ -567,6 +590,10 @@ private data class ChatDuplicateData( val messageId: String? = null, ) +/** `session_read` 载荷(api-client §3.9):`{readAt}`——毫秒游标,**不带**会话 id。 */ +@Serializable +private data class SessionReadData(val readAt: Long = 0L) + @Serializable private data class PersistedData(val message: HistoryMessageDto? = null) diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionDrawerContent.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionDrawerContent.kt index c630f1e..5802664 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionDrawerContent.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionDrawerContent.kt @@ -21,10 +21,16 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import top.yeij.cyrene.ui.common.PreferenceCard +import top.yukonga.miuix.kmp.basic.Badge import top.yukonga.miuix.kmp.basic.BasicComponent +import top.yukonga.miuix.kmp.basic.BasicComponentDefaults import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.basic.IconButton import top.yukonga.miuix.kmp.basic.Text @@ -157,6 +163,19 @@ fun ColumnScope.SessionDrawerContent( * 会话行。手势只有**一个**检测器:整个行由 `combinedClickable` 负责单击/长按, * `BasicComponent` 因此不再传 `onClick`(传了它内部会再装一个 clickable, * 子级会先吃掉按下事件,长按就永远不会触发)。 + * + * ## 为什么不用 `BasicComponent` 的 `title` / `summary` 参数 + * 那两个参数只收 `String`,塞不下未读角标;而角标**不能**放进 `endActions`—— + * 抽屉固定 300dp,卡片 + 内边距之后只剩约 244dp,而 `endActions` 里已经有三个 40dp 的 + * 图标按钮(120dp),`BasicComponent` 又把它内部可用宽度的 60% 作为 `endActions` 的硬上限: + * 再塞一个角标会把图标挤到裁剪(Row 不换行),标题列也被压到约 95dp。 + * 所以这里改用「内容重载」自己搭标题行:`[标题][角标]` 同行,角标紧挨标题、不占额外列宽。 + * + * 代价是标题 / 摘要的字号、字重、颜色要自己写,取值与库内实现(`Component.kt`)逐字一致 + * (`headline1.fontSize` + `Medium` + `BasicComponentDefaults.titleColor()`; + * `body2.fontSize` + `summaryColor()`),**唯一的差异是收紧了行数**: + * 标题与摘要都 `maxLines = 1` + 省略号——这正是「可读性要强」要的效果:长标题不再在约 116dp 的 + * 列里折成两三行把卡片撑高,而是稳定地占一行、尾部省略,列表因此等高、可扫读。 */ @Composable private fun SessionRow( @@ -167,11 +186,13 @@ private fun SessionRow( onRename: () -> Unit, onDelete: () -> Unit, ) { + val titleColor = BasicComponentDefaults.titleColor().color + val summaryColor = BasicComponentDefaults.summaryColor().color + val badge = unreadBadgeText(session.unreadCount) + PreferenceCard { BasicComponent( modifier = Modifier.combinedClickable(onClick = onClick, onLongClick = onRename), - title = session.title, - summary = session.lastMessage, endActions = { if (active) { Text( @@ -202,10 +223,53 @@ private fun SessionRow( } } }, - ) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = session.title, + // 标题是这一行唯一的权重子项,`fill = false` 让短标题只占自己需要的宽度。 + // 真正保证角标不被挤掉的是 Row 的测量顺序:**非权重子项(角标与它前面的间距) + // 先量**,权重只分配剩下的空间,标题再在这块空间里省略—— + // 所以 `99+` 永远进得来,标题也不会被压到完全看不见。 + modifier = Modifier.weight(1f, fill = false), + fontSize = MiuixTheme.textStyles.headline1.fontSize, + fontWeight = FontWeight.Medium, + color = titleColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (badge != null) { + Spacer(Modifier.width(UNREAD_BADGE_GAP)) + // Miuix 自带角标:错误色药丸 + `onError` 文字 + 16dp 下限高度, + // 在 `surfaceContainer` 卡片上对比度足够;`99+` 会自然撑成药丸而不会溢出。 + Badge( + modifier = Modifier.clearAndSetSemantics { + contentDescription = "未读 $badge" + }, + ) { + Text(badge) + } + } + } + session.lastMessage?.let { preview -> + Text( + text = preview, + fontSize = MiuixTheme.textStyles.body2.fontSize, + color = summaryColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } } } +/** 标题与角标之间的间距:太小会粘成一个词,太大又脱离标题。 */ +private val UNREAD_BADGE_GAP = 6.dp + /** 重命名对话框:服务端不接受空标题(400)也不接受超过 80 个字符,两者都在这里挡住。 */ @Composable private fun RenameDialog( diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt index 038f877..e129692 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt @@ -12,6 +12,8 @@ import top.yeij.cyrene.app.AppContainer import top.yeij.cyrene.core.auth.SessionState import top.yeij.cyrene.data.api.CyreneException import top.yeij.cyrene.data.api.dto.SessionSummaryDto +import top.yeij.cyrene.data.ws.ChatSocket +import top.yeij.cyrene.data.ws.WsEvent data class SessionItem( val id: String, @@ -20,6 +22,13 @@ data class SessionItem( val pinned: Boolean, /** 最后一条消息的一行摘要;会话还没有消息时为 null。 */ val lastMessage: String?, + /** + * 最后一条消息的时间戳(毫秒);会话还没有消息时为 null。 + * 只用于已读游标比对(见 [SessionItem.withReadCursor]),不直接显示。 + */ + val lastMessageAt: Long? = null, + /** 未读条数(服务端 `unreadCount`,D4);`0` = 无未读、不显示角标。 */ + val unreadCount: Int = 0, ) data class SessionsUiState( @@ -37,6 +46,21 @@ data class SessionsUiState( * 启动先用本地快照渲染(指纹不匹配就不用),再拉服务端;成功则整体替换并回写快照, * 失败则保留快照并提示。顺序完全信任服务端(`pinned DESC, main DESC, created_at ASC`), * 客户端不重排。 + * + * ## 未读(D4) + * 未读条数来自服务端(`GET /sessions` 的 `unreadCount`,恒下发),本类只在三处改它: + * 列表整表替换(以服务端为准)、**进入会话**、以及**正在看的会话有新消息落库**——后两处 + * 同时调 `POST /sessions/{id}/read` 把游标推上去(策略详见 [pushReadCursor])。 + * 反向同步(别的端读了)靠下行 `session_read` 帧。 + * + * ## 为什么本类自己开一条 WS 连接 + * `session_read` / `message_persisted` 都在 WS 上,而 `AppContainer` 只提供 + * `createChatSocket()`(**每次调用新建**,没有进程级共享连接),`ChatSocket` / `ChatViewModel` + * 又都在别的改动范围里。要在这里收帧,唯一不改别人的办法就是自持一条连接。 + * 代价如实说:这是本应用**已知遗留问题**(「每 VM 一条 socket、旧 socket 不关」,见 + * `devdocs/05`)之上再加一条——服务端对同一账号的多连接是支持的(多端场景本来如此), + * 而且本类只关心两个帧类型,收不到也不会让功能失效(`GET /sessions` 会收敛)。 + * 生命周期:登录后连接、登出断开、VM 销毁断开([onCleared])。 */ class SessionsViewModel(private val container: AppContainer) : ViewModel() { @@ -45,6 +69,21 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() { private val prefs = container.sessionPreferences + /** + * 本端**已知的已读游标**(会话 id → 毫秒),写入值一律取自**服务端口径**: + * - `POST /sessions/{id}/read` 响应里的 `readAt`(权威); + * - 下行 `session_read` 帧的 `readAt`(可能是别的端推的); + * - 本端对某条落库消息发起的推进,先用该消息的 `createdAt` 占位——它也是服务端时间, + * 所以不存在设备时钟偏差。 + * + * 唯一用途是 [withReadCursors]:整表替换(REST 快照)时,把「本地已知没有比游标更新的 + * 消息」的那些行的未读压回 0。不做这件事的话,刚在别处读完、或本端刚标记过的会话, + * 会因为一次竞态中的 GET 把角标又闪回来。 + */ + private val readCursors = mutableMapOf() + + private val socket: ChatSocket = container.createChatSocket() + init { viewModelScope.launch { val fingerprint = fingerprint() @@ -52,7 +91,7 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() { val snapshot = cached.snapshotFor(fingerprint) _state.update { it.copy( - sessions = snapshot.map { dto -> dto.toItem() }, + sessions = snapshot.map { dto -> dto.toItem() }.withReadCursors(readCursors), activeSessionId = if (snapshot.isEmpty()) { SessionSummaryDto.MAIN_SESSION_ID } else { @@ -65,15 +104,34 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() { refresh() } // 登出时把快照一起清掉(先例:EnumCatalogViewModel 在登出时清枚举快照)—— - // 否则换账号后第一帧会闪一份上个账号的会话列表。 + // 否则换账号后第一帧会闪一份上个账号的会话列表。已读游标同理:它是**账号级**的 + // (服务端单一游标),留着会让新账号的会话被旧账号的游标错误地清成已读。 viewModelScope.launch { container.authManager.state.collect { auth -> - if (auth == SessionState.LoggedOut) { - prefs.clear() - _state.value = SessionsUiState() + when (auth) { + SessionState.LoggedOut -> { + socket.close() + readCursors.clear() + prefs.clear() + _state.value = SessionsUiState() + } + + // 连接只在登录后建立:Socket 的令牌 / 地址都是每次重连现读的, + // connect() 幂等(已连接时直接返回)。本 VM 存活期跨越登出 → 重登, + // 所以「登出断开、重登接上」必须在这里显式表达。 + is SessionState.LoggedIn -> socket.connect() + + SessionState.Unknown -> Unit } } } + // 未读的两个下行来源都在这里收敛;见 [onEvent]。 + viewModelScope.launch { socket.events.collect(::onEvent) } + } + + override fun onCleared() { + socket.close() + super.onCleared() } /** @@ -95,7 +153,9 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() { if (active != _state.value.activeSessionId) prefs.setActive(active) _state.update { it.copy( - sessions = list.map { dto -> dto.toItem() }, + // 未读以服务端为准,但先过一遍本地已知游标:一次竞态中的 GET + // (快照早于本端刚发起的 read)不该把角标闪回来。 + sessions = list.map { dto -> dto.toItem() }.withReadCursors(readCursors), activeSessionId = active, loading = false, fromCache = false, @@ -116,9 +176,23 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() { } } + /** + * 切换 / 进入会话。 + * + * **进入即已读**(D4):用户点开这个会话,聊天页紧接着会加载并显示它的最新一页, + * 所以立刻把游标推到「现在」(`before` 缺席 = 服务端取现在)——这比推一个本地 + * `lastMessage.createdAt` 更诚实:本地的列表可能已经陈旧,而聊天页看到的一定是最新的。 + * 先就地清零(不等网络往返,角标立刻消失),再用响应里的权威游标收敛。 + * + * `createSession` / `deleteSession` 也走这里,语义同样成立:那两个动作之后, + * 屏幕上(即将)显示的就是这个会话。 + */ fun select(sessionId: String) { - _state.update { it.copy(activeSessionId = sessionId) } + _state.update { + it.copy(activeSessionId = sessionId, sessions = it.sessions.markRead(sessionId)) + } viewModelScope.launch { prefs.setActive(sessionId) } + pushReadCursor(sessionId) } fun createSession() { @@ -184,6 +258,76 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() { fun dismissError() = _state.update { it.copy(error = null) } + /** + * 下行帧收敛(未读相关只有两类)。 + * + * **这里刻意不用 [top.yeij.cyrene.data.ws.isForSession] 过滤**:那道闸是给「一个 VM 只关心 + * 一个会话」的场景(`ChatViewModel`)用的,而本 VM 持有**整个会话列表**——按 activeSessionId + * 过滤会把「别的会话被读了」这一帧直接丢掉,正是最该处理的一半。所以改为按帧里的会话 id + * **逐行定位**([applyReadCursor],影响面严格限制在那一行)。 + */ + private fun onEvent(event: WsEvent) { + when (event) { + // 别处(Web 面板 / 别的设备 / 本端另一次请求)读了:清掉那一行的红点。 + is WsEvent.SessionRead -> onSessionRead(event) + // 正在看的会话落了新消息:消息已经显示在屏幕上了,立刻推进游标, + // 否则「自己正看着的会话」会攒出一个未读角标(契约 §3.9 的客户端处理建议)。 + is WsEvent.MessagePersisted -> onMessagePersisted(event) + else -> Unit + } + } + + private fun onSessionRead(event: WsEvent.SessionRead) { + if (event.sessionId.isBlank()) return + recordReadCursor(event.sessionId, event.readAt) + _state.update { it.copy(sessions = it.sessions.applyReadCursor(event.sessionId, event.readAt)) } + } + + /** + * `message_persisted`:只有**当前打开的那个会话**才推进游标。 + * + * 别的会话攒未读是正确行为(红点本来就该亮),所以这里不碰它们的计数—— + * 本端只做「看到即已读」,不做「替服务端数未读」(增量计数会与编辑 / 删除 / 别端操作漂移, + * 而抽屉每次打开都会静默重拉,以服务端为准)。 + */ + private fun onMessagePersisted(event: WsEvent.MessagePersisted) { + val sessionId = event.sessionId?.takeIf { it.isNotBlank() } ?: return + if (sessionId != _state.value.activeSessionId) return + val message = event.message ?: return + // `createdAt` 是服务端时间:用它当 before 比「现在」更精确——只把**这条**及其之前标成 + // 已读,同时落库的其它消息(时间更晚)仍算未读。缺时间戳时退化成「现在」,语义见 select()。 + val before = message.createdAt.takeIf { it > 0 } + before?.let { recordReadCursor(sessionId, it) } + _state.update { it.copy(sessions = it.sessions.markRead(sessionId)) } + pushReadCursor(sessionId, before) + } + + /** + * 推进已读游标(D4)。**失败静默**:这是一次后台的「顺手记一下」,不是用户发起的操作—— + * 弹错误横幅会为一件用户没要求、也不影响使用的失败打扰他;而且服务端游标才是唯一真源, + * 漏标一次会在下一次 `GET /sessions` 暴露出来、再被 [select] / [onMessagePersisted] 纠正。 + * 请求本身幂等(服务端 `MAX(read_at, before)`),重复发不会把已读退回未读。 + * + * @param before null = 现在。**绝不能传 0**:服务端把 `0` / 负数当非法参数回 400。 + */ + private fun pushReadCursor(sessionId: String, before: Long? = null) { + viewModelScope.launch { + container.sessionRepository.markRead(sessionId, before) + .onSuccess { response -> + if (response.readAt > 0) recordReadCursor(sessionId, response.readAt) + _state.update { + it.copy(sessions = it.sessions.applyReadCursor(sessionId, response.readAt)) + } + } + } + } + + /** 记下已知游标:只接受正数(坏帧的 `readAt = 0` 不能污染成「读到纪元」)。 */ + private fun recordReadCursor(sessionId: String, readAt: Long) { + if (readAt <= 0) return + readCursors[sessionId] = maxOf(readCursors[sessionId] ?: 0L, readAt) + } + private fun fingerprint(): String = "${container.authManager.currentBaseUrl}|${container.sessionStore.load().username.orEmpty()}" } @@ -194,8 +338,57 @@ internal fun SessionSummaryDto.toItem(): SessionItem = SessionItem( isMain = isMain, pinned = pinned, lastMessage = lastMessage?.content?.toPreviewText()?.takeIf { it.isNotBlank() }, + lastMessageAt = lastMessage?.createdAt?.takeIf { it > 0 }, + unreadCount = unreadCount, ) +/** + * 未读角标的文本(纯函数)。`null` = **不显示**角标。 + * + * `0` 与负数都不显示(服务端只会给非负整数,负数当数据错误处理,不画一个「-1」)。 + * 超过 [UNREAD_BADGE_MAX] 收敛为 `99+`:三位数的角标在 300dp 的抽屉里会把标题挤没, + * 而三位数以上的精确值对「有多少条没看」几乎没有信息增量。 + */ +internal fun unreadBadgeText(count: Int): String? = when { + count <= 0 -> null + count > UNREAD_BADGE_MAX -> "$UNREAD_BADGE_MAX+" + else -> count.toString() +} + +/** 角标显示的未读上限;超过就显示 `99+`。 */ +internal const val UNREAD_BADGE_MAX = 99 + +/** 整表替换(REST 快照)时,把本地已知已读的那些行压回 0 未读;见 `SessionsViewModel.readCursors`。 */ +internal fun List.withReadCursors(cursors: Map): List = + map { item -> cursors[item.id]?.let { readAt -> item.withReadCursor(readAt) } ?: item } + +/** 把某一行的未读清零(`session_read` 帧、或本端刚标记过)。 */ +internal fun List.applyReadCursor(sessionId: String, readAt: Long): List = + map { if (it.id == sessionId) it.withReadCursor(readAt) else it } + +/** + * 用户**正在看**这个会话(或本端刚为它推进过游标)时,无条件清掉它的未读。 + * 与 [withReadCursor] 的区别:这里不需要游标依据——调用方已经知道「这一行就是屏幕上的那一行」 + * (进入会话、正在看的会话落了新消息)。 + */ +internal fun List.markRead(sessionId: String): List = + map { if (it.id == sessionId) it.copy(unreadCount = 0) else it } + +/** + * 按已读游标清未读(纯函数):**只在「本地已知的最新一条消息不比游标新」时清零**。 + * + * 为什么不能一律清零(api-client §3.9 说「直接清零也不会错」):那边的假设是「本地没有更完整的 + * 数据」。本端有——`lastMessage.createdAt` 就是本地已知的最新消息时间。若它**晚于**游标, + * 说明确实还有未读(游标只前进,这只能是读完以后新到的消息),此时清零就是**把真实未读藏起来**。 + * 保留旧计数更接近真相:精确条数本地算不出来(没有逐条时间线),等下次 `GET /sessions` 收敛。 + * + * 会话没有消息([SessionItem.lastMessageAt] 为 null)时一定没有未读,清零。 + */ +internal fun SessionItem.withReadCursor(readAt: Long): SessionItem { + val newest = lastMessageAt + return if (newest != null && newest > readAt) this else copy(unreadCount = 0) +} + /** * 刷新成功后校验「当前会话」是否还在列表里;不在就回落到主会话。 * diff --git a/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/SessionDtoRealPayloadTest.kt b/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/SessionDtoRealPayloadTest.kt index 17baac2..a9e5c8f 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/SessionDtoRealPayloadTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/SessionDtoRealPayloadTest.kt @@ -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) + } } diff --git a/app/src/test/kotlin/top/yeij/cyrene/data/ws/WsEventSessionReadTest.kt b/app/src/test/kotlin/top/yeij/cyrene/data/ws/WsEventSessionReadTest.kt new file mode 100644 index 0000000..ea51322 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/data/ws/WsEventSessionReadTest.kt @@ -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:` / `sess-xxx` 都合法)。 + val ev = parse("""{"type":"session_read","sessionId":"sess-9c3","data":{"readAt":7}}""") + as WsEvent.SessionRead + + assertEquals("sess-9c3", ev.sessionId) + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/sessions/SessionUnreadTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/sessions/SessionUnreadTest.kt new file mode 100644 index 0000000..63248cf --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/sessions/SessionUnreadTest.kt @@ -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) + } +}