feat(chat): 接上 A5 正向增量 after,并用 404 session_not_found 把「会话已删」如实说出来
改了什么:
- SessionsApi.listMessages 加可选 after(契约 §1 行 6)。before / q / after 三者互斥是服务端的
硬约束,本端三个调用点各只给一个;after 为 null 时 Retrofit 整个省掉这个参数 ⇒ 既有三条路径
的线上请求逐字节不变。
- MessageRepository 加 loadAfter:同一个端点,服务端按【升序】返回(与 before 的倒序相反),
仍统一转成升序的 PagedMessages。顺手去掉 toPagedMessages 参数名里那个已经不对的 newestFirst。
- ChatViewModel:
· 新增 newestKnownAt(从状态现算的 after 游标)与 absorb(只并消息与乐观气泡,不碰
loading / loadingOlder / hasMore / error —— 一次静默补拉不该把上拉位置和错误横幅掀掉)。
· 掉线那一刻记下游标、重新连上时用它做一次正向增量。记录时机必须在「掉线」而不是「重连后」:
重连的 history 帧会把最近 50 条灌进来、游标随即被推到「现在」,那时再取就取不到那个洞了。
「再拉一页最新」永远补不回中间断掉的那一段,这正是 after 存在的理由(02/A5 的欠账)。
· ensureMessageKnown 先用增量(只要新来的那几条),还认不出来才退回整页重拉。
· 循环推进抽成纯函数 loadForwardPages:游标必须严格前进(同毫秒并列且超过一页时推不动,
契约 §A5 明写的已知边界)+ 轮数上限 20,两道防死循环的闸都由单测钉住。
- 404 session_not_found(契约 v0.63):首屏 / 上拉 / 增量三条读路径都判,非主会话就置
ChatUiState.sessionGone;ChatScreen 据此显示一句实话并回调 ChatHomeScreen 重拉会话列表
(refresh 内部会把「指向已删会话」的 activeSessionId 收敛回主会话)。
主会话不置位:那说明部署本身出了问题,该显示的是普通错误横幅,而不是一句假的「已切回主会话」。
为什么:
主人那次「图片凭空消失、别的端也看不到」的真相,就是会话在别处被删掉之后,客户端把它渲染成了
一条空会话 —— 让用户对着空白猜。现在有了机器可读的 404,就能如实说一句并把人带回主会话。
另一半是重连补洞:一直是「再拉一页最新」在顶着,断线期间超过 50 条必丢中间那一段。
验证:assembleDebug + testDebugUnitTest 全绿,846 测试 / 0 失败 / 0 错误。
较基线 831 新增 15 条(MessageAfterRepositoryTest 5 条走真 OkHttp 断言线上 URI;
ChatRecoveryTest 10 条钉住 404 判据与增量循环的两道闸)。
设备侧验证(点开看到那句实话、断线补洞)另行做,不在本次提交的断言范围内。
This commit is contained in:
1 parent
a84d473e2f
commit
28c314262e
7 files changed
+549
-24
No files matched your search
@@ -0,0 +1,143 @@
|
||||
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
|
||||
|
||||
/**
|
||||
* 正向增量(A5)的**数据层**:`after` 有没有真的上线上、以及服务端的**升序**口径有没有被本端
|
||||
* 如实接住。
|
||||
*
|
||||
* 与 `MessageSearchRepositoryTest` 同一套做法(真 OkHttp + 真 Retrofit + JDK 自带的
|
||||
* `com.sun.net.httpserver`,不引新依赖),断言落在**收到的原始 URI** 上:`before` / `q` / `after`
|
||||
* 三者互斥是**服务端**的硬约束(同传回 `400`),所以「一次请求里只出现其中一个」必须由 URI 证据
|
||||
* 来钉,不能只看返回值。
|
||||
*/
|
||||
class MessageAfterRepositoryTest {
|
||||
|
||||
private val backend = FakeMessagesBackend()
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
backend.close()
|
||||
}
|
||||
|
||||
private fun repository() = MessageRepository(
|
||||
ApiClientFactory.create(baseUrl = backend.baseUrl, service = SessionsApi::class.java),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `the forward cursor is the only paging parameter on the wire`() = runBlocking {
|
||||
backend.reply(200, """{"data":[${message("m1", 100)}]}""")
|
||||
|
||||
repository().loadAfter("web", after = 100).getOrThrow()
|
||||
|
||||
val uri = backend.uris.single()
|
||||
assertTrue(uri, uri.contains("after=100"))
|
||||
assertTrue(uri, uri.contains("limit=50"))
|
||||
// 互斥的那两个一个都不许出现:服务端同传就回 400,而这条路径恰恰不需要它们。
|
||||
assertFalse(uri, uri.contains("before="))
|
||||
assertFalse(uri, uri.contains("q="))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ascending server order is preserved and the newest cursor is the max`() = runBlocking {
|
||||
// `after` 的契约是**升序**(与 `before` 的倒序相反),照抄这个形状喂进来。
|
||||
backend.reply(
|
||||
200,
|
||||
"""{"data":[${message("m1", 100)}, ${message("m2", 200)}, ${message("m3", 300)}]}""",
|
||||
)
|
||||
|
||||
val page = repository().loadAfter("web", after = 100, limit = 50).getOrThrow()
|
||||
|
||||
assertEquals(listOf("m1", "m2", "m3"), page.messages.map { it.id })
|
||||
assertEquals(100L, page.oldestCreatedAt)
|
||||
// 下一页的游标是**最新**那条(`after` 方向与 `before` 方向相反,规则也相反)。
|
||||
assertEquals(300L, page.newestCreatedAt)
|
||||
assertFalse(page.hasMore)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a full page says there may be more ahead`() = runBlocking {
|
||||
backend.reply(200, """{"data":[${message("m1", 100)}, ${message("m2", 200)}]}""")
|
||||
|
||||
val page = repository().loadAfter("web", after = 0, limit = 2).getOrThrow()
|
||||
|
||||
assertTrue(page.hasMore)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the page size is clamped to the server ceiling`() = runBlocking {
|
||||
backend.reply(200, """{"data":[]}""")
|
||||
|
||||
repository().loadAfter("web", after = 0, limit = 999).getOrThrow()
|
||||
|
||||
assertTrue(backend.uris.single(), backend.uris.single().contains("limit=200"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a deleted session surfaces as session_not_found with 404`() = runBlocking {
|
||||
backend.reply(
|
||||
404,
|
||||
"""{"error":{"code":"session_not_found","message":"session \"web:gone\" not found"}}""",
|
||||
)
|
||||
|
||||
val error = repository().loadAfter("web:gone", after = 0).exceptionOrNull() as CyreneException
|
||||
|
||||
val server = error.error as CyreneError.Server
|
||||
assertEquals("session_not_found", server.code)
|
||||
assertEquals(404, server.httpStatus)
|
||||
}
|
||||
|
||||
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,163 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
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 top.yeij.cyrene.data.repository.PagedMessages
|
||||
|
||||
/**
|
||||
* 断线恢复的两块纯逻辑:**「会话没了」怎么判**(契约 v0.63 的 `404 session_not_found`)
|
||||
* 与 **正向增量循环什么时候停**(契约 §1 行 6 的 `after`)。
|
||||
*
|
||||
* 两者都是本次新增的判据,所以都从 `ChatViewModel` 里拎出来做成可单测的纯函数:
|
||||
* 真要跑起那个 VM 得搭一整套 `AppContainer`,而这里要证的只是两句话。
|
||||
*/
|
||||
class ChatRecoveryTest {
|
||||
|
||||
// —— 一、「这个会话在服务端已经不存在了」——
|
||||
|
||||
@Test
|
||||
fun `the v0 63 code marks the session as gone`() {
|
||||
val error = CyreneException(
|
||||
CyreneError.Server(
|
||||
code = "session_not_found",
|
||||
message = """session "web:gone" not found""",
|
||||
httpStatus = 404,
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(error.isSessionGone())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 404 without an error envelope still counts`() {
|
||||
// 老部署 / 代理兜底时可能没有 `error` 信封(code 是空串):`/messages` 上的 404 只有
|
||||
// 一个含义,不能因为少一个字段就把它读成「空会话」——那正是主人那次「图凭空消失」。
|
||||
assertTrue(CyreneException(CyreneError.Server("", "", 404)).isSessionGone())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the code wins over the status`() {
|
||||
// 码是机器可读的那一份,状态码只是兜底:有码就按码判。
|
||||
assertTrue(CyreneException(CyreneError.Server("session_not_found", "", 410)).isSessionGone())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `other failures are not a deleted session`() {
|
||||
assertFalse(CyreneException(CyreneError.Server("bad_request", "invalid limit", 400)).isSessionGone())
|
||||
assertFalse(CyreneException(CyreneError.Server("internal", "boom", 500)).isSessionGone())
|
||||
assertFalse(CyreneException(CyreneError.Server("insufficient_quota", "", 402)).isSessionGone())
|
||||
assertFalse(CyreneException(CyreneError.Unauthorized("", "")).isSessionGone())
|
||||
assertFalse(CyreneException(CyreneError.Local(CyreneError.LocalKind.Timeout)).isSessionGone())
|
||||
assertFalse(RuntimeException("boom").isSessionGone())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `any 404 from the messages endpoint counts as gone`() {
|
||||
// `/messages` 上的 404 只有「会话不存在」这一个来源(消息端点 `/messages/{id}` 走的是
|
||||
// 另一条路径),所以码对不上也照判:宁可如实说「这个会话没了」,也不要把它渲染成
|
||||
// 一条空会话——那正是主人那次「图片凭空消失」。
|
||||
assertTrue(CyreneException(CyreneError.Server("not_found", "会话不存在", 404)).isSessionGone())
|
||||
}
|
||||
|
||||
// —— 二、正向增量循环 ——
|
||||
|
||||
@Test
|
||||
fun `it keeps asking with the newest cursor until a short page`() = runBlocking {
|
||||
val cursors = mutableListOf<Long>()
|
||||
val pages = listOf(
|
||||
page(listOf("m1" to 100L, "m2" to 200L), hasMore = true),
|
||||
page(listOf("m3" to 300L), hasMore = false),
|
||||
)
|
||||
|
||||
val taken = loadForwardPages(from = 50L) { cursor ->
|
||||
cursors += cursor
|
||||
Result.success(pages[cursors.size - 1])
|
||||
}
|
||||
|
||||
// 游标取的是**最新**那条(升序口径),不是最旧的。
|
||||
assertEquals(listOf(50L, 200L), cursors)
|
||||
assertEquals(2, taken.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty page ends the walk`() = runBlocking {
|
||||
var calls = 0
|
||||
|
||||
val taken = loadForwardPages(from = 100L) {
|
||||
calls++
|
||||
Result.success(page(emptyList(), hasMore = false))
|
||||
}
|
||||
|
||||
assertEquals(1, calls)
|
||||
assertTrue(taken.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cursor that cannot advance stops instead of looping forever`() = runBlocking {
|
||||
// 同毫秒并列且超过一页:契约 §A5 明写的已知边界——纯 createdAt 游标推不动。
|
||||
// 不做这道闸,这里会拿同一批消息无限请求下去。
|
||||
var calls = 0
|
||||
|
||||
val taken = loadForwardPages(from = 100L) {
|
||||
calls++
|
||||
Result.success(page(listOf("m1" to 100L, "m2" to 100L), hasMore = true))
|
||||
}
|
||||
|
||||
assertEquals(1, calls)
|
||||
assertEquals(1, taken.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the round cap bounds one catch-up`() = runBlocking {
|
||||
var calls = 0
|
||||
|
||||
val taken = loadForwardPages(from = 0L, maxRounds = 3) { cursor ->
|
||||
calls++
|
||||
Result.success(page(listOf("m$calls" to cursor + 1), hasMore = true))
|
||||
}
|
||||
|
||||
assertEquals(3, calls)
|
||||
assertEquals(3, taken.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failure keeps what was already fetched and stops`() = runBlocking {
|
||||
var calls = 0
|
||||
|
||||
val taken = loadForwardPages(from = 0L) {
|
||||
calls++
|
||||
if (calls == 1) {
|
||||
Result.success(page(listOf("m1" to 100L), hasMore = true))
|
||||
} else {
|
||||
Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Timeout)))
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(2, calls)
|
||||
assertEquals(listOf("m1"), taken.single().messages.map { it.id })
|
||||
}
|
||||
|
||||
private fun page(entries: List<Pair<String, Long>>, hasMore: Boolean): PagedMessages {
|
||||
val messages = entries.map { (id, createdAt) ->
|
||||
HistoryMessageDto(
|
||||
id = id,
|
||||
sessionId = "web",
|
||||
role = "user",
|
||||
content = "",
|
||||
createdAt = createdAt,
|
||||
)
|
||||
}
|
||||
return PagedMessages(
|
||||
messages = messages,
|
||||
hasMore = hasMore,
|
||||
oldestCreatedAt = messages.minOfOrNull { it.createdAt },
|
||||
newestCreatedAt = messages.maxOfOrNull { it.createdAt },
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user