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:
1 parent
f5576540b4
commit
c09a21136c
10 files changed
+654
-71
No files matched your search
@@ -0,0 +1,51 @@
|
||||
package top.yeij.cyrene.core.prefs
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
|
||||
|
||||
/**
|
||||
* 会话快照的指纹闸门:`baseUrl + username` 变了,上一份快照就作废——
|
||||
* 绝不把别的服务器/账号的会话列表拿来渲染。
|
||||
*
|
||||
* 纯粹的 seam 测试:`SessionPreferences` 本体要 Android + DataStore,这里只钉规则。
|
||||
*/
|
||||
class SessionPrefsTest {
|
||||
|
||||
private val snapshot = listOf(
|
||||
SessionSummaryDto(
|
||||
id = "web",
|
||||
type = SessionSummaryDto.TYPE_MAIN,
|
||||
title = null,
|
||||
createdAt = 1L,
|
||||
),
|
||||
)
|
||||
|
||||
private val fingerprint = "http://host:10942/|testuser"
|
||||
|
||||
@Test
|
||||
fun `a matching fingerprint yields the snapshot`() {
|
||||
val prefs = SessionPrefs(snapshot = snapshot, fingerprint = fingerprint)
|
||||
|
||||
assertEquals(snapshot, prefs.snapshotFor(fingerprint))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `another account or another server invalidates the snapshot`() {
|
||||
val prefs = SessionPrefs(snapshot = snapshot, fingerprint = fingerprint)
|
||||
|
||||
assertEquals(emptyList<SessionSummaryDto>(), prefs.snapshotFor("http://host:10942/|someone-else"))
|
||||
assertEquals(emptyList<SessionSummaryDto>(), prefs.snapshotFor("http://other-host:10942/|testuser"))
|
||||
// 老安装里没有指纹(或指纹恰好为空的极端情况):同样不认。
|
||||
assertEquals(emptyList<SessionSummaryDto>(), prefs.snapshotFor(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no snapshot yet stays empty even for the matching fingerprint`() {
|
||||
assertEquals(
|
||||
emptyList<SessionSummaryDto>(),
|
||||
SessionPrefs(fingerprint = fingerprint).snapshotFor(fingerprint),
|
||||
)
|
||||
assertEquals(emptyList<SessionSummaryDto>(), SessionPrefs().snapshotFor(""))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package top.yeij.cyrene.data.api.dto
|
||||
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
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.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.ApiEnvelope
|
||||
|
||||
/**
|
||||
* 会话契约钉死测试:用的是**线上后端实际抓下来的**响应体
|
||||
* (`http://127.0.0.1:10942`,owner 账号,2026-10-05),逐字节照抄(含 `\u003c` 形式的 HTML 转义)。
|
||||
*
|
||||
* 解码器用 [ApiClientFactory.json] —— 与生产同一份 `Json` 配置
|
||||
* (`ignoreUnknownKeys` / `explicitNulls=false` / `coerceInputValues`),
|
||||
* 所以这里过不了的形状,线上也一定过不了。
|
||||
*
|
||||
* 覆盖真实出现、而手写 JSON 容易漏掉的形状:
|
||||
* - 服务端排序是 `pinned DESC, type='main' DESC, created_at ASC`,
|
||||
* **置顶的次级会话合法地排在主会话前面**(不能假设下标 0 是主会话);
|
||||
* - 还没有用户消息的会话 `title` 键**整个缺席**(自动命名只在首条用户消息时写入);
|
||||
* - 没有任何消息的会话 `lastMessage` 键整个缺席;
|
||||
* - `pinned` 恒下发(主会话上是显式 `false`),`unreadCount` 无关键照收。
|
||||
*/
|
||||
class SessionDtoRealPayloadTest {
|
||||
|
||||
private val json = ApiClientFactory.json
|
||||
|
||||
private val sessionList = ListSerializer(SessionSummaryDto.serializer())
|
||||
|
||||
// —— 以下是真实响应体(未做任何裁剪) ——
|
||||
|
||||
/** `GET /api/v1/sessions`:被置顶的次级会话在主会话**之前**。 */
|
||||
private val realSessionList = """
|
||||
{"data":[{"id":"web:299bbf0b4e6a45d7acecf4e848665f14","type":"sub","title":"Renamed by PATCH","createdAt":1791092728237,"pinned":true,"unreadCount":0},{"id":"web","type":"main","createdAt":1788724031196,"pinned":false,"lastMessage":{"role":"assistant","content":"\u003cp\u003e\u003caction\u003e愣了一下,随即弯起眼睛,笑得比平时还软\u003c/action\u003e\u003c/p\u003e \u003cp\u003e原来人家能一直好好待在这里…","createdAt":1791090628688},"unreadCount":6}]}
|
||||
""".trimIndent()
|
||||
|
||||
/** `PATCH /api/v1/sessions/{id}` 改标题的真实响应:**局部**形状,没有 `pinned`。 */
|
||||
private val realPatchTitle = """
|
||||
{"data":{"id":"web:299bbf0b4e6a45d7acecf4e848665f14","title":"Renamed by PATCH"}}
|
||||
""".trimIndent()
|
||||
|
||||
/** 同一次会话改置顶的真实响应:**局部**形状,没有 `title`。 */
|
||||
private val realPatchPinned = """
|
||||
{"data":{"id":"web:299bbf0b4e6a45d7acecf4e848665f14","pinned":true}}
|
||||
""".trimIndent()
|
||||
|
||||
private fun decode(body: String): List<SessionSummaryDto> =
|
||||
json.decodeFromString(ApiEnvelope.serializer(sessionList), body).data!!
|
||||
|
||||
private fun decodeUpdate(body: String): UpdateSessionResponse =
|
||||
json.decodeFromString(ApiEnvelope.serializer(UpdateSessionResponse.serializer()), body).data!!
|
||||
|
||||
@Test
|
||||
fun `a pinned sub session legitimately precedes the main session`() {
|
||||
val sessions = decode(realSessionList)
|
||||
|
||||
assertEquals(2, sessions.size)
|
||||
// 下标 0 是被置顶的次级会话——「主会话恒首位」是错的。
|
||||
assertEquals(SessionSummaryDto.TYPE_SUB, sessions[0].type)
|
||||
assertFalse(sessions[0].isMain)
|
||||
assertTrue(sessions[0].pinned)
|
||||
assertEquals("web:299bbf0b4e6a45d7acecf4e848665f14", sessions[0].id)
|
||||
|
||||
assertEquals("web", sessions[1].id)
|
||||
assertTrue(sessions[1].isMain)
|
||||
// 服务端是顺序的权威:客户端原样渲染,不重排。
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a missing title key decodes to null instead of a placeholder`() {
|
||||
val main = decode(realSessionList)[1]
|
||||
|
||||
// 主会话已经有 32 条用户消息却没有标题:自动命名是「首条用户消息」那一刻才写的,
|
||||
// 之前存下来的会话永远拿不到标题——所以 title 缺失是常态,不是异常。
|
||||
assertNull(main.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a missing lastMessage key decodes to null`() {
|
||||
val sub = decode(realSessionList)[0]
|
||||
|
||||
// 新建但还没发过消息的会话:整个 lastMessage 键缺席。
|
||||
assertNull(sub.lastMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an explicit false pinned is kept as false`() {
|
||||
val main = decode(realSessionList)[1]
|
||||
|
||||
// 主会话上 pinned 是显式 false(恒下发),不能与「键缺失」混为一谈。
|
||||
assertFalse(main.pinned)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a present lastMessage carries role content and createdAt`() {
|
||||
val last = decode(realSessionList)[1].lastMessage!!
|
||||
|
||||
assertEquals("assistant", last.role)
|
||||
assertEquals(1_791_090_628_688L, last.createdAt)
|
||||
// 服务端只折叠空白 + 截断到 60 字符,**保留原始标记**(HTML 转义解码后才看到标签)。
|
||||
assertTrue(last.content, last.content.startsWith("<p><action>"))
|
||||
assertTrue(last.content, last.content.endsWith("…"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the real patch responses are partial rows, not session rows`() {
|
||||
val renamed = decodeUpdate(realPatchTitle)
|
||||
assertEquals("web:299bbf0b4e6a45d7acecf4e848665f14", renamed.id)
|
||||
assertEquals("Renamed by PATCH", renamed.title)
|
||||
assertNull(renamed.pinned)
|
||||
|
||||
val pinned = decodeUpdate(realPatchPinned)
|
||||
assertEquals("web:299bbf0b4e6a45d7acecf4e848665f14", pinned.id)
|
||||
assertEquals(true, pinned.pinned)
|
||||
assertNull(pinned.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pinned only update omits title on the wire`() {
|
||||
val body = json.encodeToString(
|
||||
UpdateSessionRequest.serializer(),
|
||||
UpdateSessionRequest(pinned = true),
|
||||
)
|
||||
|
||||
// 服务端逐字段用指针判断:title 缺席 = 保留原值。
|
||||
// 序列化成显式 null 会被 Go 解成「要改的标题是空」——所以这里必须是整键缺席。
|
||||
assertEquals("""{"pinned":true}""", body)
|
||||
assertFalse(body, body.contains("title"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a title only update omits pinned on the wire`() {
|
||||
val body = json.encodeToString(
|
||||
UpdateSessionRequest.serializer(),
|
||||
UpdateSessionRequest(title = "Hello Cyrene"),
|
||||
)
|
||||
|
||||
assertEquals("""{"title":"Hello Cyrene"}""", body)
|
||||
assertFalse(body, body.contains("pinned"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty title is still serialized, so the blank guard has to be client side`() {
|
||||
// `explicitNulls=false` 只吞掉 null;空串照样上线,服务端会回 400 `title is required`。
|
||||
// 挡在输入框那一层(非空 + ≤80 字符)才是唯一防线。
|
||||
val body = json.encodeToString(
|
||||
UpdateSessionRequest.serializer(),
|
||||
UpdateSessionRequest(title = ""),
|
||||
)
|
||||
|
||||
assertEquals("""{"title":""}""", body)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an update with neither field still serializes to an empty object`() {
|
||||
val body = json.encodeToString(
|
||||
UpdateSessionRequest.serializer(),
|
||||
UpdateSessionRequest(),
|
||||
)
|
||||
|
||||
// 服务端对 `{}` 回 400 `title or pinned is required`;调用方必须保证至少带一个字段。
|
||||
assertEquals("{}", body)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user