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:
326
tests/unit/test_focus_watcher.py
Normal file
326
tests/unit/test_focus_watcher.py
Normal file
@ -0,0 +1,326 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user