Files
InputRemapper/tests/unit/test_focus_service.py
Blomios af10ec8bd1 fix(focus): détecter correctement l'unfocus sur les backends Wayland
Le suivi du focus laissait les injections app-controlled actives lors d'une
perte de focus réelle (fermeture de la fenêtre courante, retour à un bureau
vide). On corrige la détection sur toute la chaîne :

- FocusService: un app_id vide est désormais traité comme un unfocus réel
  (et non un focus transitoire ignoré), ce qui stoppe les injections liées
  à l'application.
- Sway: on ignore les events 'new' (pas un changement de focus) et on relit
  le focus courant sur un event 'title' non focused.
- WlrForeignToplevelBackend: suit _current_handle, le clear sur stop() et
  émet un FocusEvent vide quand le toplevel actuellement focus se ferme.

Tests: test_focus_service + test_focus_watcher (67) OK; lot feature
historique (295) OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 00:42:15 +02:00

365 lines
15 KiB
Python

#!/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_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()