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