feat(W53N): 她主动说的话在后台/不在聊天页时发一条系统通知(只这一档,普通消息不弹)

改了什么:
1. WsEvent:把落库帧上被丢掉的 `proactive` 判据**读回来**(新增 MessagePersisted.proactive)。
   此前 PersistedData 只解 message,而生产 Json 是 ignoreUnknownKeys=true ⇒ 这个键既不解也不报错,
   「她主动说的话走了 IM 桥那条落库路」在客户端完全不可见。两层落点都认(data.proactive 与帧壳
   顶层 proactive 取或,契约只说了「帧顶」);值用**宽容读法**(只认字面 true),形状不认识时按
   「不是 proactive」——判错成真会给普通消息弹通知,判错成假只是漏一次提醒,方向必须偏不打扰。
2. core/notify(新):判据 + 去重 + 落地
   - shouldNotifyProactive(appInForeground, viewingTargetSession):语义只有一条——**这条消息此刻
     会不会正好被用户看到**。四档组合都写进 KDoc(含「不在前台、聊天页却还挂着」那一档)。
   - RecentProactiveNotices:进程级、容量 64、按插入序淘汰。记的是「**到达过**」而不是「弹过」,
     因为「用户正看着」那一次也算这条已经到达(他看过了,另一条路再补一帧时不该为一句看过的话弹)。
   - ProactiveNotifier:两条路共用一处;顺序是 归属 → 有没有字 → 记账/查重 → 判时机。
   - proactiveNotifyKey:落库行 id > IM 消息号 > 时间字面量 > 正文(服务端一个键都不给时的兜底)。
     实时帧的 data.id 与落库帧的 message.id 是**同一个值**,去重就靠它。
   - proactiveNotificationText:复用权威解析器 domain.parse 剥标记(不再写第二份剥标签正则)、
     折成一行、超 120 字截断;截断不劈开代理对(emoji)。
3. 前台判定不引新依赖:MainActivity 的 onStart/onStop 按**实例**登记到一个进程级集合
   (配置变更时新实例的 onStart 先于旧实例的 onStop,布尔量在那里必然误判成「后台」);
   聊天页那一半由 ChatScreen 的组合报告(DisposableEffect)。两者在 AppContainer 那层汇合。
4. 通知本体:自建渠道 `proactive`(DEFAULT,**要提醒人**,与 keep_alive 的 LOW 分开)、通知 id=2
   (与保活那条 id=1 分开,同 id 是替换会把常驻通知顶掉)、标题「昔涟」+ 正文是她的原话、
   setAutoCancel、PendingIntent.FLAG_IMMUTABLE(且 request code 与保活那条分开:两者的 Intent
   filterEquals 相等而 PendingIntent 判据不含 extras,同 code 会复用先建的那个、丢掉这个 extra)。
   通知权限现读(复用 core.keepalive.notificationsAllowed),没授权就不发。
5. 点通知 ⇒ 打开 App 并切回主会话:Intent extra → 进程级待落地意图(ProactiveTapIntake,通知可能
   在 App 没起来 / 没登录时被点)→ ChatHomeScreen 落地成「关掉设置/管理/关于/抽屉 + select(web)」,
   走的是用户点抽屉里那一行的同一条路,不引导航库。

为什么这样切:通知逻辑**绝不碰连接**(那条共享连接的所有权仍只在登录态那一处),只在帧**已经到达
之后**做判断;判断与去重全是纯函数/纯类,可单测;Android 落地只有一层薄薄的通知构造。

验证:W53N worktree(基线 f8ac95c)`assembleDebug testDebugUnitTest` 全绿 —— 109 suite / 1320
tests / 0 失败(基线 101 / 1270 / 0,新增 8 suite / 50 tests)。真机弹通知、点通知的落地未验(留给主人)。
This commit is contained in:
AskaEth committed 2026-10-06 18:51:24 +08:00
1 parent f8ac95ceed
commit 611bc5f58a
16 files changed
+1387 -10

No files matched your search

@@ -0,0 +1,56 @@
package top.yeij.cyrene.app
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 「点通知进来要看主会话」那笔待落地的意图(W53N)。
*
* 这里**刻意不测** [ProactiveTapIntake.accept](它要 `android.content.Intent`,单元测试里没有
* 真实现)——那条判断只有一条 extra 的字面量比较,而真正会出错的是这一半:**记账与消费的时机**
* (记账必须在通知被点的那一刻就发生,消费必须只发生一次、且只在真的落地之后)。
*/
class ProactiveTapIntakeTest {
@Test
fun `nothing is requested before a tap`() {
assertFalse(ProactiveTapIntake().openMainSession.value)
}
@Test
fun `a request survives until it is consumed`() {
// 通知可能在 App 还没起来、甚至还没登录时就被点:意图必须留着,等 ChatHomeScreen 能落地
// 的那一刻再来取(它一旦被丢掉,用户点了通知却什么都没发生)。
val intake = ProactiveTapIntake()
intake.requestMainSession()
assertTrue(intake.openMainSession.value)
}
@Test
fun `consuming clears it exactly once`() {
val intake = ProactiveTapIntake()
intake.requestMainSession()
intake.consume()
assertFalse(intake.openMainSession.value)
// 再消费一次也不该把「已经取走了」翻回成「还欠着」(它是个纯写入,没有副作用)。
intake.consume()
assertFalse(intake.openMainSession.value)
}
@Test
fun `a new tap after a consumed one is taken again`() {
// 第二次点通知必须照样生效:消费清的只是「这一笔」,不是把整个入口关掉。
val intake = ProactiveTapIntake()
intake.requestMainSession()
intake.consume()
intake.requestMainSession()
assertTrue(intake.openMainSession.value)
}
}
@@ -0,0 +1,98 @@
package top.yeij.cyrene.core.notify
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 「App 在不在前台」这个事实的记账(W53N)。
*
* 为什么值得单测:它最容易做成一个布尔量,而布尔量在**配置变更**上必然出错——旋转 / 系统深浅色
* 切换时,**新实例的 `onStart` 先于旧实例的 `onStop`**。记成布尔量的话,用户正看着聊天页,
* 她一句话却会弹出一条通知来。下面第二条就是这一幕。
*/
class AppPresenceTest {
private val presence = AppPresence()
private class Screen
@Test
fun `a process with no started activity is not in the foreground`() {
assertFalse(presence.appInForeground)
}
@Test
fun `a config change keeps the app in the foreground`() {
val old = Screen()
val new = Screen()
presence.onActivityStarted(old)
// 旋转:新实例先起来,旧实例之后才停。
presence.onActivityStarted(new)
presence.onActivityStopped(old)
assertTrue("新实例还活着,就不算后台", presence.appInForeground)
}
@Test
fun `two activities keep it in the foreground until the last one stops`() {
val first = Screen()
val second = Screen()
presence.onActivityStarted(first)
presence.onActivityStarted(second)
presence.onActivityStopped(first)
assertTrue("还有一屏在被看", presence.appInForeground)
presence.onActivityStopped(second)
assertFalse(presence.appInForeground)
}
@Test
fun `a stop from an activity that never started does not flip anything`() {
// 「只删自己那一行」的等价纪律:登出 / 销毁路径上多报一次 stop 不该把别人算掉。
val alive = Screen()
presence.onActivityStarted(alive)
presence.onActivityStopped(Screen())
assertTrue(presence.appInForeground)
}
@Test
fun `the chat surface reports the session it shows, verbatim`() {
presence.onChatSurface("web")
assertTrue(presence.isShowingChatSurface("web"))
// 主会话 `web` 与次级会话 `web:xxx` 是两个会话:等值比较,不是前缀。
presence.onChatSurface("web:7f3a")
assertFalse(presence.isShowingChatSurface("web"))
assertTrue(presence.isShowingChatSurface("web:7f3a"))
}
@Test
fun `leaving the chat page shows no session at all`() {
presence.onChatSurface("web")
presence.onChatSurface(null)
assertFalse(presence.isShowingChatSurface("web"))
}
@Test
fun `a blank session id is never being shown`() {
// 「没显示任何会话」与「显示了一个空 id 的会话」不是同一件事——空 id 不是任何会话的键。
presence.onChatSurface("")
assertFalse(presence.isShowingChatSurface(""))
}
@Test
fun `the chat surface fact does not imply the foreground fact`() {
// 两件事各报各的:Android 上 App 退到后台时组合并不会销毁,聊天页那一半会原样留着。
presence.onChatSurface("web")
assertFalse("没有 Activity 在可见档,就是后台", presence.appInForeground)
}
}
@@ -0,0 +1,297 @@
package top.yeij.cyrene.core.notify
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
/**
* 通知通路的整体判据(W53N):**去重**与「什么时候弹」。
*
* 这里钉的都是**错了就肉眼可见**的边界:
* 1. 同一条消息走两条路(实时帧带落库行 `id`、落库帧带同一个 `message.id`)——**只许弹一次**;
* 2. 普通消息(不带 `proactive`)根本到不了这里,到了也不该弹(见 `WsEventPersistedProactiveTest`);
* 3. 后台 + 聊天页仍挂着主会话那一档**要弹**(Android 上真实存在);
* 4. 不在主会话的帧绝不弹。
*/
class ProactiveNotifierTest {
private val presence = AppPresence()
private val posted = mutableListOf<String>()
private val notifier = ProactiveNotifier(presence = presence, post = { posted += it })
/** 一个「Activity 实例」(身份即语义,见 [AppPresence])。 */
private val screen = Any()
/** 后台、聊天页不在(最常见的「她说了句话」):这一档必须弹。 */
private fun background() {
presence.onChatSurface(null)
}
/** 前台、正看着主会话:她的话出现在屏幕上,不该再有通知。 */
private fun onMainSessionInForeground() {
presence.onActivityStarted(screen)
presence.onChatSurface(SessionSummaryDto.MAIN_SESSION_ID)
}
// —— 两条路只弹一次 ——
@Test
fun `the live frame and the persisted frame notify once`() {
background()
// 实时帧:落库行 id 在 `data.id` 里(IM 侧消息号是另一个 id)。
val live = notifier.onProactiveFrame(
sessionId = null,
id = "1371",
messageId = "im-7",
timestamp = "2026-10-06T01:20:05.123+08:00",
content = "记得喝水哦",
)
// 落库帧:同一条的 `message.id`(服务端两侧同一个值)。
val persisted = notifier.onProactiveFrame(
sessionId = SessionSummaryDto.MAIN_SESSION_ID,
id = "1371",
messageId = null,
timestamp = null,
content = "记得喝水哦",
)
assertEquals(ProactiveNotifyOutcome.Posted, live)
assertEquals(ProactiveNotifyOutcome.Duplicate, persisted)
assertEquals(listOf("记得喝水哦"), posted)
}
@Test
fun `a frame without a row id is deduplicated by the im message id`() {
// 落库失败那一档:`id` / `createdAt` 一起缺席,但 IM 侧消息号还在——服务端重推同一帧
// (或本端重放)不该变成两条通知。
background()
fun frame() = notifier.onProactiveFrame(
sessionId = null,
id = null,
messageId = "im-9",
timestamp = "2026-10-06T01:20:05+08:00",
content = "在忙吗",
)
assertEquals(ProactiveNotifyOutcome.Posted, frame())
assertEquals(ProactiveNotifyOutcome.Duplicate, frame())
assertEquals(1, posted.size)
}
@Test
fun `a frame with no server key at all falls back to the text`() {
// 服务端一个键都没给:兜底键取正文。**少弹**是刻意选的那一侧(多弹一次才是骚扰)。
background()
fun frame() = notifier.onProactiveFrame(
sessionId = null,
id = null,
messageId = null,
timestamp = null,
content = "想你了",
)
assertEquals(ProactiveNotifyOutcome.Posted, frame())
assertEquals(ProactiveNotifyOutcome.Duplicate, frame())
}
@Test
fun `a blank id is treated as absent`() {
// 形状漂移(空串)与「这个键不在」必须是同一个结论,否则一条空 id 会把后面的都判成重复。
background()
notifier.onProactiveFrame(null, id = " ", messageId = "im-3", timestamp = null, content = "甲")
val second = notifier.onProactiveFrame(null, id = null, messageId = "im-4", timestamp = null, content = "乙")
assertEquals(ProactiveNotifyOutcome.Posted, second)
assertEquals(2, posted.size)
}
// —— 什么时候弹 ——
@Test
fun `watching the main session in the foreground stays silent`() {
onMainSessionInForeground()
val outcome = notifier.onProactiveFrame(null, "1371", "im-7", null, "记得喝水哦")
assertEquals(ProactiveNotifyOutcome.SeenOnScreen, outcome)
assertTrue(posted.isEmpty())
}
@Test
fun `watching another session still notifies`() {
// 「不在聊天页」包括「在另一个会话的聊天页」:她的话进的是主会话,用户看不到它。
presence.onActivityStarted(this)
presence.onChatSurface("web:7f3a")
val outcome = notifier.onProactiveFrame(null, "1371", "im-7", null, "记得喝水哦")
assertEquals(ProactiveNotifyOutcome.Posted, outcome)
}
@Test
fun `the settings page still notifies`() {
// 前台但聊天页不在组合里(设置页 / 管理页 / 关于):那一刻聊天页报的是 null。
presence.onActivityStarted(this)
presence.onChatSurface(null)
assertEquals(
ProactiveNotifyOutcome.Posted,
notifier.onProactiveFrame(null, "1371", "im-7", null, "记得喝水哦"),
)
}
@Test
fun `a backgrounded app notifies even though the chat page is still composed`() {
// 这是 AppPresence 的类注释说的那一档:Activity onStop 之后组合还挂着,「聊天页显示着主会话」
// 仍然为真,而用户根本看不见它。只看聊天页那一半就会把后台的每一句都漏掉。
presence.onChatSurface(SessionSummaryDto.MAIN_SESSION_ID)
assertEquals(
ProactiveNotifyOutcome.Posted,
notifier.onProactiveFrame(null, "1371", "im-7", null, "记得喝水哦"),
)
}
@Test
fun `a message that was already seen on screen is not notified by the other route`() {
// 先到的那一次说了算:他当场看过了,随后落库帧再补一帧时他可能已经切走——
// 但**看过就是看过**,不该为一句看过的话弹通知。
onMainSessionInForeground()
assertEquals(
ProactiveNotifyOutcome.SeenOnScreen,
notifier.onProactiveFrame(null, "1371", "im-7", null, "记得喝水哦"),
)
presence.onChatSurface("web:7f3a")
assertEquals(
ProactiveNotifyOutcome.Duplicate,
notifier.onProactiveFrame(SessionSummaryDto.MAIN_SESSION_ID, "1371", null, null, "记得喝水哦"),
)
assertTrue(posted.isEmpty())
}
// —— 硬边界:不该弹的绝不弹 ——
@Test
fun `a frame from another session never notifies`() {
background()
val outcome = notifier.onProactiveFrame(
sessionId = "sess-9",
id = "1371",
messageId = "im-7",
timestamp = null,
content = "今天在忙什么",
)
assertEquals(ProactiveNotifyOutcome.OtherSession, outcome)
assertTrue("别处的消息不许说成是她在找我说话", posted.isEmpty())
}
@Test
fun `an absent - blank session id on the frame is treated as the main session`() {
// 实时帧的帧壳**不带** sessionId(服务端构造时不设)⇒「无法判定」放行,归属由主会话那条事实兜住。
background()
assertEquals(
ProactiveNotifyOutcome.Posted,
notifier.onProactiveFrame(sessionId = null, id = "1", messageId = null, timestamp = null, content = "在吗"),
)
assertEquals(
ProactiveNotifyOutcome.Posted,
notifier.onProactiveFrame(sessionId = "", id = "2", messageId = null, timestamp = null, content = "在吗"),
)
}
@Test
fun `a frame with nothing to show is skipped`() {
background()
val outcome = notifier.onProactiveFrame(null, "1371", "im-7", null, " ")
assertEquals(ProactiveNotifyOutcome.NothingToSay, outcome)
assertTrue(posted.isEmpty())
}
@Test
fun `a contentless frame does not burn the key of the message that does have content`() {
// 空帧**不记账**:同一条消息的另一条路上正文可能是齐的(形状漂移),记了就会把它挡成重复。
background()
notifier.onProactiveFrame(null, "1371", null, null, "")
assertEquals(
ProactiveNotifyOutcome.Posted,
notifier.onProactiveFrame(null, "1371", null, null, "记得喝水哦"),
)
}
@Test
fun `the posted text is her own words, not the raw markup`() {
background()
notifier.onProactiveFrame(null, "1", null, null, "<p>在的</p><action>轻轻笑</action>")
assertEquals(listOf("在的 轻轻笑"), posted)
}
@Test
fun `the main session constant has one spelling`() {
// `ui.chat.MAIN_SESSION_ID` 与 `SessionSummaryDto.MAIN_SESSION_ID` 是同一个事实的两处写法
// (判据里用的是后者)。两处一旦分叉,「主会话」这条归属就会静默判错——钉住它。
assertEquals("web", SessionSummaryDto.MAIN_SESSION_ID)
assertEquals(SessionSummaryDto.MAIN_SESSION_ID, top.yeij.cyrene.ui.chat.MAIN_SESSION_ID)
}
}
/**
* 去重账本本身(与 [ProactiveNotifierTest] 分开:一个是「弹不弹」,一个是「凭什么记得住」)。
*/
class RecentProactiveNoticesTest {
@Test
fun `the first arrival of a key is new and the second is not`() {
val recent = RecentProactiveNotices()
assertTrue(recent.markIfNew("1371"))
assertTrue(!recent.markIfNew("1371"))
assertTrue(recent.markIfNew("1372"))
}
@Test
fun `the ledger is bounded and drops the oldest first`() {
// 进程级账本**不许无限涨**:容量满了丢掉最老的那些(丢掉的代价最多是重复弹一次,
// 而那要等到「同一条又被重推」才可能发生)。
val recent = RecentProactiveNotices(capacity = 3)
recent.markIfNew("a")
recent.markIfNew("b")
recent.markIfNew("c")
recent.markIfNew("d")
assertEquals(3, recent.size())
assertTrue("最老的 a 已经被挤出去", recent.markIfNew("a"))
assertTrue("'b' 也早被挤出去(容量 3 只留 c/d/a)", recent.markIfNew("b"))
}
@Test
fun `a blank key is never considered new`() {
// 「没有键」不是一个可去重的身份:把它当新消息就等于每次都弹。
val recent = RecentProactiveNotices()
assertTrue(!recent.markIfNew(""))
assertTrue(!recent.markIfNew(" "))
assertEquals(0, recent.size())
}
@Test
fun `a zero capacity still remembers one key`() {
// 容量被误传成 0 / 负数时不许退化成「每条都弹」:至少记住最近一条。
val recent = RecentProactiveNotices(capacity = 0)
assertTrue(recent.markIfNew("1371"))
assertTrue(!recent.markIfNew("1371"))
assertEquals(1, recent.size())
}
}
@@ -0,0 +1,134 @@
package top.yeij.cyrene.core.notify
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 「她主动说的话」的通知判据(W53N)——**纯函数**,四档组合穷举在这里。
*
* 主人对时机的原话是「App 不在前台,或者不在聊天页」;这个判据是它唯一的落地处,
* 而那四种组合**各有各的真实场景**(尤其是「不在前台、聊天页却还显示着主会话」那一档:
* Android 上 Activity `onStop` 之后组合并不销毁,只看聊天页就会把后台收到的**每一句**都漏掉)。
*/
class ShouldNotifyProactiveTest {
@Test
fun `being on the main session in the foreground is the only silent case`() {
assertFalse(
"用户正看着主会话,她的话就出现在他眼前——再弹一条通知是打扰",
shouldNotifyProactive(appInForeground = true, viewingTargetSession = true),
)
}
@Test
fun `any other combination notifies`() {
assertTrue(
"前台但不在聊天页(设置页 / 管理页 / 别的会话)——这条消息他不会正好看到",
shouldNotifyProactive(appInForeground = true, viewingTargetSession = false),
)
assertTrue(
"退到后台:两个事实都假",
shouldNotifyProactive(appInForeground = false, viewingTargetSession = false),
)
assertTrue(
"退到后台而组合还挂着(聊天页仍「显示」主会话):用户根本看不见它,所以照弹",
shouldNotifyProactive(appInForeground = false, viewingTargetSession = true),
)
}
}
/**
* 通知正文(纯函数):剥标记、折成一行、过长截断。
*
* 这几条**每一条错了都肉眼可见**:露一个 `<p>` 出来、把两句话粘成一个词、或截断劈开一个 emoji
* (半个代理码元在通知里是一块乱码方块)。
*/
class ProactiveNotificationTextTest {
@Test
fun `the markup she writes is stripped instead of shown`() {
// 服务端原样落库她的标记,直接显示会把标签露给用户(会话列表那一行早就踩过同一件事)。
assertEquals(
"今天下午 悄悄戳了戳你 你有空吗",
proactiveNotificationText("<p>今天下午</p><action>悄悄戳了戳你</action><p>你有空吗</p>"),
)
}
@Test
fun `newlines collapse into one line`() {
assertEquals("第一句 第二句", proactiveNotificationText("第一句\n\n第二句"))
}
@Test
fun `a short line is left untouched`() {
assertEquals("记得喝水哦", proactiveNotificationText("记得喝水哦"))
}
@Test
fun `a long line is truncated with an ellipsis`() {
val text = proactiveNotificationText("她".repeat(200), maxChars = 10)
assertEquals("她".repeat(10) + "…", text)
}
@Test
fun `truncation never splits a surrogate pair`() {
// 😀 在 UTF-16 里是两个码元。maxChars = 2 正好落在它的**高代理项**上:不后退一位就会留下
// 半个字符(通知里显示成乱码方块)。
val text = proactiveNotificationText("a😀b", maxChars = 2)
assertEquals("a…", text)
assertFalse(
"结果里不许有孤立的代理码元",
text!!.any { it.isHighSurrogate() || it.isLowSurrogate() },
)
}
@Test
fun `nothing to say returns null rather than an empty notification`() {
assertNull(proactiveNotificationText(""))
assertNull(proactiveNotificationText(" \n "))
// 只有标记、没有一个字:解析器把它解成一串空白,也不该弹一条空通知。
assertNull(proactiveNotificationText("<p></p>"))
}
}
/**
* 通知 id / 渠道 id 的**对外形状**(与 `KeepAliveNotificationTest` 同一套理由)。
*
* 这两条只能在单元测试里钉住(文案与图标要 Android 运行时),而它们恰恰是最容易出大错的两处:
* 同 id 会**替换**掉保活那条常驻通知;同渠道会让「保活开始响铃」与「她的话不响铃」二选一。
*/
class ProactiveNotificationShapeTest {
@Test
fun `the proactive notification never replaces the keep-alive one`() {
assertTrue(
"同 id 的 notify 是替换:撞上就把「正在后台保持连接」那条常驻通知顶掉",
PROACTIVE_NOTIFICATION_ID != top.yeij.cyrene.core.keepalive.KEEP_ALIVE_NOTIFICATION_ID,
)
}
@Test
fun `proactive has its own channel`() {
assertTrue(
"两条渠道各自表达一件事,混在一条里就只能二选一",
PROACTIVE_CHANNEL_ID != top.yeij.cyrene.core.keepalive.KEEP_ALIVE_CHANNEL_ID,
)
// 恒定不变:改 id = 换渠道 = 用户对旧渠道做过的静音设置全部作废。
assertEquals("proactive", PROACTIVE_CHANNEL_ID)
}
@Test
fun `the channel is allowed to alert unlike the keep-alive one`() {
// 这一条**是**要来提醒人的;与保活那条(LOW)刻意相反,所以这里钉的是「它比 LOW 高」。
assertTrue(
"她要提醒人,重要级必须高于保活那条",
PROACTIVE_IMPORTANCE > top.yeij.cyrene.core.keepalive.KEEP_ALIVE_CHANNEL_IMPORTANCE,
)
assertEquals(android.app.NotificationManager.IMPORTANCE_DEFAULT, PROACTIVE_IMPORTANCE)
}
}
@@ -0,0 +1,114 @@
package top.yeij.cyrene.data.ws
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 落库帧上的 `proactive` 判据键(W53N)。
*
* 这是 W53N 的**第一半**:她主动说的话有两条线上路径,其中走 IM 桥那条会把判据打在落库帧上,
* 而这个键此前被本端**整个丢掉**了(`PersistedData` 只解 `message`,生产 `Json` 又是
* `ignoreUnknownKeys = true` ⇒ 既不解也不报错)。丢掉它不报任何错,只是「她主动说的话」在客户端
* 少了一半入口——通知永远不会为那条路弹。
*
* 另外两件事也必须在这里钉住,它们各自都足以让整帧消失或让普通消息响起来:
* 1. 这个键**缺席**时必须是普通消息(主人只批了 proactive 这一档,普通消息绝不弹);
* 2. 这个键**形状不认识**时,帧照旧成形(宁可判成「不是 proactive」,也不能让整帧消失——
* 那会连普通消息的落库广播一起弄没)。
*/
class WsEventPersistedProactiveTest {
private fun frame(data: String) = """{"type":"message_persisted","sessionId":"web","data":$data}"""
private val row = """
{"id":"1371","sessionId":"web","role":"assistant","content":"记得喝水哦","createdAt":1791212405123}
""".trimIndent()
@Test
fun `a proactive flag inside the payload is read back`() {
val event = WsEvent.parse(
frame("""{"proactive":true,"message":$row}"""),
) as WsEvent.MessagePersisted
assertTrue("判据键必须真的读回来——这是整批任务的前提", event.proactive)
assertEquals("1371", event.message?.id)
assertEquals("记得喝水哦", event.message?.content)
}
@Test
fun `a proactive flag on the frame shell is read too`() {
// 契约说的是「帧顶带它」,而没说清是帧壳还是载荷。**猜错的方向是这个功能静默失效**,
// 所以两层都认(这里钉住帧壳那一层)。
val event = WsEvent.parse(
"""{"type":"message_persisted","sessionId":"web","proactive":true,"data":{"message":$row}}""",
) as WsEvent.MessagePersisted
assertTrue(event.proactive)
assertEquals("1371", event.message?.id)
}
@Test
fun `a string shaped flag is accepted instead of killing the frame`() {
// 同 `proactive_message` 的 `timestamp` 那条教训:线上形状只要不是客户端声明的那一种,
// 严格解码就会抛、被 `parse` 的 runCatching 吞成 null ⇒ **整帧消失**(连普通消息都受影响)。
// 宽容读法把「不认识」折成「不是 proactive」,帧本身照旧成形。
val event = WsEvent.parse(
frame("""{"proactive":"true","message":$row}"""),
) as WsEvent.MessagePersisted
assertTrue(event.proactive)
assertEquals("1371", event.message?.id)
}
@Test
fun `an absent flag means an ordinary message`() {
// 硬边界:普通消息绝不弹通知。缺席是**唯一**的现实形状(老服务端 / 非 proactive 路由)。
val event = WsEvent.parse(frame("""{"message":$row}""")) as WsEvent.MessagePersisted
assertFalse(event.proactive)
assertEquals("1371", event.message?.id)
}
@Test
fun `a false flag means an ordinary message`() {
val event = WsEvent.parse(
frame("""{"proactive":false,"message":$row}"""),
) as WsEvent.MessagePersisted
assertFalse(event.proactive)
}
@Test
fun `an unrecognised flag shape keeps the frame and reads as not proactive`() {
// 形状漂移(对象 / 数字 / null)一律按假:这一侧判错只会漏一次通知,
// 判成真会给普通消息弹一条通知——方向必须偏「不打扰」。
listOf("""{"proactive":{"v":1},"message":$row}""", """{"proactive":1,"message":$row}""", """{"proactive":null,"message":$row}""")
.forEach { data ->
val event = WsEvent.parse(frame(data)) as WsEvent.MessagePersisted
assertFalse("这条形状该被按成普通消息:$data", event.proactive)
assertNotNull("整帧不许因为一个判据键消失:$data", event.message)
}
}
@Test
fun `the frame still decodes when the payload is missing entirely`() {
val event = WsEvent.parse("""{"type":"message_persisted","sessionId":"web"}""") as WsEvent.MessagePersisted
assertFalse(event.proactive)
assertNull(event.message)
}
@Test
fun `channel persisted frames still decode`() {
// 两条帧共用一个载荷类型:给 `PersistedData` 加字段不许影响 IM 日志页那条路。
val event = WsEvent.parse(
"""{"type":"channel_persisted","sessionId":"sess-9","data":{"message":{"id":"m-1","sessionId":"sess-9","role":"user","content":"在吗","createdAt":1}}}""",
) as WsEvent.ChannelPersisted
assertEquals("m-1", event.message?.id)
}
}