merge: W49 —— 非激活会话落新消息时节流静默重拉(3 秒窗口 + 结构性 trailing),让抽屉红点在不开抽屉时也能出现

This commit is contained in:
AskaEth committed 2026-10-05 22:28:39 +08:00
commit 5dc6408bb9
3 files changed
+556 -15

No files matched your search

@@ -0,0 +1,210 @@
package top.yeij.cyrene.ui.sessions
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 非激活会话的新消息 ⇒ 节流地静默重拉(W49 修的 S6:「停在主会话时次级会话来了新消息,
* 抽屉红点不出现,必须开一次抽屉才补上」)。
*
* 两块纯判据([persistedRoute] 定这一帧走哪条路,[silentRefreshStep] 定这一刻该不该拉、拉完窗口怎么走),
* 钉住四条要一直成立的边界:
* 1. 非激活会话的新消息 ⇒ **该拉**;
* 2. 同一节流窗口内的后续消息 ⇒ **不拉**,但窗口到点**必定**还会再拉一次(trailing,红点不会停在旧值);
* 3. 当前打开的那个会话的新消息 ⇒ **不拉**(那条路本来就有「看到即已读」,多拉一次列表就是白花两个
* 请求);
* 4. 换账号 / 换地址 ⇒ **不被窗口挡住**(该拉)。切会话与它同类,但那条路刻意没接进来 ——
* 理由见 [silentRefreshStep] 与 SessionsViewModel.select。
*
* 时钟一律由测试喂(判据自己不读 `System.currentTimeMillis()`),所以这里不用 sleep、也没有 flaky。
*/
class InactiveSessionPullTest {
// —— 路由:哪一帧有资格触发拉列表 ——
@Test
fun `a message in another session takes the pull route`() {
assertEquals(
PersistedRoute.InactiveSession,
persistedRoute(sessionId = "web:other", activeSessionId = "web"),
)
}
@Test
fun `a message in the active session never pulls the list`() {
// 「别加请求」:当前会话的未读由「看到即已读」处理掉了,抽屉里的未读不会因这一帧变多。
assertEquals(
PersistedRoute.ActiveSession,
persistedRoute(sessionId = "web", activeSessionId = "web"),
)
}
@Test
fun `ids are compared verbatim, not by prefix`() {
// 主会话 `web` 与次级会话 `web:xxx` 是**两个**会话(前缀相同):判据必须是等值,不是前缀。
assertEquals(
PersistedRoute.InactiveSession,
persistedRoute(sessionId = "web:1", activeSessionId = "web"),
)
assertEquals(
PersistedRoute.InactiveSession,
persistedRoute(sessionId = "web", activeSessionId = "web:1"),
)
}
// —— 节流:该拉的那一刻拉,窗口内不重复拉 ——
@Test
fun `the first inactive message pulls right away`() {
val step = silentRefreshStep(
cause = SilentRefreshCause.InactiveSessionMessage,
nowMillis = 1_000L,
throttle = SilentRefreshThrottle(),
)
assertEquals(SilentRefreshAction.PullNow, step.action)
// 账本:窗口从这一拉开始,且没有悬着的补拉。
assertEquals(1_000L, step.throttle.lastPullAt)
assertFalse(step.throttle.trailingPending)
}
@Test
fun `a second message inside the window does not pull, but arms the trailing pull`() {
val armed = silentRefreshStep(
cause = SilentRefreshCause.InactiveSessionMessage,
nowMillis = 1_000L,
throttle = SilentRefreshThrottle(lastPullAt = 0L),
)
assertEquals(SilentRefreshAction.ScheduleTrailing, armed.action)
// 等到窗口走完(0 + 窗口),不是「从这一刻再等一个窗口」。
assertEquals(SILENT_REFRESH_WINDOW_MILLIS - 1_000L, armed.delayMillis)
assertTrue(armed.throttle.trailingPending)
}
@Test
fun `more messages inside the same window do not arm a second trailing pull`() {
val armed = SilentRefreshThrottle(lastPullAt = 0L, trailingPending = true)
val step = silentRefreshStep(
cause = SilentRefreshCause.InactiveSessionMessage,
nowMillis = 2_000L,
throttle = armed,
)
// 已经排上的那次补拉就是「窗口里所有成因的合并」,再排一次只会多一个请求。
assertEquals(SilentRefreshAction.Ignore, step.action)
assertEquals(armed, step.throttle)
}
@Test
fun `a message exactly one window later pulls again`() {
// 边界口径:正好等于窗口**算窗口已过**(见 silentRefreshStep 的 KDoc)。
val step = silentRefreshStep(
cause = SilentRefreshCause.InactiveSessionMessage,
nowMillis = SILENT_REFRESH_WINDOW_MILLIS,
throttle = SilentRefreshThrottle(lastPullAt = 0L),
)
assertEquals(SilentRefreshAction.PullNow, step.action)
assertEquals(SILENT_REFRESH_WINDOW_MILLIS, step.throttle.lastPullAt)
}
@Test
fun `one millisecond before the window is over still does not pull`() {
val step = silentRefreshStep(
cause = SilentRefreshCause.InactiveSessionMessage,
nowMillis = SILENT_REFRESH_WINDOW_MILLIS - 1L,
throttle = SilentRefreshThrottle(lastPullAt = 0L),
)
assertEquals(SilentRefreshAction.ScheduleTrailing, step.action)
assertEquals(1L, step.delayMillis)
}
// —— trailing:连发之后一定还会再拉一次 ——
@Test
fun `a burst of five messages ends as two pulls`() {
// 别人连发 5 条(第 1 条时就拉过了):总共**两次**请求 —— 第 1 条那次,以及窗口到点的补拉。
// 只有上限没有下限的话,第 2..5 条会被窗口吃掉,红点停在「第 1 条」的旧值上。
var ledger = throttleAfterPull(0L)
// 第 2 条:窗口内 ⇒ 不拉,但排上补拉。
val second = silentRefreshStep(SilentRefreshCause.InactiveSessionMessage, 500L, ledger)
assertEquals(SilentRefreshAction.ScheduleTrailing, second.action)
ledger = second.throttle
// 第 3、4、5 条:同一窗口内 ⇒ 安静(补拉已经排上了)。
for (at in listOf(900L, 1_500L, 2_800L)) {
val step = silentRefreshStep(SilentRefreshCause.InactiveSessionMessage, at, ledger)
assertEquals(SilentRefreshAction.Ignore, step.action)
ledger = step.throttle
}
// 补拉到点:把它拉掉(VM 到点后走的就是这一步 —— 账本按「真的拉过一次」推进)。
val trailingAt = 500L + second.delayMillis
assertEquals(SILENT_REFRESH_WINDOW_MILLIS, trailingAt)
ledger = throttleAfterPull(trailingAt)
assertEquals(SILENT_REFRESH_WINDOW_MILLIS, ledger.lastPullAt)
assertFalse(ledger.trailingPending)
// 补拉**不是终点**:下一个窗口里的新消息照样能拉(不是「只补一次就哑了」)。
val later = silentRefreshStep(
SilentRefreshCause.InactiveSessionMessage,
SILENT_REFRESH_WINDOW_MILLIS * 2,
ledger,
)
assertEquals(SilentRefreshAction.PullNow, later.action)
}
@Test
fun `the trailing delay lands exactly on the window boundary`() {
// 补拉要等的时间与判据用的是同一个常量:从「排上的那一刻」往后等,正好落在窗口结束那一点
// (VM 到点就直接拉、不再回头问判据,所以这里钉的是那个时长本身没算错)。
val armed = silentRefreshStep(
cause = SilentRefreshCause.InactiveSessionMessage,
nowMillis = 700L,
throttle = SilentRefreshThrottle(lastPullAt = 0L),
)
assertEquals(0L + SILENT_REFRESH_WINDOW_MILLIS, 700L + armed.delayMillis)
}
// —— 身份变化不被窗口挡住 ——
@Test
fun `a context change pulls even inside the window`() {
val insideWindow = SilentRefreshThrottle(lastPullAt = 1_000L, trailingPending = true)
val step = silentRefreshStep(
cause = SilentRefreshCause.ContextChanged,
nowMillis = 1_500L,
throttle = insideWindow,
)
// 换账号 / 换地址那一拉与「别的会话又攒了几条」无关,不该被窗口挡住。
assertEquals(SilentRefreshAction.PullNow, step.action)
// 这一拉已经把窗口里那条消息要的刷新一并做掉 ⇒ 窗口重开,不再补一次。
assertEquals(1_500L, step.throttle.lastPullAt)
assertFalse(step.throttle.trailingPending)
}
// —— 账本:只记真的拉过的那一次 ——
@Test
fun `an empty ledger has no pull time`() {
assertNull(SilentRefreshThrottle().lastPullAt)
}
@Test
fun `after a pull the window restarts and nothing is left pending`() {
val ledger = throttleAfterPull(42L)
assertEquals(42L, ledger.lastPullAt)
assertFalse(ledger.trailingPending)
}
}