feat(chat): 单条消息的编辑与删除(D3)

契约 §1 行 6a/6b 的两个端点与 §3.8 的两帧,整条链接完(不含「重发」——契约
明确没有重发端点,客户端用编辑后的内容再 POST /chat 即可,本模块不做)。

数据层
- SessionsApi 增 PATCH / DELETE api/v1/messages/{id}(与既有消息端点同源,因此
  MessageRepository 能直接复用、不必动 AppContainer),MessageRepository 增
  editMessage / deleteMessage,走既有 apiCall / Result 风格。
- 响应形状照契约不照旧文:PATCH 的 data 直接就是消息 DTO(没有 message 包一层),
  DELETE 回 {deleted: id};编辑内容按「非空、≤4000 rune(码点,不是 length)」
  预校验,不合格一个请求都不发。

帧解析
- WsEvent 增 message_edited / message_deleted,形态照 message_persisted 那一套:
  会话归属先取帧壳、其次取消息自身的 sessionId,未知键安全、未知帧与坏帧安静忽略、
  永不抛。
- message_edited 的 data 就是消息本身(与 message_persisted 的 data.message 不同形),
  照抄那一套会解出 null——这一点由单测钉住。

VM
- 按 id 原位改 content / 原位移除。两条路各自只有一个落地出口:本端 PATCH/DELETE 的
  成功回执与随后到达的广播都调同一对纯函数,而它们幂等(同一内容做两次返回同一实例)
  ⇒ 「响应 + 广播」双到达收敛成一次,不需要去重表、也不会出现两套状态源。
- 编辑只对 role == user 开放、删除对 user 与 assistant 都开放,判据是同一份纯函数,
  气泡(隐藏入口)与 VM(再挡一次)共用。
- 失败一律如实:弹层还开着就留在弹层里(输入不丢、可原地重试),弹层已被关掉就挂到
  页面顶部那行横幅,绝不静默。

UI
- 长按气泡打开动作菜单;由于正文包在 SelectionContainer 里、长按正文同时也是选字手势
  (两者抢同一个长按,谁先消费由运行时决定),另给一枚看得见的「⋯」
  (content-desc 消息操作)作为同一条入口,避免只有知道的人才会用。
- 编辑弹层:原始 content 作初值 + 码点计数 + 取消/保存;删除必须二次确认。
- ChatScreen 只加 24 行接线(两个回调 + 两个弹层的渲染)。

测试 +49(766 → 815,全绿):帧解析 14、纯逻辑 15、Retrofit 层 7、失败文案与落点 13。
This commit is contained in:
AskaEth committed 2026-10-04 21:26:52 +08:00
1 parent 020687a82b
commit 351891714a
13 files changed
+1717 -15

No files matched your search

@@ -0,0 +1,181 @@
package top.yeij.cyrene.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException
/**
* 编辑 / 删除失败的**如实告知**(纯函数),以及失败该落在哪里。
*
* 两件事各自钉一组:
*
* 1. [messageActionFailure]:401 / 403 / 404 / 501 / 网络 / 未知,每一档**要用户做的事不同**,
* 文案就不能混。尤其 401——`CyreneError.Unauthorized` 自带的兜底文案是「用户名或密码不正确」,
* 直接透出去会让人去改密码,而正确的动作是重新登录;
* 2. [ChatUiState.applyMessageActionFailure]:弹层还开着就留在弹层里(输入不丢、原地重试),
* 弹层已经关了(用户在等结果时退出)就挂到页面横幅上——**任何一条都不得静默**。
*/
class MessageActionFailureTest {
private fun server(code: String, message: String = "", status: Int) =
CyreneException(CyreneError.Server(code, message, status))
private fun state(action: MessageActionState?) = ChatUiState(messageAction = action)
// —— 文案 ——
@Test
fun `an expired session asks the user to log in again, not to change the password`() {
val expired = CyreneException(CyreneError.Unauthorized("unauthorized", "auth: unauthorized"))
val edit = messageActionFailure(expired, MessageActionKind.Edit)
val delete = messageActionFailure(expired, MessageActionKind.Delete)
assertEquals("登录状态已失效,请重新登录后再编辑", edit)
assertEquals("登录状态已失效,请重新登录后再删除", delete)
assertTrue(edit, !edit.contains("密码"))
}
@Test
fun `a forbidden edit names the missing permission`() {
val forbidden = server("forbidden", "forbidden", status = 403)
assertEquals("没有权限编辑这条消息", messageActionFailure(forbidden, MessageActionKind.Edit))
assertEquals("没有权限删除这条消息", messageActionFailure(forbidden, MessageActionKind.Delete))
}
@Test
fun `a message the server no longer has says so, because another client probably removed it`() {
val gone = server("not_found", "message not found", status = 404)
// 契约 §1 行 6a/6b:不存在回 404 not_found。状态码与错误码两条路都要认(代理可能改状态码)。
val goneByCode = server("not_found", "", status = 400)
assertEquals(
"这条消息在服务端已不存在,可能已被其它端删除",
messageActionFailure(gone, MessageActionKind.Edit),
)
assertEquals(
"这条消息在服务端已不存在,可能已被其它端删除",
messageActionFailure(goneByCode, MessageActionKind.Delete),
)
}
@Test
fun `a server without a history store says the deployment cannot do it`() {
val notImplemented = server("not_implemented", "history store unavailable", status = 501)
assertEquals(
"服务端没有开启消息存储,无法编辑",
messageActionFailure(notImplemented, MessageActionKind.Edit),
)
}
@Test
fun `a rejection keeps the server wording instead of inventing a number`() {
val rejected = server("bad_request", "content must be at most 4000 runes", status = 400)
assertEquals("content must be at most 4000 runes", messageActionFailure(rejected, MessageActionKind.Edit))
}
@Test
fun `network failures name the step that failed and keep their own wording`() {
val offline = CyreneException(CyreneError.Local(CyreneError.LocalKind.NoNetwork))
val timeout = CyreneException(CyreneError.Local(CyreneError.LocalKind.Timeout))
assertEquals("编辑失败,网络不可用,请检查连接", messageActionFailure(offline, MessageActionKind.Edit))
assertEquals("删除失败,连接超时,服务器没有响应", messageActionFailure(timeout, MessageActionKind.Delete))
}
@Test
fun `an unknown throwable still produces an actionable sentence`() {
assertEquals("编辑失败,请重试", messageActionFailure(IllegalStateException("boom"), MessageActionKind.Edit))
assertEquals("删除失败,请重试", messageActionFailure(RuntimeException(), MessageActionKind.Delete))
}
@Test
fun `no failure path ever yields an empty message`() {
val failures = listOf(
messageActionFailure(server("forbidden", "", status = 403), MessageActionKind.Edit),
messageActionFailure(server("not_found", "", status = 404), MessageActionKind.Edit),
messageActionFailure(server("not_implemented", "", status = 501), MessageActionKind.Edit),
messageActionFailure(server("internal", "", status = 500), MessageActionKind.Delete),
messageActionFailure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Unknown)), MessageActionKind.Delete),
messageActionFailure(IllegalStateException(), MessageActionKind.Edit),
)
assertTrue(failures.toString(), failures.all { it.isNotBlank() })
}
// —— 失败落在哪里 ——
@Test
fun `a failure lands inside the dialog while it is still open`() {
val action = MessageActionState.Edit("m1", draft = "改过的内容", inFlight = true)
val after = state(action).applyMessageActionFailure(
MessageActionKind.Edit,
"m1",
"登录状态已失效,请重新登录后再编辑",
)
// 留在弹层里:用户就站在那儿,改完能直接重试,草稿一个字都不丢。
assertEquals("登录状态已失效,请重新登录后再编辑", (after.messageAction as MessageActionState.Edit).error)
assertEquals("改过的内容", (after.messageAction as MessageActionState.Edit).draft)
assertEquals(false, after.messageAction?.inFlight)
assertNull(after.error)
}
@Test
fun `a failed delete keeps its own dialog too`() {
val action = MessageActionState.Delete("m1", inFlight = true)
val after = state(action).applyMessageActionFailure(
MessageActionKind.Delete,
"m1",
"这条消息在服务端已不存在,可能已被其它端删除",
)
assertEquals(
"这条消息在服务端已不存在,可能已被其它端删除",
(after.messageAction as MessageActionState.Delete).error,
)
}
@Test
fun `a failure for another message or another kind goes to the page banner`() {
// 用户在等结果时又去操作了别的消息(或关掉了弹层)⇒ 失败挂到页面顶部那行,**绝不静默**。
val otherMessage = state(MessageActionState.Edit("m2", draft = "别的"))
val otherKind = state(MessageActionState.Delete("m1"))
val afterOtherMessage = otherMessage.applyMessageActionFailure(MessageActionKind.Edit, "m1", "改失败了")
val afterOtherKind = otherKind.applyMessageActionFailure(MessageActionKind.Edit, "m1", "改失败了")
assertEquals("改失败了", afterOtherMessage.error)
assertEquals("别的", (afterOtherMessage.messageAction as MessageActionState.Edit).draft)
assertEquals("改失败了", afterOtherKind.error)
assertEquals("m1", afterOtherKind.messageAction?.messageId)
}
@Test
fun `a failure with no dialog open still shows up on the page`() {
val after = ChatUiState().applyMessageActionFailure(MessageActionKind.Delete, "m1", "删不掉")
assertEquals("删不掉", after.error)
assertNull(after.messageAction)
}
@Test
fun `an unrelated failure leaves the state untouched`() {
// 幂等的一面:失败的 messageId 与弹层无关且已有关闭前的横幅时,不做多余的改写。
val before = state(MessageActionState.Edit("m1", draft = "x", inFlight = true))
val after = before.applyMessageActionFailure(MessageActionKind.Edit, "m2", "别的消息失败了")
assertSame(before.messageAction, after.messageAction)
assertEquals("别的消息失败了", after.error)
}
}