mcpField
I
injecting-mouse-and-keyboard

injecting-mouse-and-keyboard

github.com/skydoves测试与质量33961 次安装22 周前更新
代码仓库

关于 injecting-mouse-and-keyboard

面向 Compose UI 测试的鼠标、键盘和多模态输入技能指南,适用于桌面或 Compose Multiplatform 界面测试。它说明输入范围、节点局部坐标、快捷键及跨模态共享状态的用法。

主要功能

  1. 01注入鼠标点击、悬停与滚动
  2. 02注入键盘按键和快捷键
  3. 03组合触摸、鼠标与键盘输入
  4. 04执行鼠标拖放和移动
  5. 05控制按键及指针状态

适用场景

  1. 01测试桌面界面的右键菜单
  2. 02验证悬停提示和悬停状态
  3. 03测试 Ctrl+S 等键盘快捷键
  4. 04模拟拖放及组合输入操作

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/skydoves/android-testing-skills --skill injecting-mouse-and-keyboard

安装后在 Claude Code 中输入/injecting-mouse-and-keyboard确认已加载。

详细介绍

注入鼠标和键盘输入 — performMouseInput、performKeyInput、performMultiModalInput

在 Compose 测试中,鼠标和键盘输入是一级输入方式。两者各自都有注入作用域和入口,此外还有一个共享的 performMultiModalInput,可在一次批处理手势中组合所有输入方式(触摸、鼠标、按键、旋转、触控板、间接指针)。坐标始终相对于节点本地,与触摸采用相同规则;在同一节点上,完整的注入状态会跨 perform.*Input 代码块保留。

何时使用此技能

  • 测试针对桌面或 Compose Multiplatform UI,该 UI 会响应点击、悬停、右键单击、滚轮或修饰键。
  • 测试必须触发键盘快捷键(例如 Ctrl+S、Shift+Tab、方向键导航)。
  • 测试必须验证仅悬停时的行为:工具提示弹出、Modifier.hoverable 的颜色变化、仅鼠标触发的涟漪状态。
  • 测试通过鼠标指针模拟拖放操作。
  • 测试必须组合多种输入方式,例如“悬停在 (x, y) 处,然后在按住 Shift 的同时点击并按住”。

何时不使用此技能

  • 交互是手指点击或拖动 — 使用 ../injecting-touch-gestures/SKILL.md。在 Android 设备测试中,鼠标不能替代触摸。
  • 简单的 performClick() 已会根据平台使用正确的输入方式 — 使用 ../clicking-and-scrolling/SKILL.md。
  • 测试在 TextField 中输入内容 — 使用 ../entering-text/SKILL.md。此处的按键输入用于快捷键,而非文本输入。
  • 测试等待由悬停触发的动画结束 — 先暂停时钟,参阅 ../../synchronization/testing-animations-deterministically/SKILL.md。

前提条件

  • 按照 ../../setup/configuring-test-dependencies/SKILL.md 配置 androidx.compose.ui:ui-test-junit4(或用于 runComposeUiTest 的 ui-test)。
  • 在 Android 上接收鼠标悬停事件:需要使用支持悬停的平台级别(通常为 API 23+)——Robolectric 主机测试也可运行,但请检查 @Config(minSdk = …)。
  • 若按键输入要作用于可聚焦节点,该节点必须可聚焦(例如使用 Modifier.focusable() 或 Modifier.focusRequester(...) 包装)。否则,按键事件会路由到当前获得焦点的根节点。

工作流

Mouse:performMouseInput
  1. 打开鼠标作用域 — performMouseInput(block: MouseInjectionScope.() -> Unit) (Actions.kt:448-461)。与触摸操作类似,事件会批量处理,并在代码块返回时发送;重组不会在代码块内穿插执行。

  2. 使用节点局部坐标。 (0, 0) 是节点的左上角,与 TouchInjectionScope 相同。MouseInjectionScope 扩展了 InjectionScope,因此可以使用 center、topLeft、bottomRight、percentOffset(...)(InjectionScope.kt:34-206)。

  3. 选择合适的辅助方法。 均位于 MouseInjectionScope.kt 中:

辅助方法默认值功能
click(position = center, button = MouseButton.Primary)在中心位置单击主键按下 → 等待 60 ms → 释放
rightClick(position = center)在中心位置单击副键click(position, MouseButton.Secondary) 的简写
doubleClick(position = center, button = Primary)位于双击间隔最小值与最大值的中间click(); advanceEventTime(delay); click()
tripleClick(position = center, button = Primary)与双击相同的间隔连续单击三次
longClick(position = center, button = Primary)按住 longPressTimeoutMillis + 100 ms按下、按住、释放
animateMoveTo(position, durationMillis = 300)300 ms沿线性路径持续发送移动事件
animateMoveBy(delta, durationMillis = 300)相对形式—
animateMoveAlong(curve, durationMillis = 300)任意曲线—
dragAndDrop(start, end, button = Primary, durationMillis = 300)主键拖动updatePointerTo(start); press; animateMoveTo(end); release
smoothScroll(scrollAmount, durationMillis = 300, scrollWheel = Vertical)垂直持续发送滚动事件
  1. 需要时使用底层事件:

    • press(button: MouseButton = Primary) / release(button: MouseButton = Primary)。
    • moveTo(position, delayMillis = eventPeriodMillis) / moveBy(delta, delayMillis = eventPeriodMillis)。
    • updatePointerTo(position) / updatePointerBy(delta) — 调整位置但不发送事件。
    • enter(position) / exit(position) — 显式触发悬停进入 / 悬停退出。
    • scroll(delta, scrollWheel = Vertical) — 单次滚轮事件。
    • cancel(delayMillis) — 发送 ACTION_CANCEL。
    • currentPosition: Offset — 鼠标最后已知的位置。
  2. 从 Mouse.kt 引用 MouseButton 和 ScrollWheel:

    • MouseButton.Primary、MouseButton.Secondary、MouseButton.Tertiary — expect value class MouseButton。
    • ScrollWheel.Vertical、ScrollWheel.Horizontal — value class ScrollWheel。
键盘:performKeyInput
  1. 打开按键作用域 — performKeyInput(block: KeyInjectionScope.() -> Unit)(Actions.kt:532-545)。

  2. 发送按键事件。 核心接口(KeyInjectionScope.kt:55-107):

    • keyDown(key: Key) / keyUp(key: Key) — 基本操作。若状态无效(已按下、已抬起),则抛出 IllegalStateException。
    • isKeyDown(key: Key): Boolean — 查询注入状态。
    • 修饰键状态属性:isCtrlDown、isAltDown、isShiftDown、isMetaDown、isFnDown(KeyInjectionScope.kt:236-269)、isCapsLockOn、isNumLockOn、isScrollLockOn(KeyInjectionScope.kt:64-81)。这些反映的是注入的状态,而非主机上的状态。
  3. 使用辅助方法(KeyInjectionScope.kt:146-229):

    • pressKey(key, pressDurationMillis = 50L) — keyDown(key); advanceEventTime(50); keyUp(key)。
    • withKeyDown(key) { … } — 在按住 key 的同时运行 block;并在 finally 中自动释放。不得在 block 内使用已按住的键。
    • withKeysDown(listOf(key1, key2)) { … } — 同上,但同时按住多个键。
    • withKeyToggled(key) { … } / withKeysToggled(keys) { … } — 在 block 执行前后调用 pressKey。适用于 CapsLock、NumLock、ScrollLock。
  4. 按键重复行为。 按住某个键并推进事件时间(通过 InjectionScope 中的 advanceEventTime)会产生重复事件:首次重复在 500 ms 后触发,之后每隔 50 ms 触发一次(KeyInjectionScope.kt:43-50)。这不会由 MainTestClock.advanceTimeBy 触发——它推进的是测试时钟,而不是注入事件时间。

  5. 直接注入 KeyEvent — KeyInputHelpers.kt:27 中的 SemanticsNodeInteraction.performKeyPress(KeyEvent): Boolean。如果事件已被消费,则返回 true。当你已有 KeyEvent 时使用(例如通过 KeyEvent(NativeKeyEvent(...)) 构造,以便精细控制来源/扫描码);否则优先使用 DSL。

多模态:performMultiModalInput
  1. 打开多模态作用域 — performMultiModalInput(block: MultiModalInjectionScope.() -> Unit)(Actions.kt:582-594)。

  2. 分派到子作用域 — touch { … }、mouse { … }、key { … }、rotary { … }、trackpad { … }、indirectPointer(...)(MultiModalInjectionScope.kt:51-90)。所有子作用域共享相同的注入状态,因此在 touch 中保持“按下”的手指,在接下来运行 mouse 时仍处于按下状态。

  3. 在单个测试步骤组合多种输入方式时选择此入口点——例如,按住 Shift 键的同时悬停,而不是拼接两个独立的 perform.*Input 调用。

模式

模式:右键菜单
// RIGHT
rule.onNodeWithTag(RowTag).performMouseInput {
    rightClick(center)
}
rule.onNodeWithText("Delete").assertIsDisplayed()

rightClick 是 click(position, MouseButton.Secondary) 的简写(MouseInjectionScope.kt:356-357)。标签中使用“右”是为了便于理解,但它实际触发的是次要按键——对于左手鼠标同样正确。

模式:悬停触发的工具提示

摘自 CombinedClickableTest.kt:3415-3422:

rule.onNodeWithTag("myClickable").performMouseInput { enter(center) }

rule.runOnIdle {
    assertThat(interactions).hasSize(1)
    assertThat(interactions.first()).isInstanceOf(HoverInteraction.Enter::class.java)
}

rule.onNodeWithTag("myClickable").performMouseInput { exit(Offset(-1f, -1f)) }

enter(position) 会发出悬停进入事件;exit(position) 会发出悬停退出事件。将节点外的 Offset(-1f, -1f) 传给 exit,即可模拟光标离开表面。

模式:Ctrl+S 快捷键——冗长写法与惯用写法
// WRONG
rule.onNodeWithTag(EditorTag).performKeyInput {
    keyDown(Key.CtrlLeft)
    pressKey(Key.S)
    keyUp(Key.CtrlLeft)
}
// WRONG because: a thrown assertion or early-return inside the block leaks Key.CtrlLeft
// in the "down" state, polluting subsequent tests. The release is not in a finally.
// RIGHT
rule.onNodeWithTag(EditorTag).performKeyInput {
    withKeyDown(Key.CtrlLeft) {
        pressKey(Key.S)
    }
}

withKeyDown 会在 finally 块中释放按键(KeyInjectionScope.kt:164-171)。对于包含多个修饰键的快捷键,请使用 withKeysDown(listOf(Key.CtrlLeft, Key.ShiftLeft)) { pressKey(Key.S) }。

模式:按键自动重复
rule.onNodeWithTag(InputTag).performKeyInput {
    keyDown(Key.DirectionDown)
    advanceEventTime(800)   // 500 ms initial delay + 6 repeats at 50 ms
    keyUp(Key.DirectionDown)
}

advanceEventTime 是输入分发器时钟——它会按文档所述的频率触发重复事件(首次重复在 500 ms 后,之后每隔 50 ms 触发一次——KeyInjectionScope.kt:43-50)。不得替换为 rule.mainClock.advanceTimeBy(…):主测试时钟推进的是 Compose 的帧时钟,而非输入分发器的事件时间,因此不会将重复事件加入队列。

模式:使用鼠标拖放
rule.onNodeWithTag(SourceTag).performMouseInput {
    dragAndDrop(
        start = center,
        end = Offset(center.x + 200f, center.y),
        durationMillis = 250,
    )
}

dragAndDrop 会将指针移到 start,按下鼠标键,将指针移动到 end,然后松开鼠标键(MouseInjectionScope.kt:523-533)。默认按钮为 MouseButton.Primary;中键拖动时,传入 button = MouseButton.Tertiary。

模式:平滑滚动
rule.onNodeWithTag(ScrollAreaTag).performMouseInput {
    smoothScroll(
        scrollAmount = -10f,                  // negative scrollAmount = scroll back; new content appears at the top (MouseInjectionScope.kt:537-542)
        durationMillis = 200,
        scrollWheel = ScrollWheel.Vertical,
    )
}

正值 scrollAmount 会显示竖列底部或横行末尾的内容(MouseInjectionScope.kt:537-542)。如需单次滚轮事件,可改用 scroll(delta, scrollWheel)。

模式:多模态——悬停后在按住 Shift 时单击

MultiModalInjectionScope 提供 mouse { }、key { }、touch { } 等子作用域;一次 key { withKeyDown(...) { } } 调用设置的修饰键状态,会在外围 performMultiModalInput 的剩余部分持续生效,因此后续的 mouse { } 块会在 Shift 仍处于按下状态时派发。withKeyDown 块本身以 KeyInjectionScope 为接收者——在其中调用 mouse { } 无法编译。

rule.onNodeWithTag(NodeTag).performMultiModalInput {
    mouse { enter(center) }
    key { keyDown(Key.ShiftLeft) }
    mouse { click() }                 // dispatched with Shift still held
    key { keyUp(Key.ShiftLeft) }
}

一次 performMultiModalInput 会将所有输入模态保留在同一批次中统一刷新;各子作用域共享修饰键状态、指针位置和按下的鼠标键(MultiModalInjectionScope.kt)。需要交错使用多种输入模态时使用它;否则,分别调用 performMouseInput / performKeyInput 会更清晰。

模式:直接注入 KeyEvent,以实现精确控制
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.test.performKeyPress

val event = KeyEvent(NativeKeyEvent(NativeKeyEvent.ACTION_DOWN, NativeKeyEvent.KEYCODE_TAB))
rule.onRoot().performKeyPress(event)

performKeyPress 会返回事件是否被消费(KeyInputHelpers.kt:27)。它绕过 DSL,适合重放捕获的 KeyEvent 或测试由 IME 生成的按键输入。

强制规则

  • 必须使用 withKeyDown / withKeysDown 包裹修饰键快捷键,以便在断言抛出异常后,由自动释放的 finally 块完成清理。手动配对调用 keyDown / keyUp 会导致修饰键状态泄漏到其他测试中。
  • 必须在需要触发按键自动重复事件时使用 advanceEventTime(即 InjectionScope 时钟),不得使用 MainTestClock.advanceTimeBy。重复事件由输入派发器的事件时间流生成(KeyInjectionScope.kt:43-50)。
  • 必须使用 center、topLeft、bottomRight、percentOffset(...) 等保持坐标相对于节点。硬编码屏幕坐标会让测试依赖设备形状——参见 ../injecting-touch-gestures/SKILL.md。
  • 必须通过 rule.runOnIdle { … } 执行操作后的断言(skydoves 的观点 #5)。
  • 不得在 Android 设备测试中使用 performMouseInput 测试手指点击——performClick() 会自动在 Android 上解析为触摸点击,在桌面端解析为鼠标单击。
  • 不得使用 Thread.sleep 等待按键或鼠标事件之间的间隔。在代码块内使用 advanceEventTime(事件时间延迟),或在代码块外使用 mainClock.advanceTimeBy(帧时钟延迟)。
  • **建议:**仅在确实需要交错使用多种输入模态时选择 performMultiModalInput;否则,分别调用 performMouseInput 和 performKeyInput 可读性更好。

验证

  • 所有修饰键组合都使用 withKeyDown / withKeysDown,而非直接配对调用 keyDown / keyUp。
  • performMouseInput / performKeyInput 块之间或块内没有使用 Thread.sleep。
  • 没有硬编码屏幕坐标——每个 Offset(...) 都由 center、top*、bottom*、percentOffset(...) 或相对位移计算得出。
  • 测试按键自动重复行为的断言使用 advanceEventTime,而非 mainClock.advanceTimeBy。
  • 操作后的断言在 rule.runOnIdle { … } 内执行。
  • 对于所修改的测试,./gradlew :app:connectedDebugAndroidTest(或对应的宿主端 / 桌面任务)运行通过。

参考资料

  • Compose 测试概述:https://developer.android.com/develop/ui/compose/testing
  • Compose Multiplatform 测试:https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-test.html
  • Compose 测试速查表:https://developer.android.com/develop/ui/compose/testing-cheatsheet
  • compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Actions.kt — performMouseInput(Actions.kt:448)、performKeyInput(Actions.kt:532)、performMultiModalInput(Actions.kt:582)、performTrackpadInput、performRotaryScrollInput。
  • compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/MouseInjectionScope.kt — press、release、moveTo、moveBy、enter、exit、scroll、cancel,以及扩展函数 click、rightClick、doubleClick、tripleClick、longClick、animateMoveTo、animateMoveBy、animateMoveAlong、dragAndDrop、smoothScroll。
  • compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/KeyInjectionScope.kt — keyDown、keyUp、isKeyDown、修饰键状态 vals、pressKey、withKeyDown、withKeysDown、withKeyToggled、withKeysToggled,以及重复按键约定。
  • compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/KeyInputHelpers.kt — performKeyPress(KeyEvent): Boolean(KeyInputHelpers.kt:27)。
  • compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Mouse.kt — MouseButton.Primary / Secondary / Tertiary、ScrollWheel.Horizontal / Vertical。
  • compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/MultiModalInjectionScope.kt — touch、mouse、key、rotary、trackpad、indirectPointer。
  • compose/foundation/foundation/src/androidDeviceTest/.../CombinedClickableTest.kt — performMouseInput { enter(center) }、performMouseInput { exit(Offset(-1f, -1f)) }(CombinedClickableTest.kt:3415-3422)。
  • skydoves — compose-performance-skills:https://github.com/skydoves/compose-performance-skills

评论

支持 Markdown