fix(sessions): 非激活会话落新消息时自愈式节流重拉,抽屉红点不再等用户开一次抽屉(S6)
现象:App 停在主会话时,次级会话落了新消息 ⇒ 抽屉红点不出现,必须打开一次抽屉 (那次静默 refresh)才补上。 根因:message_persisted 只在 sessionId == activeSessionId 时才推进 / 更新,非激活 会话那一帧直接 return;而会话列表更新的触发点此前只有抽屉打开 / 新建 / 删除 / init 四处,于是「别的会话攒了新消息」没有任何人会去发现。 为什么是客户端自愈而不是等后端:这一帧不带 unreadCount(契约面缺一个增量),而本端 没有逐条时间线 —— 自己累加出来的数会与编辑 / 删除 / 别端操作漂移,那是造数不是修复。 帧壳本身已经说清「某个会话有新消息落库」这件事,所以本端只是据此去问一次服务端, 走的是与抽屉打开时那次完全相同的路径(refresh(silent = true),含已读游标遮罩), 只是不需要用户先开一次抽屉。后端把增量补上之后这条路可以退成不必走,但在此之前它 是唯一能把红点点亮的路。 节流窗口 3 秒,理由: - 够快:3 秒落在「看起来就是刚发生的」这个感知里;5 秒往上就开始像卡了一下才补上; - 够粗:一次静默重拉是两个请求(refresh 里还跟着一次角色目录),而播出方是按每条 消息推帧的 —— 3 秒能把人手连打 5 条(约 2~4 秒)或一轮回复的连续落库收进同一个 窗口,收到 1 秒就会漏成两三次请求; - 不另立尺子:3 秒正是本项目已有的「等够久才值得为一次等待开口」那个量级 (ChatScreen 的 SHARE_WAIT_NOTICE_MILLIS)。 trailing 怎么保证:窗口内的第二条起不拉,但排一次「窗口到点补拉」(等待时长 = 窗口 - 已过时间),到点直接拉一次并把窗口重开;同一时刻最多一个补拉任务(已排上时 后续成因只更新账本、不重排),所以最后一条之后必定还会再拉一次,红点不会停在旧值。 另外「正好等于窗口」算窗口已过(>= 而不是 >),补拉到点那一刻不会被自己刚开的窗口 挡回去。 哪几种情况不拉:当前打开的那个会话落新消息(那条路本来就有「看到即已读」,多拉一次 列表就是白花两个请求);同一窗口内的后续消息;窗口内且补拉已经排上。另外切会话那条 路刻意不接进来 —— select 紧接着就发推进已读游标的请求,此时重拉会与还没落地的游标 抢跑、把刚清掉的角标闪回来(抽屉打开时那一次紧跟在游标之后,本来就会补上这一拉)。 判据抽成纯函数(新增 InactiveSessionPull.kt):persistedRoute 决定这一帧走哪条路, silentRefreshStep 决定这一刻该不该拉、拉完账本怎么走;时钟由调用方喂,不用 sleep 也就能穷举边界。 顺带把身份变化(重登成另一个账号 / 换了服务器地址)也接进这条路径,用 ContextChanged 绕过节流窗口 —— 那份列表换了主人,等满一个为「别的会话又攒了几条」而设的窗口没有 道理;登出时把账本与补拉任务一起作废(否则补拉会带着一个已经没有令牌的身份发请求)。 抽屉打开时那次 refresh、activeSessionId 的推进、已读游标的既有行为都没动。 验证:gradlew.bat assembleDebug testDebugUnitTest —— BUILD SUCCESSFUL; 1171 条单测 failures 0 / errors 0 / skipped 0,其中新增 InactiveSessionPullTest 13 条。
This commit is contained in:
1 parent
df8df67742
commit
93cf3e359a
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user