fix(chat): 分享落地必须等到连接就绪,超时不再等于发送失败
为什么超时不能等于发送失败 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)。
This commit is contained in:
1 parent
44b58db58a
commit
aeb37458e8
4 files changed
+277
-20
No files matched your search
@@ -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])。
|
||||
*
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("重试"))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user