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:
AskaEth committed 2026-10-05 13:41:52 +08:00
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)),
)
}
}