feat(chat): 会话内消息搜索 D2 —— 顶栏入口 + 搜索态面板 + 点结果定位

改了什么
- data/api/SessionsApi.kt:messages 端点加可选 q(契约 §1 行 6:q 是同一个端点上的
  查询参数,不是新端点)。不传 q 时 Retrofit 整个省掉该参数,既有调用线上请求逐字不变
  —— 这条用真 HTTP 服务端读原始 URI 钉住,而不是靠读代码相信。
- data/repository/MessageRepository.kt:新增 search()(走既有 apiCall/Result 风格,
  倒序返回统一转成升序的 PagedMessages),以及 normalizeSearchQuery(只 trim:
  不转义 % _ \、不改大小写 —— 这两件事服务端已做,两边都做必然错一边)。
  空查询不发请求(契约里空 q = 未传,那会把整段历史当成搜索结果回给调用方)。
- ui/chat/SessionSearchModel.kt(新):全部纯逻辑 —— 命中片段投影(先过 domain/parse
  这一处解析器再拼回纯文本,绝不把标记当富文本;标记里才有的词只显示不高亮)、
  结果合并去重排序(倒序,与聊天页的升序 MessageOrder 刻意相反)、游标推进、
  「这条在不在窗口里」的判定、失败文案。
- ui/chat/SessionSearchViewModel.kt(新):只属于聊天页搜索态的小 VM,带代际号,
  连打两次回车时慢的那个请求回来不会顶掉新关键词的结果。
- ui/chat/SessionSearchPanel.kt(新):全屏搜索面板 —— 自动聚焦输入框(IME 搜索键与
  放大镜都能提交)+ 结果列表(角色 / 时间 / 命中片段,BasicComponent 包 PreferenceCard)
  + 往下翻加载更早的命中 + 未搜/搜索中/空结果/失败 四态分开渲染。
- ui/chat/ChatScreen.kt:只加接线 —— 顶栏搜索入口、面板叠层、返回键先关搜索、
  点结果之后的如实告知。

为什么这么做
- 搜索态独立成小 VM:ChatViewModel 正被另一个并行模块改动,而搜索不改消息窗、不发消息、
  不碰 WS,没有任何理由挤进去。
- 面板叠在聊天页之上而不是替换它:底下的 LazyColumn 必须保持挂载,点一条命中要
  animateScrollToItem 定位,而 LazyColumn 一旦被移出组合,滚动就落空。
- 命中不在「已加载窗口」里时不插一条临时气泡,而是如实提示(比一个假的跳转诚实):
  聊天列表是升序倒过来喂给 reverseLayout 的,凭空插一条几千条之前的消息会出现在列表最底
  ——也就是「刚刚发出的位置」——与它自己的时间戳矛盾,还会带偏跟随底部的判断;
  而把消息真的并进窗口要改 ChatViewModel,本模块不许。
- 失败绝不渲染成「没有找到」:两者都是一张空列表,但一个该换关键词、一个该修网络或权限。
- 返回键先关搜索:新处理器注册在 ChatHomeScreen 那几条之后(优先级更高),
  且搜索没开时是 disabled,抽屉 / 管理页 / 设置页的既有层级不变。

测试 706 -> 737(+31:纯逻辑 25 + 数据层 6)
This commit is contained in:
AskaEth committed 2026-10-04 20:44:03 +08:00
1 parent 76d49b73d1
commit 23b46f9310
8 files changed
+1456 -106

No files matched your search

@@ -0,0 +1,308 @@
package top.yeij.cyrene.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import java.time.ZoneId
/**
* 会话内搜索(D2)的纯逻辑回归钉子:片段投影与高亮区间、结果合并与游标推进、
* 「这条在不在窗口里」的判定、失败文案。
*
* 这些判断错了都不会抛异常(只是屏幕上看着不对),所以必须在这里钉住,而不能指望 UI 测试。
*/
class SessionSearchModelTest {
private fun message(
id: String,
createdAt: Long,
role: String = "user",
content: String = "内容",
) = HistoryMessageDto(
id = id,
sessionId = "web",
role = role,
content = content,
createdAt = createdAt,
)
// ------------------------------------------------------------------ 片段投影
@Test
fun `plain content keeps the exact match offsets`() {
val snippet = searchSnippet("她说今天下午要下雨", "下雨")
assertEquals("她说今天下午要下雨", snippet.text)
assertEquals(7, snippet.matchStart)
assertEquals(9, snippet.matchEnd)
assertTrue(snippet.highlighted)
}
@Test
fun `match is case insensitive like the server side like`() {
val snippet = searchSnippet("用 Compose 写的界面", "compose")
assertEquals(2, snippet.matchStart)
assertEquals(9, snippet.matchEnd)
}
@Test
fun `markup is projected to plain text instead of being rendered`() {
val content = "<action>她凑近了一点</action><p>今天下午要下雨</p>"
val snippet = searchSnippet(content, "下雨")
// 标记没了、旁白与正文都还在,命中落在正文里。
assertEquals("她凑近了一点 今天下午要下雨", snippet.text)
assertEquals(12, snippet.matchStart)
assertEquals(14, snippet.matchEnd)
assertTrue(snippet.highlighted)
}
@Test
fun `a hit inside a narration is still located`() {
val snippet = searchSnippet("<action>她凑近了一点</action><p>正文</p>", "凑近")
assertEquals(1, snippet.matchStart)
assertEquals(3, snippet.matchEnd)
}
@Test
fun `a keyword that only exists in the markup is shown without a fake highlight`() {
// 服务端在**原始内容**上做 LIKE,所以搜 `action` 真的会命中这条(标记里就有这四个字母);
// 但投影后的纯文本里没有它 —— 这时**不许**高亮任何东西去假装命中。
val snippet = searchSnippet("<action>她凑近了一点</action>", "action")
assertFalse(snippet.highlighted)
assertEquals(-1, snippet.matchStart)
assertTrue(snippet.text, snippet.text.contains("凑近"))
}
@Test
fun `whitespace and newlines collapse so the snippet stays one line`() {
val snippet = searchSnippet("<p>第一行\n\n第二行 要下雨</p>", "下雨")
assertEquals("第一行 第二行 要下雨", snippet.text)
assertEquals(9, snippet.matchStart)
}
@Test
fun `a long message is windowed around the hit with ellipsis on both sides`() {
val content = "甲".repeat(200) + "需要找的词" + "乙".repeat(200)
val snippet = searchSnippet(content, "需要找的词")
assertTrue(snippet.text.startsWith("…"))
assertTrue(snippet.text.endsWith("…"))
assertTrue(snippet.highlighted)
// 关键不变量:高亮区间切出来的就是用户搜的那个词。
assertEquals("需要找的词", snippet.text.substring(snippet.matchStart, snippet.matchEnd))
// 窗口本身有上限(多了两个省略号)。
assertEquals(SEARCH_SNIPPET_CHARS + 2, snippet.text.length)
}
@Test
fun `a hit at the very start gets no leading ellipsis`() {
val snippet = searchSnippet("开头就命中" + "乙".repeat(300), "开头")
assertFalse(snippet.text.startsWith("…"))
assertTrue(snippet.text.endsWith("…"))
assertEquals(0, snippet.matchStart)
assertEquals(2, snippet.matchEnd)
}
@Test
fun `a hit at the very end is not cut off by the window`() {
val snippet = searchSnippet("甲".repeat(300) + "结尾命中", "结尾命中")
assertTrue(snippet.text.endsWith("结尾命中"))
assertEquals(snippet.text.length, snippet.matchEnd)
}
@Test
fun `a missing needle shows the head of the message without a highlight`() {
val snippet = searchSnippet("甲".repeat(300), "不存在")
assertFalse(snippet.highlighted)
assertEquals(SEARCH_SNIPPET_CHARS + 1, snippet.text.length)
assertTrue(snippet.text.endsWith("…"))
}
@Test
fun `empty content and blank query are handled without a highlight`() {
assertEquals("", searchSnippet("", "下雨").text)
assertEquals("", searchSnippet("<p></p>", "下雨").text)
assertFalse(searchSnippet("有内容", " ").highlighted)
}
// ------------------------------------------------------------------ 角色与时间
@Test
fun `role labels fall back to the raw value for unknown roles`() {
assertEquals("我", searchRoleLabel("user"))
assertEquals("昔涟", searchRoleLabel("assistant"))
// role 是开放集合:未知取值原样显示,不丢弃也不占位。
assertEquals("system", searchRoleLabel("system"))
}
@Test
fun `epoch millis are rendered as local minute precision time`() {
// 1759644000000 = 2025-10-05 14:00:00 +08:00
assertEquals("10-05 14:00", searchHitTime(1_759_644_000_000L, ZoneId.of("Asia/Shanghai")))
assertEquals("10-05 06:00", searchHitTime(1_759_644_000_000L, ZoneId.of("UTC")))
}
// ------------------------------------------------------------------ 结果合并
@Test
fun `hits are ordered newest first`() {
val merge = mergeSearchPage(
existing = emptyList(),
incoming = listOf(message("a", 100), message("c", 300), message("b", 200)),
pageHasMore = true,
)
assertEquals(listOf("c", "b", "a"), merge.hits.map { it.id })
assertTrue(merge.hasMore)
}
@Test
fun `same millisecond is broken by id descending so the order is stable`() {
val merge = mergeSearchPage(
existing = emptyList(),
incoming = listOf(message("m1", 100), message("m3", 100), message("m2", 100)),
pageHasMore = false,
)
assertEquals(listOf("m3", "m2", "m1"), merge.hits.map { it.id })
}
@Test
fun `duplicate ids are deduplicated and the newer copy wins`() {
val merge = mergeSearchPage(
existing = listOf(message("c", 300), message("b", 200, content = "旧的"), message("a", 100)),
incoming = listOf(message("b", 200, content = "服务端更新的"), message("d", 50)),
pageHasMore = true,
)
assertEquals(listOf("c", "b", "a", "d"), merge.hits.map { it.id })
assertEquals("服务端更新的", merge.hits.first { it.id == "b" }.content)
}
@Test
fun `a page that adds nothing stops the pagination instead of looping forever`() {
val loaded = listOf(message("c", 300), message("b", 200), message("a", 100))
// 服务端说「还有更多」,但这一页全是已经拿到的同毫秒消息 —— 游标不会前进,
// 再翻一次还是同一批,所以必须在这里停住。
val merge = mergeSearchPage(loaded, loaded, pageHasMore = true)
assertFalse(merge.hasMore)
assertEquals(loaded.map { it.id }, merge.hits.map { it.id })
}
@Test
fun `an empty page stops pagination and keeps what we already have`() {
val loaded = listOf(message("c", 300), message("a", 100))
val merge = mergeSearchPage(loaded, emptyList(), pageHasMore = true)
assertFalse(merge.hasMore)
assertEquals(loaded, merge.hits)
}
@Test
fun `the cursor is the oldest hit of the whole result set`() {
assertEquals(
100L,
nextSearchCursor(listOf(message("c", 300), message("b", 200), message("a", 100))),
)
assertEquals(null, nextSearchCursor(emptyList()))
}
// ------------------------------------------------------------------ 「在不在窗口里」
@Test
fun `a loaded hit maps to the reverse layout index`() {
val loaded = listOf(message("m1", 100), message("m2", 200), message("m3", 300))
// items = [m1, m2, m3, 待发, 流式] → asReversed() = [流式, 待发, m3, m2, m1],
// 所以升序下标 1(m2)落在下拉列表的第 3 项。
val jump = planSearchJump(message("m2", 200), loaded, displayItemCount = 5)
assertEquals(SearchJump.ScrollTo(lazyIndex = 3, messageId = "m2"), jump)
}
@Test
fun `an older hit is reported as not loaded with the pull up hint`() {
val loaded = listOf(message("m3", 300), message("m4", 400))
val jump = planSearchJump(message("m1", 100), loaded, displayItemCount = 2)
val notice = (jump as SearchJump.NotLoaded).notice
assertTrue(notice, notice.contains("上滑加载更早的消息"))
}
@Test
fun `a newer hit is reported as not loaded as well`() {
val loaded = listOf(message("m1", 100))
val jump = planSearchJump(message("m9", 900), loaded, displayItemCount = 1)
val notice = (jump as SearchJump.NotLoaded).notice
assertTrue(notice, notice.contains("还新"))
}
@Test
fun `a hit inside the time range but absent locally is not silently treated as a jump`() {
val loaded = listOf(message("m1", 100), message("m3", 300))
val jump = planSearchJump(message("m2", 200), loaded, displayItemCount = 2)
val notice = (jump as SearchJump.NotLoaded).notice
assertTrue(notice, notice.contains("不在当前已加载的窗口里"))
}
@Test
fun `an empty window reports that nothing is loaded yet`() {
val jump = planSearchJump(message("m1", 100), emptyList(), displayItemCount = 0)
assertTrue(jump is SearchJump.NotLoaded)
}
// ------------------------------------------------------------------ 失败文案
@Test
fun `failures are spelled out instead of being reported as no results`() {
assertEquals(
"登录状态已失效,请重新登录",
searchFailureMessage(CyreneException(CyreneError.Unauthorized("unauthorized", ""))),
)
assertEquals(
"当前账号没有权限搜索这个会话",
searchFailureMessage(
CyreneException(CyreneError.Server("forbidden", "forbidden", 403)),
),
)
assertEquals(
"服务端还没有开启消息存储,搜索暂时不可用",
searchFailureMessage(
CyreneException(CyreneError.Server("not_implemented", "", 501)),
),
)
// 服务端有话说就照它说的显示,不自己编。
assertEquals(
"搜索失败:invalid limit",
searchFailureMessage(CyreneException(CyreneError.Server("bad_request", "invalid limit", 400))),
)
assertEquals(
"搜索失败:连接超时,服务器没有响应",
searchFailureMessage(CyreneException(CyreneError.Local(CyreneError.LocalKind.Timeout))),
)
assertEquals("搜索失败,请稍后重试", searchFailureMessage(RuntimeException("boom")))
}
}