From 74a46bd2e380138504a3ca162bedafb67e6a429c Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 6 Oct 2026 15:47:49 +0800 Subject: [PATCH] =?UTF-8?q?fix(chat):=20=E9=99=8D=E7=BA=A7=E6=B0=94?= =?UTF-8?q?=E6=B3=A1=E4=B8=8D=E5=86=8D=E7=BB=99=E5=88=A0=E6=94=B9=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=EF=BC=88W52/D=EF=BC=89=EF=BC=9B=E7=A9=BA=E6=80=81?= =?UTF-8?q?=E8=A1=A5=E4=B8=80=E6=9E=9A=E6=98=BE=E5=BC=8F=E3=80=8C=E9=87=8D?= =?UTF-8?q?=E8=AF=95=E3=80=8D=EF=BC=88W52/E=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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):按钮的位置与观感、以及长按降级气泡能选字,只能真机看。 --- .../top/yeij/cyrene/ui/chat/ChatScreen.kt | 72 +++++++++++++- .../top/yeij/cyrene/ui/chat/ChatViewModel.kt | 39 ++++++-- .../top/yeij/cyrene/ui/chat/MessageBubble.kt | 13 +-- .../cyrene/ui/chat/MessageServerActions.kt | 43 +++++++++ .../yeij/cyrene/ui/chat/FirstPageRetryTest.kt | 79 +++++++++++++++ .../ui/chat/MessageServerActionsTest.kt | 95 +++++++++++++++++++ 6 files changed, 323 insertions(+), 18 deletions(-) create mode 100644 app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageServerActions.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/ui/chat/FirstPageRetryTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/ui/chat/MessageServerActionsTest.kt diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt index 6b08fe7..52948e4 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt @@ -82,6 +82,7 @@ import top.yukonga.miuix.kmp.basic.IconButton import top.yukonga.miuix.kmp.basic.Scaffold import top.yukonga.miuix.kmp.basic.SmallTopAppBar import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.basic.TextButton import top.yukonga.miuix.kmp.basic.TextField import top.yukonga.miuix.kmp.icon.MiuixIcons import top.yukonga.miuix.kmp.icon.basic.Close @@ -511,7 +512,20 @@ fun ChatScreen( ) { when { state.loading && state.items.isEmpty() -> CenterHint("加载中…") - state.items.isEmpty() -> CenterHint("还没有消息") + // 空列表这一档有两种真相,屏幕上必须分得开(W52/E):**真的还没有消息**, + // 与**消息没能加载出来**(首屏 REST 失败)。后者此前也念「还没有消息」, + // 于是唯一的出路只剩「切走再回来」——现在给一枚显式「重试」。 + state.items.isEmpty() -> { + val retry = shouldOfferRetryOnEmpty( + loading = state.loading, + hasError = state.error != null, + hasItems = state.items.isEmpty(), + ) + CenterHint( + text = if (retry) FIRST_PAGE_FAILED_HINT else EMPTY_CONVERSATION_HINT, + onRetry = if (retry) viewModel::retryFirstPage else null, + ) + } else -> LazyColumn( state = listState, reverseLayout = true, @@ -927,14 +941,66 @@ private fun DraftImagePreviews(images: List, onRemove: (Int) -> Unit) { } } +/** + * 空列表这一刻该不该给一个显式「重试」(**纯函数**,W52/E)。 + * + * 三件事同时成立才给:**不在加载中**(在飞的时候给重试只会把同一趟请求发两遍)、 + * **有一个错误挂着**、**列表是空的**。任一条不成立就不给——正常空会话(服务端真的没消息) + * 与正在加载首屏那两个时刻,屏幕上都不该多出一枚按了没什么用的按钮。 + * + * ## 它**不**声称「这就是首屏失败」 + * 「首屏有没有成功过」这件事只有 VM 里的 `firstPageState` 说得准,而按 W51 的纪律那个字段 + * **只许由 `loadFirstPage()` 的两个出口写**([reconnectPull] 拿它当判据),所以这里刻意不去读它、 + * 也不去镜像一份。这里只看**屏幕上成立的三件事**:什么都没显示、而且刚出过错。这三件事一起成立时, + * 用户能做的下一件事就是「再试一次」——按下去走的正是首屏那一份实现(`ChatViewModel.retryFirstPage`)。 + * + * ⚠️ 已知的一处不精确:错误横幅上的那个 X 会把 `error` 清掉(`dismissError`), + * 于是按钮**跟着一起消失**。这是判据只看公开状态的代价,本轮按主人拍的口径留着。 + * + * @param hasItems 列表里有没有东西(含乐观气泡与流式气泡——它们在也算「有东西」, + * 那时候屏幕不是空的,不该出现这枚按钮)。 + */ +internal fun shouldOfferRetryOnEmpty( + loading: Boolean, + hasError: Boolean, + hasItems: Boolean, +): Boolean = !loading && hasError && !hasItems + +/** 空列表 + 没出错:服务端就是没有消息,如实说一句(既有文案,一个字没改)。 */ +private const val EMPTY_CONVERSATION_HINT = "还没有消息" + +/** + * 空列表 + 挂着一个错误:消息没能加载出来(首屏 REST 失败那一档)。 + * + * 与 [EMPTY_CONVERSATION_HINT] 分开是**如实**的要求:「还没有消息」说的是这个会话里什么都没有, + * 而这一刻本端其实**不知道**——它只是没拿到。原因由顶部那行错误横幅说,这里只说结论, + * 并配一枚「重试」。 + */ +private const val FIRST_PAGE_FAILED_HINT = "消息没能加载出来" + +/** + * 屏幕正中那句提示;[onRetry] 非空时在它下面多一枚 Miuix 的 `TextButton`。 + * + * 按钮用的就是本文件输入栏「发送」那枚 `Button` 的同一个 Miuix 控件族里的 `TextButton` + * (编辑 / 删除弹层用的也是它):项目里没有 Miuix 的菜单类组件,而「凭空去猜一个没见过的控件」 + * 是刻意避开的做法(见 `MessageActionMenu` 的 KDoc)。底部居中放一枚文字按钮,不抢正文区。 + */ @Composable -private fun CenterHint(text: String) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { +private fun CenterHint(text: String, onRetry: (() -> Unit)? = null) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { Text( text = text, style = MiuixTheme.textStyles.main, color = MiuixTheme.colorScheme.onSurfaceVariantSummary, ) + if (onRetry != null) { + Spacer(Modifier.height(12.dp)) + TextButton(text = "重试", onClick = onRetry) + } } } diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt index c40a59d..f3edef8 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt @@ -32,8 +32,6 @@ import top.yeij.cyrene.data.repository.MessageEditDraft import top.yeij.cyrene.data.repository.MessageRepository import top.yeij.cyrene.data.repository.PagedMessages import top.yeij.cyrene.data.repository.applyMessageEdit -import top.yeij.cyrene.data.repository.canDeleteMessage -import top.yeij.cyrene.data.repository.canEditMessage import top.yeij.cyrene.data.repository.historyForSession import top.yeij.cyrene.data.repository.mergeMessages import top.yeij.cyrene.data.repository.removeMessageById @@ -785,8 +783,10 @@ data class ChatUiState( * 用的还是同一个键——**同一条消息无论在路上摔了几次,服务端都只认一条**。 * * ## 单条消息的编辑 / 删除(D3) - * 编辑**只对 `user` 消息开放**([canEditMessage]),删除对 `user` 与 `assistant` 都开放 - * ([canDeleteMessage])——判据是同一份纯函数,UI 与这里共用,不存在两套口径。 + * 编辑**只对 `user` 消息开放**,删除对 `user` 与 `assistant` 都开放,而且两条都要求**这条消息在 + * 服务端存在**([canEditMessageOnServer] / [canDeleteMessageOnServer]:角色口径 + `id` 前缀那道闸, + * W52/D)——判据是同一份纯函数,UI 与这里共用,不存在两套口径。降级气泡因此两个入口都没有: + * 它服务端根本没有那一行,拿本地 id 打过去只会白摔一趟。 * * 两条路各自**只有一个落地出口**([applyMessageEdited] / [applyMessageDeleted]):本端自己发起的 * `PATCH` / `DELETE` 成功回执,与随后到达的 `message_edited` / `message_deleted` 广播,都调它们; @@ -1016,6 +1016,24 @@ class ChatViewModel( } } + /** + * **用户显式重试首屏**(W52/E:空态上那枚「重试」)。 + * + * 它补的是登记在案的那个缺口:**REST 首屏失败了、而 WS 从头到尾没断过**。这种时候没有任何 + * 状态变化可挂——连接没变过([onConnectionChanged] 不会再来一次)、也没有断线窗口要补 + * ([gapCursor] 是 null);此前唯一的出路是用户自己切走再回来,或者上拉去触发 [loadOlder]。 + * + * 修法刻意是**用户动作**而不是定时器:本项目从 W38 起就定死了「状态驱动,不按时间轮」 + * (见 [onConnectionChanged] 末尾那段),而且定时重试会反复把「首屏失败」这个事实擦成 + * [FirstPageState.Loading],那正是 [reconnectPull] 拿它当判据要避免的。 + * + * 实现上**只是**转调 [loadFirstPage](首屏唯一的一份实现,与连接恢复时补的那一次共用): + * `firstPageState` 的两个出口仍然只有那一处,本方法**一个状态位都不自己写**—— + * W51 的判据依赖「这个字段只由 [loadFirstPage] 写」这条纪律。[repository] 还没建好时 + * [loadFirstPage] 自己会原样返回,这里不必再判一次。 + */ + fun retryFirstPage() = loadFirstPage() + /** * 连接状态变化:除了照抄给界面,还要顺手维护「断线期间漏了哪一段」这个游标,并在连接恢复 * 那一刻决定**这一轮要不要补拉、补哪一段**(判据是纯函数 [reconnectPull])。 @@ -1123,8 +1141,8 @@ class ChatViewModel( // —— D3:单条消息的编辑 / 删除 —— /** - * 打开编辑弹层。**只对 `user` 消息开放**(口径见 [canEditMessage],UI 隐藏入口 + 这里再挡一次, - * 判据是同一份纯函数)。 + * 打开编辑弹层。**只对服务端存在的 `user` 消息开放**(口径见 [canEditMessageOnServer], + * UI 隐藏入口 + 这里再挡一次,判据是同一份纯函数)。 * * 初值走 [editableMessageOf]:编辑框里是**纯文本**(剥掉 `

` / `` / ``), * 原文本来就是纯文本时逐字节不变。原文真带标记的话,那件事**不能不说**——所以 @@ -1133,7 +1151,7 @@ class ChatViewModel( * 只算一次:`_state.update` 的 lambda 可能被重跑(CAS 重试),别把纯函数调用塞进它里面。 */ fun startEdit(message: HistoryMessageDto) { - if (!canEditMessage(message)) return + if (!canEditMessageOnServer(message)) return val editable = editableMessageOf(message.content) _state.update { it.copy( @@ -1146,9 +1164,12 @@ class ChatViewModel( } } - /** 打开删除的二次确认弹层(`user` 与 `assistant` 都开放,见 [canDeleteMessage])。 */ + /** + * 打开删除的二次确认弹层(服务端存在的 `user` 与 `assistant` 消息都开放, + * 见 [canDeleteMessageOnServer]——**降级气泡不在其中**,服务端没有那一行,删它只会白打一趟)。 + */ fun startDelete(message: HistoryMessageDto) { - if (!canDeleteMessage(message)) return + if (!canDeleteMessageOnServer(message)) return _state.update { it.copy(messageAction = MessageActionState.Delete(message.id)) } } diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt index c157c83..d824e53 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt @@ -33,8 +33,6 @@ import coil3.compose.AsyncImage import top.yeij.cyrene.core.prefs.LocalAppPrefs import top.yeij.cyrene.data.api.dto.DocumentRefDto import top.yeij.cyrene.data.api.dto.HistoryMessageDto -import top.yeij.cyrene.data.repository.canDeleteMessage -import top.yeij.cyrene.data.repository.canEditMessage import top.yeij.cyrene.domain.chat.DocumentChipModel import top.yeij.cyrene.domain.chat.DocumentChips import top.yeij.cyrene.domain.chat.DraftDocument @@ -122,8 +120,11 @@ fun isContentlessUserMessage(message: HistoryMessageDto): Boolean = * 恰恰可能不响。因此多给一个看得见、点得着的入口,长按照旧保留(气泡内边距、附件芯片、 * 图片这些不参与选字的区域按下去就是菜单)。 * - * 哪些动作可用由**产品口径**的纯函数给出([canEditMessage] / [canDeleteMessage]), - * 与 VM 里那道闸是同一份判据;两个都不成立时(未知 role)两个入口都不出现。 + * 哪些动作可用由纯函数给出([canEditMessageOnServer] / [canDeleteMessageOnServer]:角色口径 + * 加上「这条在服务端存在吗」),与 VM 里那道闸是同一份判据;两个都不成立时两个入口都不出现 + * ——未知 `role` 是一档,**本端自铸的降级气泡**(`proactive-local:` 前缀,服务端根本没有这一行) + * 是另一档。后者只是不给入口:气泡的**选字**照旧(它是 `SelectionContainer` 那条独立的路, + * 与动作菜单无关)。 * * @param imageBaseUrl 当前的服务器地址(`AuthManager.currentBaseUrl`)。只用来把历史图片里**绑死在 * 上传那一刻 host 上的同源绝对地址**换到现在的 host(`02/F6①`:`POST /upload` 的 `url` 按请求时的 @@ -157,8 +158,8 @@ fun MessageBubble( parsed.ifEmpty { listOf(plainBubble(message.content)) } } - val editable = canEditMessage(message) - val deletable = canDeleteMessage(message) + val editable = canEditMessageOnServer(message) + val deletable = canDeleteMessageOnServer(message) val canAct = editable || deletable // 每个气泡各自记住菜单开着没有:重建 / 滚动复用不会把上一个气泡的菜单带过来。 var menuOpen by remember(message.id) { mutableStateOf(false) } diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageServerActions.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageServerActions.kt new file mode 100644 index 0000000..3b18dc4 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageServerActions.kt @@ -0,0 +1,43 @@ +package top.yeij.cyrene.ui.chat + +import top.yeij.cyrene.data.api.dto.HistoryMessageDto +import top.yeij.cyrene.data.repository.canDeleteMessage +import top.yeij.cyrene.data.repository.canEditMessage + +/** + * 这条消息在**服务端**有没有对应的一行(纯函数)。 + * + * 判据只有一条:`id` 带不带 [PROACTIVE_LOCAL_ID_PREFIX]。那个前缀是本端给**降级气泡** + * (`proactive_message` 落库失败那一档,见 [ProactiveIngest.Bubble])自铸的,服务端的 id + * 永远不会长这样([proactiveIngestFor] 的 KDoc 说明了它是怎么来的)。 + * + * 为什么这件事要单独成判据:一条消息「在服务端存在」是**打服务端那类动作的前提**。 + * 拿一个服务端没见过的 id 去 `PATCH` / `DELETE`,最好的结果是 404,其余是一句用户看不懂的报错—— + * 而这一切仅仅因为他长按了一下一条屏幕上看起来很正常的消息。 + * + * **空 `id` 不在本判据的处理范围内**(今天的行为逐字不变):那属于「形状漂移」的坏帧, + * 是另一件事,不在本轮收口的范围里。 + */ +fun isServerBackedMessage(message: HistoryMessageDto): Boolean = + !message.id.startsWith(PROACTIVE_LOCAL_ID_PREFIX) + +/** + * 这条消息**能不能编辑**——在角色口径([canEditMessage])之上再叠一道「它在服务端存在吗」。 + * + * 两道合起来才是界面与 VM 该用的判据:角色那一道回答「产品上允不允许改她说过的话」, + * 本判据回答「这一趟请求有没有意义」。任一道不成立都是**入口不出现**(fail-closed)。 + * + * **只有会打服务端的动作走这里**:气泡里的文本选择 / 复制是纯本端的事 + * (`SelectionContainer`,与动作菜单是两条独立的路),不归这两个函数管,也不该被它们挡住。 + */ +fun canEditMessageOnServer(message: HistoryMessageDto): Boolean = + isServerBackedMessage(message) && canEditMessage(message) + +/** + * 这条消息**能不能删除**——在角色口径([canDeleteMessage])之上再叠一道「它在服务端存在吗」。 + * + * 降级气泡恰恰是个必须被挡住的例子:它是 `assistant` 行 ⇒ [canDeleteMessage] 会说「能删」, + * 但服务端根本没有这一行,删它只会用本地 id 打出一趟注定失败的请求(见 [isServerBackedMessage])。 + */ +fun canDeleteMessageOnServer(message: HistoryMessageDto): Boolean = + isServerBackedMessage(message) && canDeleteMessage(message) diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/FirstPageRetryTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/FirstPageRetryTest.kt new file mode 100644 index 0000000..7021cee --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/FirstPageRetryTest.kt @@ -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) + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/MessageServerActionsTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/MessageServerActionsTest.kt new file mode 100644 index 0000000..38c3771 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/MessageServerActionsTest.kt @@ -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)) + } +}