#!/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 unittest from unittest.mock import MagicMock import gi gi.require_version("Gtk", "3.0") gi.require_version("GLib", "2.0") from gi.repository import Gtk, GLib from inputremapper.configs.app_binding import AppBinding, BoundPreset, MatchType from inputremapper.gui.components.app_bindings import ( AppBindingsEditor, _BindingRow, ) from inputremapper.gui.controller import Controller from inputremapper.gui.messages.message_broker import MessageBroker, MessageType from inputremapper.gui.messages.message_data import ( AppBindingsData, FocusAppData, GroupsData, ) from inputremapper.gui.utils import gtk_iteration from tests.lib.test_setup import test_setup @test_setup class TestAppBindingsEditor(unittest.TestCase): def setUp(self) -> None: self.message_broker = MessageBroker() self.controller_mock: Controller = MagicMock() self.controller_mock.get_presets_for_group.return_value = ("preset1", "preset2") self.enabled_switch = Gtk.Switch() self.status_label = Gtk.Label() self.listbox = Gtk.ListBox() self.add_button = Gtk.Button() self.editor = AppBindingsEditor( self.message_broker, self.controller_mock, self.enabled_switch, self.status_label, self.listbox, self.add_button, ) def tearDown(self) -> None: super().tearDown() self.message_broker.signal(MessageType.terminate) for widget in ( self.enabled_switch, self.status_label, self.listbox, self.add_button, ): GLib.timeout_add(0, widget.destroy) GLib.timeout_add(0, Gtk.main_quit) Gtk.main() def _publish_bindings(self, bindings=(), enabled=False, **kwargs): defaults = dict(backend="xorg", supported=True, reachable=True) defaults.update(kwargs) self.message_broker.publish( AppBindingsData(enabled=enabled, bindings=tuple(bindings), **defaults) ) gtk_iteration() def _binding(self, app_id="firefox", presets=()): return AppBinding( app_id=app_id, presets=[BoundPreset(group_key=g, preset=p) for g, p in presets], ) # -- enable toggle ----------------------------------------------------- def test_toggle_enabled_calls_controller(self): self.enabled_switch.emit("state-set", True) gtk_iteration() self.controller_mock.set_app_binding_enabled.assert_called_with(True) def test_app_bindings_message_updates_switch_without_callback(self): # the incoming state must not echo back into the controller self.controller_mock.set_app_binding_enabled.reset_mock() self._publish_bindings(enabled=True) self.assertTrue(self.enabled_switch.get_active()) self.controller_mock.set_app_binding_enabled.assert_not_called() # -- add / remove binding ---------------------------------------------- def test_add_binding(self): self.add_button.emit("clicked") gtk_iteration() self.assertEqual(len(self.listbox.get_children()), 1) self.assertEqual(len(self.editor._rows), 1) self.controller_mock.update_app_bindings.assert_called() def test_rebuild_from_message(self): self._publish_bindings( [ self._binding("firefox", [("Foo Device", "preset1")]), self._binding("kitty"), ], enabled=True, ) self.assertEqual(len(self.listbox.get_children()), 2) self.assertEqual(len(self.editor._rows), 2) def test_remove_binding(self): self._publish_bindings([self._binding("firefox")], enabled=True) row = self.editor._rows[0] self.controller_mock.update_app_bindings.reset_mock() self.editor.remove_row(row) gtk_iteration() self.assertEqual(len(self.editor._rows), 0) self.assertEqual(len(self.listbox.get_children()), 0) self.controller_mock.update_app_bindings.assert_called() def test_editing_guard_prevents_rebuild(self): self._publish_bindings([self._binding("firefox")], enabled=True) self.assertEqual(len(self.editor._rows), 1) # simulate being mid-save: the echo of our own write must not rebuild self.editor._editing = True self._publish_bindings([self._binding("a"), self._binding("b")], enabled=True) self.assertEqual(len(self.editor._rows), 1) # -- preset rows ------------------------------------------------------- def test_add_and_remove_preset(self): self._publish_bindings([self._binding("firefox")], enabled=True) row: _BindingRow = self.editor._rows[0] self.assertEqual(len(row._preset_rows), 0) row._on_add_preset_clicked() gtk_iteration() self.assertEqual(len(row._preset_rows), 1) preset_row = row._preset_rows[0] row.remove_preset(preset_row) gtk_iteration() self.assertEqual(len(row._preset_rows), 0) def test_binding_to_model(self): self._publish_bindings( [self._binding("firefox", [("Foo Device", "preset1")])], enabled=True ) row: _BindingRow = self.editor._rows[0] model = row.to_model() self.assertIsNotNone(model) self.assertEqual(model.app_id, "firefox") self.assertEqual(model.match, MatchType.wm_class) self.assertEqual(model.presets[0].group_key, "Foo Device") def test_empty_app_id_row_raises(self): self.add_button.emit("clicked") gtk_iteration() row: _BindingRow = self.editor._rows[0] row.set_app_id("") with self.assertRaises(ValueError): row.to_model() def test_save_rejects_empty_app_id_with_visible_feedback(self): self.add_button.emit("clicked") gtk_iteration() row: _BindingRow = self.editor._rows[0] row.set_app_id("") self.controller_mock.update_app_bindings.reset_mock() self.editor.save() gtk_iteration() self.controller_mock.update_app_bindings.assert_not_called() self.assertTrue(row._error_label.get_visible()) self.assertIn("identifier", row._error_label.get_text()) def test_save_rejects_invalid_title_regex_with_visible_feedback(self): self._publish_bindings([self._binding("firefox")], enabled=True) row: _BindingRow = self.editor._rows[0] row.set_app_id("(") row._match_combo.set_active_id(MatchType.title_regex.value) self.controller_mock.update_app_bindings.reset_mock() self.editor.save() gtk_iteration() self.controller_mock.update_app_bindings.assert_not_called() self.assertTrue(row._error_label.get_visible()) self.assertIn("valid title regex", row._error_label.get_text()) def test_save_rejects_partial_preset_with_visible_feedback(self): self.message_broker.publish(GroupsData({"Foo Device": []})) gtk_iteration() self._publish_bindings([self._binding("firefox")], enabled=True) row: _BindingRow = self.editor._rows[0] row._on_add_preset_clicked() preset_row = row._preset_rows[0] preset_row._device_combo.set_active_id("Foo Device") self.controller_mock.update_app_bindings.reset_mock() self.editor.save() gtk_iteration() self.controller_mock.update_app_bindings.assert_not_called() self.assertTrue(row._error_label.get_visible()) self.assertIn("device and a preset", row._error_label.get_text()) # -- detection --------------------------------------------------------- def test_detect_flow(self): self._publish_bindings([self._binding("firefox")], enabled=True) row: _BindingRow = self.editor._rows[0] self.editor.request_detection(row) self.controller_mock.start_app_detection.assert_called_once() self.assertIs(self.editor._detecting_row, row) # a transient (empty) focus is ignored, detection keeps running self.message_broker.publish(FocusAppData(app_id="", title="")) gtk_iteration() self.assertIs(self.editor._detecting_row, row) # a real focus fills the app_id and stops detection self.controller_mock.update_app_bindings.reset_mock() self.message_broker.publish(FocusAppData(app_id="kitty", title="Terminal")) gtk_iteration() self.assertIsNone(self.editor._detecting_row) self.controller_mock.stop_app_detection.assert_called() self.controller_mock.update_app_bindings.assert_called() self.assertEqual(row.to_model().app_id, "kitty") def test_detect_toggle_off(self): self._publish_bindings([self._binding("firefox")], enabled=True) row: _BindingRow = self.editor._rows[0] self.editor.request_detection(row) # requesting again on the same row toggles detection off self.editor.request_detection(row) self.assertIsNone(self.editor._detecting_row) self.controller_mock.stop_app_detection.assert_called() # -- status label ------------------------------------------------------ def test_status_label_disabled(self): self._publish_bindings(enabled=False) self.assertIn("Enable application bindings", self.status_label.get_text()) def test_status_label_starting(self): self._publish_bindings(enabled=True, reachable=False, supported=False) self.assertIn("Starting", self.status_label.get_text()) def test_status_label_unsupported(self): self._publish_bindings( enabled=True, reachable=True, supported=False, backend=None ) self.assertIn("not supported", self.status_label.get_text()) def test_status_label_active(self): self._publish_bindings( enabled=True, reachable=True, supported=True, backend="xorg" ) self.assertIn("xorg", self.status_label.get_text()) # -- groups ------------------------------------------------------------ def test_groups_message_updates_available_groups(self): self.message_broker.publish(GroupsData({"Foo Device": [], "Bar": []})) gtk_iteration() self.assertEqual(self.editor.groups, ("Foo Device", "Bar")) if __name__ == "__main__": unittest.main()