#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # 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 . 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_empty_focus_stops_app_controlled(self): service = self._make_service([self._binding("firefox", [("devX", "p1")])]) service._reconcile(_event("firefox")) self.daemon.calls["start_injecting"].clear() service._on_focus(_event(app_id="")) service._flush() self.assertEqual(self.daemon.calls["stop_injecting"], ["devX"]) self.assertEqual(service._app_controlled, {}) self.assertEqual(self.daemon.calls["start_injecting"], []) def test_focus_event_for_bound_app_starts_injecting(self): service = self._make_service([self._binding("firefox", [("devX", "p1")])]) service._on_focus(_event("firefox")) service._flush() self.assertEqual(self.daemon.calls["start_injecting"], [("devX", "p1")]) self.assertEqual(service._app_controlled, {"devX": "p1"}) def test_focus_event_for_unbound_app_stops_app_controlled(self): service = self._make_service([self._binding("firefox", [("devX", "p1")])]) service._reconcile(_event("firefox")) self.daemon.calls["start_injecting"].clear() service._on_focus(_event("code")) service._flush() self.assertEqual(self.daemon.calls["stop_injecting"], ["devX"]) self.assertEqual(service._app_controlled, {}) 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()