Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions shared/examples/config.example.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
{
"schema_version": 1,
"bindings": {
"mouse.side_front.press": "toggle_recording",
"mouse.side_rear.press": "trigger_secondary_action",
"hotkey.record_toggle": "toggle_recording"
},
"transcriber": {
"backend": "funasr_onnx",
"model_name": "iic/SenseVoiceSmall",
Expand Down
17 changes: 16 additions & 1 deletion shared/schema/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,23 @@
"bindings": {
"type": "object",
"default": {},
"propertyNames": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)*$"
},
"additionalProperties": {
"type": "string"
"type": "string",
"enum": [
"noop",
"reload_config",
"send_enter",
"shutdown",
"submit_recording",
"toggle_recording",
"trigger_secondary_action",
"workspace_left",
"workspace_right"
]
}
},
"transcriber": {
Expand Down
98 changes: 98 additions & 0 deletions tests/bindings/test_resolver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
from __future__ import annotations

import unittest
from types import SimpleNamespace

from vibemouse.bindings.actions import (
build_default_bindings,
build_resolved_bindings,
command_for_legacy_gesture_action,
)
from vibemouse.bindings.resolver import BindingResolver
from vibemouse.core.commands import (
COMMAND_NOOP,
COMMAND_SEND_ENTER,
COMMAND_SUBMIT_RECORDING,
COMMAND_TOGGLE_RECORDING,
COMMAND_TRIGGER_SECONDARY_ACTION,
COMMAND_WORKSPACE_LEFT,
COMMAND_WORKSPACE_RIGHT,
EVENT_GESTURE_DOWN,
EVENT_GESTURE_LEFT,
EVENT_GESTURE_RIGHT,
EVENT_GESTURE_UP,
EVENT_HOTKEY_RECORDING_SUBMIT,
EVENT_HOTKEY_RECORD_TOGGLE,
EVENT_MOUSE_SIDE_FRONT_PRESS,
EVENT_MOUSE_SIDE_REAR_PRESS,
)


class BindingActionsTests(unittest.TestCase):
@staticmethod
def _make_config(**overrides: object) -> SimpleNamespace:
values = {
"bindings": {},
"gesture_up_action": "record_toggle",
"gesture_down_action": "noop",
"gesture_left_action": "workspace_left",
"gesture_right_action": "workspace_right",
"recording_submit_keycode": 28,
}
values.update(overrides)
return SimpleNamespace(**values)

def test_default_bindings_follow_runtime_defaults(self) -> None:
config = self._make_config()

bindings = build_default_bindings(config)

self.assertEqual(bindings[EVENT_MOUSE_SIDE_FRONT_PRESS], COMMAND_TOGGLE_RECORDING)
self.assertEqual(
bindings[EVENT_MOUSE_SIDE_REAR_PRESS],
COMMAND_TRIGGER_SECONDARY_ACTION,
)
self.assertEqual(bindings[EVENT_HOTKEY_RECORD_TOGGLE], COMMAND_TOGGLE_RECORDING)
self.assertEqual(
bindings[EVENT_HOTKEY_RECORDING_SUBMIT],
COMMAND_SUBMIT_RECORDING,
)
self.assertEqual(bindings[EVENT_GESTURE_UP], COMMAND_TOGGLE_RECORDING)
self.assertEqual(bindings[EVENT_GESTURE_DOWN], COMMAND_NOOP)
self.assertEqual(bindings[EVENT_GESTURE_LEFT], COMMAND_WORKSPACE_LEFT)
self.assertEqual(bindings[EVENT_GESTURE_RIGHT], COMMAND_WORKSPACE_RIGHT)

def test_custom_bindings_override_defaults(self) -> None:
config = self._make_config(
bindings={EVENT_MOUSE_SIDE_FRONT_PRESS: COMMAND_SEND_ENTER}
)

bindings = build_resolved_bindings(config)

self.assertEqual(bindings[EVENT_MOUSE_SIDE_FRONT_PRESS], COMMAND_SEND_ENTER)

def test_legacy_gesture_action_names_translate_to_commands(self) -> None:
self.assertEqual(
command_for_legacy_gesture_action("record_toggle"),
COMMAND_TOGGLE_RECORDING,
)
self.assertEqual(
command_for_legacy_gesture_action("workspace_right"),
COMMAND_WORKSPACE_RIGHT,
)
self.assertEqual(command_for_legacy_gesture_action("noop"), COMMAND_NOOP)


class BindingResolverTests(unittest.TestCase):
def test_resolve_returns_bound_command(self) -> None:
resolver = BindingResolver({EVENT_MOUSE_SIDE_FRONT_PRESS: COMMAND_SEND_ENTER})

self.assertEqual(
resolver.resolve(EVENT_MOUSE_SIDE_FRONT_PRESS),
COMMAND_SEND_ENTER,
)

def test_resolve_returns_none_for_unbound_event(self) -> None:
resolver = BindingResolver({EVENT_MOUSE_SIDE_FRONT_PRESS: COMMAND_SEND_ENTER})

self.assertIsNone(resolver.resolve("mouse.middle.press"))
28 changes: 28 additions & 0 deletions tests/core/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from typing import cast
from unittest.mock import patch

from vibemouse.core.commands import COMMAND_SEND_ENTER, EVENT_MOUSE_SIDE_FRONT_PRESS
from vibemouse.app import VoiceMouseApp


Expand Down Expand Up @@ -276,6 +277,33 @@ def test_recording_submit_press_is_ignored_when_idle(self) -> None:

self.assertEqual(rear_calls, [])

def test_handle_input_event_routes_through_binding_resolver(self) -> None:
subject = self._make_subject()
send_enter_calls: list[str] = []
setattr(
subject,
"_binding_resolver",
SimpleNamespace(
resolve=lambda event_name: COMMAND_SEND_ENTER
if event_name == EVENT_MOUSE_SIDE_FRONT_PRESS
else None
),
)
setattr(
subject,
"_output",
SimpleNamespace(send_enter=lambda mode: send_enter_calls.append(mode)),
)
setattr(subject, "_config", SimpleNamespace(enter_mode="enter"))

handle_event = cast(
Callable[[str], None],
getattr(subject, "_handle_input_event"),
)
handle_event(EVENT_MOUSE_SIDE_FRONT_PRESS)

self.assertEqual(send_enter_calls, ["enter"])

def test_transcribe_and_output_openclaw_uses_openclaw_sender(self) -> None:
subject = self._make_subject()
recording = SimpleNamespace(duration_s=1.0, path=Path("/tmp/transcribe.wav"))
Expand Down
22 changes: 22 additions & 0 deletions tests/listener/test_keyboard_listener.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from collections.abc import Callable
from typing import cast

from vibemouse.core.commands import EVENT_HOTKEY_RECORD_TOGGLE
from vibemouse.keyboard_listener import KeyboardHotkeyListener


Expand All @@ -16,6 +17,13 @@ def test_constructor_rejects_empty_combo(self) -> None:
with self.assertRaisesRegex(ValueError, "keycodes must not be empty"):
_ = KeyboardHotkeyListener(on_hotkey=_noop, keycodes=())

def test_constructor_requires_callback_or_event(self) -> None:
with self.assertRaisesRegex(
ValueError,
"on_hotkey or on_event/event_name must be configured",
):
_ = KeyboardHotkeyListener(keycodes=(42, 125, 193))

def test_combo_fires_once_until_released(self) -> None:
listener = KeyboardHotkeyListener(
on_hotkey=_noop, keycodes=(42, 125, 193), debounce_s=0.0
Expand Down Expand Up @@ -60,3 +68,17 @@ def test_reset_pressed_state_clears_latched_combo(self) -> None:
self.assertFalse(process(42, 1))
self.assertFalse(process(125, 1))
self.assertTrue(process(193, 1))

def test_dispatch_hotkey_emits_configured_event(self) -> None:
seen: list[str] = []
listener = KeyboardHotkeyListener(
on_event=seen.append,
event_name=EVENT_HOTKEY_RECORD_TOGGLE,
keycodes=(42, 125, 193),
debounce_s=0.0,
)

dispatch = cast(Callable[[], None], getattr(listener, "_dispatch_hotkey"))
dispatch()

self.assertEqual(seen, [EVENT_HOTKEY_RECORD_TOGGLE])
42 changes: 42 additions & 0 deletions tests/listener/test_mouse_listener.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from typing import cast
from unittest.mock import patch

from vibemouse.core.commands import EVENT_GESTURE_UP, EVENT_MOUSE_SIDE_FRONT_PRESS
from vibemouse.mouse_listener import SideButtonListener


Expand Down Expand Up @@ -98,6 +99,13 @@ def test_constructor_accepts_right_trigger_button(self) -> None:

self.assertIsNotNone(listener)

def test_constructor_requires_event_or_button_callbacks(self) -> None:
with self.assertRaisesRegex(
ValueError,
"on_event or on_front_press/on_rear_press must be configured",
):
_ = SideButtonListener(front_button="x1", rear_button="x2")

def test_constructor_clamps_rescan_interval_to_minimum(self) -> None:
listener = SideButtonListener(
on_front_press=_noop_button,
Expand Down Expand Up @@ -131,6 +139,40 @@ def on_gesture(direction: str) -> None:
dispatch_gesture("up")
self.assertEqual(seen, ["up"])

def test_dispatch_front_press_emits_normalized_event_when_configured(self) -> None:
seen: list[str] = []
listener = SideButtonListener(
on_event=seen.append,
front_button="x1",
rear_button="x2",
debounce_s=0.0,
)

dispatch_front = cast(
Callable[[], None], getattr(listener, "_dispatch_front_press")
)
dispatch_front()

self.assertEqual(seen, [EVENT_MOUSE_SIDE_FRONT_PRESS])

def test_dispatch_gesture_emits_normalized_event_when_on_event_is_used(
self,
) -> None:
seen: list[str] = []
listener = SideButtonListener(
on_event=seen.append,
front_button="x1",
rear_button="x2",
)

dispatch_gesture = cast(
Callable[[str], None],
getattr(listener, "_dispatch_gesture"),
)
dispatch_gesture("up")

self.assertEqual(seen, [EVENT_GESTURE_UP])

def test_finish_gesture_restores_cursor_after_direction_action(self) -> None:
seen: list[str] = []
restored: list[tuple[int, int]] = []
Expand Down
1 change: 1 addition & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ def test_defaults_disable_trust_remote_code(self) -> None:
self.assertEqual(config.rear_button, "x2")
self.assertEqual(config.record_hotkey_keycodes, (42, 125, 193))
self.assertIsNone(config.recording_submit_keycode)
self.assertEqual(config.bindings, {})

def test_record_hotkey_keycodes_can_be_configured(self) -> None:
with patch.dict(
Expand Down
35 changes: 35 additions & 0 deletions tests/test_config_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ def test_json_config_values_are_loaded(self) -> None:
json.dumps(
{
"schema_version": 1,
"bindings": {
"mouse.side_front.press": "send_enter",
},
"transcriber": {
"backend": "funasr",
"model_name": "custom/model",
Expand Down Expand Up @@ -64,6 +67,10 @@ def test_json_config_values_are_loaded(self) -> None:
self.assertTrue(config.trust_remote_code)
self.assertEqual(config.gesture_trigger_button, "right")
self.assertEqual(config.record_hotkey_keycodes, (30, 31, 32))
self.assertEqual(
config.bindings,
{"mouse.side_front.press": "send_enter"},
)
self.assertTrue(config.auto_paste)
self.assertEqual(config.enter_mode, "ctrl_enter")
self.assertEqual(config.log_level, "ERROR")
Expand Down Expand Up @@ -121,6 +128,9 @@ def test_save_document_writes_normalized_config(self) -> None:
store.save_document(
{
"schema_version": 1,
"bindings": {
"mouse.side_front.press": "send_enter",
},
"input": {
"front_button": "x2",
"rear_button": "x1",
Expand All @@ -132,12 +142,37 @@ def test_save_document_writes_normalized_config(self) -> None:
payload = json.loads(config_path.read_text(encoding="utf-8"))

self.assertEqual(payload["schema_version"], 1)
self.assertEqual(
payload["bindings"],
{"mouse.side_front.press": "send_enter"},
)
self.assertEqual(payload["input"]["front_button"], "x2")
self.assertEqual(payload["input"]["rear_button"], "x1")
self.assertEqual(payload["input"]["record_hotkey_keycodes"], [10, 20, 30])
self.assertIn("transcriber", payload)
self.assertIn("runtime", payload)

def test_invalid_binding_command_is_rejected(self) -> None:
with tempfile.TemporaryDirectory(prefix="vibemouse-config-") as tmp:
config_path = Path(tmp) / "config.json"
config_path.write_text(
json.dumps(
{
"schema_version": 1,
"bindings": {
"mouse.side_front.press": "paste_now",
},
}
),
encoding="utf-8",
)

with self.assertRaisesRegex(
ValueError,
"bindings\\['mouse\\.side_front\\.press'\\] must be one of",
):
_ = load_config(config_path, env={})


class StatusStoreTests(unittest.TestCase):
def test_write_persists_normalized_status_payload(self) -> None:
Expand Down
1 change: 1 addition & 0 deletions vibemouse/bindings/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__all__ = []
Loading
Loading