fix(chat): 分享告知不再两句叠加;未连接与发送失败分开说;Offline 不再承诺「连上后自动发出」
三处「对用户不够诚实」的措辞缺陷,各自独立修复。行为上只多了一处「少说一句」, 其余语义一字未改。 ① 分享告知两句叠加(ChatScreen.shareNoticeFor) 实机读到的原文是「有 2 个文件读不到,未发送;分享的内容是空的,没有可发送的内容」, 读起来像两个不同的问题。可这两句说的是不同的免责,而「认下了地址却读不到」本身就蕴含 「没有可发送的内容」——叠上去一个字都没多说。故丢件计数为真时不再追加「内容是空的」那句; 其余组合保持今天的行为(只有丢件计数时只说丢件计数;「读不进来」与丢件计数说的是两件事, 照样并列)。KDoc 里记下了这一档今天的真实出口:worthDelivering 要求 isEmpty 的分享必须 有丢件计数或 contentUnreadable 才值得往下走,而后者已被「读不进来」那一档接走, 所以那句加了前提之后一次都不会再出现。 ② deliver 的 WS 分支:连接未就绪不等于发送失败(ChatViewModel) sendChat 回 false 有三种成因(连接状态不是 Connected / socket 对象已空或已被 cancel / 序列化抛了异常),此前共用一句「发送失败,请重试」,用户无从分辨「这条根本没上路」 与「发出去了但失败了」,而后者在连接未就绪时不是事实。现在按失败那一刻的连接状态分开说: 不是 Connected 时说「还没连上,这条没发出去,请稍后手动重试」,仍是 Connected 时逐字保持 「发送失败,请重试」。回滚照旧(摘掉气泡、内容退回输入栏、记下幂等键),没有自动重发, 普通发送的其它语义没动。判据抽成纯函数 sendFailureMessage(WsState),用例覆盖 WsState.entries 的每一个取值。连接状态取 socket 自己的 StateFlow——那正是 sendChat 判的值, 不用界面镜像过来的 ChatUiState.connection,免得晚一帧读到 Connected 反而说错话。 ③ WsState.Offline 不再等一句永不到来的「连上」(ShareLanding / ChatScreen) Offline 是终态(握手 401 且续不上、或缺少 token / 服务器地址),它不会自动重连。 对它说「连上后自动发出」,用户等的是一个永远不会到来的连接——拿一句不兑现的承诺糊住 一个需要他动手的状况,比不说更糟。新增 ShareLanding.TellOffline:等够久、同一轮只说一次 这些时机与可恢复的那几档完全一致,只有措辞不同:「现在处于离线,需要重新登录或检查 服务器地址;分享的内容一个字节都没发出去,不会丢」。W38 的落地时机一个字没改: 仍然不 take()、不发送,连接恢复后照旧自动补发。哪档配哪句由纯函数 shareWaitingNotice 定, 不再散在 Compose 的 when 里,于是「Offline 换口径」这件事不需要组合界面就能测。 测试:ShareNoticeTest 把「两句都在」的断言改成新事实并穷举同族组合;ShareLandingTest 新增 Offline 的差异、时机与文案用例;新增 SendFailureMessageTest 覆盖每一种 WsState。 assembleDebug 成功;testDebugUnitTest 1101 条,failures 0 / errors 0。
This commit is contained in:
1 parent
4eaee04255
commit
a763592ace
6 files changed
+309
-22
No files matched your search
@@ -0,0 +1,59 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.ws.WsState
|
||||
|
||||
/**
|
||||
* 「`sendChat` 回了 `false`」之后该说的那句话([sendFailureMessage])。
|
||||
*
|
||||
* 背景:WS 那条路上 `sendChat` 有三种失败成因(连接状态不是 `Connected` / socket 对象已空或已被
|
||||
* cancel / 序列化抛了异常),此前共用一句「发送失败,请重试」。2026-10-05 的实机取证(原文见
|
||||
* `ShareLandingTest` 的类注释)说明代价:连接还没就绪时那条消息**一个字节都没上路**(后端零请求、
|
||||
* 也没有任何 error 帧),而横幅说成「发送失败」——用户于是去怀疑网络与对方,而事实是连帧都没发出去。
|
||||
*
|
||||
* 本测试钉住三件事:
|
||||
*
|
||||
* 1. 连接**没就绪**的每一档([WsState.Idle] / [WsState.Connecting] / [WsState.Reconnecting] /
|
||||
* [WsState.Offline])都说「还没连上」那句,**不是**「发送失败」;
|
||||
* 2. 连接状态**仍是** `Connected` 时逐字保持今天那句(那种失败里「发送失败」是事实);
|
||||
* 3. 两句文案各说各的:新那句里不许出现「发送失败」。
|
||||
*/
|
||||
class SendFailureMessageTest {
|
||||
|
||||
@Test
|
||||
fun `a failure with the connection still up keeps today's wording byte for byte`() {
|
||||
assertEquals("发送失败,请重试", sendFailureMessage(WsState.Connected))
|
||||
assertEquals(SEND_FAILED_MESSAGE, sendFailureMessage(WsState.Connected))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every state that is not connected says the message never left`() {
|
||||
WsState.entries.filter { it != WsState.Connected }.forEach { connection ->
|
||||
val said = sendFailureMessage(connection)
|
||||
|
||||
assertEquals("$connection 该说「还没连上」那句", SEND_NOT_CONNECTED_MESSAGE, said)
|
||||
assertNotEquals("$connection 上不该说成发送失败", SEND_FAILED_MESSAGE, said)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the not connected wording never claims the send failed`() {
|
||||
assertTrue(SEND_NOT_CONNECTED_MESSAGE.contains("还没连上"))
|
||||
// 说清**没发生什么**:这条消息压根没上路(否则用户会以为东西已经出去了)。
|
||||
assertTrue(SEND_NOT_CONNECTED_MESSAGE.contains("没发出去"))
|
||||
assertFalse(SEND_NOT_CONNECTED_MESSAGE.contains("发送失败"))
|
||||
// 也别说成「失败了」——失败的是连接,不是这次发送。而「重试」是唯一该留给用户的动作:
|
||||
// 内容已经被回滚退回输入栏,全链路没有任何自动重发。
|
||||
assertFalse(SEND_NOT_CONNECTED_MESSAGE.contains("失败"))
|
||||
assertTrue(SEND_NOT_CONNECTED_MESSAGE.contains("重试"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the two sentences are really two`() {
|
||||
assertNotEquals(SEND_FAILED_MESSAGE, SEND_NOT_CONNECTED_MESSAGE)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package top.yeij.cyrene.ui.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.ws.WsState
|
||||
@@ -14,13 +16,16 @@ import top.yeij.cyrene.data.ws.WsState
|
||||
* 的返回值被丢掉了 ⇒ **超时与成功走同一条下游**:照样取走内容去发。而 `ShareIntake.take()` 是
|
||||
* 原子出队、没有归还路径,那一次「其实一次都没发出去」的失败把内容从待发队列里抹掉了。
|
||||
*
|
||||
* 本测试钉住三件事:
|
||||
* 本测试钉住四件事:
|
||||
*
|
||||
* 1. **取走内容的前提是连接就绪**:未就绪时恒不 [ShareLanding.Send](不取、不发)。
|
||||
* 2. **等够久只说一次**「还没连上、连上后会自动发出」([ShareLanding.TellWaiting]),
|
||||
* 同一轮等待里连接反复抖动不再说第二遍。
|
||||
* 3. 反面边界:**没有待发内容**时任何连接状态下都不做事、不告知;**已经连上**时不告知
|
||||
* (发送压过告知,返回值是单值动作 ⇒ 两者在类型上就不可能同时发生)。
|
||||
* 4. **`Offline`(不可恢复的那一档)换一句可执行的措辞**:它不会自动重连,所以说的是
|
||||
* 「重新登录 / 检查服务器地址」而不是那个永远不会到来的「连上后自动发出」;措辞虽是另一句,
|
||||
* **时机完全一样**(等够久、同一轮只说一次)。哪档配哪句由 [shareWaitingNotice] 定。
|
||||
*/
|
||||
class ShareLandingTest {
|
||||
|
||||
@@ -109,6 +114,54 @@ class ShareLandingTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offline is told differently from the states that can still come back`() {
|
||||
// 同样的输入(等够久、还没说过)下,Offline 与其余未就绪档必须给出**不同**的结果:
|
||||
// 那个 Offline 不会自动重连,而其余几档只是还没连上。
|
||||
val offline = shareLanding(true, WsState.Offline, waitedLong = true, alreadyTold = false)
|
||||
|
||||
assertEquals(ShareLanding.TellOffline, offline)
|
||||
WsState.entries
|
||||
.filter { it != WsState.Connected && it != WsState.Offline }
|
||||
.forEach { connection ->
|
||||
val told = shareLanding(true, connection, waitedLong = true, alreadyTold = false)
|
||||
|
||||
assertEquals("$connection 该说「等连上」那句", ShareLanding.TellWaiting, told)
|
||||
assertNotEquals("$connection 不该跟着 Offline 一起换口径", offline, told)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offline is told with the same timing as the others, only the wording differs`() {
|
||||
// 换的只是措辞:该等的那一下照等(刚分享进来就弹「离线」同样是噪声)。
|
||||
assertEquals(
|
||||
ShareLanding.KeepWaiting,
|
||||
shareLanding(true, WsState.Offline, waitedLong = false, alreadyTold = false),
|
||||
)
|
||||
// 同一轮等待也照样只说一次。
|
||||
assertEquals(
|
||||
ShareLanding.KeepWaiting,
|
||||
shareLanding(true, WsState.Offline, waitedLong = true, alreadyTold = true),
|
||||
)
|
||||
// 而告知终归是告知:这一档**绝不**变成「取走并发送」(内容不许丢)。
|
||||
assertNotEquals(
|
||||
ShareLanding.Send,
|
||||
shareLanding(true, WsState.Offline, waitedLong = true, alreadyTold = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the landing decides which of the two notices is shown`() {
|
||||
// 「哪个动作配哪句话」也是纯判据([shareWaitingNotice]):两句话搞混的代价不对称,所以它
|
||||
// 不该只活在 Compose 的 when 里。
|
||||
assertEquals(SHARE_NOT_CONNECTED_NOTICE, shareWaitingNotice(ShareLanding.TellWaiting))
|
||||
assertEquals(SHARE_OFFLINE_NOTICE, shareWaitingNotice(ShareLanding.TellOffline))
|
||||
// 不是告知档的三档一个字都不说。
|
||||
listOf(ShareLanding.Nothing, ShareLanding.KeepWaiting, ShareLanding.Send).forEach { landing ->
|
||||
assertNull("$landing 不是告知档", shareWaitingNotice(landing))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the waiting notice says it is not connected and that it will go out by itself`() {
|
||||
assertTrue(SHARE_NOT_CONNECTED_NOTICE.contains("还没连上"))
|
||||
@@ -117,4 +170,21 @@ class ShareLandingTest {
|
||||
assertFalse(SHARE_NOT_CONNECTED_NOTICE.contains("失败"))
|
||||
assertFalse(SHARE_NOT_CONNECTED_NOTICE.contains("重试"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the offline notice never promises a connection that will not come back`() {
|
||||
assertTrue(SHARE_OFFLINE_NOTICE.contains("离线"))
|
||||
// 说清用户**能做的事**:会话过期那条路(重新登录)与配置不对那条路(服务器地址)。
|
||||
assertTrue(SHARE_OFFLINE_NOTICE.contains("重新登录"))
|
||||
assertTrue(SHARE_OFFLINE_NOTICE.contains("服务器地址"))
|
||||
// 内容一个字节都没发出去、但留着——后一半不说清,用户会以为东西丢了、重新分享一遍。
|
||||
assertTrue(SHARE_OFFLINE_NOTICE.contains("没发出去"))
|
||||
assertTrue(SHARE_OFFLINE_NOTICE.contains("不会丢"))
|
||||
// 「连上后自动发出」在 Offline 上永远不会兑现 ⇒ 这一档一个字都不许提。
|
||||
assertFalse(SHARE_OFFLINE_NOTICE.contains("自动发"))
|
||||
assertFalse(SHARE_OFFLINE_NOTICE.contains("连上"))
|
||||
// 也没有任何发送发生过——说成「失败」会让人以为东西已经出去了。
|
||||
assertFalse(SHARE_OFFLINE_NOTICE.contains("失败"))
|
||||
assertNotEquals(SHARE_NOT_CONNECTED_NOTICE, SHARE_OFFLINE_NOTICE)
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,9 @@ import top.yeij.cyrene.domain.share.SharedDocument
|
||||
*
|
||||
* 1. 「内容没能读进来」要成句,且**不说成发送失败**——失败的是读取,不是发送;
|
||||
* 2. 正常落地(一条文本 / 一张图 / 一个文件)一个字都不说;
|
||||
* 3. 边界:整条分享里什么都没有时**仍然静默**(那种情况没什么可说的,说了才是噪声)。
|
||||
* 3. 边界:整条分享里什么都没有时**仍然静默**(那种情况没什么可说的,说了才是噪声);
|
||||
* 4. **「读不到」与「内容是空的」不许叠说**:2026-10-05 实机读到的「有 2 个文件读不到,未发送;
|
||||
* 分享的内容是空的,没有可发送的内容」会让用户以为出了两个问题,而前者本身就蕴含后者。
|
||||
*/
|
||||
class ShareNoticeTest {
|
||||
|
||||
@@ -66,10 +68,45 @@ class ShareNoticeTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dropped content next to an empty body says both things`() {
|
||||
val said = shareNoticeFor(SharedContent(failedImages = 2))!!
|
||||
fun `dropped pieces are not doubled up with the empty sentence`() {
|
||||
// 2026-10-05 实机原文:「有 2 个文件读不到,未发送;分享的内容是空的,没有可发送的内容」。
|
||||
// 两句说的是**不同的免责**,叠起来读着像出了两个问题;而「认下了地址却读不到」已经蕴含
|
||||
// 「没有可发送的内容」⇒ 只说前面那句就够了。
|
||||
assertEquals("有 2 张图片读不到,未发送", shareNoticeFor(SharedContent(failedImages = 2)))
|
||||
assertEquals("有 2 个文件读不到,未发送", shareNoticeFor(SharedContent(failedDocuments = 2)))
|
||||
}
|
||||
|
||||
assertTrue(said.contains("有 2 张图片读不到,未发送"))
|
||||
assertTrue(said.contains("分享的内容是空的,没有可发送的内容"))
|
||||
@Test
|
||||
fun `an empty share never ends up as two problems`() {
|
||||
// 同族组合穷举一遍:只要这条分享是「空的」、又有丢件计数留下,就**只**说丢件计数。
|
||||
listOf(0, 2).forEach { failedImages ->
|
||||
listOf(0, 2).forEach { failedDocuments ->
|
||||
listOf(false, true).forEach { unreadable ->
|
||||
val share = SharedContent(
|
||||
failedImages = failedImages,
|
||||
failedDocuments = failedDocuments,
|
||||
contentUnreadable = unreadable,
|
||||
)
|
||||
val said = shareNoticeFor(share)
|
||||
if (share.isEmpty && said != null) {
|
||||
assertFalse("$share 上叠了「内容为空」那句:$said", said.contains("分享的内容是空的"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the other pieces of the notice are unchanged`() {
|
||||
// 「其余组合保持今天的行为」:图片与文件各读不到一份时照旧两句并列;
|
||||
// 「读不进来」与丢件计数说的**不是**同一件事,所以它照旧跟在后面。
|
||||
assertEquals(
|
||||
"有 1 张图片读不到,未发送;有 1 个文件读不到,未发送",
|
||||
shareNoticeFor(SharedContent(failedImages = 1, failedDocuments = 1)),
|
||||
)
|
||||
assertEquals(
|
||||
"有 2 张图片读不到,未发送;$SHARE_UNREADABLE_NOTICE",
|
||||
shareNoticeFor(SharedContent(failedImages = 2, contentUnreadable = true)),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user