merge: W16 — 会话内消息搜索(顶栏入口 + 搜索面板 + 点结果定位)
This commit is contained in:
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")))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user