merge: W16 — 会话内消息搜索(顶栏入口 + 搜索面板 + 点结果定位)

This commit is contained in:
AskaEth committed 2026-10-04 20:45:09 +08:00
commit 46ac15a538
8 files changed
+1456 -106

No files matched your search

@@ -0,0 +1,145 @@
package top.yeij.cyrene.data.repository
import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpServer
import kotlinx.coroutines.runBlocking
import org.junit.After
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.ApiClientFactory
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.SessionsApi
import java.net.InetSocketAddress
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.Executors
/**
* 会话内搜索的**数据层**:`q` 到底有没有上线上、没传时线上请求是不是一字未变。
*
* 用 JDK 自带的 `com.sun.net.httpserver` 跑真 OkHttp + 真 Retrofit(形状与本仓库既有的
* `ChatRepositoryTest` 一致,不引新依赖),断言落在**收到的原始 URI** 上——这是唯一能证明
* 「不传 `q` 时既有调用逐字不变」的地方:Retrofit 对 null 的 `@Query` 是**整个省掉参数**,
* 而不是发一个空的 `q=`。
*/
class MessageSearchRepositoryTest {
private val backend = FakeMessagesBackend()
@After
fun tearDown() {
backend.close()
}
private fun repository() = MessageRepository(
ApiClientFactory.create(baseUrl = backend.baseUrl, service = SessionsApi::class.java),
)
@Test
fun `a plain page never carries q`() = runBlocking {
backend.reply(200, """{"data":[${message("m1", 100)}]}""")
repository().loadPage("web", limit = 50).getOrThrow()
val uri = backend.uris.single()
assertFalse(uri, uri.contains("q="))
assertTrue(uri, uri.contains("limit=50"))
}
@Test
fun `search sends q together with the paging cursor`() = runBlocking {
backend.reply(200, """{"data":[${message("m2", 200)}, ${message("m1", 100)}]}""")
val page = repository().search("web", "下雨", limit = 30, before = 300).getOrThrow()
val uri = backend.uris.single()
assertTrue(uri, uri.contains("q="))
assertTrue(uri, uri.contains("before=300"))
assertTrue(uri, uri.contains("limit=30"))
// 命中仍按服务端的倒序回来,仓库统一转成升序(与普通分页同一口径)。
assertEquals(listOf("m1", "m2"), page.messages.map { it.id })
assertEquals(100L, page.oldestCreatedAt)
}
@Test
fun `the query keeps its original case and is only trimmed`() = runBlocking {
backend.reply(200, """{"data":[]}""")
repository().search("web", " Compose ").getOrThrow()
// 大小写敏感的是**客户端**这里:大小写不敏感是服务端 LIKE 的行为,客户端擅自 lower()
// 只是把用户输入改掉(对 `%` / `_` 的转义同理,两边都转必然错一边)。
val uri = backend.uris.single()
assertTrue(uri, uri.contains("q=Compose"))
}
@Test
fun `a blank query does not hit the network at all`() = runBlocking {
val page = repository().search("web", " ").getOrThrow()
assertTrue(backend.uris.isEmpty())
assertTrue(page.messages.isEmpty())
assertFalse(page.hasMore)
}
@Test
fun `a server error surfaces as a failure instead of an empty result`() = runBlocking {
backend.reply(400, """{"error":{"code":"bad_request","message":"invalid limit"}}""")
val error = repository().search("web", "下雨").exceptionOrNull() as CyreneException
assertEquals("bad_request", (error.error as CyreneError.Server).code)
}
@Test
fun `normalize only trims and treats whitespace as no query`() {
assertEquals("下雨", normalizeSearchQuery(" 下雨 "))
assertEquals("a b", normalizeSearchQuery("a b"))
assertEquals(null, normalizeSearchQuery(""))
assertEquals(null, normalizeSearchQuery(" \n\t "))
// `%` / `_` / `\` 原样保留:服务端自己把它们当字面量处理。
assertEquals("100%_\\", normalizeSearchQuery(" 100%_\\ "))
}
private fun message(id: String, createdAt: Long): String =
"""{"id":"$id","sessionId":"web","role":"user","content":"内容","createdAt":$createdAt}"""
/** 只回一份固定响应、但把每次请求的原始 URI 记下来的假后端。 */
private inner class FakeMessagesBackend : AutoCloseable {
private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply {
executor = Executors.newFixedThreadPool(2)
createContext("/", ::handle)
start()
}
val baseUrl: String get() = "http://127.0.0.1:${server.address.port}/"
val uris = CopyOnWriteArrayList<String>()
@Volatile
private var status: Int = 200
@Volatile
private var body: String = """{"data":[]}"""
fun reply(status: Int, body: String) {
this.status = status
this.body = body
}
private fun handle(exchange: HttpExchange) {
uris += exchange.requestURI.toString()
val bytes = body.toByteArray()
exchange.responseHeaders.add("Content-Type", "application/json")
exchange.sendResponseHeaders(status, bytes.size.toLong())
exchange.responseBody.use { it.write(bytes) }
}
override fun close() {
server.stop(0)
}
}
}
@@ -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")))
}
}