#!/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 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_backend import FocusEvent 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_new_window(self): backend = SwayFocusBackend() received = [] backend._on_focus = received.append payload = json.dumps( {"change": "new", "container": {"app_id": "firefox", "name": "Mozilla"}} ).encode() backend._handle_event(payload) self.assertEqual(received, []) def test_handle_event_title_nonfocused_requeries_current_focus(self): backend = SwayFocusBackend() received = [] backend._on_focus = received.append current = FocusEvent(app_id="code", title="editor", backend="sway") with patch.object(backend, "get_current", return_value=current) as get_current: payload = json.dumps( { "change": "title", "container": { "focused": False, "app_id": "firefox", "name": "Background Mozilla", }, } ).encode() backend._handle_event(payload) get_current.assert_called_once_with() self.assertEqual(received, [current]) def test_handle_event_title_focused_uses_payload(self): backend = SwayFocusBackend() received = [] backend._on_focus = received.append payload = json.dumps( { "change": "title", "container": {"focused": True, "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") self.assertIs(backend._current_handle, handle) 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_closed_current_toplevel_emits_unfocus(self): backend = WlrForeignToplevelBackend() received = [] backend._on_focus = received.append handle = object() backend._toplevels[handle] = { "app_id": "firefox", "title": "Mozilla", "activated": True, } backend._on_done(handle) received.clear() backend._on_closed(handle) self.assertNotIn(handle, backend._toplevels) self.assertIsNone(backend._current_handle) self.assertEqual( backend.get_current(), FocusEvent(app_id="", title="", backend="wlr-foreign-toplevel"), ) self.assertEqual(received, [backend.get_current()]) def test_closed_non_current_toplevel_does_not_emit(self): backend = WlrForeignToplevelBackend() received = [] backend._on_focus = received.append current_handle = object() closed_handle = object() backend._toplevels[current_handle] = { "app_id": "code", "title": "Editor", "activated": True, } backend._toplevels[closed_handle] = { "app_id": "firefox", "title": "Mozilla", "activated": False, } backend._on_done(current_handle) current = backend.get_current() received.clear() backend._on_closed(closed_handle) self.assertNotIn(closed_handle, backend._toplevels) self.assertIs(backend._current_handle, current_handle) self.assertEqual(backend.get_current(), current) self.assertEqual(received, []) 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()