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

@ -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()
)