feat(notify): W57 —— 「她主动说的话 ⇒ 弹通知」的帧消费点提到进程级
改了什么
- 新增 app/ProactiveFrameIntake:订 ChatSocket.events,把两类帧(proactive_message、
message_persisted{proactive:true})折成四个入参交给 ProactiveNotifier。纯函数
proactiveFrameOf 管「哪些帧算她主动说的话、每个入参取自帧上的哪个键」。构造即订阅,
刻意不提供 start/stop。
- AppContainer:新增 proactiveFrameIntake(与 chatSocket 同一层,进程级)。
- SessionsViewModel:删掉 onEvent 里那两处转交 proactiveNotifier 的调用,本类不再判断
通知的任何一件事(未读、「谁在跑」原样不动)。
- AppPresence / ProactiveNotifier 的 KDoc 跟着改:谁在读「在不在前台」、谁在喂帧。
为什么
改前这份订阅挂在 SessionsViewModel 的 viewModelScope 上。连接本身是进程级的
(ChatSocket 自带 scope,只有 close() 取消它,而 SessionsViewModel.onCleared 刻意不关),
所以从最近任务把 Activity 划掉之后连接照旧收帧——但 events 是 replay=0 的 SharedFlow,
没有订阅者的 tryEmit 当场丢掉:她的话进了连接、然后没了,通知永远不弹。这是 W53N 登记
的那一格缺口。
边界
- 判据与去重一个字节没动(仍在 ProactiveNotifier 的纯逻辑里)。
- 连接所有权没动:仍然只在登录态那一处 connect/close。
- 不是「新起一条连接」,只是把帧的消费点从 Activity 级提到进程级;VM 里那两处转交是
删而不是留(留两处等于靠去重去遮一次重复消费,且「谁在喂通知」会有两个答案)。
- deliver 里接住落地异常:这条订阅不会再被重建,抛出去就是进程崩溃 + 通知整个哑掉。
验收
assembleDebug 通过;testDebugUnitTest 全量 113 suites / 1370 tests / 0 failures /
0 errors(基线 112/1361,新增的 9 条全在 ProactiveFrameIntakeTest)。
This commit is contained in:
1 parent
6de2f7656a
commit
0e03eba6a4
6 files changed
+352
-38
No files matched your search
@@ -0,0 +1,182 @@
|
||||
package top.yeij.cyrene.app
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.core.notify.AppPresence
|
||||
import top.yeij.cyrene.core.notify.ProactiveNotifier
|
||||
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
|
||||
import top.yeij.cyrene.data.ws.WsEvent
|
||||
|
||||
/**
|
||||
* 「她主动说的话」在**帧这一层**的消费点(W57)。
|
||||
*
|
||||
* 这里钉的是**接线**,不是判据(判据与去重仍在 `ProactiveNotifierTest`):
|
||||
* 1. **它不需要任何 Activity 活着就能收帧**——改前那份订阅挂在 `SessionsViewModel` 的
|
||||
* `viewModelScope` 上,从最近任务划掉 Activity 就没了,而 `ChatSocket.events` 是 `replay = 0`
|
||||
* 的 `SharedFlow` ⇒ 没有订阅者的帧**当场丢掉**。本类的第一条测试就是那个 bug 的现场;
|
||||
* 2. 哪些帧算「她主动说的话」、四个入参各取帧上的哪个键([proactiveFrameOf])——错在这两个方向
|
||||
* 都是静默的(她开口了却不弹、普通消息也弹),所以逐键钉住;
|
||||
* 3. 判据与去重在新的消费点上**没有被放松**:两条路仍只弹一次、看着主会话仍不弹、别的会话仍不弹。
|
||||
*
|
||||
* 收集用的调度器传 `Dispatchers.Unconfined`:`launch` 与 `tryEmit` 都在当前线程上把 collect 体
|
||||
* 走完,于是测试是同步的(不需要等、也不需要 `runBlocking`)。
|
||||
* **测不了的**:真·进程存活(Activity 没了而进程还在)这件事只有真机能造,本层只能钉住「订阅不挂
|
||||
* 在任何会随 UI 结束的 scope 上」这个可测的部分。
|
||||
*/
|
||||
class ProactiveFrameIntakeTest {
|
||||
|
||||
private val presence = AppPresence()
|
||||
private val posted = mutableListOf<String>()
|
||||
|
||||
/** 与 `ChatSocket` 同一档的订阅方式:无 replay、有缓冲、溢出丢最旧。 */
|
||||
private val events = MutableSharedFlow<WsEvent>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = EVENT_BUFFER,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
/** 真实的帧(键名与类型都对服务端):实时帧带落库行 `id` + IM 号 + RFC3339 时间戳。 */
|
||||
private val liveJson = """
|
||||
{"type":"proactive_message","data":{"platform":"cyrene-backthink","channelId":"chan-1","channelType":"private","content":"记得喝水哦","contentType":"text","sender":"Cyrene","timestamp":"2026-10-06T01:20:05.123+08:00","messageId":"im-7","id":"1371","createdAt":1791212405123}}
|
||||
""".trimIndent()
|
||||
|
||||
/** 同一条消息的另一条路:IM 桥那条路由补发的落库帧(判据键在载荷里)。 */
|
||||
private val persistedProactiveJson = """
|
||||
{"type":"message_persisted","sessionId":"web","data":{"proactive":true,"message":{"id":"1371","sessionId":"web","role":"assistant","content":"记得喝水哦","createdAt":1791212405123}}}
|
||||
""".trimIndent()
|
||||
|
||||
/** 普通消息的落库广播(她正常回话):**不是**她主动说的话。 */
|
||||
private val ordinaryPersistedJson = """
|
||||
{"type":"message_persisted","sessionId":"web","data":{"message":{"id":"1372","sessionId":"web","role":"assistant","content":"在的","createdAt":1791212405123}}}
|
||||
""".trimIndent()
|
||||
|
||||
/** 她主动说的、但不属主会话:绝不弹(宁可漏,也不要把别处的消息说成是她在找我说话)。 */
|
||||
private val liveJsonForAnotherSession = """
|
||||
{"type":"proactive_message","sessionId":"sess-abc","data":{"content":"在吗","messageId":"im-9","id":"1373"}}
|
||||
""".trimIndent()
|
||||
|
||||
private fun intake() = ProactiveFrameIntake(
|
||||
events = events,
|
||||
notifier = ProactiveNotifier(presence = presence, post = { text, _ -> posted += text }),
|
||||
dispatcher = Dispatchers.Unconfined,
|
||||
)
|
||||
|
||||
private fun parse(json: String): WsEvent = requireNotNull(WsEvent.parse(json)) { "帧本身要能成形" }
|
||||
|
||||
// —— 这一档就是那个 bug 的现场 ——
|
||||
|
||||
@Test
|
||||
fun `her words still notify when no activity is alive`() {
|
||||
// Activity 已经从最近任务里划掉(没有任何实例在 started 档、聊天页也不在),进程被
|
||||
// KeepAliveService 撑着、连接照旧在收帧。改前这里是空的:订阅随 VM 一起没了。
|
||||
assertFalse("这一档的前提就是前台没有任何 Activity", presence.appInForeground)
|
||||
intake()
|
||||
|
||||
events.tryEmit(parse(liveJson))
|
||||
|
||||
assertEquals(listOf("记得喝水哦"), posted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the notification id comes from the notifier not from this layer`() {
|
||||
// 号必须按消息派生(同一条 ⇒ 同一个号、不同的句子各占一条)。这一层只转交帧,不许自己
|
||||
// 造号——这里用一个把号也记下来的假 post 确认它拿到的确实是「按消息派生」的那个值。
|
||||
val ids = mutableListOf<Int>()
|
||||
ProactiveFrameIntake(
|
||||
events = events,
|
||||
notifier = ProactiveNotifier(presence = presence, post = { _, id -> ids += id }),
|
||||
dispatcher = Dispatchers.Unconfined,
|
||||
)
|
||||
|
||||
events.tryEmit(parse(liveJson))
|
||||
events.tryEmit(parse(ordinaryPersistedJson))
|
||||
|
||||
assertEquals(1, ids.size)
|
||||
}
|
||||
|
||||
// —— 判据与去重在新的消费点上没有被放松 ——
|
||||
|
||||
@Test
|
||||
fun `the live frame and the persisted frame of one message notify once`() {
|
||||
intake()
|
||||
|
||||
events.tryEmit(parse(liveJson))
|
||||
// 同一条的落库帧(另一条路):认得出是同一条 ⇒ 不再弹。
|
||||
events.tryEmit(parse(persistedProactiveJson))
|
||||
|
||||
assertEquals(listOf("记得喝水哦"), posted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a frame that lands on screen while the main session is open does not notify`() {
|
||||
// 用户正看着她说话的那个会话:话就出现在他眼前,不打扰(判据本身在 ProactiveNotify.kt)。
|
||||
presence.onActivityStarted(Any())
|
||||
presence.onChatSurface(SessionSummaryDto.MAIN_SESSION_ID)
|
||||
intake()
|
||||
|
||||
events.tryEmit(parse(liveJson))
|
||||
|
||||
assertEquals(emptyList<String>(), posted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a frame for another session never notifies`() {
|
||||
intake()
|
||||
|
||||
events.tryEmit(parse(liveJsonForAnotherSession))
|
||||
|
||||
assertEquals(emptyList<String>(), posted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an ordinary persisted frame never notifies`() {
|
||||
intake()
|
||||
|
||||
events.tryEmit(parse(ordinaryPersistedJson))
|
||||
|
||||
assertEquals(emptyList<String>(), posted)
|
||||
}
|
||||
|
||||
// —— 哪些帧算她的话、四个入参各取自帧上的哪里 ——
|
||||
|
||||
@Test
|
||||
fun `only the two proactive shapes are frames of her words`() {
|
||||
assertNull("普通落库广播不是", proactiveFrameOf(parse(ordinaryPersistedJson)))
|
||||
assertNull("流式增量不是", proactiveFrameOf(WsEvent.MessageDelta(delta = "在的", index = 0)))
|
||||
assertNull("开始帧不是", proactiveFrameOf(WsEvent.MessageStart(index = 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the live frame takes the history row id and the im id apart`() {
|
||||
val frame = requireNotNull(proactiveFrameOf(parse(liveJson)))
|
||||
|
||||
// 帧壳不带会话归属 ⇒「无法判定」,由「她主动说的话落主会话」这条事实兜住(见 ProactiveNotifier)。
|
||||
assertNull(frame.sessionId)
|
||||
assertEquals("1371", frame.id)
|
||||
assertEquals("im-7", frame.messageId)
|
||||
assertEquals("2026-10-06T01:20:05.123+08:00", frame.timestamp)
|
||||
assertEquals("记得喝水哦", frame.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the persisted frame has only the row id and never invents the other two`() {
|
||||
val frame = requireNotNull(proactiveFrameOf(parse(persistedProactiveJson)))
|
||||
|
||||
assertEquals(SessionSummaryDto.MAIN_SESSION_ID, frame.sessionId)
|
||||
assertEquals("1371", frame.id)
|
||||
// 帧上就没有这两个键。编一个出来(比如把 `id` 也当 IM 号)就是伪造服务端键,去重会跟着漂。
|
||||
assertNull(frame.messageId)
|
||||
assertNull(frame.timestamp)
|
||||
assertEquals("记得喝水哦", frame.content)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** 与 `ChatSocket.EVENT_BUFFER` 同档:订阅方不设上限、发射方不阻塞。 */
|
||||
const val EVENT_BUFFER = 8
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user