diff --git a/apps/android-viewer/app/src/test/java/com/verify/remote/RelPointerTest.kt b/apps/android-viewer/app/src/test/java/com/verify/remote/RelPointerTest.kt new file mode 100644 index 0000000..a40f1c7 --- /dev/null +++ b/apps/android-viewer/app/src/test/java/com/verify/remote/RelPointerTest.kt @@ -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) + } +}