feat(app-binding): lier les presets aux applications

Ajoute la configuration des associations application/preset, le service de suivi du focus, l'intégration GUI et les fichiers d'installation nécessaires.

Ajoute les tests unitaires ciblés des bindings, du service de focus, des migrations et des composants GUI associés.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 23:52:34 +02:00
parent 0ab77d2a64
commit 9ef2c1439c
42 changed files with 4353 additions and 12 deletions

View File

@ -0,0 +1,154 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
import unittest
try:
from pydantic.v1 import ValidationError
except ImportError:
from pydantic import ValidationError # type: ignore[assignment, no-redef]
from inputremapper.configs.app_binding import (
AppBinding,
BoundPreset,
MatchType,
normalize_app_id,
)
from inputremapper.focus.focus_backend import FocusEvent
from tests.lib.test_setup import test_setup
def _event(app_id="", title="", backend="fake") -> FocusEvent:
return FocusEvent(app_id=app_id, title=title, backend=backend)
@test_setup
class TestNormalizeAppId(unittest.TestCase):
def test_lower_and_strip(self):
self.assertEqual(normalize_app_id(" FireFox "), "firefox")
self.assertEqual(normalize_app_id("ALACRITTY"), "alacritty")
self.assertEqual(normalize_app_id(""), "")
@test_setup
class TestBoundPreset(unittest.TestCase):
def test_valid(self):
bound = BoundPreset(group_key="Foo Device", preset="preset1")
self.assertEqual(bound.group_key, "Foo Device")
self.assertEqual(bound.preset, "preset1")
def test_empty_group_key_rejected(self):
with self.assertRaises(ValidationError):
BoundPreset(group_key="", preset="preset1")
with self.assertRaises(ValidationError):
BoundPreset(group_key=" ", preset="preset1")
def test_empty_preset_rejected(self):
with self.assertRaises(ValidationError):
BoundPreset(group_key="Foo Device", preset="")
with self.assertRaises(ValidationError):
BoundPreset(group_key="Foo Device", preset=" ")
@test_setup
class TestAppBinding(unittest.TestCase):
def test_defaults(self):
binding = AppBinding(app_id="firefox")
self.assertEqual(binding.match, MatchType.wm_class)
self.assertEqual(binding.presets, [])
def test_default_presets_are_not_shared(self):
binding_a = AppBinding(app_id="firefox")
binding_b = AppBinding(app_id="chromium")
binding_a.presets.append(BoundPreset(group_key="Foo Device", preset="preset1"))
self.assertEqual(binding_b.presets, [])
def test_empty_app_id_rejected(self):
with self.assertRaises(ValidationError):
AppBinding(app_id="")
with self.assertRaises(ValidationError):
AppBinding(app_id=" ")
def test_invalid_regex_rejected(self):
with self.assertRaises(ValidationError):
AppBinding(app_id="(unclosed", match=MatchType.title_regex)
def test_invalid_regex_allowed_for_wm_class(self):
# an unbalanced parenthesis is a fine literal wm_class, only title_regex
# compiles it as a pattern.
binding = AppBinding(app_id="(unclosed", match=MatchType.wm_class)
self.assertEqual(binding.app_id, "(unclosed")
def test_with_presets(self):
binding = AppBinding(
app_id="firefox",
presets=[BoundPreset(group_key="Foo Device", preset="preset1")],
)
self.assertEqual(len(binding.presets), 1)
self.assertEqual(binding.presets[0].group_key, "Foo Device")
# -- matches() ---------------------------------------------------------
def test_matches_wm_class_normalized(self):
binding = AppBinding(app_id="FireFox", match=MatchType.wm_class)
self.assertTrue(binding.matches(_event(app_id="firefox")))
self.assertTrue(binding.matches(_event(app_id=" FIREFOX ")))
self.assertFalse(binding.matches(_event(app_id="chromium")))
def test_matches_wm_class_ignores_title(self):
binding = AppBinding(app_id="firefox", match=MatchType.wm_class)
self.assertFalse(
binding.matches(_event(app_id="chromium", title="firefox - page"))
)
def test_matches_title_regex(self):
binding = AppBinding(app_id="Privat.*Browsing", match=MatchType.title_regex)
self.assertTrue(
binding.matches(_event(app_id="firefox", title="Private Browsing"))
)
self.assertFalse(
binding.matches(_event(app_id="firefox", title="Normal Window"))
)
def test_matches_title_regex_searches_substring(self):
binding = AppBinding(app_id="vim", match=MatchType.title_regex)
# re.search, not fullmatch
self.assertTrue(binding.matches(_event(title="file.py - nvim")))
def test_matches_title_regex_case_sensitive(self):
# unlike wm_class, the title regex is not normalized
binding = AppBinding(app_id="Private", match=MatchType.title_regex)
self.assertFalse(binding.matches(_event(title="private browsing")))
def test_roundtrip_through_json(self):
binding = AppBinding(
app_id="firefox",
match=MatchType.wm_class,
presets=[BoundPreset(group_key="Foo Device", preset="preset1")],
)
import json
as_dict = json.loads(binding.json())
restored = AppBinding(**as_dict)
self.assertEqual(restored, binding)
if __name__ == "__main__":
unittest.main()

View File

@ -1625,3 +1625,73 @@ class TestController(unittest.TestCase):
output_type=None,
output_code=None,
)
@test_setup
class TestControllerAppBindings(unittest.TestCase):
def setUp(self) -> None:
self.message_broker = MessageBroker()
self.data_manager = MagicMock()
self.controller = Controller(self.message_broker, self.data_manager)
self.user_interface = MagicMock()
self.controller.set_gui(self.user_interface)
def test_load_app_bindings(self):
self.controller.load_app_bindings()
self.data_manager.publish_app_bindings.assert_called_once()
def test_set_app_binding_enabled_true_starts_service(self):
with patch(
"inputremapper.gui.controller.ensure_focus_service_running"
) as ensure, patch(
"inputremapper.gui.controller.GLib.timeout_add"
) as timeout_add:
self.controller.set_app_binding_enabled(True)
ensure.assert_called_once()
self.data_manager.set_app_binding_enabled.assert_called_once_with(True)
# a delayed status refresh is scheduled
timeout_add.assert_called_once()
def test_set_app_binding_enabled_false_does_not_start_service(self):
with patch(
"inputremapper.gui.controller.ensure_focus_service_running"
) as ensure, patch(
"inputremapper.gui.controller.GLib.timeout_add"
) as timeout_add:
self.controller.set_app_binding_enabled(False)
ensure.assert_not_called()
self.data_manager.set_app_binding_enabled.assert_called_once_with(False)
timeout_add.assert_not_called()
def test_refresh_app_bindings_once(self):
result = self.controller._refresh_app_bindings_once()
self.assertFalse(result) # GLib: do not repeat
self.data_manager.publish_app_bindings.assert_called_once()
def test_update_app_bindings(self):
from inputremapper.configs.app_binding import AppBinding
bindings = [AppBinding(app_id="firefox")]
self.controller.update_app_bindings(bindings)
self.data_manager.set_app_bindings.assert_called_once_with(bindings)
def test_update_app_bindings_permission_error(self):
self.data_manager.set_app_bindings.side_effect = PermissionError("nope")
with patch.object(self.controller, "show_status") as show_status:
self.controller.update_app_bindings([])
show_status.assert_called_once()
self.assertEqual(show_status.call_args[0][0], CTX_ERROR)
def test_get_presets_for_group(self):
self.data_manager.get_presets_for_group.return_value = ("a", "b")
result = self.controller.get_presets_for_group("Foo Device")
self.assertEqual(result, ("a", "b"))
self.data_manager.get_presets_for_group.assert_called_once_with("Foo Device")
def test_start_and_stop_app_detection(self):
self.controller.start_app_detection()
self.data_manager.start_app_detection.assert_called_once()
self.controller.stop_app_detection()
self.data_manager.stop_app_detection.assert_called_once()

View File

@ -969,3 +969,168 @@ class TestDataManager(unittest.TestCase):
def test_cannot_get_injector_state_without_group(self):
self.assertRaises(DataManagementError, self.data_manager.get_state)
class FakeFocusServiceClient:
"""A FocusServiceClient stand-in that never touches the session bus."""
def __init__(self, status=None):
self._status = status
self.calls: List = []
self.handler = None
def get_status(self):
self.calls.append("get_status")
return self._status
def reload(self):
self.calls.append("reload")
def set_enabled(self, enabled):
self.calls.append(("set_enabled", enabled))
def connect_focus_changed(self, callback):
self.handler = callback
self.calls.append("connect")
return True
def disconnect_focus_changed(self):
self.calls.append("disconnect")
self.handler = None
@test_setup
class TestDataManagerAppBindings(unittest.TestCase):
def _make_data_manager(self, status=None) -> DataManager:
message_broker = MessageBroker()
uinputs = GlobalUInputs(FrontendUInput)
uinputs.prepare_all()
self.focus_service = FakeFocusServiceClient(status=status)
return DataManager(
message_broker,
GlobalConfig(),
ReaderClient(message_broker, _Groups()),
FakeDaemonProxy(),
uinputs,
keyboard_layout,
focus_service=self.focus_service,
)
def setUp(self) -> None:
self.data_manager = self._make_data_manager()
self.message_broker = self.data_manager.message_broker
@staticmethod
def _binding(app_id="firefox", presets=()):
from inputremapper.configs.app_binding import AppBinding, BoundPreset
return AppBinding(
app_id=app_id,
presets=[BoundPreset(group_key=g, preset=p) for g, p in presets],
)
def test_publish_app_bindings_unreachable(self):
from inputremapper.gui.messages.message_data import AppBindingsData
listener = Listener()
self.message_broker.subscribe(MessageType.app_bindings, listener)
self.data_manager.publish_app_bindings()
data: AppBindingsData = listener.calls[0]
self.assertFalse(data.reachable)
self.assertFalse(data.supported)
self.assertIsNone(data.backend)
self.assertFalse(data.enabled)
self.assertEqual(data.bindings, ())
def test_publish_app_bindings_reachable(self):
self.data_manager = self._make_data_manager(status={"backend": "xorg"})
self.message_broker = self.data_manager.message_broker
listener = Listener()
self.message_broker.subscribe(MessageType.app_bindings, listener)
self.data_manager.publish_app_bindings()
data = listener.calls[0]
self.assertTrue(data.reachable)
self.assertTrue(data.supported)
self.assertEqual(data.backend, "xorg")
def test_publish_app_bindings_reachable_but_unsupported(self):
# service reachable but reports no backend (e.g. GNOME-Wayland / null)
self.data_manager = self._make_data_manager(status={"backend": None})
self.message_broker = self.data_manager.message_broker
listener = Listener()
self.message_broker.subscribe(MessageType.app_bindings, listener)
self.data_manager.publish_app_bindings()
data = listener.calls[0]
self.assertTrue(data.reachable)
self.assertFalse(data.supported)
def test_set_and_get_app_bindings(self):
bindings = [self._binding("firefox", [("Foo Device", "preset1")])]
listener = Listener()
self.message_broker.subscribe(MessageType.app_bindings, listener)
self.data_manager.set_app_bindings(bindings)
self.assertEqual(self.data_manager.get_app_bindings(), bindings)
self.assertIn("reload", self.focus_service.calls)
# publishes an app_bindings update
self.assertEqual(listener.calls[-1].bindings, tuple(bindings))
def test_set_and_get_app_binding_enabled(self):
listener = Listener()
self.message_broker.subscribe(MessageType.app_bindings, listener)
self.data_manager.set_app_binding_enabled(True)
self.assertTrue(self.data_manager.get_app_binding_enabled())
self.assertIn(("set_enabled", True), self.focus_service.calls)
self.assertTrue(listener.calls[-1].enabled)
def test_start_and_stop_app_detection(self):
self.data_manager.start_app_detection()
self.assertIn("connect", self.focus_service.calls)
self.assertIsNotNone(self.focus_service.handler)
self.data_manager.stop_app_detection()
self.assertIn("disconnect", self.focus_service.calls)
self.assertIsNone(self.focus_service.handler)
def test_on_focus_changed_publishes_focused_app(self):
from inputremapper.gui.messages.message_data import FocusAppData
listener = Listener()
self.message_broker.subscribe(MessageType.focused_app, listener)
self.data_manager.start_app_detection()
# simulate the service emitting a focus_changed signal
self.focus_service.handler({"app_id": "firefox", "title": "Mozilla"})
data: FocusAppData = listener.calls[0]
self.assertEqual(data.app_id, "firefox")
self.assertEqual(data.title, "Mozilla")
def test_on_focus_changed_handles_missing_fields(self):
from inputremapper.gui.messages.message_data import FocusAppData
listener = Listener()
self.message_broker.subscribe(MessageType.focused_app, listener)
self.data_manager.start_app_detection()
self.focus_service.handler({})
data: FocusAppData = listener.calls[0]
self.assertEqual(data.app_id, "")
self.assertEqual(data.title, "")
def test_get_presets_for_group(self):
prepare_presets()
presets = self.data_manager.get_presets_for_group("Foo Device")
# newest first
self.assertEqual(presets, ("preset3", "preset2", "preset1"))
def test_get_presets_for_unknown_group(self):
self.assertEqual(
self.data_manager.get_presets_for_group("Does Not Exist"), tuple()
)

View File

@ -0,0 +1,342 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
import json
import unittest
from typing import List, Optional
from unittest.mock import MagicMock
from inputremapper.configs.app_binding import AppBinding, BoundPreset, MatchType
from inputremapper.configs.global_config import GlobalConfig
from inputremapper.focus.focus_backend import FocusBackend, FocusEvent
from inputremapper.focus.focus_service import FocusService
from tests.lib.patches import FakeDaemonProxy
from tests.lib.test_setup import test_setup
def _event(app_id="firefox", title="", backend="fake") -> FocusEvent:
return FocusEvent(app_id=app_id, title=title, backend=backend)
class FakeBackend(FocusBackend):
name = "fake"
def __init__(self, current: Optional[FocusEvent] = None):
self._current = current
self.started = False
self.stopped = False
self._on_focus = None
self.calls = []
@staticmethod
def is_available() -> bool:
return True
def start(self, on_focus) -> None:
self.started = True
self._on_focus = on_focus
self.calls.append("start")
def stop(self) -> None:
self.stopped = True
def get_current(self) -> Optional[FocusEvent]:
self.calls.append("get_current")
return self._current
def emit(self, event: FocusEvent) -> None:
assert self._on_focus is not None
self._on_focus(event)
@test_setup
class TestFocusService(unittest.TestCase):
def setUp(self) -> None:
self.daemon = FakeDaemonProxy()
self.backend = FakeBackend()
def _make_service(
self,
bindings: Optional[List[AppBinding]] = None,
enabled: bool = True,
) -> FocusService:
config = GlobalConfig()
config.set_app_bindings(bindings or [])
config.set_app_binding_enabled(enabled)
service = FocusService(config, self.backend, self.daemon, debounce_ms=10)
# never touch the real session bus in tests
service.publish = MagicMock()
self.addCleanup(service.stop)
return service
@staticmethod
def _binding(app_id, presets, match=MatchType.wm_class) -> AppBinding:
return AppBinding(
app_id=app_id,
match=match,
presets=[BoundPreset(group_key=g, preset=p) for g, p in presets],
)
# -- reconciliation ----------------------------------------------------
def test_loads_config_on_init(self):
service = self._make_service(
[self._binding("firefox", [("devX", "p1")])], enabled=True
)
self.assertTrue(service._enabled)
self.assertEqual(len(service._bindings), 1)
def test_focus_bound_app_starts_injecting(self):
service = self._make_service(
[self._binding("firefox", [("devX", "p1"), ("devY", "p2")])]
)
service._reconcile(_event("firefox"))
self.assertCountEqual(
self.daemon.calls["start_injecting"],
[("devX", "p1"), ("devY", "p2")],
)
self.assertEqual(service._app_controlled, {"devX": "p1", "devY": "p2"})
def test_switching_apps_replaces_and_stops(self):
service = self._make_service(
[
self._binding("a", [("devX", "p1")]),
self._binding("b", [("devX", "p2"), ("devY", "p3")]),
self._binding("c", [("devY", "p3")]),
]
)
service._reconcile(_event("a"))
self.assertEqual(service._app_controlled, {"devX": "p1"})
# switch to b: devX replaced (p1->p2), devY added, nothing stopped
self.daemon.calls["start_injecting"].clear()
service._reconcile(_event("b"))
self.assertCountEqual(
self.daemon.calls["start_injecting"], [("devX", "p2"), ("devY", "p3")]
)
self.assertEqual(self.daemon.calls["stop_injecting"], [])
self.assertEqual(service._app_controlled, {"devX": "p2", "devY": "p3"})
# switch to c: only devY wanted (already correct), devX stopped
self.daemon.calls["start_injecting"].clear()
service._reconcile(_event("c"))
self.assertEqual(self.daemon.calls["stop_injecting"], ["devX"])
# devY p3 already running -> not restarted
self.assertEqual(self.daemon.calls["start_injecting"], [])
self.assertEqual(service._app_controlled, {"devY": "p3"})
def test_focus_unbound_app_stops_everything(self):
service = self._make_service([self._binding("firefox", [("devX", "p1")])])
service._reconcile(_event("firefox"))
self.daemon.calls["start_injecting"].clear()
service._reconcile(_event("someuncontrolledapp"))
self.assertEqual(self.daemon.calls["stop_injecting"], ["devX"])
self.assertEqual(service._app_controlled, {})
def test_replace_does_not_restart_identical(self):
service = self._make_service([self._binding("firefox", [("devX", "p1")])])
service._reconcile(_event("firefox"))
service._reconcile(_event("firefox"))
# only injected once even though reconciled twice
self.assertEqual(self.daemon.calls["start_injecting"], [("devX", "p1")])
def test_start_injecting_failure_not_tracked(self):
service = self._make_service([self._binding("firefox", [("devX", "p1")])])
self.daemon.start_injecting = MagicMock(return_value=False)
service._reconcile(_event("firefox"))
self.assertEqual(service._app_controlled, {})
# -- manual override ---------------------------------------------------
def test_manual_preset_is_not_stopped(self):
# a preset applied manually is not in _app_controlled, so focusing an
# unbound app must not stop it.
service = self._make_service([self._binding("firefox", [("devX", "p1")])])
# devY has a manually applied preset, unknown to the service
service._reconcile(_event("unbound"))
self.assertEqual(self.daemon.calls["stop_injecting"], [])
def test_bound_app_reclaims_manually_used_device(self):
# another bound app taking focus replaces whatever is on the device
service = self._make_service([self._binding("firefox", [("devX", "p9")])])
service._reconcile(_event("firefox"))
self.assertEqual(self.daemon.calls["start_injecting"], [("devX", "p9")])
# -- enable / disable --------------------------------------------------
def test_set_enabled_false_stops_app_controlled(self):
service = self._make_service([self._binding("firefox", [("devX", "p1")])])
service._current_event = _event("firefox")
service._reconcile(service._current_event)
self.assertEqual(service._app_controlled, {"devX": "p1"})
service.set_enabled(False)
self.assertFalse(service._enabled)
self.assertEqual(self.daemon.calls["stop_injecting"], ["devX"])
self.assertEqual(service._app_controlled, {})
def test_set_enabled_true_reconciles_current(self):
service = self._make_service(
[self._binding("firefox", [("devX", "p1")])], enabled=False
)
service._current_event = _event("firefox")
service.set_enabled(True)
self.assertTrue(service._enabled)
self.assertEqual(self.daemon.calls["start_injecting"], [("devX", "p1")])
def test_set_enabled_true_without_current_event(self):
service = self._make_service(
[self._binding("firefox", [("devX", "p1")])], enabled=False
)
service.set_enabled(True)
self.assertEqual(self.daemon.calls["start_injecting"], [])
# -- reload ------------------------------------------------------------
def test_reload_picks_up_new_bindings(self):
service = self._make_service([], enabled=True)
service._current_event = _event("firefox")
service.global_config.set_app_bindings(
[self._binding("firefox", [("devX", "p1")])]
)
service.reload()
self.assertEqual(self.daemon.calls["start_injecting"], [("devX", "p1")])
def test_reload_when_disabled_stops_app_controlled(self):
service = self._make_service([self._binding("firefox", [("devX", "p1")])])
service._current_event = _event("firefox")
service._reconcile(service._current_event)
service.global_config.set_app_binding_enabled(False)
service.reload()
self.assertEqual(self.daemon.calls["stop_injecting"], ["devX"])
self.assertEqual(service._app_controlled, {})
# -- debounce / transient focus ---------------------------------------
def test_transient_empty_focus_is_ignored(self):
service = self._make_service([self._binding("firefox", [("devX", "p1")])])
service._on_focus(_event(app_id=""))
self.assertIsNone(service._pending_event)
self.assertIsNone(service._debounce_id)
self.assertEqual(self.daemon.calls["start_injecting"], [])
def test_debounce_collapses_rapid_focus(self):
service = self._make_service(
[
self._binding("a", [("devX", "p1")]),
self._binding("b", [("devX", "p2")]),
]
)
service._on_focus(_event("a"))
service._on_focus(_event("b"))
# nothing reconciled until the debounce flushes
self.assertEqual(self.daemon.calls["start_injecting"], [])
self.assertEqual(service._pending_event, _event("b"))
service._flush()
# only the most recent focus is applied
self.assertEqual(self.daemon.calls["start_injecting"], [("devX", "p2")])
self.assertEqual(service._current_event, _event("b"))
def test_flush_without_pending_is_noop(self):
service = self._make_service([])
self.assertFalse(service._flush())
def test_flush_emits_focus_changed_on_change(self):
service = self._make_service([])
service.focus_changed = MagicMock()
service._pending_event = _event("firefox", title="Mozilla")
service._flush()
service.focus_changed.assert_called_once()
payload = json.loads(service.focus_changed.call_args[0][0])
self.assertEqual(payload, {"app_id": "firefox", "title": "Mozilla"})
def test_flush_no_emit_when_unchanged(self):
service = self._make_service([])
service._current_event = _event("firefox")
service.focus_changed = MagicMock()
service._pending_event = _event("firefox")
service._flush()
service.focus_changed.assert_not_called()
# -- lifecycle / dbus surface -----------------------------------------
def test_start_reconciles_initial_focus(self):
self.backend = FakeBackend(current=_event("firefox"))
service = self._make_service([self._binding("firefox", [("devX", "p1")])])
service.start()
self.assertTrue(self.backend.started)
self.assertEqual(self.backend.calls[:2], ["start", "get_current"])
self.assertEqual(self.daemon.calls["start_injecting"], [("devX", "p1")])
def test_start_stops_on_publish_failure(self):
service = self._make_service([])
service.publish = MagicMock(side_effect=RuntimeError("dbus failed"))
with self.assertRaises(RuntimeError):
service.start()
self.assertFalse(self.backend.started)
def test_stop_releases_app_controlled(self):
service = self._make_service([self._binding("firefox", [("devX", "p1")])])
service._reconcile(_event("firefox"))
service.stop()
self.assertTrue(self.backend.stopped)
self.assertIn("devX", self.daemon.calls["stop_injecting"])
self.assertEqual(service._app_controlled, {})
def test_get_focused_app_json(self):
service = self._make_service([])
self.assertEqual(
json.loads(service.get_focused_app()), {"app_id": "", "title": ""}
)
service._current_event = _event("firefox", title="Moz")
self.assertEqual(
json.loads(service.get_focused_app()),
{"app_id": "firefox", "title": "Moz"},
)
def test_get_status_json(self):
service = self._make_service([self._binding("firefox", [("devX", "p1")])])
service._reconcile(_event("firefox", title="Moz"))
status = json.loads(service.get_status())
self.assertEqual(status["backend"], "fake")
self.assertTrue(status["enabled"])
self.assertEqual(status["focused"], {"app_id": "", "title": ""})
self.assertEqual(status["app_controlled"], {"devX": "p1"})
def test_compute_desired_merges_multiple_bindings(self):
# two bindings can match the same event (wm_class + title regex)
service = self._make_service(
[
self._binding("firefox", [("devX", "p1")]),
self._binding(
"Moz", [("devY", "p2")], match=MatchType.title_regex
),
]
)
desired = service._compute_desired(_event("firefox", title="Mozilla"))
self.assertEqual(desired, {"devX": "p1", "devY": "p2"})
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,173 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
import unittest
from unittest.mock import MagicMock, patch
from inputremapper.gui.focus_service_client import (
FocusServiceClient,
ensure_focus_service_running,
)
from tests.lib.test_setup import test_setup
@test_setup
class TestFocusServiceClient(unittest.TestCase):
def setUp(self) -> None:
self.bus = MagicMock()
self.proxy = MagicMock()
self.bus.get_proxy.return_value = self.proxy
self.client = FocusServiceClient(bus=self.bus)
# -- get_status --------------------------------------------------------
def test_get_status_success(self):
self.proxy.get_status.return_value = '{"backend": "xorg", "enabled": true}'
status = self.client.get_status()
self.assertEqual(status, {"backend": "xorg", "enabled": True})
def test_get_status_failure_returns_none_and_resets(self):
self.proxy.get_status.side_effect = Exception("no service")
self.assertIsNone(self.client.get_status())
# the cached proxy is dropped so the next call reconnects
self.assertIsNone(self.client._proxy)
def test_get_focused_app_success(self):
self.proxy.get_focused_app.return_value = (
'{"app_id": "firefox", "title": "Moz"}'
)
self.assertEqual(
self.client.get_focused_app(), {"app_id": "firefox", "title": "Moz"}
)
def test_get_focused_app_failure_returns_empty(self):
self.proxy.get_focused_app.side_effect = Exception("boom")
self.assertEqual(
self.client.get_focused_app(), {"app_id": "", "title": ""}
)
# -- reload / set_enabled (fire and forget) ----------------------------
def test_reload_success(self):
self.client.reload()
self.proxy.reload.assert_called_once()
def test_reload_failure_is_swallowed(self):
self.proxy.reload.side_effect = Exception("boom")
# must not raise
self.client.reload()
self.assertIsNone(self.client._proxy)
def test_set_enabled(self):
self.client.set_enabled(True)
self.proxy.set_enabled.assert_called_once_with(True)
def test_set_enabled_failure_is_swallowed(self):
self.proxy.set_enabled.side_effect = Exception("boom")
self.client.set_enabled(False)
self.assertIsNone(self.client._proxy)
# -- focus_changed signal ---------------------------------------------
def test_connect_focus_changed_success(self):
received = []
ok = self.client.connect_focus_changed(received.append)
self.assertTrue(ok)
self.proxy.focus_changed.connect.assert_called_once()
# the wrapped handler parses the json payload before calling back
handler = self.proxy.focus_changed.connect.call_args[0][0]
handler('{"app_id": "firefox", "title": "Moz"}')
self.assertEqual(received, [{"app_id": "firefox", "title": "Moz"}])
def test_connect_focus_changed_invalid_payload_does_not_raise(self):
received = []
self.client.connect_focus_changed(received.append)
handler = self.proxy.focus_changed.connect.call_args[0][0]
# invalid json must be swallowed (no callback, no exception)
handler("not json")
self.assertEqual(received, [])
def test_connect_focus_changed_failure(self):
self.proxy.focus_changed.connect.side_effect = Exception("boom")
ok = self.client.connect_focus_changed(lambda app: None)
self.assertFalse(ok)
self.assertIsNone(self.client._proxy)
def test_disconnect_without_handler_is_noop(self):
self.client.disconnect_focus_changed()
self.proxy.focus_changed.disconnect.assert_not_called()
def test_disconnect_after_connect(self):
self.client.connect_focus_changed(lambda app: None)
self.client.disconnect_focus_changed()
self.proxy.focus_changed.disconnect.assert_called_once()
self.assertIsNone(self.client._focus_changed_handler)
def test_disconnect_failure_clears_handler(self):
self.client.connect_focus_changed(lambda app: None)
self.proxy.focus_changed.disconnect.side_effect = Exception("boom")
self.client.disconnect_focus_changed()
# handler is cleared even if the disconnect failed
self.assertIsNone(self.client._focus_changed_handler)
@test_setup
class TestEnsureFocusServiceRunning(unittest.TestCase):
def test_does_not_start_when_already_running(self):
with patch(
"inputremapper.gui.focus_service_client.ProcessUtils."
"count_python_processes",
return_value=1,
), patch(
"inputremapper.gui.focus_service_client.subprocess.Popen"
) as popen:
ensure_focus_service_running()
popen.assert_not_called()
def test_starts_when_not_running(self):
with patch(
"inputremapper.gui.focus_service_client.ProcessUtils."
"count_python_processes",
return_value=0,
), patch(
"inputremapper.gui.focus_service_client.subprocess.Popen"
) as popen:
ensure_focus_service_running()
popen.assert_called_once()
self.assertEqual(
popen.call_args[0][0], ["input-remapper-focus-service"]
)
def test_start_failure_is_swallowed(self):
with patch(
"inputremapper.gui.focus_service_client.ProcessUtils."
"count_python_processes",
return_value=0,
), patch(
"inputremapper.gui.focus_service_client.subprocess.Popen",
side_effect=OSError("boom"),
):
# must not raise
ensure_focus_service_running()
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,326 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
import json
import unittest
from unittest.mock import patch
from inputremapper.focus.backends.hyprland import HyprlandFocusBackend
from inputremapper.focus.backends.null import NullBackend
from inputremapper.focus.backends.sway import (
SwayFocusBackend,
_container_to_event,
_find_focused,
)
from inputremapper.focus.backends.wlr_foreign_toplevel import (
WlrForeignToplevelBackend,
)
from inputremapper.focus.backends.xorg import XorgFocusBackend
from inputremapper.focus.focus_watcher import focus_backend_classes, select_backend
from tests.lib.test_setup import test_setup
@test_setup
class TestSelectBackend(unittest.TestCase):
def _patch_availability(self, available, exclude=()):
"""Patch is_available of every backend class from a {name: bool} map."""
patchers = []
excluded = set(exclude)
for backend_class in focus_backend_classes:
if backend_class.name in excluded:
continue
value = available.get(backend_class.name, False)
patcher = patch.object(
backend_class,
"is_available",
staticmethod(lambda v=value: v),
)
patcher.start()
patchers.append(patcher)
self.addCleanup(lambda: [p.stop() for p in patchers])
def test_priority_order(self):
# registry order: sway, hyprland, wlr, xorg, null
self.assertEqual(
[c.name for c in focus_backend_classes],
["sway", "hyprland", "wlr-foreign-toplevel", "xorg", "null"],
)
def test_selects_sway_first(self):
self._patch_availability({"sway": True, "xorg": True})
self.assertIsInstance(select_backend(), SwayFocusBackend)
def test_selects_hyprland_when_no_sway(self):
self._patch_availability({"hyprland": True, "xorg": True})
self.assertIsInstance(select_backend(), HyprlandFocusBackend)
def test_selects_xorg_when_only_xorg(self):
self._patch_availability({"xorg": True})
self.assertIsInstance(select_backend(), XorgFocusBackend)
def test_falls_back_to_null(self):
self._patch_availability({})
self.assertIsInstance(select_backend(), NullBackend)
def test_availability_exception_is_swallowed(self):
def boom():
raise RuntimeError("nope")
with patch.object(SwayFocusBackend, "is_available", staticmethod(boom)):
self._patch_availability({"xorg": True}, exclude={"sway"})
# sway raises -> skipped, xorg wins
self.assertIsInstance(select_backend(), XorgFocusBackend)
@test_setup
class TestNullBackend(unittest.TestCase):
def test_always_available(self):
self.assertTrue(NullBackend.is_available())
def test_get_current_is_none(self):
backend = NullBackend()
self.assertIsNone(backend.get_current())
def test_start_and_stop_do_nothing(self):
backend = NullBackend()
calls = []
backend.start(lambda event: calls.append(event))
backend.stop()
self.assertEqual(calls, [])
@test_setup
class TestSwayParsing(unittest.TestCase):
def test_container_to_event_app_id(self):
event = _container_to_event({"app_id": "Alacritty", "name": "shell"})
self.assertEqual(event.app_id, "Alacritty")
self.assertEqual(event.title, "shell")
self.assertEqual(event.backend, "sway")
def test_container_to_event_xwayland_class_fallback(self):
# XWayland windows have no app_id but a window_properties.class
event = _container_to_event(
{
"app_id": None,
"name": "Firefox",
"window_properties": {"class": "firefox"},
}
)
self.assertEqual(event.app_id, "firefox")
self.assertEqual(event.title, "Firefox")
def test_container_to_event_empty_container(self):
self.assertIsNone(_container_to_event({}))
def test_container_to_event_missing_fields(self):
event = _container_to_event({"app_id": "x"})
self.assertEqual(event.app_id, "x")
self.assertEqual(event.title, "")
def test_find_focused_nested(self):
tree = {
"focused": False,
"nodes": [
{"focused": False, "nodes": []},
{
"focused": False,
"nodes": [{"focused": True, "app_id": "target", "nodes": []}],
},
],
"floating_nodes": [],
}
found = _find_focused(tree)
self.assertIsNotNone(found)
self.assertEqual(found["app_id"], "target")
def test_find_focused_floating(self):
tree = {
"focused": False,
"nodes": [],
"floating_nodes": [{"focused": True, "app_id": "floater"}],
}
self.assertEqual(_find_focused(tree)["app_id"], "floater")
def test_find_focused_none(self):
tree = {"focused": False, "nodes": [{"focused": False}]}
self.assertIsNone(_find_focused(tree))
def test_handle_event_emits_on_focus_change(self):
backend = SwayFocusBackend()
received = []
backend._on_focus = received.append
payload = json.dumps(
{"change": "focus", "container": {"app_id": "kitty", "name": "term"}}
).encode()
backend._handle_event(payload)
self.assertEqual(len(received), 1)
self.assertEqual(received[0].app_id, "kitty")
def test_handle_event_ignores_unrelated_change(self):
backend = SwayFocusBackend()
received = []
backend._on_focus = received.append
payload = json.dumps(
{"change": "move", "container": {"app_id": "kitty"}}
).encode()
backend._handle_event(payload)
self.assertEqual(received, [])
def test_handle_event_invalid_json(self):
backend = SwayFocusBackend()
received = []
backend._on_focus = received.append
backend._handle_event(b"\xff not json")
self.assertEqual(received, [])
def test_is_available_without_env(self):
with patch.dict("os.environ", {}, clear=True):
self.assertFalse(SwayFocusBackend.is_available())
@test_setup
class TestHyprlandParsing(unittest.TestCase):
def test_handle_line_activewindow(self):
backend = HyprlandFocusBackend()
received = []
backend._on_focus = received.append
backend._handle_line("activewindow>>firefox,Mozilla Firefox")
self.assertEqual(len(received), 1)
self.assertEqual(received[0].app_id, "firefox")
self.assertEqual(received[0].title, "Mozilla Firefox")
self.assertEqual(received[0].backend, "hyprland")
def test_handle_line_title_with_comma(self):
backend = HyprlandFocusBackend()
received = []
backend._on_focus = received.append
backend._handle_line("activewindow>>code,file.py, project - VSCode")
self.assertEqual(received[0].app_id, "code")
# only the first comma splits app_id from title
self.assertEqual(received[0].title, "file.py, project - VSCode")
def test_handle_line_ignores_other_events(self):
backend = HyprlandFocusBackend()
received = []
backend._on_focus = received.append
backend._handle_line("workspace>>2")
backend._handle_line("openwindow>>0x1,1,firefox,Title")
self.assertEqual(received, [])
def test_handle_line_no_separator(self):
backend = HyprlandFocusBackend()
received = []
backend._on_focus = received.append
backend._handle_line("garbage line")
self.assertEqual(received, [])
def test_is_available_without_signature(self):
with patch.dict("os.environ", {}, clear=True):
self.assertFalse(HyprlandFocusBackend.is_available())
@test_setup
class TestXorgBackend(unittest.TestCase):
def test_is_available_without_display(self):
with patch.dict("os.environ", {}, clear=True):
self.assertFalse(XorgFocusBackend.is_available())
def test_is_available_without_xlib(self):
with patch.dict("os.environ", {"DISPLAY": ":0"}, clear=True):
with patch.dict("sys.modules", {"Xlib": None, "Xlib.display": None}):
# importing Xlib.display raises ImportError -> not available
self.assertFalse(XorgFocusBackend.is_available())
def test_get_current_without_display(self):
backend = XorgFocusBackend()
self.assertIsNone(backend.get_current())
def test_stop_without_start(self):
backend = XorgFocusBackend()
# must not raise even though nothing was started
backend.stop()
@test_setup
class TestWlrBackend(unittest.TestCase):
def test_is_available_without_wayland(self):
with patch.dict("os.environ", {}, clear=True):
self.assertFalse(WlrForeignToplevelBackend.is_available())
def test_is_available_without_pywayland(self):
# pywayland is not installed in the dev environment, so even with
# WAYLAND_DISPLAY set the backend must report itself unavailable cleanly.
with patch.dict("os.environ", {"WAYLAND_DISPLAY": "wayland-0"}, clear=True):
with patch(
"inputremapper.focus.backends.wlr_foreign_toplevel._import_protocol",
return_value=None,
):
self.assertFalse(WlrForeignToplevelBackend.is_available())
def test_get_current_initially_none(self):
backend = WlrForeignToplevelBackend()
self.assertIsNone(backend.get_current())
def test_toplevel_activation_flow(self):
backend = WlrForeignToplevelBackend()
received = []
backend._on_focus = received.append
handle = object()
backend._toplevels[handle] = {"app_id": "", "title": "", "activated": False}
backend._on_app_id(handle, "firefox")
backend._on_title(handle, "Mozilla")
backend._on_state(handle, [2]) # _STATE_ACTIVATED
backend._on_done(handle)
self.assertEqual(len(received), 1)
self.assertEqual(received[0].app_id, "firefox")
self.assertEqual(received[0].title, "Mozilla")
self.assertEqual(backend.get_current().app_id, "firefox")
def test_done_without_activation_is_ignored(self):
backend = WlrForeignToplevelBackend()
received = []
backend._on_focus = received.append
handle = object()
backend._toplevels[handle] = {
"app_id": "x",
"title": "y",
"activated": False,
}
backend._on_done(handle)
self.assertEqual(received, [])
def test_closed_removes_toplevel(self):
backend = WlrForeignToplevelBackend()
handle = object()
backend._toplevels[handle] = {"app_id": "x", "title": "", "activated": True}
backend._on_closed(handle)
self.assertNotIn(handle, backend._toplevels)
def test_state_unknown_handle_ignored(self):
backend = WlrForeignToplevelBackend()
# must not raise for a handle we never registered
backend._on_app_id(object(), "x")
backend._on_state(object(), [2])
if __name__ == "__main__":
unittest.main()

View File

@ -19,10 +19,12 @@
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
import json
import os
import unittest
from inputremapper.configs.global_config import GlobalConfig
from inputremapper.logging.logger import VERSION
from tests.lib.test_setup import test_setup
@ -81,8 +83,16 @@ class TestGlobalConfig(unittest.TestCase):
self.assertTrue(os.path.exists(global_config.path))
with open(global_config.path, "r") as file:
contents = file.read()
self.assertIn('"autoload": {}', contents)
config = json.load(file)
self.assertEqual(
config,
{
"version": VERSION,
"autoload": {},
"app_binding_enabled": False,
"app_bindings": [],
},
)
def test_save_load(self):
global_config = GlobalConfig()
@ -101,6 +111,71 @@ class TestGlobalConfig(unittest.TestCase):
[("d1", "a"), ("d2", "b")],
)
# -- app bindings ------------------------------------------------------
def test_app_binding_enabled_default_false(self):
global_config = GlobalConfig()
self.assertFalse(global_config.get_app_binding_enabled())
def test_set_app_binding_enabled_roundtrip(self):
global_config = GlobalConfig()
global_config.set_app_binding_enabled(True)
self.assertTrue(global_config.get_app_binding_enabled())
# persisted and reloadable
reloaded = GlobalConfig()
reloaded.load_config()
self.assertTrue(reloaded.get_app_binding_enabled())
global_config.set_app_binding_enabled(False)
self.assertFalse(global_config.get_app_binding_enabled())
def test_app_bindings_default_empty(self):
global_config = GlobalConfig()
self.assertEqual(global_config.get_app_bindings(), [])
def test_set_app_bindings_roundtrip(self):
from inputremapper.configs.app_binding import (
AppBinding,
BoundPreset,
MatchType,
)
bindings = [
AppBinding(
app_id="firefox",
match=MatchType.wm_class,
presets=[BoundPreset(group_key="Foo Device", preset="preset1")],
),
AppBinding(app_id="term.*", match=MatchType.title_regex),
]
global_config = GlobalConfig()
global_config.set_app_bindings(bindings)
# serialized as plain dicts in config.json
with open(global_config.path, "r") as file:
raw = json.load(file)
self.assertEqual(len(raw["app_bindings"]), 2)
self.assertEqual(raw["app_bindings"][0]["app_id"], "firefox")
# reloaded into model objects
reloaded = GlobalConfig()
reloaded.load_config()
result = reloaded.get_app_bindings()
self.assertEqual(result, bindings)
def test_get_app_bindings_skips_invalid(self):
global_config = GlobalConfig()
global_config.load_config()
# inject an invalid binding (empty app_id) directly into the raw config
global_config._config["app_bindings"] = [
{"app_id": "valid", "match": "wm_class", "presets": []},
{"app_id": "", "match": "wm_class", "presets": []},
]
result = global_config.get_app_bindings()
self.assertEqual(len(result), 1)
self.assertEqual(result[0].app_id, "valid")
if __name__ == "__main__":
unittest.main()

View File

@ -659,7 +659,13 @@ class TestMigrations(unittest.TestCase):
with open(PathUtils.get_config_path("config.json"), "r") as f:
config_json = json.load(f)
self.assertDictEqual(
config_json, {"autoload": {device_name: "foo"}, "version": VERSION}
config_json,
{
"autoload": {device_name: "foo"},
"version": VERSION,
"app_binding_enabled": False,
"app_bindings": [],
},
)
with open(PathUtils.get_preset_path(device_name, "foo.json"), "r") as f:
os.system(f'cat { PathUtils.get_preset_path(device_name, "foo.json") }')
@ -702,7 +708,13 @@ class TestMigrations(unittest.TestCase):
with open(PathUtils.get_config_path("config.json"), "r") as f:
config_json = json.load(f)
self.assertDictEqual(
config_json, {"autoload": {device_name: "bar"}, "version": VERSION}
config_json,
{
"autoload": {device_name: "bar"},
"version": VERSION,
"app_binding_enabled": False,
"app_bindings": [],
},
)
with open(PathUtils.get_preset_path(device_name, "bar.json"), "r") as f:
os.system(f'cat { PathUtils.get_preset_path(device_name, "bar.json") }')
@ -721,6 +733,60 @@ class TestMigrations(unittest.TestCase):
],
)
def _write_config(self, data):
config_file = os.path.join(PathUtils.config_path(), "config.json")
PathUtils.touch(config_file)
with open(config_file, "w") as file:
json.dump(data, file, indent=4)
return config_file
def _read_config(self, config_file):
with open(config_file, "r") as file:
return json.load(file)
def test_add_app_binding_config(self):
# an old config without the app-binding keys gets them added
config_file = self._write_config(
{"version": "2.2.1", "autoload": {"Foo Device 2": "preset2"}}
)
self.migrations.migrate()
config = self._read_config(config_file)
self.assertEqual(config["app_binding_enabled"], False)
self.assertEqual(config["app_bindings"], [])
# additive: existing keys are preserved
self.assertEqual(config["autoload"], {"Foo Device 2": "preset2"})
self.assertEqual(config["version"], VERSION)
def test_add_app_binding_config_preserves_existing(self):
# a config already carrying app-binding data must not be reset
existing_binding = {
"app_id": "firefox",
"match": "wm_class",
"presets": [{"group_key": "Foo Device", "preset": "preset1"}],
}
config_file = self._write_config(
{
"version": "2.2.1",
"autoload": {},
"app_binding_enabled": True,
"app_bindings": [existing_binding],
}
)
self.migrations.migrate()
config = self._read_config(config_file)
self.assertEqual(config["app_binding_enabled"], True)
self.assertEqual(config["app_bindings"], [existing_binding])
def test_add_app_binding_config_no_file(self):
# no config.json yet -> migration is a no-op and does not create one
config_file = os.path.join(PathUtils.config_path(), "config.json")
if os.path.exists(config_file):
os.remove(config_file)
self.migrations._add_app_binding_config()
self.assertFalse(os.path.exists(config_file))
if __name__ == "__main__":
unittest.main()