From aeb37458e8665f0ffdc292113290a097e1acf263 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 5 Oct 2026 13:20:03 +0800 Subject: [PATCH] =?UTF-8?q?fix(chat):=20=E5=88=86=E4=BA=AB=E8=90=BD?= =?UTF-8?q?=E5=9C=B0=E5=BF=85=E9=A1=BB=E7=AD=89=E5=88=B0=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E5=B0=B1=E7=BB=AA=EF=BC=8C=E8=B6=85=E6=97=B6=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E7=AD=89=E4=BA=8E=E5=8F=91=E9=80=81=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么超时不能等于发送失败 WS 还没连上时投递的分享(ACTION_SEND)此前走成了「发送失败,请重试」,内容还从待发队列里 永久消失。来处是「等连接」那句 withTimeoutOrNull 的返回值被丢掉了:超时与成功走同一条下游, 照样 take() 再 sendShared → deliver → ChatSocket.sendChat 回 false ⇒ ChatViewModel 落到 rollback(localId, "发送失败,请重试")。实机取证(2026-10-05 11:56):GET /ws/chat status=200 前后的窗口里投递分享,界面上是失败横幅 + 正文退回输入栏,而后端零请求(POST /api/v1/chat 一条都没有,也没有任何 error 帧)——那条横幅把「一次都没尝试发出去」说成了「发送失败」。 ShareIntake.take() 是原子出队且没有归还路径:那一次「其实一次都没发出去」的失败之后,内容 既不在 holder 里、也不在服务端,只剩回滚进输入栏的那一份(多图分享里除首条之外,连输入栏 都回不去)。重试资格因此一起丢了。所以判据只有一条:没连上就一个字节都别动——不 take()、 不发送、不产生任何 error 横幅。 为什么取走时机必须晚于连接就绪 短文本走 WS chat 帧,而分享冷启动必然撞上握手还没完成;帧发不发得出去完全取决于握手结果。 所以等待里不再有「到点就照发」这一档,只有「连上才发」:连接恢复之后由连接状态自己把这条 落地重新触发一遍。 改法 - 判据抽成纯函数 shareLanding(ui/chat/ShareLanding.kt,可单测):没有待发 ⇒ Nothing;已连接 ⇒ Send(压过后两条,且返回的是单值动作,所以发送与告知不可能同时发生);未就绪且还没等够 ⇒ KeepWaiting;未就绪、已等够、还没说过 ⇒ TellWaiting;已经说过 ⇒ KeepWaiting(不刷屏)。 - ChatScreen 的那个 effect 把连接状态并进键,连接后来就绪会自动重跑、自动补发;「不会重复 发送」仍然由 ShareIntake.take() 的原子性兜住——哪怕某一次拿着已经过期的快照进来,take() 给出的也只是队列里的下一条或 null。 - 等太久只做一件事:经既有的柔和通道 ChatViewModel.showShareNotice 说一句「还没连上,分享的 内容会留着,连上后自动发出」(不是带 X 的 error 横幅),同一次等待只说一次。 - SHARE_CONNECT_TIMEOUT_MILLIS 改名 SHARE_WAIT_NOTICE_MILLIS:它不再是发送的时限,只是 「等多久才值得说那一句」;「等够久」的计时只跟「队列里有没有东西」与「连上了没有」走, 连接在 Connecting / Reconnecting 之间抖动不重置它。 - 普通发送路径(输入框、图片、附件)一行未动。 测试 - ShareLandingTest(新,7 条):未就绪在任何连接状态下都恒不 Send(不取、不发);未就绪且没 等够恒 KeepWaiting(也不告知);等够了只说一次、说过了就不再刷屏;已连接恒 Send(压过告知); 无待发恒 Nothing(任何连接状态下都不做事);文案必须说清「还没连上」与「自动发出」,且不含 「失败」「重试」。 assembleDebug 与 testDebugUnitTest 全绿(1091 tests / 0 failures / 0 errors)。 --- .../top/yeij/cyrene/ui/chat/ChatScreen.kt | 86 ++++++++++--- .../top/yeij/cyrene/ui/chat/ChatViewModel.kt | 21 ++- .../top/yeij/cyrene/ui/chat/ShareLanding.kt | 70 ++++++++++ .../yeij/cyrene/ui/chat/ShareLandingTest.kt | 120 ++++++++++++++++++ 4 files changed, 277 insertions(+), 20 deletions(-) create mode 100644 app/src/main/kotlin/top/yeij/cyrene/ui/chat/ShareLanding.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareLandingTest.kt 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 c9d50bf..12cd618 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 @@ -54,10 +54,8 @@ import coil3.compose.AsyncImage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull import top.yeij.cyrene.app.AppContainer import top.yeij.cyrene.app.ShareIntake import top.yeij.cyrene.data.repository.DocumentCacheStore @@ -95,8 +93,14 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme /** 判定「贴着底部」的容差(px)。 */ private const val BOTTOM_TOLERANCE = 64 -/** 分享落地前等 WS 连接就绪的上限(毫秒):超时也照样发,让失败落在看得见的地方。 */ -private const val SHARE_CONNECT_TIMEOUT_MILLIS = 3_000L +/** + * 分享落地前「等到该说一句」的上限(毫秒)。 + * + * **不是发送的时限**:到点之后内容照旧留在待发队列里等连接,只是界面上多一句如实告知 + * ([SHARE_NOT_CONNECTED_NOTICE])。此前这个数被当成「超时就照发」的口径,那正是 + * 「超时 = 发送失败」那条假象的来处——见 [shareLanding] 的 KDoc。 + */ +private const val SHARE_WAIT_NOTICE_MILLIS = 3_000L /** 「已定位 / 这条不在窗口里」那句告知挂多久(毫秒)。 */ private const val SEARCH_NOTICE_MILLIS = 8_000L @@ -326,21 +330,62 @@ fun ChatScreen( ) { uris -> viewModel.onDocumentsPicked(uris.map { it.toString() }) } // 「分享到 App」的落地时机。待落地的内容活在 Compose 之外(`ShareIntake`),这里只做三件事: - // 挑时机、取走一条、交给既有发送路径。 - // - **先等连接、但只等一小会儿**:短文本走 WS,而冷启动时握手还没完成(分享冷启动必然撞上), - // 抢在那之前发就只换来一次「发送失败,请重试」。等待有上限——真的没网时宁可退化成 - // 「内容回到输入框、用户自己重试」,也不要让它停在界面上看不见的地方。 + // 挑时机、取走一条、交给既有发送路径。判据本身是纯函数 [shareLanding](可单测)。 + // - **取走必须晚于「连接就绪」**:短文本走 WS,而冷启动时握手还没完成(分享冷启动必然撞上), + // 抢在那之前发只换来一次 `sendChat` 回 false 的「发送失败,请重试」;而 [ShareIntake.take] 是 + // 原子出队、没有归还路径 ⇒ 那一次「其实一次都没发出去」的失败会把内容从待发队列里抹掉, + // 连重试资格一起丢。**超时绝不等于发送失败**:没连上时一个字节都不发,内容原地等着, + // 连接恢复后由下面这个 effect 自动补发。 + // - **等太久要说一句,但只说一次**:「还没连上、内容会等连上后自动发出」走 [ChatViewModel.showShareNotice] + // 这条柔和通道(不是带 X 的 error 横幅),文案见 [SHARE_NOT_CONNECTED_NOTICE]。 // - **只有当前会话的 ChatScreen 在组合**:已访问过的会话的 VM 都还活着(Activity 级 // ViewModelStore),由 UI 层认领才不会让旧会话把内容抢走。 - // - [ShareIntake.take] 是原子出队:同一条内容不会被发两次(重组 / 切会话 / 换连接都会重跑 - // 这个 effect)。 + // - [ShareIntake.take] 是原子出队:同一条内容不会被发两次。重组 / 切会话 / 连接变化都会重跑这个 + // effect,哪怕其中一次拿着**已经过期的**快照进来,`take()` 给出的也只是队列里的下一条或 null。 val pendingShares by ShareIntake.pending.collectAsState() - LaunchedEffect(sessionId, pendingShares) { - if (pendingShares.isEmpty()) return@LaunchedEffect - withTimeoutOrNull(SHARE_CONNECT_TIMEOUT_MILLIS) { - viewModel.state.first { it.connection == WsState.Connected } + val shareConnection = state.connection + // 「这一轮等待已经等够久了」。计时只跟**「队列里有东西」与「连上了没有」这两个事实**走: + // 连接在 Connecting / Reconnecting 之间抖动不重置它——否则一直连不上时,该说的那句话反而 + // 永远等不到(每次抖动都从头数秒)。 + var shareWaitedLong by remember(sessionId) { mutableStateOf(false) } + // 「这一轮等待已经说过了」。它与上面那个标志的生命周期刻意不同:连接抖动(effect 因键变化重跑) + // 不把它归零,否则同一句话会跟着抖动反复弹——刷屏。内容被取走、或队列清空,才算了结这一轮。 + var shareToldWaiting by remember(sessionId) { mutableStateOf(false) } + LaunchedEffect(sessionId, pendingShares.isEmpty(), shareConnection == WsState.Connected) { + if (pendingShares.isEmpty() || shareConnection == WsState.Connected) { + shareWaitedLong = false + return@LaunchedEffect + } + delay(SHARE_WAIT_NOTICE_MILLIS) + shareWaitedLong = true + } + // 这个 effect 的键里有连接状态 ⇒ 连接后来就绪时会重跑,内容因此**一次都不会漏发**(也不需要 + // 任何轮询:状态一变就重来一遍判据)。 + LaunchedEffect(sessionId, pendingShares, shareConnection, shareWaitedLong) { + when ( + shareLanding( + hasPending = pendingShares.isNotEmpty(), + connection = shareConnection, + waitedLong = shareWaitedLong, + alreadyTold = shareToldWaiting, + ) + ) { + // 取走之后这一轮等待就结束了:下一批内容(或下一次断线)重新计时、重新有资格说那句话。 + ShareLanding.Send -> { + shareToldWaiting = false + ShareIntake.take()?.let { share -> viewModel.sendShared(share, shareNoticeFor(share)) } + } + + ShareLanding.TellWaiting -> { + shareToldWaiting = true + viewModel.showShareNotice(SHARE_NOT_CONNECTED_NOTICE) + } + + // 未就绪:**绝不 take()**(内容原封不动留在 holder 里)、也不说任何话。 + ShareLanding.KeepWaiting -> Unit + + ShareLanding.Nothing -> shareToldWaiting = false } - ShareIntake.take()?.let { share -> viewModel.sendShared(share, shareNoticeFor(share)) } } // 是否跟随底部(微信/QQ 式):只有用户主动滚到别处时才停止跟随。 @@ -868,6 +913,17 @@ private fun busyLabel(state: ChatUiState): String { ).joinToString(" ").ifBlank { "" } } +/** + * 「分享进来的内容还在等连接」的如实告知(判据见 [shareLanding],落点是它的 [ShareLanding.TellWaiting])。 + * + * 说清两件事:**还没连上**(所以一个字节都还没发出去——别让人以为东西已经在路上), + * 以及**内容留着、连上后自动发出**(用户不需要做任何事,也不用重来一遍)。 + * 措辞刻意不写「失败」,更不写「请重试」:失败的是连接,而内容一条没少、就在待发队列里等着; + * 说成「发送失败」既不是事实(没发生过任何发送),也会把人推向「重新分享/重新输入」这种多余动作。 + * 它走 `ChatUiState.shareNotice` 这条柔和通道,**不是**带 X 的 error 横幅(error 的语义是「出错了」)。 + */ +const val SHARE_NOT_CONNECTED_NOTICE = "还没连上,分享的内容会留着,连上后自动发出" + /** * 「这次分享的内容没能读进来」的如实告知(判据见 [shareNoticeFor])。 * 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 79b9300..f67b178 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 @@ -552,10 +552,13 @@ data class ChatUiState( */ val errorIsQueueFullNotice: Boolean = false, /** - * 「分享到 App」落地的如实告知(例如有几张图没能发送);null = 没有要说的。 + * 「分享到 App」相关的如实告知;null = 没有要说的。 * - * 与 [error] 分开:分享通常**成功**,只是被本端的接收上限截掉了一部分,那不是错误。 - * 它到期自动消失([SHARE_NOTICE_MILLIS]),因为它描述的是刚刚发生的一次动作。 + * 两个来源共用这一块牌子:分享**落地之后**的那条(例如有几张图没能发送,经 [sendShared]), + * 以及**落地之前**的那条——内容还在待发队列里等 WS 连接([top.yeij.cyrene.ui.chat.ShareLanding.TellWaiting], + * 由渲染层经 [showShareNotice] 记入)。两者都**不是错误**,所以都与 [error] 分开: + * 前者是分享通常成功、只是被本端的接收上限截掉了一部分;后者是发送**还没发生**(连接一到就自动发)。 + * 它到期自动消失([SHARE_NOTICE_MILLIS]),因为它描述的是刚刚发生(或刚刚等着)的一次动作。 */ val shareNotice: String? = null, /** @@ -1135,8 +1138,16 @@ class ChatViewModel( } } - /** 记一条分享提示,到期自动收走(同一条未过期就不重复刷)。 */ - private fun showShareNotice(text: String) { + /** + * 记一条分享提示,到期自动收走(同一条未过期就不重复刷)。 + * + * 公开是给渲染层用的:有一条分享相关的告知**不产生于本 VM 的发送路径**—— + * 「内容还在待发队列里等连接」([top.yeij.cyrene.ui.chat.ShareLanding.TellWaiting])。 + * 判它的是 UI 层(`ChatScreen` 的那个 effect,认领待发内容的也只有它),因为「分享进来的内容」 + * 按约定只由**当前会话在组合中**的界面认领;但牌子该怎么挂、挂多久属于本 VM 的状态, + * 所以判据留在 UI、通道留在 VM,而不是在 UI 里自己造一块牌子。 + */ + fun showShareNotice(text: String) { _state.update { it.copy(shareNotice = text) } viewModelScope.launch { kotlinx.coroutines.delay(SHARE_NOTICE_MILLIS) 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 new file mode 100644 index 0000000..011534b --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ShareLanding.kt @@ -0,0 +1,70 @@ +package top.yeij.cyrene.ui.chat + +import top.yeij.cyrene.data.ws.WsState + +/** 「分享进来的内容」这一刻该做什么([shareLanding] 的取值)。 */ +enum class ShareLanding { + /** 没有待发内容:什么都不做(任何连接状态下都一样)。 */ + Nothing, + + /** 还没连上,继续等:**既不取走也不发送**,内容原封不动留在待发队列里。 */ + KeepWaiting, + + /** 等到该说一句的时候了:说一次「还没连上、连上后自动发出」(柔和通道,不是错误横幅)。 */ + TellWaiting, + + /** 连接就绪:取走一条并交给既有发送路径。 */ + Send, +} + +/** + * 分享落地时机的判据(纯函数,UI 与测试共用)。 + * + * ## 为什么取走必须晚于「连接就绪」 + * 短文本走 WS `chat` 帧,而**分享冷启动必然赶上握手还没完成**:`ChatSocket` 未就绪时 + * `sendChat` 回 `false`,`ChatViewModel.deliver` 随即 `rollback(localId, "发送失败,请重试")`。 + * 2026-10-05 11:56 实机取证:客户端在 `GET /ws/chat status=200`(握手成功)前后的窗口里投递分享, + * 得到「发送失败,请重试」+ 正文退回输入栏,而**后端零请求**(`POST /api/v1/chat` 一条都没有, + * 也没有任何 `error` 帧)——那条横幅把「一次都没尝试发出去」说成了「发送失败」。 + * + * 更糟的是它会**吃掉重试资格**:[top.yeij.cyrene.domain.share.PendingShareHolder.take] 是原子出队、 + * **没有归还路径**。失败之后内容既不在待发队列里、也不在服务端,只剩回滚进输入栏的那一份 + * (多图分享里除了第一张,其余几条连输入栏都回不去)。所以在连接就绪之前**一个字节都别动**: + * 不 `take()`、不 `sendShared`、不产生任何 error 横幅——**超时绝不等于发送失败**。 + * + * ## 判据与优先级 + * 1. **没有待发内容** ⇒ [ShareLanding.Nothing]:任何连接状态下都不做事、也不说话。 + * 2. **[WsState.Connected]** ⇒ [ShareLanding.Send]:取走一条并发送。它**压过**后两条——连接一到就该发, + * 哪怕此前已经等够久、那句「等连上会自动发」也说过了。返回值是**单值动作**,所以「发送」与 + * 「告知」在类型上就不可能同时发生(不会既发了又报怨还没连上)。 + * 3. 未就绪且**还没等够久**(`waitedLong = false`)⇒ [ShareLanding.KeepWaiting]:继续等,什么都不说 + * (刚分享进来就弹一句「还没连上」是噪声,握手正常也就几百毫秒)。 + * 4. 未就绪、已等够久、且**还没说过** ⇒ [ShareLanding.TellWaiting]:说一次。 + * 5. 未就绪、已等够久、且**已经说过** ⇒ [ShareLanding.KeepWaiting]:同一轮等待只说一次。 + * 这一步不是可有可无的:连接在 `Connecting` 与 `Reconnecting` 之间抖动会让调用方反复问到 + * 同一个问题,没有这一条就是反复弹同一句话。 + * + * ## 「等够久」与「说过了」为什么不在这里判 + * 判据本身**看不出时间与历史**——那样它才是一个能穷举、能在 JVM 上单测的函数(见 `ShareLandingTest`), + * 而不是又一份会随重组漂移的 Compose 状态。两个事实由调用方各自记账(`ChatScreen` 里那两个 + * `remember`),且**它们的生命周期也不一样**:「说过了」在连接抖动时不归零(不刷屏), + * 而「等够久」的计时只看「队列里有没有东西」与「连上了没有」这两个事实。 + * + * @param hasPending 待发队列里有没有内容。只看一眼的判据是 `ShareIntake.pending`(StateFlow), + * **绝不是 `take()`**——那是出队。 + * @param connection 这一刻的 WS 连接状态。 + * @param waitedLong 这一轮等待是否已经等够久(够久才值得说那句话)。 + * @param alreadyTold 这一轮等待是否已经告知过(同一轮只说一次)。 + */ +fun shareLanding( + hasPending: Boolean, + connection: WsState, + waitedLong: Boolean, + alreadyTold: Boolean, +): ShareLanding = when { + !hasPending -> ShareLanding.Nothing + connection == WsState.Connected -> ShareLanding.Send + !waitedLong -> ShareLanding.KeepWaiting + alreadyTold -> ShareLanding.KeepWaiting + else -> ShareLanding.TellWaiting +} 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 new file mode 100644 index 0000000..ad6f4f0 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareLandingTest.kt @@ -0,0 +1,120 @@ +package top.yeij.cyrene.ui.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import top.yeij.cyrene.data.ws.WsState + +/** + * 分享落地时机的判据([shareLanding])。 + * + * 背景:主人实机复现——WS 还没连上时投递分享,得到「发送失败,请重试」+ 正文退回输入栏, + * 而后端**零请求**(`POST /api/v1/chat` 一条都没有)。原来是「等连接」那个 `withTimeoutOrNull` + * 的返回值被丢掉了 ⇒ **超时与成功走同一条下游**:照样取走内容去发。而 `ShareIntake.take()` 是 + * 原子出队、没有归还路径,那一次「其实一次都没发出去」的失败把内容从待发队列里抹掉了。 + * + * 本测试钉住三件事: + * + * 1. **取走内容的前提是连接就绪**:未就绪时恒不 [ShareLanding.Send](不取、不发)。 + * 2. **等够久只说一次**「还没连上、连上后会自动发出」([ShareLanding.TellWaiting]), + * 同一轮等待里连接反复抖动不再说第二遍。 + * 3. 反面边界:**没有待发内容**时任何连接状态下都不做事、不告知;**已经连上**时不告知 + * (发送压过告知,返回值是单值动作 ⇒ 两者在类型上就不可能同时发生)。 + */ +class ShareLandingTest { + + @Test + fun `content is taken only once the connection is ready`() { + val ready = shareLanding( + hasPending = true, + connection = WsState.Connected, + waitedLong = false, + alreadyTold = false, + ) + assertEquals(ShareLanding.Send, ready) + + // 其余任何连接状态都不是「可以取走」:Idle / Connecting / Reconnecting / Offline 一律不动它。 + WsState.entries.filter { it != WsState.Connected }.forEach { connection -> + listOf(false, true).forEach { waitedLong -> + listOf(false, true).forEach { alreadyTold -> + val landed = shareLanding(true, connection, waitedLong, alreadyTold) + assertTrue( + "$connection / waitedLong=$waitedLong / alreadyTold=$alreadyTold 不该取走内容", + landed != ShareLanding.Send, + ) + } + } + } + } + + @Test + fun `before the connection is ready nothing is taken and nothing is said`() { + assertEquals( + ShareLanding.KeepWaiting, + shareLanding(true, WsState.Connecting, waitedLong = false, alreadyTold = false), + ) + assertEquals( + ShareLanding.KeepWaiting, + shareLanding(true, WsState.Reconnecting, waitedLong = false, alreadyTold = true), + ) + } + + @Test + fun `waiting too long with no connection says it, once`() { + assertEquals( + ShareLanding.TellWaiting, + shareLanding(true, WsState.Connecting, waitedLong = true, alreadyTold = false), + ) + } + + @Test + fun `an already told wait stays quiet however the connection flaps`() { + // 这一条是「不刷屏」:连接在 Connecting 与 Reconnecting 之间抖动会让界面反复问到同一个问题。 + WsState.entries.filter { it != WsState.Connected }.forEach { connection -> + assertEquals( + "$connection 上不该把同一句话弹第二遍", + ShareLanding.KeepWaiting, + shareLanding(true, connection, waitedLong = true, alreadyTold = true), + ) + } + } + + @Test + fun `sending wins over telling and they never happen together`() { + // 连接一到就该发:哪怕此前已经等够久、那句「等连上会自动发」也说过了,此刻的动作也只有「发送」。 + listOf(false, true).forEach { waitedLong -> + listOf(false, true).forEach { alreadyTold -> + assertEquals( + "waitedLong=$waitedLong / alreadyTold=$alreadyTold 时该发就发", + ShareLanding.Send, + shareLanding(true, WsState.Connected, waitedLong, alreadyTold), + ) + } + } + } + + @Test + fun `nothing pending does nothing in any connection state`() { + WsState.entries.forEach { connection -> + listOf(false, true).forEach { waitedLong -> + listOf(false, true).forEach { alreadyTold -> + assertEquals( + "$connection 上没有待发内容就不该有动作", + ShareLanding.Nothing, + shareLanding(false, connection, waitedLong, alreadyTold), + ) + } + } + } + } + + @Test + 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("自动发出")) + // 「失败」「重试」这一族措辞会把用户推向「重新分享一遍」——内容明明一条没少、就在队列里等着。 + assertFalse(SHARE_NOT_CONNECTED_NOTICE.contains("失败")) + assertFalse(SHARE_NOT_CONNECTED_NOTICE.contains("重试")) + } +}