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 12cd618..c1313cd 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 @@ -337,7 +337,10 @@ fun ChatScreen( // 连重试资格一起丢。**超时绝不等于发送失败**:没连上时一个字节都不发,内容原地等着, // 连接恢复后由下面这个 effect 自动补发。 // - **等太久要说一句,但只说一次**:「还没连上、内容会等连上后自动发出」走 [ChatViewModel.showShareNotice] - // 这条柔和通道(不是带 X 的 error 横幅),文案见 [SHARE_NOT_CONNECTED_NOTICE]。 + // 这条柔和通道(不是带 X 的 error 横幅),文案见 [SHARE_NOT_CONNECTED_NOTICE];而连接处在 + // **不可恢复**的 `Offline` 那一档时说 [SHARE_OFFLINE_NOTICE](「重新登录 / 检查服务器地址」)—— + // 那一档不会自动重连,「连上后自动发出」等的是一个永远不会到来的连接。哪档配哪句是纯函数 + // [shareWaitingNotice],两档的落地时机一模一样。 // - **只有当前会话的 ChatScreen 在组合**:已访问过的会话的 VM 都还活着(Activity 级 // ViewModelStore),由 UI 层认领才不会让旧会话把内容抢走。 // - [ShareIntake.take] 是原子出队:同一条内容不会被发两次。重组 / 切会话 / 连接变化都会重跑这个 @@ -362,23 +365,24 @@ fun ChatScreen( // 这个 effect 的键里有连接状态 ⇒ 连接后来就绪时会重跑,内容因此**一次都不会漏发**(也不需要 // 任何轮询:状态一变就重来一遍判据)。 LaunchedEffect(sessionId, pendingShares, shareConnection, shareWaitedLong) { - when ( - shareLanding( - hasPending = pendingShares.isNotEmpty(), - connection = shareConnection, - waitedLong = shareWaitedLong, - alreadyTold = shareToldWaiting, - ) - ) { + val landing = shareLanding( + hasPending = pendingShares.isNotEmpty(), + connection = shareConnection, + waitedLong = shareWaitedLong, + alreadyTold = shareToldWaiting, + ) + when (landing) { // 取走之后这一轮等待就结束了:下一批内容(或下一次断线)重新计时、重新有资格说那句话。 ShareLanding.Send -> { shareToldWaiting = false ShareIntake.take()?.let { share -> viewModel.sendShared(share, shareNoticeFor(share)) } } - ShareLanding.TellWaiting -> { + // 两个告知档只差措辞(时机一致):说哪句由纯函数 [shareWaitingNotice] 定, + // `Offline` 因此说的是「重新登录 / 检查服务器地址」而不是不会兑现的「连上后自动发出」。 + ShareLanding.TellWaiting, ShareLanding.TellOffline -> { shareToldWaiting = true - viewModel.showShareNotice(SHARE_NOT_CONNECTED_NOTICE) + shareWaitingNotice(landing)?.let { viewModel.showShareNotice(it) } } // 未就绪:**绝不 take()**(内容原封不动留在 holder 里)、也不说任何话。 @@ -934,6 +938,22 @@ const val SHARE_NOT_CONNECTED_NOTICE = "还没连上,分享的内容会留着 const val SHARE_UNREADABLE_NOTICE = "这次分享的内容没能读进来,没有发送任何内容;可以改从本应用里选图片或文件再试一次" +/** + * 「分享进来的内容还在等连接,而这一刻的连接已经**不可恢复**」的如实告知 + * (判据见 [shareLanding],落点是它的 [ShareLanding.TellOffline])。 + * + * 与 [SHARE_NOT_CONNECTED_NOTICE] 分开的**唯一**理由:[WsState.Offline] 是**终态**——握手 401 且 + * 续不上、或缺少 token / 服务器地址,它**不会自动重连**(见 `ChatSocket` 的连接循环,以及界面上 + * 早就把这一档单独念成「离线」的 [connectionLabel])。在那句「连上后自动发出」里,用户等的是一个 + * **永远不会到来**的连接——那是在骗他,比不说更糟。 + * + * 这一档要说的是他能做的事:**重新登录**(会话过期的路)或**检查服务器地址**(配置不对的路)。 + * 内容呢?照样一个字节都没发出去、留在待发队列里等着——这一点必须说清,否则用户会以为东西丢了、 + * 重新分享一遍,那就真发两遍了。 + */ +const val SHARE_OFFLINE_NOTICE = + "现在处于离线,需要重新登录或检查服务器地址;分享的内容一个字节都没发出去,不会丢" + /** * 分享落地的如实告知:只在「有东西没能按原样发出」时才成句,正常落地(一条文本 / 一张图 / * 一个文件)一个字都不说。 @@ -948,6 +968,19 @@ const val SHARE_UNREADABLE_NOTICE = * `ClipData` 也没有条目)时一个字都不说——用户确实没分享任何东西,没什么可说的。判据用 * [SharedContent.worthDelivering],与 `ShareIntake.accept` 决定「要不要往下接」**同一条**: * 换成两个判据就会出现「接了却不说」或者「说了却没接」。 + * + * ## 「读不到」压过「内容是空的」,两句**不许叠** + * 丢件计数([SharedContent.failedImages] / [SharedContent.failedDocuments])为真时,不再追加 + * 「分享的内容是空的,没有可发送的内容」。2026-10-05 实机读到的原文是「有 2 个文件读不到,未发送; + * 分享的内容是空的,没有可发送的内容」——**两句说的是不同的免责**,叠在一起只会让人以为出了两个 + * 问题;而「认下了地址却读不到」本身就蕴含了「没有可发送的内容」,第二句一个字都没多说。 + * 其余组合一个字没改:只有丢件计数时照旧只说丢件计数;[SharedContent.contentUnreadable] 与它们的 + * 关系也照旧(「读不进来」那一档照样成句,它与丢件计数说的是两件事,可以并列)。 + * + * 顺带记下这一档今天的**真实出口**:`worthDelivering` 要求 `isEmpty` 的分享必须有丢件计数或 + * `contentUnreadable` 才值得往下走,而后者已经被「读不进来」那一档接走 ⇒ 加了这个前提之后 + * 「内容是空的」这句话一次都不会出现(那正是这条告知想达到的效果:没东西可发时,说清**为什么** + * 没东西可发就够了)。这一档仍然留在代码里,是因为它记着一条语义,而不是因为它还有出口。 */ fun shareNoticeFor(share: SharedContent): String? { if (!share.worthDelivering) return null @@ -960,11 +993,13 @@ fun shareNoticeFor(share: SharedContent): String? { } if (share.failedImages > 0) add("有 ${share.failedImages} 张图片读不到,未发送") if (share.failedDocuments > 0) add("有 ${share.failedDocuments} 个文件读不到,未发送") + // 刚刚那两句是否成立——它就是「内容为空」那句的**前提**(两者不许叠说,理由见上面的 KDoc)。 + val hasFailedPieces = share.failedImages > 0 || share.failedDocuments > 0 when { // 「读不进来」压过「内容是空的」:两者不可能同时成立(一条都没认下来的那次分享里根本没有 // 丢件计数),而混着说会让人以为东西还在路上。 share.contentUnreadable -> add(SHARE_UNREADABLE_NOTICE) - share.isEmpty -> add("分享的内容是空的,没有可发送的内容") + share.isEmpty && !hasFailedPieces -> add("分享的内容是空的,没有可发送的内容") } // 超长正文本该落成 .txt 附件,但附件位已经被分享进来的文件占满 ⇒ 只能按正文发。 // 这是**形态变了**(不是内容少了),所以单独成句且说清为什么(见 DocumentDelivery.planSharedText)。 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 f67b178..7046a82 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 @@ -145,6 +145,47 @@ const val QUEUE_FULL_MESSAGE = "她正忙且队列已满,这条没发出去, */ const val QUEUE_FULL_UNKNOWN_MESSAGE = "她忙不过来,这条可能没发出去" +/** + * 一条消息**真的没发出去**、而失败原因与连接状态无关时的如实文案。 + * + * 今天只有一处会走到它:WS 那条路上 `sendChat` 回了 `false`,而**那一刻连接状态仍是** + * [WsState.Connected](socket 对象已空 / 已被 cancel,或序列化抛了异常)。逐字保留原样。 + */ +const val SEND_FAILED_MESSAGE = "发送失败,请重试" + +/** + * 一条消息**一次都没尝试发出去**(连接还没就绪)时的如实文案。 + * + * 它与 [SEND_FAILED_MESSAGE] 分开,因为那是两件事,而用户只能从这句话里分辨它们: + * 「还没连上」意味着这条消息**根本没上路**(网络还没好),「发送失败」却暗示它已经出去过、只是 + * 没成功——后者在连接未就绪时**根本不是事实**(`ChatSocket.send` 连帧都没序列化,服务端一个 + * 请求都收不到)。措辞照 [QUEUE_FULL_MESSAGE] 那一族的惯例:「这条没发出去」说清没发生什么, + * 「手动重试」说清要用户做什么——内容已经被 [ChatUiState.rollbackPending] 退回输入栏,而全链路 + * 没有任何自动重发,所以那一步只能由用户来。 + */ +const val SEND_NOT_CONNECTED_MESSAGE = "还没连上,这条没发出去,请稍后手动重试" + +/** + * 「`sendChat` 回了 `false`」时该显示哪一句(**纯函数**;输入只有失败那一刻的连接状态)。 + * + * `ChatSocket.send` 回 `false` 有三种成因:(1) **连接状态不是** [WsState.Connected]、 + * (2) `webSocket` 对象已空或已被 cancel、(3) `toJson()` / `send()` 抛了异常。三者在返回值上分不开, + * 但**连接状态这一格分得开**:只有 (1) 是「这条压根没上路」,那就不能说成发送失败——用户看到 + * 「发送失败」会以为东西已经出去了、以为「重试」是把同一件东西再送一次,而实际上下一次是按 + * 幂等键的一条新尝试。(2)(3) 以及已经是 `Connected` 时的任何失败照旧说 [SEND_FAILED_MESSAGE], + * 那时候「发送失败」是事实。 + * + * 判据只认 [WsState.Connected] **一个值**(不是「不等于 `Offline`」之类的近似):连接状态的另外四档 + * ([WsState.Idle] / [WsState.Connecting] / [WsState.Reconnecting] / [WsState.Offline])都意味着帧 + * 没有被交给任何服务端。socket 为 null(本 VM 还没接上那条共享连接)在这里按 [WsState.Idle] 处理: + * 「未启动」正是它的语义,而且确实没连上。 + * + * ⚠️ 这一条**只换句子、不换行为**:回滚仍是同一条回滚(气泡摘掉、内容退回输入栏、记下幂等键), + * 没有自动重发,普通发送的其它语义一个字没动。 + */ +fun sendFailureMessage(connection: WsState): String = + if (connection == WsState.Connected) SEND_FAILED_MESSAGE else SEND_NOT_CONNECTED_MESSAGE + /** 队列满的机器可读码(契约 v0.59/G5,与 REST `409` 同码)。 */ const val CHAT_BUSY_CODE = "chat_busy" @@ -1290,8 +1331,14 @@ class ChatViewModel( when (ChatSendRouting.channelFor(text, hasImage = image != null, hasDocuments = payload.isNotEmpty())) { SendChannel.Ws -> { - val sent = socket?.sendChat(sessionId, text, DEVICE_ID, localId, parallel) ?: false - if (!sent) rollback(localId, "发送失败,请重试") + // socket 只读一次:下面两处必须是**同一次**观察。`sendChat` 判的是 socket 自己的状态, + // 而界面那一格(`ChatUiState.connection`)是经 collect 镜像过来的,可能晚一帧。 + val ws = socket + val sent = ws?.sendChat(sessionId, text, DEVICE_ID, localId, parallel) ?: false + // 该说哪句按**失败那一刻的连接状态**分开(判据是纯函数 [sendFailureMessage]): + // 连接没就绪时这条消息一个字节都没上路,说成「发送失败」不是事实。回滚本身照旧, + // 一个字没改。socket 为 null 按 Idle 处理(「未启动」正是它的语义,也确实是没连上)。 + if (!sent) rollback(localId, sendFailureMessage(ws?.state?.value ?: WsState.Idle)) } SendChannel.Rest -> if (image == null) { diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ShareLanding.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ShareLanding.kt index 011534b..6277c53 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ShareLanding.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ShareLanding.kt @@ -13,6 +13,13 @@ enum class ShareLanding { /** 等到该说一句的时候了:说一次「还没连上、连上后自动发出」(柔和通道,不是错误横幅)。 */ TellWaiting, + /** + * 等到该说一句的时候了,而这一刻的未就绪是**不可恢复**的那一档([WsState.Offline]): + * 说一次 [SHARE_OFFLINE_NOTICE]——说清「需要重新登录或检查服务器地址」,**不**说 + * 「连上后自动发出」(那句话在 `Offline` 上永远不会到来)。 + */ + TellOffline, + /** 连接就绪:取走一条并交给既有发送路径。 */ Send, } @@ -34,16 +41,28 @@ enum class ShareLanding { * * ## 判据与优先级 * 1. **没有待发内容** ⇒ [ShareLanding.Nothing]:任何连接状态下都不做事、也不说话。 - * 2. **[WsState.Connected]** ⇒ [ShareLanding.Send]:取走一条并发送。它**压过**后两条——连接一到就该发, + * 2. **[WsState.Connected]** ⇒ [ShareLanding.Send]:取走一条并发送。它**压过**后面每一条——连接一到就该发, * 哪怕此前已经等够久、那句「等连上会自动发」也说过了。返回值是**单值动作**,所以「发送」与 * 「告知」在类型上就不可能同时发生(不会既发了又报怨还没连上)。 * 3. 未就绪且**还没等够久**(`waitedLong = false`)⇒ [ShareLanding.KeepWaiting]:继续等,什么都不说 * (刚分享进来就弹一句「还没连上」是噪声,握手正常也就几百毫秒)。 - * 4. 未就绪、已等够久、且**还没说过** ⇒ [ShareLanding.TellWaiting]:说一次。 + * 4. 未就绪、已等够久、且**还没说过** ⇒ 说一次。说哪一句由连接状态分**两档**:可恢复的那三档 + * ([WsState.Idle] / [WsState.Connecting] / [WsState.Reconnecting])是 [ShareLanding.TellWaiting], + * 不可恢复的 [WsState.Offline] 是 [ShareLanding.TellOffline](哪句配哪档见 [shareWaitingNotice])。 + * 两档**只有措辞不同**:第 3、5 条对它们一模一样(等够久才说、同一轮只说一次),取走与发送的时机 + * 一个字都没改。 * 5. 未就绪、已等够久、且**已经说过** ⇒ [ShareLanding.KeepWaiting]:同一轮等待只说一次。 * 这一步不是可有可无的:连接在 `Connecting` 与 `Reconnecting` 之间抖动会让调用方反复问到 * 同一个问题,没有这一条就是反复弹同一句话。 * + * ## 为什么 `Offline` 要单独一档 + * [WsState.Offline] 是**终态**:握手 401 且续不上、或缺少 token / 服务器地址,它**不会自动重连**。 + * 对它说「连上后自动发出」,用户等的是一个**永远不会到来**的连接——那是拿一句不会兑现的承诺糊住 + * 一个需要他动手的状况,比不说更糟。这一档仍要告知(内容确实一个字节都没发出去,用户得知道, + * 否则会以为东西丢了、重新分享一遍),但要说他**能做的事**:重新登录或检查服务器地址。 + * 这里没有发明任何新的状态语义:`Offline` 是 [WsState] 自己的一档,`ChatScreen` 的 `connectionLabel` + * 早就把它在界面上单独念成「离线」,也正是那一档的分野(会不会自己重连)。 + * * ## 「等够久」与「说过了」为什么不在这里判 * 判据本身**看不出时间与历史**——那样它才是一个能穷举、能在 JVM 上单测的函数(见 `ShareLandingTest`), * 而不是又一份会随重组漂移的 Compose 状态。两个事实由调用方各自记账(`ChatScreen` 里那两个 @@ -66,5 +85,25 @@ fun shareLanding( connection == WsState.Connected -> ShareLanding.Send !waitedLong -> ShareLanding.KeepWaiting alreadyTold -> ShareLanding.KeepWaiting + // 只有 Offline 这一档不会自己回来(见上面「为什么 Offline 要单独一档」)。 + connection == WsState.Offline -> ShareLanding.TellOffline else -> ShareLanding.TellWaiting } + +/** + * 告知这一档该说的那句话(**纯函数**;`null` = 这一刻什么都不该说)。 + * + * 把「哪个动作配哪句话」也做成可单测的,是因为两条告知**唯一**的差别就是措辞,而搞混它们的代价 + * 并不对称:对 `Offline` 说「连上后自动发出」是承诺一件不会发生的事(那句文案永远不会兑现), + * 对 `Connecting` / `Reconnecting` 说「重新登录」则是把一次几百毫秒的握手说成了故障。 + * 两句话各自写在 `ChatScreen` 的 [SHARE_NOT_CONNECTED_NOTICE] / [SHARE_OFFLINE_NOTICE] 旁边, + * 这里只做映射——于是「哪档说哪句」不需要组合一个界面就能测(见 `ShareLandingTest`)。 + * + * [ShareLanding.Send] / [ShareLanding.Nothing] / [ShareLanding.KeepWaiting] 都回 `null`: + * 它们不是告知档(要么发送、要么什么都不说)。 + */ +fun shareWaitingNotice(landing: ShareLanding): String? = when (landing) { + ShareLanding.TellWaiting -> SHARE_NOT_CONNECTED_NOTICE + ShareLanding.TellOffline -> SHARE_OFFLINE_NOTICE + ShareLanding.Nothing, ShareLanding.KeepWaiting, ShareLanding.Send -> null +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/SendFailureMessageTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/SendFailureMessageTest.kt new file mode 100644 index 0000000..6e5ec8d --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/SendFailureMessageTest.kt @@ -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) + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareLandingTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareLandingTest.kt index ad6f4f0..f13341c 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareLandingTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareLandingTest.kt @@ -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) + } } diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareNoticeTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareNoticeTest.kt index 1c50d69..47f0a74 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareNoticeTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareNoticeTest.kt @@ -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)), + ) } }