fix(share): 转屏不再静默丢掉「已取走、还没确认」的那份分享 —— 面板状态移进 ChatViewModel
分享进来的内容被 `ShareIntake.take()` 取走之后没有归还路径,而它此前只挂在一格 `remember(sessionId)` 上:Activity 因转屏 / 深浅色切换重建时那次组合整份丢掉,那份分享 **既不在待发队列里、也不在界面上**(用户什么都没看到,也没有任何提示);用户已经输入到 一半的那句描述同样丢。 改法:把「已取走、正等确认的那份分享 + 用户补的描述」移进 `ChatUiState.shareConfirm` (`ShareConfirmDraft`),那一格由 VM 持有,而 VM 活在 `ChatSessionSlots` 的 `ViewModelStore` 里、由 AppContainer 撑着 —— 比组合与 Activity 都活得久,所以重建之后面板照旧长出来, 描述也跟着回来(描述与正文绑在同一个值里,两者同生共死)。 `take()` 仍然只发生一次:认领那一步收进 `ChatViewModel.claimSharedContent`,闸门是纯函数 `shareConfirmClaim`,且**在 `take()` 之前** —— 手上已经有那份内容时一个字节都不动队列 (既不取新的一条,也不会把用户正看着的那份顶掉)。重建后的新一帧照样会走到判据这里, 而那时手上已经有东西(它活在 VM 里)⇒ 不会再取第二次。判据、出队、落地之间没有挂起点, 所以也不存在「取走了却没记住」的窗口。 顺带把这一格计入 `holdsUnsentWork`:这份内容已经没有退路,那一格被回收就等于把它删掉。 `ShareLanding` / `ShareIntake.take()` 里「取走之后直接发」的旧说法一并改成「交给确认面板」。 新增 ShareConfirmDraftTest:钉住「重建后的第二帧不再调 take、队列里那条也不许被取走」、 「内容与描述原样还在」、「队列为空时不造一个空面板」。
This commit is contained in:
1 parent
33c320219a
commit
1142bf5ed1
6 files changed
+281
-59
No files matched your search
@@ -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<SharedContent>()
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user