fix(chat): 降级气泡不再给删改入口(W52/D);空态补一枚显式「重试」(W52/E)

## D —— W50 留下的窄尾巴:服务端不存在的那条气泡没有动作入口

`proactive_message` 落库失败那一档,客户端会画一条**只活在本端内存里**的气泡,它的 id 由本端自铸
(`proactive-local:` 前缀,见 `ProactiveIngest`)。而删除入口的判据 `canDeleteMessage` 只看 role ⇒
它是 assistant 行、长按能删 ⇒ 拿一个服务端没见过的 id 打 `DELETE`,最好的结果是 404。

修法按主人拍的口径收在 **UI 层**:新增 `ui/chat/MessageServerActions.kt`,在角色口径之上再叠一道
「这条消息在服务端存在吗」(判据只有 `id` 前缀这一条),组合成
`canEditMessageOnServer` / `canDeleteMessageOnServer`;气泡(菜单 + 「⋯」两个入口)与 VM 里那道闸
(`startEdit` / `startDelete`)**共用同一份**,不存在两套口径。
- **不动** `ChatDisplayItem`、数据模型与 `mergeMessages` 语义(原方案要加「是否服务端行」标志位,
  会连带动气泡渲染,超出范围)。
- 动作菜单里**只有**编辑与删除两个(一个 PATCH、一个 DELETE),都是打服务端的 ⇒ 降级气泡整菜单不出现,
  正是要的效果。**文本选择 / 复制不受影响**:它走 `SelectionContainer` 那条独立的路,`onLongPress`
  为 null 时反而不再与选字抢长按。

## E —— 首屏失败时给一个显式「重试」

登记在案的缺口:**REST 首屏失败、而 WS 从头到尾没断过**。这时没有任何状态变化可挂(连接没变过、
也没有断线窗口要补),屏幕会停在空态,直到用户切走再回来或上拉。

- `ChatViewModel.retryFirstPage()`:**只是转调** W51 提好的 `loadFirstPage()`(首屏唯一的一份实现,
  与连接恢复时补的那一次共用),**一个状态位都不自己写** —— `firstPageState` 的两个出口仍然只有那一处,
  W51 的 `reconnectPull` 判据依赖这条纪律。刻意**不是**定时器:项目从 W38 起就定死「状态驱动,不按时间轮」,
  而且反复重试会把「首屏失败」这个事实擦成 `Loading`。
- 空态那一档拆成两种真相(纯函数 `shouldOfferRetryOnEmpty`:不在加载中 + 挂着一个错误 + 列表为空):
  「还没有消息」照旧;「消息没能加载出来」配一枚 Miuix `TextButton`(`CenterHint` 多一个可选 `onRetry`)。
  其余时刻一个像素都不多画。

## 测试

- 新增 `MessageServerActionsTest`(6 条):钉住降级气泡两个入口都没有(并断言**旧判据确实说「能删」**,
  把「为什么需要这道闸」写进断言)、前缀与角色无关、只认**开头**的前缀、服务端行行为逐字不变、
  未知 role 仍 fail-closed。
- 新增 `FirstPageRetryTest`(5 条):正题一格成立;正常空会话 / 首屏在飞 / 屏幕上有东西(含乐观与流式气泡)
  都不给;三个布尔八种组合穷举,钉住「有且只有一格成立」。
- `./gradlew.bat --console=plain assembleDebug testDebugUnitTest`:**95 suite / 1216 tests / 0 失败 / 0 error**
  (基线 93 / 1205,+2 suite +11 tests,全为新增)。
- 真机验收未做(任务书禁碰 adb):按钮的位置与观感、以及长按降级气泡能选字,只能真机看。
This commit is contained in:
AskaEth committed 2026-10-06 15:47:49 +08:00
1 parent 4fce12249e
commit 74a46bd2e3
6 files changed
+323 -18

No files matched your search

@@ -0,0 +1,79 @@
package top.yeij.cyrene.ui.chat
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 空态这一刻该不该给一枚显式「重试」(W52/E)。
*
* 补的是那个没法挂任何状态变化的缺口:**REST 首屏失败、而 WS 从头到尾没断过**——
* 连接没变过(`onConnectionChanged` 不会再来一次)、也没有断线窗口要补,于是屏幕就停在空态。
* 修法是给一枚**用户显式动作**的重试(不是定时器:本项目从 W38 起就定死了「状态驱动,不按时间轮」)。
*
* 判据只看**屏幕上成立的三件事**(不在加载中 / 挂着一个错误 / 列表是空的),不声称「这就是首屏
* 失败」——那个事实只有 VM 里那个只许由 `loadFirstPage()` 写的字段说得准(见函数 KDoc)。
* 所以这里钉的是两组边界:
* - **该给**:什么都没显示、而且刚出过错 ⇒ 用户能做的下一件事就是再试一次;
* - **不该给**:正常空会话(服务端真的没消息——按钮按下去什么都不会变)、首屏还在飞、
* 以及屏幕上有东西(乐观气泡 / 流式气泡也算有东西)的那几档。
*/
class FirstPageRetryTest {
@Test
fun `an empty screen with an error offers the retry`() {
// 正题:首屏 REST 失败后的空屏(loading 已是 false、错误挂在顶部横幅上)。
assertTrue(shouldOfferRetryOnEmpty(loading = false, hasError = true, hasItems = false))
}
@Test
fun `a normal empty conversation offers nothing extra`() {
// 服务端真的没有消息:这时候「还没有消息」是**准确的**,多一枚按了不会变样的按钮只会让人
// 以为是它坏了。
assertFalse(shouldOfferRetryOnEmpty(loading = false, hasError = false, hasItems = false))
}
@Test
fun `a first page still in flight offers no retry`() {
// 还在飞时给重试 = 把同一趟请求发两遍(首屏只有一份实现,但两次并发调用仍然是两次请求)。
assertFalse(shouldOfferRetryOnEmpty(loading = true, hasError = false, hasItems = false))
// 加载中 + 旧错误(上一条错误还没被清掉)也照样不给:这一刻屏幕上写的是「加载中…」。
assertFalse(shouldOfferRetryOnEmpty(loading = true, hasError = true, hasItems = false))
}
@Test
fun `a screen with anything on it never offers the retry`() {
// 有东西(含乐观气泡、流式气泡)就不是空态:那几档屏幕上没有这枚按钮的位置,
// 而这个判据正是「只在列表空时成立」的那个出口。
for (loading in listOf(false, true)) {
for (hasError in listOf(false, true)) {
assertFalse(
"loading=$loading hasError=$hasError",
shouldOfferRetryOnEmpty(loading = loading, hasError = hasError, hasItems = true),
)
}
}
}
@Test
fun `every combination of the three facts is enumerated`() {
// 三个布尔一共八种组合,穷举一遍钉住「有且只有一格成立」——判据里将来多一个条件、
// 或者某一条写成 `||`,这里会立刻红。
val expected = setOf(
Triple(false, true, false), // 该给:空屏 + 有错 + 不在加载
)
val actual = buildSet {
for (loading in listOf(false, true)) {
for (hasError in listOf(false, true)) {
for (hasItems in listOf(false, true)) {
if (shouldOfferRetryOnEmpty(loading, hasError, hasItems)) {
add(Triple(loading, hasError, hasItems))
}
}
}
}
}
assertTrue("$actual", actual == expected)
}
}
@@ -0,0 +1,95 @@
package top.yeij.cyrene.ui.chat
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.data.repository.canDeleteMessage
import top.yeij.cyrene.data.repository.canEditMessage
/**
* 「这条消息能不能给服务端动作」(W52/D:W50 留下的那条窄尾巴)。
*
* 钉住的核心是一句话:**降级气泡不许有编辑 / 删除入口**。它是 `proactive_message` 落库失败那一档
* 画出来的本地行(id 由本端自铸、带 `proactive-local:` 前缀),服务端**根本没有这一行**——
* 拿它的 id 去打 `PATCH` / `DELETE` 只会白摔一趟,用户看到的是一句莫名其妙的报错。
*
* 所以这里第一件事就是把**旧判据为什么不够**写进断言:角色口径([canDeleteMessage])对那个气泡
* 说的是「能删」(它就是一条 `assistant` 行),闸门必须来自「它在服务端存在吗」这一道。
*
* 另外几条是「别为了修一个洞开一个新洞」:服务端存在的消息**行为逐字不变**
* (user 能改能删、assistant 只能删、未知 role fail-closed),而前缀判定只认**开头**——
* 服务端的 id 里恰好含有这几个字(不是开头)时照旧放行。
*/
class MessageServerActionsTest {
private fun message(id: String, role: String) =
HistoryMessageDto(
id = id,
sessionId = MAIN_SESSION_ID,
role = role,
createdAt = 1_791_212_405_123L,
)
// —— 正题:降级气泡两个入口都没有 ——
@Test
fun `the local-only bubble gets no server action even though its role says it could be deleted`() {
// 这里刻意用**字面量**而不是常量:这一串就是线上那一档气泡的真实 id 形状
// (见 ProactiveIngestTest 对 `proactiveIngestFor` 的同一条断言),常量改了这里也要跟着响。
val bubble = message(id = "proactive-local:im-9", role = "assistant")
// 旧判据(只认 role)会说「能删」—— 这正是它此前长按能删、删了必报错的原因。
assertTrue(canDeleteMessage(bubble))
// 加上「服务端存在吗」这道闸之后,两个入口都不成立 ⇒ 动作菜单与那枚「⋯」都不会出现。
assertFalse(isServerBackedMessage(bubble))
assertFalse(canDeleteMessageOnServer(bubble))
assertFalse(canEditMessageOnServer(bubble))
}
@Test
fun `the local id gate does not depend on the role`() {
// 降级气泡今天恒是 assistant(服务端 persistProactiveToMain 定死),但判据不该把这件事
// 当成前提:前缀是**本端铸的**,凡带它的 id 服务端都不认识,与角色无关。
assertFalse(isServerBackedMessage(message("proactive-local:im-9", role = "user")))
assertFalse(canEditMessageOnServer(message("proactive-local:im-9", role = "user")))
}
@Test
fun `only the leading prefix is recognized`() {
// 判据是 startsWith,不是 contains:服务端的 id 里恰好出现这几个字(位置不对)时照旧放行,
// 否则一个合法的服务端 id 会因为「字符串里含有某段文本」而莫名失去入口。
assertTrue(isServerBackedMessage(message("1371-proactive-local", role = "assistant")))
assertTrue(isServerBackedMessage(message("msg-proactive-local:9", role = "assistant")))
}
// —— 服务端存在的消息:行为逐字不变 ——
@Test
fun `a server backed assistant row keeps delete and still has no edit`() {
val row = message(id = "1371", role = "assistant")
assertTrue(isServerBackedMessage(row))
assertTrue(canDeleteMessageOnServer(row))
assertFalse(canEditMessageOnServer(row))
}
@Test
fun `a server backed user row keeps both actions`() {
val row = message(id = "1372", role = "user")
assertTrue(canEditMessageOnServer(row))
assertTrue(canDeleteMessageOnServer(row))
}
@Test
fun `an unrecognized role is refused by both gates`() {
// fail-closed:未知 role 照旧两个都不给(这一条由 data 层的角色判据保证,这里把它钉在
// 组合之后的判据上,免得将来有人把两道闸的顺序写反)。
val row = message(id = "1373", role = "system")
assertFalse(canEditMessageOnServer(row))
assertFalse(canDeleteMessageOnServer(row))
assertFalse(canEditMessage(row))
}
}