feat(sessions): 会话列表用服务端标题 + 置顶 + 最后消息预览(A4/D5/B3)

列表此前显示的是本地硬生成的「会话 N」,因为后端 title 恒空。后端后来做了
「首条用户消息自动命名」+ PATCH 改名/置顶 + lastMessage 预览,本轮接入,
并把本地那套序号机器整块删掉。

- data:SessionSummaryDto 补 pinned / lastMessage(均带默认值,老快照仍能解);
  新增 UpdateSessionRequest / Response 与 @PATCH api/v1/sessions/{id}
- core/prefs:**整块退役**本地序号机器(order / KEY_ORDER / OrderSerializer /
  decodeOrder / ensureOrder / 「会话 ?」)——有了服务端标题,本地序号永远不会一致,
  且 id 没有序号时会渲染出字面的「会话 ?」
- 标题规则:服务端标题非空白即原样显示,否则兜底(主会话「与昔涟」/ 次级「新会话」)。
  主会话刻意保留「与昔涟」而不采用服务端自己的「主会话」
- 顺序**完全信任服务端**(pinned DESC, main DESC, created_at ASC),客户端不重排;
  因此置顶的次级会排在主会话之前——顺带修正 SessionDto 里「主会话恒首位」的过时注释
- 置顶:主会话与次级都可置顶(契约允许);先就地翻转拿即时反馈 → PATCH →
  成功后静默重拉(服务端才是顺序权威,快照顺带修正)→ 失败回滚并报错
- 改名:长按行打开对话框(预填当前标题 + n/80 计数),不做乐观更新(服务端会 trim 后
  回写,等静默重拉取权威值);**不提供「恢复自动命名」**——服务端无法表达(空白即 400)
- **刷新触发(前置条件,不是锦上添花)**:列表此前只在 VM 初始化 / 新建 / 删除时拉取,
  所以自动标题与预览要重启 App 才可见。现改为**打开抽屉时静默刷新**,
  且不闪「同步中…」横幅
- 顺带修同文件里两个既有缺陷:① **快照的指纹闸门其实没实现**(fingerprint 写了却从不
  比较、clear() 是死代码)→ 现按 baseUrl+username 指纹过滤,并把 clear() 接到登出
  (否则换账号后第一帧会闪上个账号的会话列表);已实测确认指纹与写入时逐字节一致,
  不会误丢老快照。② 预览剥标签时旁白与正文会粘连(看截图发现的排版瑕疵)→
  标签改替成空格再折叠空白

测试:175 全绿(153 → +22)。含真实抓取响应体的 DTO 契约测试、标题兜底与预览规则的
纯函数测试、指纹闸门测试。
This commit is contained in:
AskaEth committed 2026-10-04 13:55:52 +08:00
1 parent f5576540b4
commit c09a21136c
10 files changed
+654 -71

No files matched your search

@@ -0,0 +1,109 @@
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
/**
* 列表行的显示规则(纯函数):服务端标题 + 客户端兜底 + 预览文本。
*
* 兜底只发生在「服务端没有标题」时——会话还没有用户消息,`title` 键整个缺席。
* 主会话兜底用「与昔涟」而不是服务端的「主会话」。
*/
class SessionItemTest {
private fun dto(
title: String? = null,
isMain: Boolean = false,
lastMessage: SessionLastMessageDto? = null,
) = SessionSummaryDto(
id = if (isMain) "web" else "web:1",
type = if (isMain) SessionSummaryDto.TYPE_MAIN else SessionSummaryDto.TYPE_SUB,
title = title,
createdAt = 0L,
lastMessage = lastMessage,
)
@Test
fun `a main session without a server title falls back to the app's own label`() {
assertEquals("与昔涟", dto(isMain = true).displayTitle())
assertEquals("与昔涟", dto(title = "", isMain = true).displayTitle())
assertEquals("与昔涟", dto(title = " ", isMain = true).displayTitle())
}
@Test
fun `a sub session without a server title falls back to the generic label`() {
assertEquals("新会话", dto().displayTitle())
assertEquals("新会话", dto(title = "", isMain = false).displayTitle())
assertEquals("新会话", dto(title = "\n\t", isMain = false).displayTitle())
}
@Test
fun `a server title is shown verbatim`() {
assertEquals("Hello Cyrene", dto(title = "Hello Cyrene").displayTitle())
// 服务端自己的主会话兜底是「主会话」:一旦真的存了这个标题就照显示,不再走本端兜底。
assertEquals("主会话", dto(title = "主会话", isMain = true).displayTitle())
// 只有全空白才兜底,带空白但非空白的标题原样显示。
assertEquals(" 两边留白 ", dto(title = " 两边留白 ").displayTitle())
}
@Test
fun `toItem keeps the server order fields and the pin state`() {
val item = dto(title = "Hello Cyrene", lastMessage = null).toItem()
assertEquals("web:1", item.id)
assertEquals("Hello Cyrene", item.title)
assertEquals(false, item.isMain)
assertEquals(false, item.pinned)
assertNull(item.lastMessage)
}
@Test
fun `the preview strips the markup the server keeps in the snippet`() {
val last = SessionLastMessageDto(
role = "assistant",
content = "<p><action>愣了一下</action></p> <p>原来人家能一直好好待在这里…</p>",
createdAt = 0L,
)
// 服务端只折叠空白、截断 60 字,标签原样保留——直接显示会露出 <p><action>。
assertEquals("愣了一下 原来人家能一直好好待在这里…", last.content.toPreviewText())
}
@Test
fun `a truncated snippet does not leak the half open paragraph tag`() {
// 线上真实形状:截断把尾部那个 <p> 的开标签留下了,没有任何闭合标签配它。
val truncated = "<p><action>愣了一下,随即弯起眼睛,笑得比平时还软</action></p>" +
" <p>原来人家能一直好好待在这里…"
assertEquals(
"愣了一下,随即弯起眼睛,笑得比平时还软 原来人家能一直好好待在这里…",
truncated.toPreviewText(),
)
}
@Test
fun `a tag with the same prefix is left alone`() {
// 只剥 p / action / narration,`<pre>` 这类同前缀标签不能被误删。
assertEquals("<pre>x</pre>", "<pre>x</pre>".toPreviewText())
}
@Test
fun `stripping a tag does not glue the text on either side together`() {
// 线上真实形状:旁白紧贴正文。若把标签替成空串,「…了一点」与「今天下午…」会粘成一个词。
val adjacent = "<p>人家轻轻凑近了一点<action>悄悄戳了戳你</action>今天下午的翁法罗斯…</p>"
assertEquals(
"人家轻轻凑近了一点 悄悄戳了戳你 今天下午的翁法罗斯…",
adjacent.toPreviewText(),
)
}
@Test
fun `an empty snippet becomes no summary at all`() {
assertEquals("", " ".toPreviewText())
assertNull(dto(lastMessage = SessionLastMessageDto("assistant", "", 0L)).toItem().lastMessage)
}
}