feat(net,pose): 连接状态机与保活对齐 + MediaPipe 崩溃修复(T-AND-010/011/012)
契约 websocket-keypoints 0.1.0 -> 0.2.0(向后兼容,子协议名与帧 version 不变;
先在 opendance-web 修改并提升版本,再同步三端快照)。
T-AND-011:连接状态异常、频繁重连(查实 3 个根因)
1. 保活用错了层:Windows 端有 5s 静默超时(只看应用层收到的帧),而本端只有
OkHttp 的协议层 ping —— 它由底层库自动应答、**不暴露给应用层**,根本不能续命。
画面里没人时 5s 必被踢、再重连,循环往复。
- 契约新增 §1.1(welcome/config 声明 silentTimeoutMs 与 heartbeatIntervalMs,
且心跳必须 < 静默超时的一半)与 §3.6(应用层 ping/pong);
- Windows 端超时 5s -> 15s、期望心跳 5s,并在 welcome/config 中下发;
- 本端按声明发 ping,连续 3 个周期无 pong 才判定链路已死并主动重连。
2. 不该重连时也重连:契约新增 §7 关闭码策略,1000/1001/1002 与主动停止一律不重连。
3. frame_id 恒为 0:PoseDetector.detect() 算了 safeTimestamp 却忘了写回
lastTimestampMs,接收端的丢包检测因此完全失效。已修。
其他对齐:
- 契约新增 §8 连接状态机 idle -> resolving -> connecting -> handshaking ->
streaming -> degraded -> reconnecting -> stopped,两端同名便于对照日志;
resolving 只对主机名生效,局域网直连 IP 直接进 connecting;
- 退避与抖动按 §7.1 冻结:0.5s -> 1 -> 2 -> 4 -> 8 -> 15s 封顶 + ±20% 抖动;
- 契约新增 §5 设备去重:hello 携带稳定 deviceId(ANDROID_ID 的 SHA-256 前 16 位,
不含硬件序列号),Windows 端据此去重、旧连接以 1000 关闭;
- 新增 util/ConnectionLog(环形缓冲,最近 500 条:状态迁移 / close code /
子协议协商 / 首帧时间 / 保活参数),运行页可一键导出(FileProvider)。
T-AND-012:MediaPipe 初始化崩溃(ExceptionInInitializerError: null)
- 诊断:新增 pose/PoseDiagnostics 展开 cause 链取真因(通常 UnsatisfiedLinkError)、
区分 native 加载失败、比对设备 ABI 与 APK 内 ABI 并给出可执行结论;
- native 解压:packaging { jniLibs { useLegacyPackaging = true } }(AGP 8 release
默认 extractNativeLibs=false,MediaPipe JNI 对它不兼容);
- R8:补 -keepclasseswithmembernames 的 native 方法保留规则,并 -printseeds 输出
seeds 供发布前核对(实测保留 20169 条 MediaPipe 条目);
- 构建期新增 verifyReleaseAbi:release ABI 必须恰为 arm64-v8a/armeabi-v7a、
每个 ABI 下必须有 MediaPipe native 库、模型资源必须存在;
- 未换库:诊断指向集成配置而非 MediaPipe 本身,保留 33 点契约零成本。
T-AND-010:版本号展示与单一来源
- 版本唯一来源 versionName(= project.yaml 的 version);versionCode 按
10000*主+100*次+修订 递增(0.2.0 -> 200);
- 契约版本改为构建期解析 contracts/VERSION 后注入 BuildConfig(六项各自独立
递增,原先的单个 CONTRACT_VERSION 常量已失真);界面不再手写任何版本号;
- 新增 checkReleaseConsistency 并挂到 preBuild,三者不一致直接 BUILD FAILED。
README/DSH/THIRD_PARTY/tasks 同步更新(含「导出连接日志」「保活参数」
「最近断开原因」等新增可观察项与排查表)。
测试:56 项全部通过(新增 ReconnectPolicyTest 11 项、PoseDiagnosticsTest 12 项);
build/verify.sh 全通过(单元测试 -> 构建 -> 签名/ABI/native/seeds/dex 校验)
This commit is contained in:
@@ -71,6 +71,78 @@ class CreditsTest {
|
||||
|
||||
@Test
|
||||
fun contractVersionIsPinned() {
|
||||
assertEquals("契约版本必须与 opendance-web v0.1.0 对齐", "0.1.0", Credits.CONTRACT_VERSION)
|
||||
// 本端是 websocket-keypoints 的发送方,这项必须与契约快照一致
|
||||
assertEquals(
|
||||
"websocket-keypoints 契约版本必须与 opendance-web 冻结版本一致",
|
||||
"0.2.0",
|
||||
Credits.CONTRACT_VERSION,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everyContractVersionMatchesTheSnapshot() {
|
||||
// 契约版本是"单一来源":contracts/VERSION 决定一切,界面不得手写
|
||||
val snapshot = readContractVersionFile()
|
||||
assertTrue("能读到 contracts/VERSION", snapshot.isNotEmpty())
|
||||
assertEquals(
|
||||
"契约项数与 contracts/VERSION 一致(新增契约时忘记同步会导致这里失败)",
|
||||
snapshot.size,
|
||||
Credits.contractVersions.size,
|
||||
)
|
||||
for ((name, version) in Credits.contractVersions) {
|
||||
assertEquals(
|
||||
"契约 $name 的版本与快照一致",
|
||||
snapshot[name] ?: "",
|
||||
version,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appVersionMatchesProjectYaml() {
|
||||
// 文档 §11.2:界面展示的版本必须与 project.yaml / 产物文件名同源
|
||||
val yaml = readProjectYamlVersion()
|
||||
assertTrue("能读到 project.yaml 的 version", yaml.isNotEmpty())
|
||||
assertEquals("BuildConfig.VERSION_NAME 必须等于 project.yaml 的 version", yaml, Credits.appVersion)
|
||||
assertEquals("Credits.appVersion 与 BuildConfig 同源(不得手写)", BuildConfig.VERSION_NAME, Credits.appVersion)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contractSummaryListsAllContracts() {
|
||||
val summary = Credits.contractSummary()
|
||||
for ((name, version) in Credits.contractVersions) {
|
||||
assertTrue("摘要含 $name", summary.contains(name))
|
||||
assertTrue("摘要含 $name 的版本 v$version", summary.contains("v$version"))
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 文件读取
|
||||
|
||||
/** 从模块目录逐级向上找仓库根(含 contracts/VERSION 的那一级) */
|
||||
private fun repoFile(relative: String): java.io.File? {
|
||||
var dir: java.io.File = java.io.File(System.getProperty("user.dir")).absoluteFile
|
||||
repeat(6) {
|
||||
val candidate = java.io.File(dir, relative)
|
||||
if (candidate.exists()) return candidate
|
||||
dir = dir.parentFile ?: return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun readContractVersionFile(): Map<String, String> {
|
||||
val file = repoFile("contracts/VERSION") ?: return emptyMap()
|
||||
return file.readLines()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() && !it.startsWith("#") && it.contains(":") }
|
||||
.associate { it.substringBefore(":").trim() to it.substringAfter(":").trim() }
|
||||
}
|
||||
|
||||
private fun readProjectYamlVersion(): String {
|
||||
val file = repoFile("project.yaml") ?: return ""
|
||||
return file.readLines()
|
||||
.firstOrNull { it.trimStart().startsWith("version:") }
|
||||
?.substringAfter(":")
|
||||
?.trim()
|
||||
?: ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package com.opendance.studio
|
||||
|
||||
import com.opendance.studio.pose.PoseDiagnostics
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* T-AND-012:MediaPipe 初始化失败诊断
|
||||
*
|
||||
* `ExceptionInInitializerError: null` 本身没有信息量,真因在 cause 链里。
|
||||
* 这里逐条核对"能不能把真因翻出来、能不能给出 ABI 维度的人话结论"。
|
||||
*/
|
||||
class PoseDiagnosticsTest {
|
||||
|
||||
// ------------------------------------------------------------ cause 链
|
||||
|
||||
@Test
|
||||
fun causeChainUnwrapsInitializerError() {
|
||||
val link = UnsatisfiedLinkError("dlopen failed: library \"libmediapipe_tasks_vision_jni.so\" not found")
|
||||
val outer = ExceptionInInitializerError(link)
|
||||
|
||||
val chain = PoseDiagnostics.causeChain(outer)
|
||||
assertTrue("链里含外层异常类名", chain.contains("ExceptionInInitializerError"))
|
||||
assertTrue("链里含真因类名", chain.contains("UnsatisfiedLinkError"))
|
||||
assertTrue("链里含缺失的库名", chain.contains("libmediapipe_tasks_vision_jni.so"))
|
||||
|
||||
assertSame("rootCause 应返回最内层真因", link, PoseDiagnostics.rootCause(outer))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun causeChainTerminatesOnCyclicCauses() {
|
||||
// 手工 setCause 过的第三方库可能形成环(A -> B -> A);处理不当会死循环。
|
||||
// 注意 JVM 禁止自引用(t.initCause(t) 直接抛 IllegalArgumentException),
|
||||
// 因此这里构造的是两节点环。
|
||||
val a = RuntimeException("A")
|
||||
val b = RuntimeException("B")
|
||||
a.initCause(b)
|
||||
b.initCause(a)
|
||||
val chain = PoseDiagnostics.causeChain(a)
|
||||
assertTrue("环上的异常都出现在链里", chain.contains("A") && chain.contains("B"))
|
||||
assertTrue("环不会导致死循环(有长度上限)", chain.length < 1000)
|
||||
val root = PoseDiagnostics.rootCause(a)
|
||||
assertTrue("rootCause 在环上收敛(不抛错)", root === a || root === b)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun causeChainHandlesNullMessage() {
|
||||
// message 为 null 时仍要给出类名,否则用户只看到 "null"
|
||||
val chain = PoseDiagnostics.causeChain(ExceptionInInitializerError(RuntimeException()))
|
||||
assertTrue("消息为 null 时仍输出外层类名", chain.contains("ExceptionInInitializerError"))
|
||||
assertTrue("消息为 null 时仍输出真因类名", chain.contains("RuntimeException"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nativeLoadFailureIsDetectedThroughTheChain() {
|
||||
assertTrue(
|
||||
"ExceptionInInitializerError(cause=UnsatisfiedLinkError) 属于 native 加载失败",
|
||||
PoseDiagnostics.isNativeLoadFailure(
|
||||
ExceptionInInitializerError(UnsatisfiedLinkError("dlopen failed")),
|
||||
),
|
||||
)
|
||||
assertTrue("直接的 UnsatisfiedLinkError 属于 native 加载失败", PoseDiagnostics.isNativeLoadFailure(UnsatisfiedLinkError("x")))
|
||||
assertTrue("NoClassDefFoundError 也归入该类", PoseDiagnostics.isNativeLoadFailure(NoClassDefFoundError("x")))
|
||||
assertFalse("普通业务异常不算 native 加载失败", PoseDiagnostics.isNativeLoadFailure(IllegalStateException("模型文件缺失")))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ ABI
|
||||
|
||||
@Test
|
||||
fun abisAreExtractedFromApkEntries() {
|
||||
val entries = listOf(
|
||||
"lib/arm64-v8a/libmediapipe_tasks_vision_jni.so",
|
||||
"lib/arm64-v8a/libopencv_java4.so",
|
||||
"lib/armeabi-v7a/libmediapipe_tasks_vision_jni.so",
|
||||
"assets/pose_landmarker_lite.task",
|
||||
"classes.dex",
|
||||
"META-INF/MANIFEST.MF",
|
||||
)
|
||||
assertEquals(
|
||||
"只取 lib/<abi>/ 下的目录名并去重排序",
|
||||
listOf("arm64-v8a", "armeabi-v7a"),
|
||||
PoseDiagnostics.abisFromEntries(entries),
|
||||
)
|
||||
assertTrue("arm64-v8a 含 mediapipe native 库", PoseDiagnostics.hasMediaPipeNative(entries, "arm64-v8a"))
|
||||
assertFalse("x86_64 没有 mediapipe native 库", PoseDiagnostics.hasMediaPipeNative(entries, "x86_64"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun abisAreEmptyWhenApkHasNoNativeLibs() {
|
||||
assertEquals(
|
||||
"没有 lib/ 条目时返回空列表",
|
||||
emptyList<String>(),
|
||||
PoseDiagnostics.abisFromEntries(listOf("classes.dex", "assets/x.task")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun releaseApkOnX86EmulatorIsDiagnosed() {
|
||||
// 这是"debug 好用、release 崩"的根因:release 只打包真机 ABI
|
||||
val hint = PoseDiagnostics.abiMismatchHint(
|
||||
deviceAbis = listOf("x86_64", "x86"),
|
||||
apkAbis = listOf("arm64-v8a", "armeabi-v7a"),
|
||||
)
|
||||
assertNotNull("x86_64 模拟器 + 仅 ARM 的 APK 必须给出提示", hint)
|
||||
assertTrue("提示里要说清 APK 实际包含的 ABI", hint!!.contains("arm64-v8a"))
|
||||
assertTrue("提示里要说清设备需要的 ABI", hint.contains("x86_64"))
|
||||
assertTrue("提示里要给出可执行建议", hint.contains("真机") || hint.contains("debug"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun arm64DeviceWithArmApkIsNotFlagged() {
|
||||
assertNull(
|
||||
"真机 arm64 + ARM APK 正常,不应提示",
|
||||
PoseDiagnostics.abiMismatchHint(
|
||||
deviceAbis = listOf("arm64-v8a", "armeabi-v7a"),
|
||||
apkAbis = listOf("arm64-v8a", "armeabi-v7a"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun arm64DeviceCanStillUseApkThatAlsoShipsArm32() {
|
||||
assertNull(
|
||||
"设备支持多个 ABI 时只要有一个命中即可",
|
||||
PoseDiagnostics.abiMismatchHint(
|
||||
deviceAbis = listOf("arm64-v8a"),
|
||||
apkAbis = listOf("arm64-v8a", "armeabi-v7a"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownAbisAreNotFlagged() {
|
||||
assertNull(
|
||||
"信息缺失时不应误报",
|
||||
PoseDiagnostics.abiMismatchHint(emptyList(), listOf("arm64-v8a")),
|
||||
)
|
||||
assertNull(
|
||||
"APK 无 native 库时不误报(由别的检查负责)",
|
||||
PoseDiagnostics.abiMismatchHint(listOf("arm64-v8a"), emptyList()),
|
||||
)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 汇总报告
|
||||
|
||||
@Test
|
||||
fun describeProducesActionableReport() {
|
||||
val error = ExceptionInInitializerError(UnsatisfiedLinkError("dlopen failed: library \"libx.so\" not found"))
|
||||
val report = PoseDiagnostics.describe(
|
||||
t = error,
|
||||
deviceAbis = listOf("x86_64"),
|
||||
apkAbis = listOf("arm64-v8a", "armeabi-v7a"),
|
||||
)
|
||||
assertTrue("报告含原始异常", report.contains("ExceptionInInitializerError"))
|
||||
assertTrue("报告含真因", report.contains("UnsatisfiedLinkError"))
|
||||
assertTrue("报告含判定结论", report.contains("native 库加载"))
|
||||
assertTrue("报告含设备 ABI", report.contains("x86_64"))
|
||||
assertTrue("报告含 APK ABI", report.contains("arm64-v8a"))
|
||||
assertTrue("报告给出可执行建议", report.contains("真机") || report.contains("debug"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun describeOnOrdinaryFailureDoesNotClaimNativeProblem() {
|
||||
val report = PoseDiagnostics.describe(
|
||||
t = IllegalStateException("模型文件 pose_landmarker_lite.task 缺失"),
|
||||
deviceAbis = listOf("arm64-v8a"),
|
||||
apkAbis = listOf("arm64-v8a"),
|
||||
)
|
||||
assertFalse("普通异常不应被判定为 native 失败", report.contains("native 库加载"))
|
||||
assertTrue("仍然带上设备与 APK 的 ABI 供排查", report.contains("arm64-v8a"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.opendance.studio
|
||||
|
||||
import com.opendance.studio.net.ReconnectPolicy
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* T-AND-011:重连策略(契约 websocket-keypoints.md v0.2.0 §7 / §7.1)
|
||||
*
|
||||
* 这是"频繁重连"缺陷的核心逻辑,必须逐值核对:
|
||||
* 1. 退避序列 0.5s → 1 → 2 → 4 → 8 → 15s(封顶)+ ±20% 抖动;
|
||||
* 2. 只有真正断开才重连(1000 / 1001 / 1002 与主动停止都不重连);
|
||||
* 3. 心跳间隔必须严格小于静默超时的一半(契约 §1.1)。
|
||||
*/
|
||||
class ReconnectPolicyTest {
|
||||
|
||||
@Test
|
||||
fun backoffSequenceMatchesContract() {
|
||||
assertEquals("第 1 次重连等待 500ms", 500L, ReconnectPolicy.baseDelayMs(1))
|
||||
assertEquals("第 2 次重连等待 1s", 1_000L, ReconnectPolicy.baseDelayMs(2))
|
||||
assertEquals("第 3 次重连等待 2s", 2_000L, ReconnectPolicy.baseDelayMs(3))
|
||||
assertEquals("第 4 次重连等待 4s", 4_000L, ReconnectPolicy.baseDelayMs(4))
|
||||
assertEquals("第 5 次重连等待 8s", 8_000L, ReconnectPolicy.baseDelayMs(5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backoffIsCappedAt15Seconds() {
|
||||
assertEquals("第 6 次起封顶 15s(500*2^5=16s 被截断)", 15_000L, ReconnectPolicy.baseDelayMs(6))
|
||||
assertEquals("第 7 次仍封顶", 15_000L, ReconnectPolicy.baseDelayMs(7))
|
||||
assertEquals("很大的次数也不溢出", 15_000L, ReconnectPolicy.baseDelayMs(64))
|
||||
assertEquals("非法次数返回 0", 0L, ReconnectPolicy.baseDelayMs(0))
|
||||
assertEquals("负数次数返回 0", 0L, ReconnectPolicy.baseDelayMs(-3))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun jitterStaysWithinTwentyPercent() {
|
||||
// randomFraction 0.0 -> 下界 -20%;1.0 -> 上界 +20%
|
||||
assertEquals("抖动下界为 -20%", 400L, ReconnectPolicy.delayWithJitterMs(1, 0.0))
|
||||
assertEquals("抖动中点为基数", 500L, ReconnectPolicy.delayWithJitterMs(1, 0.5))
|
||||
assertEquals("抖动上界为 +20%", 600L, ReconnectPolicy.delayWithJitterMs(1, 1.0))
|
||||
// 越界的随机数必须被夹紧,不能放大到任意倍
|
||||
assertEquals("越界随机数夹紧到下界", 400L, ReconnectPolicy.delayWithJitterMs(1, -5.0))
|
||||
assertEquals("越界随机数夹紧到上界", 600L, ReconnectPolicy.delayWithJitterMs(1, 9.0))
|
||||
assertEquals("无重连计划时不抖动", 0L, ReconnectPolicy.delayWithJitterMs(0, 0.5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun jitterIsAlwaysPositiveAcrossTheWholeRange() {
|
||||
for (attempt in 1..8) {
|
||||
for (step in 0..100) {
|
||||
val delay = ReconnectPolicy.delayWithJitterMs(attempt, step / 100.0)
|
||||
assertTrue("等待时间必须为正(attempt=$attempt,step=$step)", delay > 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun intentionalClosesDoNotReconnect() {
|
||||
// 契约 §7:主动结束 / 1000 / 1001 / 1002 一律不重连
|
||||
assertFalse("1000 正常关闭不重连", ReconnectPolicy.shouldReconnect(1000, manualClose = false))
|
||||
assertFalse("1001 对端离开不重连", ReconnectPolicy.shouldReconnect(1001, manualClose = false))
|
||||
assertFalse("1002 协议错误不重连", ReconnectPolicy.shouldReconnect(1002, manualClose = false))
|
||||
assertFalse("主动停止不重连", ReconnectPolicy.shouldReconnect(1006, manualClose = true))
|
||||
assertFalse("主动停止(1000)不重连", ReconnectPolicy.shouldReconnect(1000, manualClose = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun abnormalClosesDoReconnect() {
|
||||
// 网络抖动 / 未收到关闭帧 / 对端崩溃 -> 重连
|
||||
assertTrue("1006 异常断开要重连", ReconnectPolicy.shouldReconnect(1006, manualClose = false))
|
||||
assertTrue("未收到关闭帧(0)要重连", ReconnectPolicy.shouldReconnect(0, manualClose = false))
|
||||
assertTrue("1011 对端内部错误要重连", ReconnectPolicy.shouldReconnect(1011, manualClose = false))
|
||||
assertTrue("1012 对端重启要重连", ReconnectPolicy.shouldReconnect(1012, manualClose = false))
|
||||
assertTrue("1013 对端过载要重连", ReconnectPolicy.shouldReconnect(1013, manualClose = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun closeCodeLabelsAreHumanReadable() {
|
||||
assertTrue(ReconnectPolicy.closeCodeLabel(1001).contains("对端离开"))
|
||||
assertTrue(ReconnectPolicy.closeCodeLabel(1002).contains("协议错误"))
|
||||
assertTrue(ReconnectPolicy.closeCodeLabel(0).contains("未收到关闭帧"))
|
||||
assertTrue(ReconnectPolicy.closeCodeLabel(4321).contains("4321"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun heartbeatIsAlwaysFasterThanHalfTheSilentTimeout() {
|
||||
// 契约 §1.1:heartbeatIntervalMs 必须小于 silentTimeoutMs 的一半
|
||||
val declared = listOf(5_000L, 10_000L, 15_000L, 30_000L)
|
||||
for (timeout in declared) {
|
||||
val heartbeat = ReconnectPolicy.heartbeatIntervalMs(timeout)
|
||||
assertTrue(
|
||||
"超时 ${timeout}ms 时心跳 ${heartbeat}ms 必须安全",
|
||||
ReconnectPolicy.isHeartbeatSafe(heartbeat, timeout),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun heartbeatFallsBackToConservativeDefaults() {
|
||||
// 旧版服务端(契约 v0.1.0)不在 welcome 里声明超时 -> 用保守默认值
|
||||
val heartbeat = ReconnectPolicy.heartbeatIntervalMs(0)
|
||||
assertEquals(
|
||||
"未声明超时时按默认 5s 的三分之一计算",
|
||||
ReconnectPolicy.DEFAULT_SILENT_TIMEOUT_MS / 3,
|
||||
heartbeat,
|
||||
)
|
||||
assertTrue(
|
||||
"默认心跳对默认静默超时(5s)必须安全",
|
||||
ReconnectPolicy.isHeartbeatSafe(heartbeat, ReconnectPolicy.DEFAULT_SILENT_TIMEOUT_MS),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun heartbeatIsClampedIntoReasonableRange() {
|
||||
assertEquals("极小超时也不低于下限", ReconnectPolicy.MIN_HEARTBEAT_MS, ReconnectPolicy.heartbeatIntervalMs(10))
|
||||
assertEquals("极大超时不超过上限", ReconnectPolicy.MAX_HEARTBEAT_MS, ReconnectPolicy.heartbeatIntervalMs(600_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun heartbeatSafetyRejectsUnsafeCombinations() {
|
||||
assertFalse("未声明超时视为不安全", ReconnectPolicy.isHeartbeatSafe(1_000, 0))
|
||||
assertFalse("心跳等于超时的一半不安全(必须严格小于)", ReconnectPolicy.isHeartbeatSafe(5_000, 10_000))
|
||||
assertFalse("心跳大于超时的一半不安全", ReconnectPolicy.isHeartbeatSafe(6_000, 10_000))
|
||||
assertTrue("心跳小于超时的一半才安全", ReconnectPolicy.isHeartbeatSafe(4_000, 10_000))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user