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:
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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user