diff --git a/app/src/main/kotlin/top/yeij/cyrene/app/ShareIntake.kt b/app/src/main/kotlin/top/yeij/cyrene/app/ShareIntake.kt index e311d06..9585623 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/app/ShareIntake.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/app/ShareIntake.kt @@ -139,8 +139,12 @@ object ShareIntake { /** * 取走一条待落地内容(原子出队,只有取到它的那个调用方会去发);队列为空回 `null`。 * - * 出队是**唯一的认领方式**:消费它的地方会被重建(重组 / 切会话 / Activity 重建), - * 所以「已经发过了」这件事不能记在消费方身上(见 [PendingShareHolder])。 + * 出队是**唯一的认领方式**,而它在这里是原子的:两个消费者同时来也只会各拿各的一条 + * (判据见 [PendingShareHolder.take])。而「**已经认领过了**」这件事此后记在认领它的那一格上 + * ——`ChatViewModel` 的 [top.yeij.cyrene.ui.chat.ChatUiState.shareConfirm]。那一格活在 + * `AppContainer` 那一层(`core.session.ChatSessionSlots` 的 `ViewModelStore`),比组合与 + * Activity 都活得久,所以转屏 / 深浅色切换重建之后**不会再取第二遍**,也不会把队列里下一条顶掉: + * 认领的闸门在 `ChatViewModel.claimSharedContent` 里,且在调用本函数**之前**。 */ fun take(): SharedContent? = holder.take() 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 d611609..5fe15b7 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 @@ -405,7 +405,7 @@ fun ChatScreen( ) { uris -> viewModel.onDocumentsPicked(uris.map { it.toString() }) } // 「分享到 App」的落地时机。待落地的内容活在 Compose 之外(`ShareIntake`),这里只做三件事: - // 挑时机、取走一条、交给**确认面板**(`ShareConfirmPanel`:内容一进来就发的那条老路已废, + // 挑时机、请 VM 认领一条、交给**确认面板**(`ShareConfirmPanel`:内容一进来就发的那条老路已废, // 2026-10-06 主人要求像其他 IM 一样先确认——只有点「发送」才真的发)。 // 判据本身是纯函数 [shareLanding](可单测)。 // - **取走必须晚于「连接就绪」**:短文本走 WS,而冷启动时握手还没完成(分享冷启动必然撞上), @@ -422,11 +422,12 @@ fun ChatScreen( // 见 `ChatSessionSlots`),由 UI 层认领才不会让旧会话把内容抢走。 // - [ShareIntake.take] 是原子出队:同一条内容不会被发两次。重组 / 切会话 / 连接变化都会重跑这个 // effect,哪怕其中一次拿着**已经过期的**快照进来,`take()` 给出的也只是队列里的下一条或 null。 - // - **取走只发生一次,而且只发生在面板没开的时候**:取到的那一份存进下面那格 Compose 状态, - // 面板(`ShareConfirmPanel`)靠它渲染。effect 的键里有连接状态 ⇒ 它会反复重跑,而重跑 - // **绝不会**再取一份、也绝不会重复发送:`take()` 只在 `shareConfirm == null` 时被调用一次, - // 取走之后队列已空(判据看到的是 `Nothing`),而面板开着时新来的分享**原地在队列里等**—— - // 键里的 `shareConfirm == null` 保证面板一关(发送 / 取消)就会再跑一次判据,把它接上。 + // - **取走只发生一次,而且只发生在「手上什么都没有」的那一帧**:取到的那一份落进**VM 的状态** + // ([ChatUiState.shareConfirm],不是这里的一格 `remember`),面板靠它渲染。effect 的键里有连接 + // 状态 ⇒ 它会反复重跑,而重跑**绝不会**再取一份、也绝不会重复发送:闸门在 `take()` **之前**, + // 而闸门的依据就是 VM 里那一格(`ChatViewModel.claimSharedContent` 的 KDoc 写着它为什么在 + // `take()` 之前)。面板开着时新来的分享**原地在队列里等**——键里的 `shareConfirm == null` + // 保证面板一关(发送 / 取消)就会再跑一次判据,把它接上。 val pendingShares by ShareIntake.pending.collectAsState() val shareConnection = state.connection // 「这一轮等待已经等够久了」。计时只跟**「队列里有东西」与「连上了没有」这两个事实**走: @@ -436,17 +437,13 @@ fun ChatScreen( // 「这一轮等待已经说过了」。它与上面那个标志的生命周期刻意不同:连接抖动(effect 因键变化重跑) // 不把它归零,否则同一句话会跟着抖动反复弹——刷屏。内容被取走、或队列清空,才算了结这一轮。 var shareToldWaiting by remember(sessionId) { mutableStateOf(false) } - // 「这一份分享已经取走、正在等用户确认」。它此前**没有地方待**——取到就直接送进发送链了; - // 现在取到的那一份停在这格 Compose 状态里,确认面板靠它渲染(`ShareConfirmPanel`)。 - // - // 键(sessionId)与旁边两个等待标志同一个口径:换会话等于换一格。别的会话的 VM 也还活着, - // 这份内容绝不能被另一个会话的面板认领去发——那会把分享发进错的会话。 - var shareConfirm by remember(sessionId) { mutableStateOf(null) } - // 面板输入框里的文字 = **用户附加的描述**(W53X3 更正:它不再是分享正文的复刻,初值恒为空)。 - // 它与上面那份内容**分开存**是刻意的:预览那一份说的是「分享进来的是什么」,这格说的是 - // 「用户想补一句什么」,两者本来就是两件事,合成一个字段会让预览跟着输入框一起变。 - // 真正发出去的正文由纯函数 [shareConfirmBody] 在按下「发送」那一刻拼(分享正文 + 这段描述)。 - var shareCaption by remember(sessionId) { mutableStateOf("") } + // 「一份分享已经取走、正在等用户确认」+ 用户补的那句描述。**它们刻意不在 Compose 里**:那一份 + // 已经出队、没有归还路径,而这一格 `remember` 会随转屏 / 深浅色切换整份丢掉——丢掉的后果是 + // 那份分享既不在队列里、也不在界面上,**静默消失**(用户什么都没看到,也没有任何提示)。 + // 所以它住在 VM 的状态里(面板输入框那句描述与正文绑在同一个值里,见 [ShareConfirmDraft]): + // `ChatSessionSlots` 的那一格 `ViewModelStore` 由 `AppContainer` 持有,比 Activity 活得久。 + // ⚠️ 别把它挪回这里——「比 Activity 活得久」正是它存在的全部理由。 + val shareConfirm = state.shareConfirm LaunchedEffect(sessionId, pendingShares.isEmpty(), shareConnection == WsState.Connected) { if (pendingShares.isEmpty() || shareConnection == WsState.Connected) { shareWaitedLong = false @@ -469,19 +466,14 @@ fun ChatScreen( when (landing) { // 取走之后这一轮等待就结束了:下一批内容(或下一次断线)重新计时、重新有资格说那句话。 // - // **取走只在这里、且只在面板没开的时候**:`shareConfirm == null` 既是这条闸门,也是本 - // effect 的键之一。面板开着时来的新分享一个字节都不动(原地留在待发队列里,等这一份 - // 确认完再接)——那样才不会出现「用户正看着 A,面板里的内容被换成了 B」。 + // **取走只在这一处发生**,而且闸门在 `take()` **之前**(判据是纯函数 [shareConfirmClaim], + // 落在 `ChatViewModel.claimSharedContent` 里):手上已经有了那份未确认的分享时,这一帧 + // 一个字节都不动队列——面板开着时来的新分享原地留在待发队列里,等这一份确认完再接, + // 那样才不会出现「用户正看着 A,面板里的内容被换成了 B」。转屏重建之后新的一帧照样走到 + // 这里,而那时**手上已经有东西**(它活在 VM 的状态里),所以也不会再取一次。 ShareLanding.Send -> { shareToldWaiting = false - if (shareConfirm == null) { - ShareIntake.take()?.let { share -> - shareConfirm = share - // 描述从空开始:它是用户要附加的话,**不是**这次分享进来的正文—— - // 预填一次就会在面板里把同一句话排两遍(W53X 的第一版正是这么错的)。 - shareCaption = "" - } - } + viewModel.claimSharedContent() } // 两个告知档只差措辞(时机一致):说哪句由纯函数 [shareWaitingNotice] 定, @@ -824,34 +816,19 @@ fun ChatScreen( // 分享进来的内容的**确认面板**(W53X:「一到就发」那条老路已废)。面板开着时**一个字节都还 // 没发出去**:点「发送」才走既有发送路径,点「取消」这一份就作废。这一处只做接线——内容与 - // 描述都在上面那格 Compose 状态里,面板本身与它的判据都在 `ShareConfirmPanel.kt`。 + // 描述都在 **VM 的状态**里(`state.shareConfirm`,理由见那里与 `ChatViewModel.claimSharedContent` + // 的 KDoc),面板本身与它的判据都在 `ShareConfirmPanel.kt`。转屏重建之后面板照旧在这里长出来, + // 因为 VM 比 Activity 活得久;**它绝不能被挪回这个组合里**。 // // ⚠️ 取走是不可逆的(`ShareIntake.take()` 没有归还路径),所以「取消」**不把内容塞回队列**: // 塞回去会让「用户刚作废的那一份」与「又来了新分享」变成同一件事,两种状态就再也分不开了。 - shareConfirm?.let { share -> + shareConfirm?.let { draft -> ShareConfirmPanel( - share = share, - caption = shareCaption, - onCaptionChange = { shareCaption = it }, - onSend = { - // 先收面板再发:这一次发送与面板本身互不相干,而它紧接着的那次重组里已经不该在了。 - // - // 正文 = 分享自带的那一段 + 用户补的那一句,由纯函数 [shareConfirmBody] 拼 - // (两段都在时**空行**分隔):W53X3 起输入框只代表「用户附加的描述」,所以这里 - // **不能**再直接拿输入框的内容当正文——那正是「同一句话排两遍」那一版的错法。 - val sent = share.copy(text = shareConfirmBody(share, shareCaption)) - shareConfirm = null - shareCaption = "" - // 告知按**真正发出去的那一份**算(`sent`,不是原始的那一份):正文里可能刚多了用户 - // 补的那一句,拿改之前那份去算会说出「正文较长、本想作为文件发送」这种与事实不符 - // 的话。(`shareNoticeFor` 本身一个字没改,`sendShared` 里「超长正文 → 文件」那条裁决 - // 也一字未动——现在参与那条裁决的是**拼好之后**的整段正文,那才是真发出去的东西。) - viewModel.sendShared(sent, shareNoticeFor(sent)) - }, - onCancel = { - shareConfirm = null - shareCaption = "" - }, + share = draft.share, + caption = draft.caption, + onCaptionChange = viewModel::onShareCaptionChange, + onSend = viewModel::sendShareConfirm, + onCancel = viewModel::cancelShareConfirm, ) } } 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 22750c2..51db6cf 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 @@ -633,6 +633,15 @@ data class ChatUiState( * 它到期自动消失([SHARE_NOTICE_MILLIS]),因为它描述的是刚刚发生(或刚刚等着)的一次动作。 */ val shareNotice: String? = null, + /** + * 面板手上正拿着的那份分享(**已经出队**)+ 用户补的那句描述;null = 没有面板。 + * + * 它**必须**在这里(而不是一格 `remember`):内容出队之后没有归还路径,而 Activity 转屏 / + * 深浅色切换会让组合整份重建——挂在组合上的那一格一丢,那份分享就从世界上消失了(队列里没有、 + * 界面上也没有,用户什么都没看到)。理由与「正文 + 描述为什么绑在一个值里」见 + * [ShareConfirmDraft] 的 KDoc;认领那一步见 [ChatViewModel.claimSharedContent]。 + */ + val shareConfirm: ShareConfirmDraft? = null, /** * 附件动作的如实告知(例如「有 2 个文件超过 20 MB,没有加入」);null = 没有要说的。 * @@ -816,6 +825,11 @@ class ChatViewModel( * - [ChatUiState.queuedForConnection]:一次都还没上过路、在等连接的那几条(内容挂在 `pending` * 那条气泡上,两者同生共死;这里两样都写,是为了将来只改其中一处时仍然拦得住)。 * + * 第四个来源 [ChatUiState.shareConfirm] 是**同上**的一格,只是更没有退路:那份分享已经从待发 + * 队列里出队(没有归还路径),把这一格交出去等于把它删掉——界面上那个确认面板会跟着这一格一起 + * 消失,而它连输入栏都回不去。它同样只在「本端唯一一份」这个意义上算数(用户已经输入到面板里的 + * 描述也算,两者本来就绑在同一个值里)。 + * * 流式中的回复([ChatUiState.streams])**刻意不算**:那是服务端正在生成的那一份的本地投影, * 本端这一格没了那一轮照旧跑完、照旧落库,重进会话会重新拉到;它也不具备「本端唯一」这条性质 * (而它正是「回收能省下多少」的大头之一,把它算进来等于让回收在这最该省的时候不动手)。 @@ -826,7 +840,8 @@ class ChatViewModel( it.draftImages.isNotEmpty() || it.draftDocuments.isNotEmpty() || it.pending.isNotEmpty() || - it.queuedForConnection.isNotEmpty() + it.queuedForConnection.isNotEmpty() || + it.shareConfirm != null } private var socket: ChatSocket? = null @@ -1458,6 +1473,74 @@ class ChatViewModel( } } + /** + * 认出「这一帧该落一份分享」([ShareLanding.Send]):手上没有待确认的那份时,去待发队列认领一条。 + * + * ## 为什么认领与保管在同一处、而且都在 VM 里 + * `ShareIntake.take()` **不可逆**(原子出队、没有归还路径),而触发认领的那一帧随时会消失: + * Activity 因转屏 / 深浅色切换重建会让组合整份丢掉。此前那份内容只挂在一格 `remember(sessionId)` + * 上 ⇒ 重建之后它既不在队列里(已经出队)、也不在界面上(那格状态没了),**静默丢失**。 + * 现在认领与保管是同一件事:一取走就落进 [ChatUiState.shareConfirm],而那一格活在 + * `core.session.ChatSessionSlots` 的 `ViewModelStore` 里(由 `AppContainer` 持有,比 Activity 久)。 + * + * ## 闸门在 `take()` **之前** + * 重建之后新的一帧照样会走到这里,而它看见手上已经有东西 ⇒ **一个字节都不动队列**:既不取新的 + * 一条,也不会把用户正看着的那份顶掉(判据是纯函数 [shareConfirmClaim],可单测)。于是「这份分享 + * 是在哪一步、被谁、取走了几次」有一句能说清的话:**只由当前会话这个 VM**、在**手上为空的那一帧** + * 取走**一次**——判据、出队、落地三步之间没有挂起点,所以不存在「取走了却没记住」的窗口。 + * + * 新认领的那一份描述从空开始([ShareConfirmDraft.caption] 的初值):它是用户要附加的话、**不是** + * 这次分享进来的正文——预填一次就会在面板里把同一句话排两遍(W53X 的第一版正是这么错的)。 + */ + fun claimSharedContent() { + val holding = _state.value.shareConfirm + val claimed = shareConfirmClaim(holding) { ShareIntake.take() } + if (claimed != null && claimed !== holding) { + _state.update { it.copy(shareConfirm = claimed) } + } + } + + /** + * 面板输入框里的那句描述变了。 + * + * 它与正文**同生共死**(两者都在 [ChatUiState.shareConfirm] 里),所以它活得过转屏重建—— + * 用户打到一半的描述不会因为屏幕转了一下就没了。手上没有那一份时什么都不做:没有面板的地方 + * 不该攒下一句没主的描述。 + */ + fun onShareCaptionChange(caption: String) { + _state.update { state -> + state.shareConfirm?.let { state.copy(shareConfirm = it.copy(caption = caption)) } ?: state + } + } + + /** + * 用户把面板撤了:这一份作废。 + * + * 刻意**不把内容塞回待发队列**([top.yeij.cyrene.domain.share.PendingShareHolder] 的 KDoc 里 + * 写着理由):塞回去会让「用户刚作废的那一份」与「又来了新分享」变成同一件事,两种状态就再也 + * 分不开了。撤回的是**这一格**,队列里别的内容一个字节都不动。 + */ + fun cancelShareConfirm() { + _state.update { it.copy(shareConfirm = null) } + } + + /** + * 用户点了面板上的「发送」:把**拼好的那一段正文**交给既有发送路径([sendShared])。 + * + * 正文由纯函数 [shareConfirmBody] 拼(分享自带的正文 + 用户补的描述,两段都在时**空行**分隔), + * 而告知按**真正发出去的那一份**算(`sent`,不是拼之前那份):正文里可能刚多了用户补的那一句, + * 拿改之前那份去算会说出「正文较长、本想作为文件发送」这种与事实不符的话。 + * + * 先把面板那一格清空再发是刻意的:这一次发送与面板本身互不相干,而它紧接着的那次重组里 + * 已经不该在了。 + */ + fun sendShareConfirm() { + val draft = _state.value.shareConfirm ?: return + val sent = draft.share.copy(text = shareConfirmBody(draft.share, draft.caption)) + _state.update { it.copy(shareConfirm = null) } + sendShared(sent, shareNoticeFor(sent)) + } + /** * 记一条分享提示,到期自动收走(同一条未过期就不重复刷)。 * diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmDraft.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmDraft.kt new file mode 100644 index 0000000..51f6bd2 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmDraft.kt @@ -0,0 +1,51 @@ +package top.yeij.cyrene.ui.chat + +import top.yeij.cyrene.domain.share.SharedContent + +/** + * 「一份**已经取走**、正等着用户确认的分享」,外加用户此刻补的那句描述。 + * + * ## 为什么它必须活在 VM 里,而不是一格 `remember` + * 这份内容**已经**从待发队列里出队了([top.yeij.cyrene.app.ShareIntake.take] 是原子出队、 + * **没有归还路径**),而它此前只挂在一格 `remember(sessionId)` 上——那格状态属于**那一次组合**。 + * Activity 因转屏 / 深浅色切换重建时组合整份丢掉:那份分享既不在队列里(已经出队)、也不在界面上 + * (那格状态没了),**静默消失**——用户什么都没看到,也没有任何提示。所以它待在 + * [ChatUiState.shareConfirm] 里:VM 由 `AppContainer` 那一层持有(`core.session.ChatSessionSlots` + * 的 `ViewModelStore`),比组合与 Activity 都活得久。 + * + * 正文([share])与描述([caption])**绑在同一个值里**是刻意的:描述说的是「你要补的那一句话」, + * 它只对这一次确认有意义——分成两格迟早会出现「描述换了一份、正文还是上一份」。 + * + * [caption] 初值**恒为空**:面板上半是这次分享进来的东西,下半才是用户要附加的话,预填一次就会在 + * 面板里把同一句话排两遍(W53X 的第一版正是这么错的,见 `ShareConfirmPanel` 的 KDoc)。 + */ +data class ShareConfirmDraft( + val share: SharedContent, + val caption: String = "", +) + +/** + * 「手上这一份 + 去待发队列认领一次」⇒ 面板这一刻该拿着哪一份(**纯函数**,可单测)。 + * + * ## 它回答的是**不可逆那一步该不该走** + * [take] 就是 `ShareIntake.take()`(**不可逆**),所以「什么时候允许调它」是这里唯一的正事: + * **手上已经有东西时一个字节都不动**——连队列里新来的那条也不碰,它原地等着,面板上此刻摆的 + * 仍是用户正在看的那一份,不会被后来的一条顶掉。这就是「同一份分享只被取走一次」的落点: + * Activity 重建之后新的一帧会再问一次同样的问题,而那时 [holding] 里已经装着那份内容 + * (它活在 VM 的状态里,活过了重建)⇒ 不会再 [take] 第二次。 + * + * ## 为什么不是「取完之后再拿结果比一比」 + * 不能靠「第二次 [take] 拿到 null 就当没发生过」兜底:第二次跑起来拿到的要么是 null(白跑一趟), + * 要么是**队列里的下一条**——后者会把用户正看着的那份顶掉,那是一次静默丢失。所以闸门必须在 + * [take] **之前**,而不是在它之后回滚。 + * + * @param holding 这一刻面板手上那一份([ChatUiState.shareConfirm]);null = 手上没东西。 + * @param take 去待发队列认领一条(`ShareIntake.take()`):**只在 [holding] 为 null 时被调用**, + * 且最多一次。返回 null(队列为空)时本函数也回 null——不造一个「什么都没有的面板」。 + * @return 面板该拿着的那一份:手上原有的**原样**返回(连实例都不换——`ShareConfirmPanel` 用它做 + * `remember` 的键,换一份等值的新对象会让预览白重建一次);手上没有时就是刚认领的那一条。 + */ +internal fun shareConfirmClaim( + holding: ShareConfirmDraft?, + take: () -> SharedContent?, +): ShareConfirmDraft? = holding ?: take()?.let { ShareConfirmDraft(share = it) } 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 6277c53..668e9a9 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 @@ -20,7 +20,7 @@ enum class ShareLanding { */ TellOffline, - /** 连接就绪:取走一条并交给既有发送路径。 */ + /** 连接就绪:取走一条并交给确认面板(点「发送」才真的发出去)。 */ Send, } @@ -41,9 +41,9 @@ enum class ShareLanding { * * ## 判据与优先级 * 1. **没有待发内容** ⇒ [ShareLanding.Nothing]:任何连接状态下都不做事、也不说话。 - * 2. **[WsState.Connected]** ⇒ [ShareLanding.Send]:取走一条并发送。它**压过**后面每一条——连接一到就该发, - * 哪怕此前已经等够久、那句「等连上会自动发」也说过了。返回值是**单值动作**,所以「发送」与 - * 「告知」在类型上就不可能同时发生(不会既发了又报怨还没连上)。 + * 2. **[WsState.Connected]** ⇒ [ShareLanding.Send]:取走一条并交给确认面板(点「发送」才真的发)。 + * 它**压过**后面每一条——连接一到就该认领,哪怕此前已经等够久、那句「等连上会自动发」也说过了。 + * 返回值是**单值动作**,所以「认领」与「告知」在类型上就不可能同时发生(不会既取了又报怨还没连上)。 * 3. 未就绪且**还没等够久**(`waitedLong = false`)⇒ [ShareLanding.KeepWaiting]:继续等,什么都不说 * (刚分享进来就弹一句「还没连上」是噪声,握手正常也就几百毫秒)。 * 4. 未就绪、已等够久、且**还没说过** ⇒ 说一次。说哪一句由连接状态分**两档**:可恢复的那三档 diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmDraftTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmDraftTest.kt new file mode 100644 index 0000000..d90e9de --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmDraftTest.kt @@ -0,0 +1,107 @@ +package top.yeij.cyrene.ui.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import top.yeij.cyrene.domain.share.SharedContent + +/** + * 「一份已经取走的分享怎么活过重建」([shareConfirmClaim])。 + * + * 背景:分享进来的内容先弹确认面板(主人 2026-10-06 拍板:像其他 IM 一样先确认再发),而它被 + * `ShareIntake.take()` **取走**之后就没有归还路径了;此前那份内容与用户正在输入的那句描述只挂在 + * `ChatScreen` 的一格 `remember(sessionId)` 上——Activity 因转屏 / 深浅色切换重建时组合整份丢掉, + * 那份分享既不在待发队列里、也不在界面上,**静默消失**(用户什么都没看到,也没有任何提示)。 + * + * 本测试钉住的是**判据**(它此后活在 `ChatUiState.shareConfirm` 里,由 VM 经 + * `ChatViewModel.claimSharedContent` 调): + * + * 1. **手上已经有东西时,队列一个字节都不动**——重建之后新的一帧会再问一次同样的问题,而那时 + * 手上已经有那份内容 ⇒ [shareConfirmClaim] **不会**调 `take()`。这条是「同一份分享只被取走一次」 + * 的落点;少了它,第二次取走拿到的要么是 null(白跑),要么是**队列里的下一条**——后者会把用户 + * 正看着的那份顶掉,那是一次静默丢失。 + * 2. **内容与描述都原样还在**(描述与正文绑在同一个值里,两者同生共死)。 + * 3. **手上没有时才认领,且只认领一次**;队列为空时什么都不发生(不造一个「什么都没有的面板」, + * 也不会把「空手」记成「已经取到了」——下一帧队列里有东西时照旧认领得到)。 + * + * ## 这里怎么模拟「重建」 + * 单测里没有 Activity,也没有组合。这里的做法是把 [ShareConfirmDraft] 当成**那一格不会随组合消失 + * 的值**(它在真机上就是 `ChatUiState.shareConfirm`),然后拿 `take()` 的调用次数当探针:转屏前后 + * 是**同一个值**、而队列是**同一个队列**(第二次取走一定会取出「后来又分享的那一条」)。真机上 + * 「面板转屏后照旧长出来」这一步由主人验收——那是 Compose 的观感,JVM 单测碰不到。 + */ +class ShareConfirmDraftTest { + + @Test + fun `a rebuilt screen still holds the same share and caption, and never takes twice`() { + val first = SharedContent(text = "https://example.com/某个页面?id=1") + val later = SharedContent(text = "后来又分享的一条") + val queue = ArrayDeque(listOf(first, later)) + var takeCalls = 0 + fun take(): SharedContent? { + takeCalls++ + return queue.removeFirstOrNull() + } + + // 第一帧:手上空空 ⇒ 认领队首那一条,描述从空开始(预填一次会把同一句话在面板里排两遍)。 + val claimed = shareConfirmClaim(holding = null, take = ::take) + assertEquals(1, takeCalls) + assertSame("认领的必须是队列里那条本身", first, claimed?.share) + assertEquals("描述初值恒为空", "", claimed?.caption) + + // 用户补了一句描述(它跟着同一格走)。 + var holding = requireNotNull(claimed).copy(caption = "这句话不错") + + // 转屏:旧的那次组合整份丢掉,新的一帧拿着**同一格**再走到判据这里——走两遍,因为 + // effect 的键里有连接状态,重建之后它会不止重跑一次。 + repeat(2) { + val next = shareConfirmClaim(holding = holding, take = ::take) + assertSame("重建之后内容必须还是那一份", holding.share, next?.share) + assertEquals("重建之后用户输入到一半的描述也不能丢", "这句话不错", next?.caption) + holding = requireNotNull(next) + } + + // 关键的一条:`take()` 只在第一帧跑过那一次。第二次跑起来会取出「后来又分享的一条」—— + // 那正是「用户正看着 A、面板里的内容却被换成了 B」的来处。 + assertEquals("手上已经有东西时绝不再取一次", 1, takeCalls) + assertEquals("队列里那一条也不许被白白取走", listOf(later), queue.toList()) + } + + @Test + fun `an empty queue claims nothing, and the next frame still can`() { + val queue = ArrayDeque() + var takeCalls = 0 + fun take(): SharedContent? { + takeCalls += 1 + return queue.removeFirstOrNull() + } + + // 队列为空:什么都不发生——不造一个空的「面板」,也不把「空手」记成「已经取到了」。 + assertNull(shareConfirmClaim(holding = null, take = ::take)) + assertEquals(1, takeCalls) + + // 下一帧队列里有了东西:照旧认领得到(这一档正是「面板开着时新来的分享原地在队列里等, + // 等这一份确认完再接上」)。 + queue += SharedContent(text = "等面板关掉才轮到我") + val claimed = shareConfirmClaim(holding = null, take = ::take) + assertEquals("等面板关掉才轮到我", claimed?.share?.text) + assertEquals(2, takeCalls) + } + + @Test + fun `a share that could not be read is handed over as it is`() { + // 认领只做出队这一件事:内容什么样都**原样**交给面板。`contentUnreadable` 那条分享(对方给了 + // 东西、本端连地址都没认下来)照样要弹面板——它的预览是空的,但「要不要说一句、说什么」由 + // `shareNoticeFor` 与面板自己负责,判据不该在这里替它筛一遍(筛掉就成了一次静默丢弃)。 + val unreadable = SharedContent(contentUnreadable = true) + val claimed = shareConfirmClaim(holding = null) { unreadable } + + assertSame(unreadable, claimed?.share) + assertTrue( + "原样交出去,丢件 / 读不到这些账一个字段都不动", + requireNotNull(claimed).share.contentUnreadable, + ) + } +}