merge: W54C —— 转屏不再静默丢掉已取走的分享(面板状态与描述移进 ChatUiState,take 仍只发生一次)

This commit is contained in:
AskaEth committed 2026-10-06 21:26:29 +08:00
commit a861881ec4
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,
)
}
}