test(android): 相对鼠标/长按右键纯逻辑单测 17 例

覆盖 V-B1 判据对应的行为,不是「跑通就算」:
- 慢速小幅不丢位移(10 次 0.3px 必须发出 3px)
- 负值向远离零取整(-0.7 取 -1,向零截断会让反向慢滑丢位移)
- 撞边不积债(回滑立即发出,不因补偿而空转)
- i16 越界 clamp 而非回绕
- 长按三条判据:够久、没动、非双指

已做 mutation 反向验证:把期望 3 改成 4 后测试确实红,改回后绿(断言非空转)。
This commit is contained in:
郑博文 2026-10-31 17:35:11 +08:00
parent 7c3b6b3e93
commit aede7b6c3c

View File

@ -0,0 +1,172 @@
package com.verify.remote
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 3b/V-B1:相对鼠标的纯逻辑(协议 v4)。
*
* 这些断言对应验收判据,不是「跑通就算」:
* - V-B1b「慢速小幅滑动不丢位移」→ [accumulatorKeepsSubPixelRemainders]
* - V-B1c「撞边后回滑立即响应」→ 累积器只做取整、不做补偿([accumulatorDoesNotBankLostSteps])
*/
class RelPointerTest {
// ---- 换算:视图像素 → 桌面像素 ----
@Test
fun sensitivity_one_maps_full_view_width_to_full_desktop_width() {
// Q4=A 的语义定义:1.0 = 手指扫过视图全宽 = 指针走完桌面全宽
val d = RelPointer.toDesktopDelta(
viewDeltaPx = 1080f, // 视图全宽(1080x607 letterbox)
viewExtentPx = 1080,
remoteExtentPx = 1920, // 远端桌面宽
sensitivity = 1.0f,
)
assertEquals(1920f, d, 0.01f)
}
@Test
fun sensitivity_scales_linearly() {
val base = RelPointer.toDesktopDelta(100f, 1080, 1920, 1.0f)
val twice = RelPointer.toDesktopDelta(100f, 1080, 1920, 2.0f)
assertEquals(base * 2f, twice, 0.01f)
}
@Test
fun negative_delta_stays_negative() {
// 向左滑必须得到负增量(绝对坐标是无符号的,相对通道是唯一有符号路径)
val d = RelPointer.toDesktopDelta(-54f, 1080, 1920, 1.0f)
assertTrue("向左滑应得到负增量,实际 $d", d < 0f)
}
@Test
fun invalid_extents_yield_zero_not_garbage() {
// 视图未测量 / 远端尺寸未取到时必须不动,而不是按错误比例乱飞
assertEquals(0f, RelPointer.toDesktopDelta(50f, 0, 1920, 1.0f), 0f)
assertEquals(0f, RelPointer.toDesktopDelta(50f, 1080, 0, 1.0f), 0f)
assertEquals(0f, RelPointer.toDesktopDelta(0f, 1080, 1920, 1.0f), 0f)
}
@Test
fun sensitivity_is_clamped_to_documented_range() {
assertEquals(RelPointer.MIN_SENSITIVITY, RelPointer.clampSensitivity(0.01f), 0f)
assertEquals(RelPointer.MAX_SENSITIVITY, RelPointer.clampSensitivity(99f), 0f)
assertEquals(1.5f, RelPointer.clampSensitivity(1.5f), 0f)
// 边界常量本身必须与方案 Q4=A 一致
assertEquals(0.25f, RelPointer.MIN_SENSITIVITY, 0f)
assertEquals(3.0f, RelPointer.MAX_SENSITIVITY, 0f)
assertEquals(1.0f, RelPointer.DEFAULT_SENSITIVITY, 0f)
}
// ---- 累积取整:V-B1b ----
@Test
fun accumulatorKeepsSubPixelRemainders() {
// 每次只移动 0.3 桌面像素:逐次 toInt() 会全被吞掉(位移静默丢失)。
// 累积器必须在攒够 1 像素时发出,且总量守恒。
val acc = RelAccumulator()
var total = 0
repeat(10) {
acc.take(0.3f, 0f)?.let { total += it.first }
}
// 10 * 0.3 = 3.0 → 必须发出 3 像素,而不是 0
assertEquals("慢速滑动不得丢位移", 3, total)
}
@Test
fun accumulatorDoesNotEmitBelowHalfPixel() {
// 小于 0.5 的余量四舍五入后是 0 → 不发(否则会产生大量零位移消息)。
// 注意阈值是 0.5 而不是 1.0:累积器用 roundToInt 而非 toInt,
// 0.8 会先发出 1 并把 -0.2 留作余数(自我修正,长期总量守恒)。
val acc = RelAccumulator()
assertNull("0.4 四舍五入为 0,不得发送", acc.take(0.4f, 0.4f))
// 再攒 0.4 → 合计 0.8,四舍五入为 1,此时必须发出
val d = acc.take(0.4f, 0.4f)
assertEquals(1, d!!.first)
assertEquals(1, d.second)
// 余数 -0.2:紧接着的 0.2 会被抵消成 0,不再发送(证明余数被正确保留)
assertNull(acc.take(0.2f, 0.2f))
}
@Test
fun accumulatorRoundsNegativeValuesAwayFromZero() {
// -0.7 应取 -1(向零截断会得到 0,反向慢滑就丢了)
val acc = RelAccumulator()
val d = acc.take(-0.7f, 0f)
assertEquals(-1, d!!.first)
}
@Test
fun accumulatorAxesAreIndependent() {
// 斜向慢滑:X 攒够时 Y 还没攒够,不能因为 Y 不够就把 X 也拖住
val acc = RelAccumulator()
val d = acc.take(1.0f, 0.2f)
assertEquals(1, d!!.first)
assertEquals(0, d.second)
}
@Test
fun accumulatorResetDropsRemainder() {
// 手势/会话结束时清空:上一段的余数不该漏进下一段
val acc = RelAccumulator()
acc.take(0.6f, 0.6f)
acc.reset()
assertNull(acc.take(0.3f, 0.3f))
}
@Test
fun accumulatorDoesNotBankLostSteps() {
// V-B1c:撞边时被 clamp 丢掉的位移**不回补**。累积器只负责取整,
// 不负责补偿——否则撞边后回滑会先"空转"一段,违反「立即响应」。
val acc = RelAccumulator()
acc.take(1.0f, 0f) // 发出 +1
val back = acc.take(-1.0f, 0f)
assertEquals("回滑必须立即发出,不得因补偿而空转", -1, back!!.first)
}
@Test
fun accumulatorClampsToProtocolRange() {
// 协议字段是 i16;越界必须被 clamp 而不是回绕(回绕会让指针跳到屏幕另一侧)
val acc = RelAccumulator()
val d = acc.take(100_000f, -100_000f)
assertEquals(32767, d!!.first)
assertEquals(-32768, d.second)
}
// ---- 长按右键:V-B3 ----
@Test
fun longPress_fires_at_threshold_when_still() {
assertTrue(LongPressPolicy.shouldFire(500L, 0f, 30f, false))
assertTrue(LongPressPolicy.shouldFire(900L, 2f, 30f, false))
}
@Test
fun longPress_does_not_fire_too_early() {
assertFalse(LongPressPolicy.shouldFire(499L, 0f, 30f, false))
assertFalse(LongPressPolicy.shouldFire(0L, 0f, 30f, false))
}
@Test
fun longPress_does_not_fire_after_moving() {
// V-B3「拖动窗口时不弹菜单」:超过 slop 一律不算长按
assertFalse(LongPressPolicy.shouldFire(800L, 31f, 30f, false))
assertFalse(LongPressPolicy.shouldFire(800L, 200f, 30f, false))
}
@Test
fun longPress_does_not_fire_with_two_fingers() {
// 双指有自己的右键路径,长按不能叠加(否则一次双指轻点会出两个右键)
assertFalse(LongPressPolicy.shouldFire(900L, 0f, 30f, true))
}
@Test
fun long_press_threshold_is_pinned_to_decision() {
// Q5=A:500ms,改动必须显式经过这条断言
assertEquals(500L, LongPressPolicy.LONG_PRESS_MS)
}
}