merge: W39 —— 分享告知不再两句叠加;未连接与发送失败分开说;Offline 不再承诺「连上后自动发出」
This commit is contained in:
commit
b55d220e28
6 files changed
+309
-22
No files matched your search
@@ -337,7 +337,10 @@ fun ChatScreen(
|
|||||||
// 连重试资格一起丢。**超时绝不等于发送失败**:没连上时一个字节都不发,内容原地等着,
|
// 连重试资格一起丢。**超时绝不等于发送失败**:没连上时一个字节都不发,内容原地等着,
|
||||||
// 连接恢复后由下面这个 effect 自动补发。
|
// 连接恢复后由下面这个 effect 自动补发。
|
||||||
// - **等太久要说一句,但只说一次**:「还没连上、内容会等连上后自动发出」走 [ChatViewModel.showShareNotice]
|
// - **等太久要说一句,但只说一次**:「还没连上、内容会等连上后自动发出」走 [ChatViewModel.showShareNotice]
|
||||||
// 这条柔和通道(不是带 X 的 error 横幅),文案见 [SHARE_NOT_CONNECTED_NOTICE]。
|
// 这条柔和通道(不是带 X 的 error 横幅),文案见 [SHARE_NOT_CONNECTED_NOTICE];而连接处在
|
||||||
|
// **不可恢复**的 `Offline` 那一档时说 [SHARE_OFFLINE_NOTICE](「重新登录 / 检查服务器地址」)——
|
||||||
|
// 那一档不会自动重连,「连上后自动发出」等的是一个永远不会到来的连接。哪档配哪句是纯函数
|
||||||
|
// [shareWaitingNotice],两档的落地时机一模一样。
|
||||||
// - **只有当前会话的 ChatScreen 在组合**:已访问过的会话的 VM 都还活着(Activity 级
|
// - **只有当前会话的 ChatScreen 在组合**:已访问过的会话的 VM 都还活着(Activity 级
|
||||||
// ViewModelStore),由 UI 层认领才不会让旧会话把内容抢走。
|
// ViewModelStore),由 UI 层认领才不会让旧会话把内容抢走。
|
||||||
// - [ShareIntake.take] 是原子出队:同一条内容不会被发两次。重组 / 切会话 / 连接变化都会重跑这个
|
// - [ShareIntake.take] 是原子出队:同一条内容不会被发两次。重组 / 切会话 / 连接变化都会重跑这个
|
||||||
@@ -362,23 +365,24 @@ fun ChatScreen(
|
|||||||
// 这个 effect 的键里有连接状态 ⇒ 连接后来就绪时会重跑,内容因此**一次都不会漏发**(也不需要
|
// 这个 effect 的键里有连接状态 ⇒ 连接后来就绪时会重跑,内容因此**一次都不会漏发**(也不需要
|
||||||
// 任何轮询:状态一变就重来一遍判据)。
|
// 任何轮询:状态一变就重来一遍判据)。
|
||||||
LaunchedEffect(sessionId, pendingShares, shareConnection, shareWaitedLong) {
|
LaunchedEffect(sessionId, pendingShares, shareConnection, shareWaitedLong) {
|
||||||
when (
|
val landing = shareLanding(
|
||||||
shareLanding(
|
hasPending = pendingShares.isNotEmpty(),
|
||||||
hasPending = pendingShares.isNotEmpty(),
|
connection = shareConnection,
|
||||||
connection = shareConnection,
|
waitedLong = shareWaitedLong,
|
||||||
waitedLong = shareWaitedLong,
|
alreadyTold = shareToldWaiting,
|
||||||
alreadyTold = shareToldWaiting,
|
)
|
||||||
)
|
when (landing) {
|
||||||
) {
|
|
||||||
// 取走之后这一轮等待就结束了:下一批内容(或下一次断线)重新计时、重新有资格说那句话。
|
// 取走之后这一轮等待就结束了:下一批内容(或下一次断线)重新计时、重新有资格说那句话。
|
||||||
ShareLanding.Send -> {
|
ShareLanding.Send -> {
|
||||||
shareToldWaiting = false
|
shareToldWaiting = false
|
||||||
ShareIntake.take()?.let { share -> viewModel.sendShared(share, shareNoticeFor(share)) }
|
ShareIntake.take()?.let { share -> viewModel.sendShared(share, shareNoticeFor(share)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
ShareLanding.TellWaiting -> {
|
// 两个告知档只差措辞(时机一致):说哪句由纯函数 [shareWaitingNotice] 定,
|
||||||
|
// `Offline` 因此说的是「重新登录 / 检查服务器地址」而不是不会兑现的「连上后自动发出」。
|
||||||
|
ShareLanding.TellWaiting, ShareLanding.TellOffline -> {
|
||||||
shareToldWaiting = true
|
shareToldWaiting = true
|
||||||
viewModel.showShareNotice(SHARE_NOT_CONNECTED_NOTICE)
|
shareWaitingNotice(landing)?.let { viewModel.showShareNotice(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 未就绪:**绝不 take()**(内容原封不动留在 holder 里)、也不说任何话。
|
// 未就绪:**绝不 take()**(内容原封不动留在 holder 里)、也不说任何话。
|
||||||
@@ -934,6 +938,22 @@ const val SHARE_NOT_CONNECTED_NOTICE = "还没连上,分享的内容会留着
|
|||||||
const val SHARE_UNREADABLE_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` 也没有条目)时一个字都不说——用户确实没分享任何东西,没什么可说的。判据用
|
* `ClipData` 也没有条目)时一个字都不说——用户确实没分享任何东西,没什么可说的。判据用
|
||||||
* [SharedContent.worthDelivering],与 `ShareIntake.accept` 决定「要不要往下接」**同一条**:
|
* [SharedContent.worthDelivering],与 `ShareIntake.accept` 决定「要不要往下接」**同一条**:
|
||||||
* 换成两个判据就会出现「接了却不说」或者「说了却没接」。
|
* 换成两个判据就会出现「接了却不说」或者「说了却没接」。
|
||||||
|
*
|
||||||
|
* ## 「读不到」压过「内容是空的」,两句**不许叠**
|
||||||
|
* 丢件计数([SharedContent.failedImages] / [SharedContent.failedDocuments])为真时,不再追加
|
||||||
|
* 「分享的内容是空的,没有可发送的内容」。2026-10-05 实机读到的原文是「有 2 个文件读不到,未发送;
|
||||||
|
* 分享的内容是空的,没有可发送的内容」——**两句说的是不同的免责**,叠在一起只会让人以为出了两个
|
||||||
|
* 问题;而「认下了地址却读不到」本身就蕴含了「没有可发送的内容」,第二句一个字都没多说。
|
||||||
|
* 其余组合一个字没改:只有丢件计数时照旧只说丢件计数;[SharedContent.contentUnreadable] 与它们的
|
||||||
|
* 关系也照旧(「读不进来」那一档照样成句,它与丢件计数说的是两件事,可以并列)。
|
||||||
|
*
|
||||||
|
* 顺带记下这一档今天的**真实出口**:`worthDelivering` 要求 `isEmpty` 的分享必须有丢件计数或
|
||||||
|
* `contentUnreadable` 才值得往下走,而后者已经被「读不进来」那一档接走 ⇒ 加了这个前提之后
|
||||||
|
* 「内容是空的」这句话一次都不会出现(那正是这条告知想达到的效果:没东西可发时,说清**为什么**
|
||||||
|
* 没东西可发就够了)。这一档仍然留在代码里,是因为它记着一条语义,而不是因为它还有出口。
|
||||||
*/
|
*/
|
||||||
fun shareNoticeFor(share: SharedContent): String? {
|
fun shareNoticeFor(share: SharedContent): String? {
|
||||||
if (!share.worthDelivering) return null
|
if (!share.worthDelivering) return null
|
||||||
@@ -960,11 +993,13 @@ fun shareNoticeFor(share: SharedContent): String? {
|
|||||||
}
|
}
|
||||||
if (share.failedImages > 0) add("有 ${share.failedImages} 张图片读不到,未发送")
|
if (share.failedImages > 0) add("有 ${share.failedImages} 张图片读不到,未发送")
|
||||||
if (share.failedDocuments > 0) add("有 ${share.failedDocuments} 个文件读不到,未发送")
|
if (share.failedDocuments > 0) add("有 ${share.failedDocuments} 个文件读不到,未发送")
|
||||||
|
// 刚刚那两句是否成立——它就是「内容为空」那句的**前提**(两者不许叠说,理由见上面的 KDoc)。
|
||||||
|
val hasFailedPieces = share.failedImages > 0 || share.failedDocuments > 0
|
||||||
when {
|
when {
|
||||||
// 「读不进来」压过「内容是空的」:两者不可能同时成立(一条都没认下来的那次分享里根本没有
|
// 「读不进来」压过「内容是空的」:两者不可能同时成立(一条都没认下来的那次分享里根本没有
|
||||||
// 丢件计数),而混着说会让人以为东西还在路上。
|
// 丢件计数),而混着说会让人以为东西还在路上。
|
||||||
share.contentUnreadable -> add(SHARE_UNREADABLE_NOTICE)
|
share.contentUnreadable -> add(SHARE_UNREADABLE_NOTICE)
|
||||||
share.isEmpty -> add("分享的内容是空的,没有可发送的内容")
|
share.isEmpty && !hasFailedPieces -> add("分享的内容是空的,没有可发送的内容")
|
||||||
}
|
}
|
||||||
// 超长正文本该落成 .txt 附件,但附件位已经被分享进来的文件占满 ⇒ 只能按正文发。
|
// 超长正文本该落成 .txt 附件,但附件位已经被分享进来的文件占满 ⇒ 只能按正文发。
|
||||||
// 这是**形态变了**(不是内容少了),所以单独成句且说清为什么(见 DocumentDelivery.planSharedText)。
|
// 这是**形态变了**(不是内容少了),所以单独成句且说清为什么(见 DocumentDelivery.planSharedText)。
|
||||||
|
|||||||
@@ -145,6 +145,47 @@ const val QUEUE_FULL_MESSAGE = "她正忙且队列已满,这条没发出去,
|
|||||||
*/
|
*/
|
||||||
const val QUEUE_FULL_UNKNOWN_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` 同码)。 */
|
/** 队列满的机器可读码(契约 v0.59/G5,与 REST `409` 同码)。 */
|
||||||
const val CHAT_BUSY_CODE = "chat_busy"
|
const val CHAT_BUSY_CODE = "chat_busy"
|
||||||
|
|
||||||
@@ -1290,8 +1331,14 @@ class ChatViewModel(
|
|||||||
|
|
||||||
when (ChatSendRouting.channelFor(text, hasImage = image != null, hasDocuments = payload.isNotEmpty())) {
|
when (ChatSendRouting.channelFor(text, hasImage = image != null, hasDocuments = payload.isNotEmpty())) {
|
||||||
SendChannel.Ws -> {
|
SendChannel.Ws -> {
|
||||||
val sent = socket?.sendChat(sessionId, text, DEVICE_ID, localId, parallel) ?: false
|
// socket 只读一次:下面两处必须是**同一次**观察。`sendChat` 判的是 socket 自己的状态,
|
||||||
if (!sent) rollback(localId, "发送失败,请重试")
|
// 而界面那一格(`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) {
|
SendChannel.Rest -> if (image == null) {
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ enum class ShareLanding {
|
|||||||
/** 等到该说一句的时候了:说一次「还没连上、连上后自动发出」(柔和通道,不是错误横幅)。 */
|
/** 等到该说一句的时候了:说一次「还没连上、连上后自动发出」(柔和通道,不是错误横幅)。 */
|
||||||
TellWaiting,
|
TellWaiting,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 等到该说一句的时候了,而这一刻的未就绪是**不可恢复**的那一档([WsState.Offline]):
|
||||||
|
* 说一次 [SHARE_OFFLINE_NOTICE]——说清「需要重新登录或检查服务器地址」,**不**说
|
||||||
|
* 「连上后自动发出」(那句话在 `Offline` 上永远不会到来)。
|
||||||
|
*/
|
||||||
|
TellOffline,
|
||||||
|
|
||||||
/** 连接就绪:取走一条并交给既有发送路径。 */
|
/** 连接就绪:取走一条并交给既有发送路径。 */
|
||||||
Send,
|
Send,
|
||||||
}
|
}
|
||||||
@@ -34,16 +41,28 @@ enum class ShareLanding {
|
|||||||
*
|
*
|
||||||
* ## 判据与优先级
|
* ## 判据与优先级
|
||||||
* 1. **没有待发内容** ⇒ [ShareLanding.Nothing]:任何连接状态下都不做事、也不说话。
|
* 1. **没有待发内容** ⇒ [ShareLanding.Nothing]:任何连接状态下都不做事、也不说话。
|
||||||
* 2. **[WsState.Connected]** ⇒ [ShareLanding.Send]:取走一条并发送。它**压过**后两条——连接一到就该发,
|
* 2. **[WsState.Connected]** ⇒ [ShareLanding.Send]:取走一条并发送。它**压过**后面每一条——连接一到就该发,
|
||||||
* 哪怕此前已经等够久、那句「等连上会自动发」也说过了。返回值是**单值动作**,所以「发送」与
|
* 哪怕此前已经等够久、那句「等连上会自动发」也说过了。返回值是**单值动作**,所以「发送」与
|
||||||
* 「告知」在类型上就不可能同时发生(不会既发了又报怨还没连上)。
|
* 「告知」在类型上就不可能同时发生(不会既发了又报怨还没连上)。
|
||||||
* 3. 未就绪且**还没等够久**(`waitedLong = false`)⇒ [ShareLanding.KeepWaiting]:继续等,什么都不说
|
* 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]:同一轮等待只说一次。
|
* 5. 未就绪、已等够久、且**已经说过** ⇒ [ShareLanding.KeepWaiting]:同一轮等待只说一次。
|
||||||
* 这一步不是可有可无的:连接在 `Connecting` 与 `Reconnecting` 之间抖动会让调用方反复问到
|
* 这一步不是可有可无的:连接在 `Connecting` 与 `Reconnecting` 之间抖动会让调用方反复问到
|
||||||
* 同一个问题,没有这一条就是反复弹同一句话。
|
* 同一个问题,没有这一条就是反复弹同一句话。
|
||||||
*
|
*
|
||||||
|
* ## 为什么 `Offline` 要单独一档
|
||||||
|
* [WsState.Offline] 是**终态**:握手 401 且续不上、或缺少 token / 服务器地址,它**不会自动重连**。
|
||||||
|
* 对它说「连上后自动发出」,用户等的是一个**永远不会到来**的连接——那是拿一句不会兑现的承诺糊住
|
||||||
|
* 一个需要他动手的状况,比不说更糟。这一档仍要告知(内容确实一个字节都没发出去,用户得知道,
|
||||||
|
* 否则会以为东西丢了、重新分享一遍),但要说他**能做的事**:重新登录或检查服务器地址。
|
||||||
|
* 这里没有发明任何新的状态语义:`Offline` 是 [WsState] 自己的一档,`ChatScreen` 的 `connectionLabel`
|
||||||
|
* 早就把它在界面上单独念成「离线」,也正是那一档的分野(会不会自己重连)。
|
||||||
|
*
|
||||||
* ## 「等够久」与「说过了」为什么不在这里判
|
* ## 「等够久」与「说过了」为什么不在这里判
|
||||||
* 判据本身**看不出时间与历史**——那样它才是一个能穷举、能在 JVM 上单测的函数(见 `ShareLandingTest`),
|
* 判据本身**看不出时间与历史**——那样它才是一个能穷举、能在 JVM 上单测的函数(见 `ShareLandingTest`),
|
||||||
* 而不是又一份会随重组漂移的 Compose 状态。两个事实由调用方各自记账(`ChatScreen` 里那两个
|
* 而不是又一份会随重组漂移的 Compose 状态。两个事实由调用方各自记账(`ChatScreen` 里那两个
|
||||||
@@ -66,5 +85,25 @@ fun shareLanding(
|
|||||||
connection == WsState.Connected -> ShareLanding.Send
|
connection == WsState.Connected -> ShareLanding.Send
|
||||||
!waitedLong -> ShareLanding.KeepWaiting
|
!waitedLong -> ShareLanding.KeepWaiting
|
||||||
alreadyTold -> ShareLanding.KeepWaiting
|
alreadyTold -> ShareLanding.KeepWaiting
|
||||||
|
// 只有 Offline 这一档不会自己回来(见上面「为什么 Offline 要单独一档」)。
|
||||||
|
connection == WsState.Offline -> ShareLanding.TellOffline
|
||||||
else -> ShareLanding.TellWaiting
|
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
|
||||||
|
}
|
||||||
@@ -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.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import top.yeij.cyrene.data.ws.WsState
|
import top.yeij.cyrene.data.ws.WsState
|
||||||
@@ -14,13 +16,16 @@ import top.yeij.cyrene.data.ws.WsState
|
|||||||
* 的返回值被丢掉了 ⇒ **超时与成功走同一条下游**:照样取走内容去发。而 `ShareIntake.take()` 是
|
* 的返回值被丢掉了 ⇒ **超时与成功走同一条下游**:照样取走内容去发。而 `ShareIntake.take()` 是
|
||||||
* 原子出队、没有归还路径,那一次「其实一次都没发出去」的失败把内容从待发队列里抹掉了。
|
* 原子出队、没有归还路径,那一次「其实一次都没发出去」的失败把内容从待发队列里抹掉了。
|
||||||
*
|
*
|
||||||
* 本测试钉住三件事:
|
* 本测试钉住四件事:
|
||||||
*
|
*
|
||||||
* 1. **取走内容的前提是连接就绪**:未就绪时恒不 [ShareLanding.Send](不取、不发)。
|
* 1. **取走内容的前提是连接就绪**:未就绪时恒不 [ShareLanding.Send](不取、不发)。
|
||||||
* 2. **等够久只说一次**「还没连上、连上后会自动发出」([ShareLanding.TellWaiting]),
|
* 2. **等够久只说一次**「还没连上、连上后会自动发出」([ShareLanding.TellWaiting]),
|
||||||
* 同一轮等待里连接反复抖动不再说第二遍。
|
* 同一轮等待里连接反复抖动不再说第二遍。
|
||||||
* 3. 反面边界:**没有待发内容**时任何连接状态下都不做事、不告知;**已经连上**时不告知
|
* 3. 反面边界:**没有待发内容**时任何连接状态下都不做事、不告知;**已经连上**时不告知
|
||||||
* (发送压过告知,返回值是单值动作 ⇒ 两者在类型上就不可能同时发生)。
|
* (发送压过告知,返回值是单值动作 ⇒ 两者在类型上就不可能同时发生)。
|
||||||
|
* 4. **`Offline`(不可恢复的那一档)换一句可执行的措辞**:它不会自动重连,所以说的是
|
||||||
|
* 「重新登录 / 检查服务器地址」而不是那个永远不会到来的「连上后自动发出」;措辞虽是另一句,
|
||||||
|
* **时机完全一样**(等够久、同一轮只说一次)。哪档配哪句由 [shareWaitingNotice] 定。
|
||||||
*/
|
*/
|
||||||
class ShareLandingTest {
|
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
|
@Test
|
||||||
fun `the waiting notice says it is not connected and that it will go out by itself`() {
|
fun `the waiting notice says it is not connected and that it will go out by itself`() {
|
||||||
assertTrue(SHARE_NOT_CONNECTED_NOTICE.contains("还没连上"))
|
assertTrue(SHARE_NOT_CONNECTED_NOTICE.contains("还没连上"))
|
||||||
@@ -117,4 +170,21 @@ class ShareLandingTest {
|
|||||||
assertFalse(SHARE_NOT_CONNECTED_NOTICE.contains("失败"))
|
assertFalse(SHARE_NOT_CONNECTED_NOTICE.contains("失败"))
|
||||||
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. 「内容没能读进来」要成句,且**不说成发送失败**——失败的是读取,不是发送;
|
* 1. 「内容没能读进来」要成句,且**不说成发送失败**——失败的是读取,不是发送;
|
||||||
* 2. 正常落地(一条文本 / 一张图 / 一个文件)一个字都不说;
|
* 2. 正常落地(一条文本 / 一张图 / 一个文件)一个字都不说;
|
||||||
* 3. 边界:整条分享里什么都没有时**仍然静默**(那种情况没什么可说的,说了才是噪声)。
|
* 3. 边界:整条分享里什么都没有时**仍然静默**(那种情况没什么可说的,说了才是噪声);
|
||||||
|
* 4. **「读不到」与「内容是空的」不许叠说**:2026-10-05 实机读到的「有 2 个文件读不到,未发送;
|
||||||
|
* 分享的内容是空的,没有可发送的内容」会让用户以为出了两个问题,而前者本身就蕴含后者。
|
||||||
*/
|
*/
|
||||||
class ShareNoticeTest {
|
class ShareNoticeTest {
|
||||||
|
|
||||||
@@ -66,10 +68,45 @@ class ShareNoticeTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `dropped content next to an empty body says both things`() {
|
fun `dropped pieces are not doubled up with the empty sentence`() {
|
||||||
val said = shareNoticeFor(SharedContent(failedImages = 2))!!
|
// 2026-10-05 实机原文:「有 2 个文件读不到,未发送;分享的内容是空的,没有可发送的内容」。
|
||||||
|
// 两句说的是**不同的免责**,叠起来读着像出了两个问题;而「认下了地址却读不到」已经蕴含
|
||||||
|
// 「没有可发送的内容」⇒ 只说前面那句就够了。
|
||||||
|
assertEquals("有 2 张图片读不到,未发送", shareNoticeFor(SharedContent(failedImages = 2)))
|
||||||
|
assertEquals("有 2 个文件读不到,未发送", shareNoticeFor(SharedContent(failedDocuments = 2)))
|
||||||
|
}
|
||||||
|
|
||||||
assertTrue(said.contains("有 2 张图片读不到,未发送"))
|
@Test
|
||||||
assertTrue(said.contains("分享的内容是空的,没有可发送的内容"))
|
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