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:
342
tests/unit/test_focus_service.py
Normal file
342
tests/unit/test_focus_service.py
Normal 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()
|
||||
Reference in New Issue
Block a user