Files
InputRemapper/tests/unit/test_focus_watcher.py
Blomios 9ef2c1439c 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>
2026-06-19 23:52:34 +02:00

327 lines
12 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 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()