chore(repo): baseline du fork input-remapper (upstream v2.2.1)
Le dépôt ne contenait que README.md ; ajout de l'intégralité du code fonctionnel du fork comme socle stable de la branche de release. L'état runtime d'orchestration (.ideai/) est exclu du suivi. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
0
tests/system/gui/__init__.py
Normal file
0
tests/system/gui/__init__.py
Normal file
383
tests/system/gui/gui_test_base.py
Normal file
383
tests/system/gui/gui_test_base.py
Normal file
@ -0,0 +1,383 @@
|
||||
#!/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 asyncio
|
||||
import atexit
|
||||
import multiprocessing
|
||||
import os
|
||||
import time
|
||||
import unittest
|
||||
from contextlib import contextmanager
|
||||
from typing import Tuple, List, Optional
|
||||
from unittest.mock import patch
|
||||
|
||||
import evdev
|
||||
import gi
|
||||
import sys
|
||||
|
||||
from inputremapper.injection.global_uinputs import GlobalUInputs, FrontendUInput, UInput
|
||||
from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser
|
||||
from tests.lib.cleanup import cleanup
|
||||
from tests.lib.constants import EVENT_READ_TIMEOUT
|
||||
from tests.lib.fixtures import prepare_presets
|
||||
from tests.lib.logger import logger
|
||||
|
||||
gi.require_version("Gdk", "3.0")
|
||||
gi.require_version("Gtk", "3.0")
|
||||
gi.require_version("GtkSource", "4")
|
||||
gi.require_version("GLib", "2.0")
|
||||
from gi.repository import Gtk, GLib, GtkSource
|
||||
|
||||
from inputremapper.configs.mapping import Mapping
|
||||
from inputremapper.configs.global_config import GlobalConfig
|
||||
from inputremapper.groups import _Groups
|
||||
from inputremapper.gui.data_manager import DataManager
|
||||
from inputremapper.gui.messages.message_broker import (
|
||||
MessageBroker,
|
||||
)
|
||||
from inputremapper.gui.components.editor import (
|
||||
MappingSelectionLabel,
|
||||
)
|
||||
from inputremapper.gui.controller import Controller
|
||||
from inputremapper.gui.reader_service import ReaderService
|
||||
from inputremapper.gui.utils import gtk_iteration
|
||||
from inputremapper.gui.user_interface import UserInterface
|
||||
from inputremapper.configs.input_config import InputCombination, InputConfig
|
||||
from inputremapper.daemon import Daemon, DaemonProxy
|
||||
from inputremapper.bin.input_remapper_gtk import InputRemapperGtkBin
|
||||
|
||||
|
||||
# iterate a few times when Gtk.main() is called, but don't block
|
||||
# there and just continue to the tests while the UI becomes
|
||||
# unresponsive
|
||||
Gtk.main = gtk_iteration
|
||||
|
||||
# doesn't do much except avoid some Gtk assertion error, whatever:
|
||||
Gtk.main_quit = lambda: None
|
||||
|
||||
|
||||
def launch() -> Tuple[
|
||||
UserInterface,
|
||||
Controller,
|
||||
DataManager,
|
||||
MessageBroker,
|
||||
DaemonProxy,
|
||||
GlobalConfig,
|
||||
]:
|
||||
"""Start input-remapper-gtk."""
|
||||
with patch.object(sys, "argv", ["/usr/bin/input-remapper-gtk", "-d"]):
|
||||
return_ = InputRemapperGtkBin.main()
|
||||
|
||||
gtk_iteration()
|
||||
# otherwise a new handler is added with each call to launch, which
|
||||
# spams tons of garbage when all tests finish
|
||||
atexit.unregister(InputRemapperGtkBin.stop)
|
||||
return return_
|
||||
|
||||
|
||||
def start_reader_service():
|
||||
def process():
|
||||
global_uinputs = GlobalUInputs(FrontendUInput)
|
||||
reader_service = ReaderService(_Groups(), global_uinputs)
|
||||
loop = asyncio.new_event_loop()
|
||||
loop.run_until_complete(reader_service.run())
|
||||
|
||||
multiprocessing.Process(target=process).start()
|
||||
|
||||
|
||||
def os_system_patch(cmd, original_os_system=os.system):
|
||||
# instead of running pkexec, fork instead. This will make
|
||||
# the reader-service aware of all the test patches
|
||||
if "pkexec input-remapper-control --command start-reader-service" in cmd:
|
||||
logger.info("pkexec-patch starting ReaderService process")
|
||||
start_reader_service()
|
||||
return 0
|
||||
|
||||
return original_os_system(cmd)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def patch_services():
|
||||
"""Don't connect to the dbus and don't use pkexec to start the reader-service"""
|
||||
|
||||
def bootstrap_daemon():
|
||||
# The daemon gets fresh instances of everything, because as far as I remember
|
||||
# it runs in a separate process.
|
||||
global_config = GlobalConfig()
|
||||
global_uinputs = GlobalUInputs(UInput)
|
||||
mapping_parser = MappingParser(global_uinputs)
|
||||
|
||||
return Daemon(
|
||||
global_config,
|
||||
global_uinputs,
|
||||
mapping_parser,
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
os,
|
||||
"system",
|
||||
os_system_patch,
|
||||
),
|
||||
patch.object(Daemon, "connect", bootstrap_daemon),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
def clean_up_gui_test(test):
|
||||
logger.info("clean_up_gui_test")
|
||||
test.controller.stop_injecting()
|
||||
gtk_iteration()
|
||||
test.user_interface.on_gtk_close()
|
||||
test.user_interface.window.destroy()
|
||||
gtk_iteration()
|
||||
cleanup()
|
||||
|
||||
# do this now, not when all tests are finished
|
||||
test.daemon.stop_all()
|
||||
if isinstance(test.daemon, Daemon):
|
||||
atexit.unregister(test.daemon.stop_all)
|
||||
|
||||
|
||||
class GtkKeyEvent:
|
||||
def __init__(self, keyval):
|
||||
self.keyval = keyval
|
||||
|
||||
def get_keyval(self):
|
||||
return True, self.keyval
|
||||
|
||||
|
||||
@contextmanager
|
||||
def patch_confirm_delete(
|
||||
user_interface: UserInterface,
|
||||
response=Gtk.ResponseType.ACCEPT,
|
||||
):
|
||||
original_create_dialog = user_interface._create_dialog
|
||||
|
||||
def _create_dialog_patch(*args, **kwargs):
|
||||
"""A patch for the deletion confirmation that briefly shows the dialog."""
|
||||
confirm_cancel_dialog = original_create_dialog(*args, **kwargs)
|
||||
|
||||
# the emitted signal causes the dialog to close
|
||||
GLib.timeout_add(
|
||||
100,
|
||||
lambda: confirm_cancel_dialog.emit("response", response),
|
||||
)
|
||||
|
||||
# don't recursively call the patch
|
||||
Gtk.MessageDialog.run(confirm_cancel_dialog)
|
||||
|
||||
confirm_cancel_dialog.run = lambda: response
|
||||
|
||||
return confirm_cancel_dialog
|
||||
|
||||
with patch.object(
|
||||
user_interface,
|
||||
"_create_dialog",
|
||||
_create_dialog_patch,
|
||||
):
|
||||
# Tests are run during `yield`
|
||||
yield
|
||||
|
||||
|
||||
class GuiTestBase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
prepare_presets()
|
||||
with patch_services():
|
||||
(
|
||||
self.user_interface,
|
||||
self.controller,
|
||||
self.data_manager,
|
||||
self.message_broker,
|
||||
self.daemon,
|
||||
self.global_config,
|
||||
) = launch()
|
||||
|
||||
get = self.user_interface.get
|
||||
self.device_selection: Gtk.FlowBox = get("device_selection")
|
||||
self.preset_selection: Gtk.ComboBoxText = get("preset_selection")
|
||||
self.selection_label_listbox: Gtk.ListBox = get("selection_label_listbox")
|
||||
self.target_selection: Gtk.ComboBox = get("target-selector")
|
||||
self.recording_toggle: Gtk.ToggleButton = get("key_recording_toggle")
|
||||
self.recording_status: Gtk.ToggleButton = get("recording_status")
|
||||
self.status_bar: Gtk.Statusbar = get("status_bar")
|
||||
self.autoload_toggle: Gtk.Switch = get("preset_autoload_switch")
|
||||
self.code_editor: GtkSource.View = get("code_editor")
|
||||
self.output_box: GtkSource.View = get("output")
|
||||
|
||||
self.delete_preset_btn: Gtk.Button = get("delete_preset")
|
||||
self.copy_preset_btn: Gtk.Button = get("copy_preset")
|
||||
self.create_preset_btn: Gtk.Button = get("create_preset")
|
||||
self.start_injector_btn: Gtk.Button = get("apply_preset")
|
||||
self.stop_injector_btn: Gtk.Button = get("stop_injection_preset_page")
|
||||
self.rename_btn: Gtk.Button = get("rename-button")
|
||||
self.rename_input: Gtk.Entry = get("preset_name_input")
|
||||
self.create_mapping_btn: Gtk.Button = get("create_mapping_button")
|
||||
self.delete_mapping_btn: Gtk.Button = get("delete-mapping")
|
||||
|
||||
self._test_initial_state()
|
||||
|
||||
self.grab_fails = False
|
||||
|
||||
def grab(_):
|
||||
if self.grab_fails:
|
||||
raise OSError()
|
||||
|
||||
evdev.InputDevice.grab = grab
|
||||
|
||||
self.global_config._save_config()
|
||||
|
||||
self.throttle(20)
|
||||
|
||||
self.assertIsNotNone(self.data_manager.active_group)
|
||||
self.assertIsNotNone(self.data_manager.active_preset)
|
||||
|
||||
def tearDown(self):
|
||||
clean_up_gui_test(self)
|
||||
|
||||
# this is important, otherwise it keeps breaking things in the background
|
||||
self.assertIsNone(self.data_manager._reader_client._read_timeout)
|
||||
|
||||
self.throttle(20)
|
||||
|
||||
def get_code_input(self):
|
||||
buffer = self.code_editor.get_buffer()
|
||||
return buffer.get_text(
|
||||
buffer.get_start_iter(),
|
||||
buffer.get_end_iter(),
|
||||
True,
|
||||
)
|
||||
|
||||
def _test_initial_state(self):
|
||||
# make sure each test deals with the same initial state
|
||||
self.assertEqual(self.controller.data_manager, self.data_manager)
|
||||
self.assertEqual(self.data_manager.active_group.key, "Foo Device")
|
||||
# if the modification-date from `prepare_presets` is not destroyed, preset3
|
||||
# should be selected as the newest one
|
||||
self.assertEqual(self.data_manager.active_preset.name, "preset3")
|
||||
self.assertEqual(self.data_manager.active_mapping.target_uinput, "keyboard")
|
||||
self.assertEqual(self.target_selection.get_active_id(), "keyboard")
|
||||
self.assertEqual(
|
||||
self.data_manager.active_mapping.input_combination,
|
||||
InputCombination([InputConfig(type=1, code=5)]),
|
||||
)
|
||||
self.assertEqual(
|
||||
self.data_manager.active_input_config, InputConfig(type=1, code=5)
|
||||
)
|
||||
self.assertGreater(
|
||||
len(self.user_interface.autocompletion._target_key_capabilities), 0
|
||||
)
|
||||
|
||||
def _callTestMethod(self, method):
|
||||
"""Retry all tests if they fail.
|
||||
|
||||
GUI tests suddenly started to lag a lot and fail randomly, and even
|
||||
though that improved drastically, sometimes they still do.
|
||||
"""
|
||||
attempts = 0
|
||||
while True:
|
||||
attempts += 1
|
||||
try:
|
||||
method()
|
||||
break
|
||||
except Exception as e:
|
||||
if attempts == 2:
|
||||
raise e
|
||||
|
||||
# try again
|
||||
print("Test failed, trying again...")
|
||||
self.tearDown()
|
||||
self.setUp()
|
||||
|
||||
def throttle(self, time_=10):
|
||||
"""Give GTK some time in ms to process everything."""
|
||||
# tests suddenly started to freeze my computer up completely and tests started
|
||||
# to fail. By using this (and by optimizing some redundant calls in the gui) it
|
||||
# worked again. EDIT: Might have been caused by my broken/bloated ssd. I'll
|
||||
# keep it in some places, since it did make the tests more reliable after all.
|
||||
for _ in range(time_ // 2):
|
||||
gtk_iteration()
|
||||
time.sleep(0.002)
|
||||
|
||||
def set_focus(self, widget):
|
||||
logger.info("Focusing %s", widget)
|
||||
|
||||
self.user_interface.window.set_focus(widget)
|
||||
|
||||
self.throttle(20)
|
||||
|
||||
def focus_source_view(self):
|
||||
# despite the focus and gtk_iterations, gtk never runs the event handlers for
|
||||
# the focus-in-event (_update_placeholder), which would clear the placeholder
|
||||
# text. Remove it manually, it can't be helped. Fun fact: when the
|
||||
# window gets destroyed, gtk runs the handler 10 times for good measure.
|
||||
# Lost one hour of my life on GTK again. It's gone! Forever! Use qt next time.
|
||||
source_view = self.code_editor
|
||||
self.set_focus(source_view)
|
||||
self.code_editor.get_buffer().set_text("")
|
||||
return source_view
|
||||
|
||||
def get_selection_labels(self) -> List[MappingSelectionLabel]:
|
||||
return self.selection_label_listbox.get_children()
|
||||
|
||||
def get_status_text(self):
|
||||
status_bar = self.user_interface.get("status_bar")
|
||||
return status_bar.get_message_area().get_children()[0].get_label()
|
||||
|
||||
def get_unfiltered_symbol_input_text(self):
|
||||
buffer = self.code_editor.get_buffer()
|
||||
return buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True)
|
||||
|
||||
def select_mapping(self, i: int):
|
||||
"""Select one of the mappings of a preset.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
i
|
||||
if -1, will select the last row,
|
||||
0 will select the uppermost row.
|
||||
1 will select the second row, and so on
|
||||
"""
|
||||
selection_label = self.get_selection_labels()[i]
|
||||
self.selection_label_listbox.select_row(selection_label)
|
||||
logger.info(
|
||||
'Selecting mapping %s "%s"',
|
||||
selection_label.combination,
|
||||
selection_label.name,
|
||||
)
|
||||
gtk_iteration()
|
||||
return selection_label
|
||||
|
||||
def add_mapping(self, mapping: Optional[Mapping] = None):
|
||||
self.controller.create_mapping()
|
||||
self.controller.load_mapping(InputCombination.empty_combination())
|
||||
gtk_iteration()
|
||||
if mapping:
|
||||
self.controller.update_mapping(**mapping.dict(exclude_defaults=True))
|
||||
gtk_iteration()
|
||||
|
||||
def sleep(self, num_events):
|
||||
for _ in range(num_events * 2):
|
||||
time.sleep(EVENT_READ_TIMEOUT)
|
||||
gtk_iteration()
|
||||
|
||||
time.sleep(1 / 30) # one window iteration
|
||||
|
||||
gtk_iteration()
|
||||
259
tests/system/gui/test_autocompletion.py
Normal file
259
tests/system/gui/test_autocompletion.py
Normal file
@ -0,0 +1,259 @@
|
||||
#!/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 time
|
||||
import unittest
|
||||
|
||||
import gi
|
||||
|
||||
from inputremapper.gui.autocompletion import (
|
||||
get_incomplete_parameter,
|
||||
get_incomplete_function_name,
|
||||
)
|
||||
|
||||
gi.require_version("Gdk", "3.0")
|
||||
gi.require_version("Gtk", "3.0")
|
||||
gi.require_version("GtkSource", "4")
|
||||
gi.require_version("GLib", "2.0")
|
||||
from gi.repository import Gtk, Gdk
|
||||
|
||||
from inputremapper.configs.keyboard_layout import keyboard_layout
|
||||
from inputremapper.gui.utils import gtk_iteration
|
||||
|
||||
from tests.lib.test_setup import test_setup
|
||||
from tests.system.gui.gui_test_base import GuiTestBase
|
||||
|
||||
|
||||
@test_setup
|
||||
class TestAutocompletion(GuiTestBase):
|
||||
def press_key(self, keyval):
|
||||
event = Gdk.EventKey()
|
||||
event.keyval = keyval
|
||||
self.user_interface.autocompletion.navigate(None, event)
|
||||
|
||||
def get_suggestions(self, autocompletion):
|
||||
return [
|
||||
row.get_children()[0].get_text()
|
||||
for row in autocompletion.list_box.get_children()
|
||||
]
|
||||
|
||||
def test_get_incomplete_parameter(self):
|
||||
def test(text, expected):
|
||||
text_view = Gtk.TextView()
|
||||
Gtk.TextView.do_insert_at_cursor(text_view, text)
|
||||
text_iter = text_view.get_iter_at_location(0, 0)[1]
|
||||
text_iter.set_offset(len(text))
|
||||
self.assertEqual(get_incomplete_parameter(text_iter), expected)
|
||||
|
||||
test("bar(foo", "foo")
|
||||
test("bar(a=foo", "foo")
|
||||
test("bar(qux, foo", "foo")
|
||||
test("foo", "foo")
|
||||
test("bar + foo", "foo")
|
||||
|
||||
def test_get_incomplete_function_name(self):
|
||||
def test(text, expected):
|
||||
text_view = Gtk.TextView()
|
||||
Gtk.TextView.do_insert_at_cursor(text_view, text)
|
||||
text_iter = text_view.get_iter_at_location(0, 0)[1]
|
||||
text_iter.set_offset(len(text))
|
||||
self.assertEqual(get_incomplete_function_name(text_iter), expected)
|
||||
|
||||
test("bar().foo", "foo")
|
||||
test("bar()\n.foo", "foo")
|
||||
test("bar().\nfoo", "foo")
|
||||
test("bar(\nfoo", "foo")
|
||||
test("bar(\nqux=foo", "foo")
|
||||
test("bar(KEY_A,\nfoo", "foo")
|
||||
test("foo", "foo")
|
||||
|
||||
def test_autocomplete_names(self):
|
||||
autocompletion = self.user_interface.autocompletion
|
||||
|
||||
def setup(text):
|
||||
self.set_focus(self.code_editor)
|
||||
self.code_editor.get_buffer().set_text("")
|
||||
Gtk.TextView.do_insert_at_cursor(self.code_editor, text)
|
||||
self.throttle(200)
|
||||
text_iter = self.code_editor.get_iter_at_location(0, 0)[1]
|
||||
text_iter.set_offset(len(text))
|
||||
|
||||
setup("disa")
|
||||
self.assertNotIn("KEY_A", self.get_suggestions(autocompletion))
|
||||
self.assertIn("disable", self.get_suggestions(autocompletion))
|
||||
|
||||
setup(" + _A")
|
||||
self.assertIn("KEY_A", self.get_suggestions(autocompletion))
|
||||
self.assertNotIn("disable", self.get_suggestions(autocompletion))
|
||||
|
||||
def test_autocomplete_key(self):
|
||||
self.controller.update_mapping(output_symbol="")
|
||||
gtk_iteration()
|
||||
|
||||
self.set_focus(self.code_editor)
|
||||
self.code_editor.get_buffer().set_text("")
|
||||
|
||||
complete_key_name = "Test_Foo_Bar"
|
||||
|
||||
keyboard_layout.clear()
|
||||
keyboard_layout._set(complete_key_name, 1)
|
||||
keyboard_layout._set("KEY_A", 30) # we need this for the UIMapping to work
|
||||
|
||||
# it can autocomplete a combination inbetween other things
|
||||
incomplete = "qux_1\n + + qux_2"
|
||||
Gtk.TextView.do_insert_at_cursor(self.code_editor, incomplete)
|
||||
Gtk.TextView.do_move_cursor(
|
||||
self.code_editor,
|
||||
Gtk.MovementStep.VISUAL_POSITIONS,
|
||||
-8,
|
||||
False,
|
||||
)
|
||||
|
||||
Gtk.TextView.do_insert_at_cursor(self.code_editor, "foo")
|
||||
self.throttle(200)
|
||||
gtk_iteration()
|
||||
|
||||
autocompletion = self.user_interface.autocompletion
|
||||
self.assertTrue(autocompletion.visible)
|
||||
|
||||
self.press_key(Gdk.KEY_Down)
|
||||
self.press_key(Gdk.KEY_Return)
|
||||
self.throttle(200)
|
||||
gtk_iteration()
|
||||
|
||||
# the first suggestion should have been selected
|
||||
|
||||
modified_symbol = self.get_code_input()
|
||||
self.assertEqual(modified_symbol, f"qux_1\n + {complete_key_name} + qux_2")
|
||||
|
||||
# try again, but a whitespace completes the word and so no autocompletion
|
||||
# should be shown
|
||||
Gtk.TextView.do_insert_at_cursor(self.code_editor, " + foo ")
|
||||
|
||||
time.sleep(0.11)
|
||||
gtk_iteration()
|
||||
|
||||
self.assertFalse(autocompletion.visible)
|
||||
|
||||
def test_autocomplete_function(self):
|
||||
self.controller.update_mapping(output_symbol="")
|
||||
gtk_iteration()
|
||||
|
||||
source_view = self.focus_source_view()
|
||||
|
||||
incomplete = "key(KEY_A).\nepea"
|
||||
Gtk.TextView.do_insert_at_cursor(source_view, incomplete)
|
||||
|
||||
time.sleep(0.11)
|
||||
gtk_iteration()
|
||||
|
||||
autocompletion = self.user_interface.autocompletion
|
||||
self.assertTrue(autocompletion.visible)
|
||||
|
||||
self.press_key(Gdk.KEY_Down)
|
||||
self.press_key(Gdk.KEY_Return)
|
||||
|
||||
# the first suggestion should have been selected
|
||||
modified_symbol = self.get_code_input()
|
||||
self.assertEqual(modified_symbol, "key(KEY_A).\nrepeat")
|
||||
|
||||
def test_close_autocompletion(self):
|
||||
self.controller.update_mapping(output_symbol="")
|
||||
gtk_iteration()
|
||||
|
||||
source_view = self.focus_source_view()
|
||||
|
||||
Gtk.TextView.do_insert_at_cursor(source_view, "KEY_")
|
||||
|
||||
time.sleep(0.11)
|
||||
gtk_iteration()
|
||||
|
||||
autocompletion = self.user_interface.autocompletion
|
||||
self.assertTrue(autocompletion.visible)
|
||||
|
||||
self.press_key(Gdk.KEY_Down)
|
||||
self.press_key(Gdk.KEY_Escape)
|
||||
|
||||
self.assertFalse(autocompletion.visible)
|
||||
|
||||
symbol = self.get_code_input()
|
||||
self.assertEqual(symbol, "KEY_")
|
||||
|
||||
def test_writing_still_works(self):
|
||||
self.controller.update_mapping(output_symbol="")
|
||||
gtk_iteration()
|
||||
source_view = self.focus_source_view()
|
||||
|
||||
Gtk.TextView.do_insert_at_cursor(source_view, "KEY_")
|
||||
|
||||
autocompletion = self.user_interface.autocompletion
|
||||
|
||||
time.sleep(0.11)
|
||||
gtk_iteration()
|
||||
self.assertTrue(autocompletion.visible)
|
||||
|
||||
# writing still works while an entry is selected
|
||||
self.press_key(Gdk.KEY_Down)
|
||||
|
||||
Gtk.TextView.do_insert_at_cursor(source_view, "A")
|
||||
|
||||
time.sleep(0.11)
|
||||
gtk_iteration()
|
||||
self.assertTrue(autocompletion.visible)
|
||||
|
||||
Gtk.TextView.do_insert_at_cursor(source_view, "1234foobar")
|
||||
|
||||
time.sleep(0.11)
|
||||
gtk_iteration()
|
||||
# no key matches this completion, so it closes again
|
||||
self.assertFalse(autocompletion.visible)
|
||||
|
||||
def test_cycling(self):
|
||||
self.controller.update_mapping(output_symbol="")
|
||||
gtk_iteration()
|
||||
source_view = self.focus_source_view()
|
||||
|
||||
Gtk.TextView.do_insert_at_cursor(source_view, "KEY_")
|
||||
|
||||
autocompletion = self.user_interface.autocompletion
|
||||
|
||||
time.sleep(0.11)
|
||||
gtk_iteration()
|
||||
self.assertTrue(autocompletion.visible)
|
||||
|
||||
self.assertEqual(
|
||||
autocompletion.scrolled_window.get_vadjustment().get_value(), 0
|
||||
)
|
||||
|
||||
# cycle to the end of the list because there is no element higher than index 0
|
||||
self.press_key(Gdk.KEY_Up)
|
||||
self.assertGreater(
|
||||
autocompletion.scrolled_window.get_vadjustment().get_value(), 0
|
||||
)
|
||||
|
||||
# go back to the start, because it can't go down further
|
||||
self.press_key(Gdk.KEY_Down)
|
||||
self.assertEqual(
|
||||
autocompletion.scrolled_window.get_vadjustment().get_value(), 0
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
89
tests/system/gui/test_colors.py
Normal file
89
tests/system/gui/test_colors.py
Normal file
@ -0,0 +1,89 @@
|
||||
#!/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 unittest
|
||||
|
||||
import gi
|
||||
|
||||
|
||||
gi.require_version("Gdk", "3.0")
|
||||
gi.require_version("Gtk", "3.0")
|
||||
gi.require_version("GtkSource", "4")
|
||||
gi.require_version("GLib", "2.0")
|
||||
from gi.repository import Gdk
|
||||
|
||||
from inputremapper.gui.utils import Colors
|
||||
|
||||
from tests.lib.test_setup import test_setup
|
||||
from tests.system.gui.gui_test_base import GuiTestBase
|
||||
|
||||
|
||||
@test_setup
|
||||
class TestColors(GuiTestBase):
|
||||
# requires a running ui, otherwise fails with segmentation faults
|
||||
def test_get_color_falls_back(self):
|
||||
fallback = Gdk.RGBA(0, 0.5, 1, 0.8)
|
||||
|
||||
color = Colors.get_color(["doesnt_exist_1234"], fallback)
|
||||
|
||||
self.assertIsInstance(color, Gdk.RGBA)
|
||||
self.assertAlmostEqual(color.red, fallback.red, delta=0.01)
|
||||
self.assertAlmostEqual(color.green, fallback.green, delta=0.01)
|
||||
self.assertAlmostEqual(color.blue, fallback.blue, delta=0.01)
|
||||
self.assertAlmostEqual(color.alpha, fallback.alpha, delta=0.01)
|
||||
|
||||
def test_get_color_works(self):
|
||||
fallback = Gdk.RGBA(1, 0, 1, 0.1)
|
||||
|
||||
color = Colors.get_color(
|
||||
["accent_bg_color", "theme_selected_bg_color"], fallback
|
||||
)
|
||||
|
||||
self.assertIsInstance(color, Gdk.RGBA)
|
||||
self.assertNotAlmostEqual(color.red, fallback.red, delta=0.01)
|
||||
self.assertNotAlmostEqual(color.green, fallback.blue, delta=0.01)
|
||||
self.assertNotAlmostEqual(color.blue, fallback.green, delta=0.01)
|
||||
self.assertNotAlmostEqual(color.alpha, fallback.alpha, delta=0.01)
|
||||
|
||||
def _test_color_wont_fallback(self, get_color, fallback):
|
||||
color = get_color()
|
||||
self.assertIsInstance(color, Gdk.RGBA)
|
||||
if (
|
||||
(abs(color.green - fallback.green) < 0.01)
|
||||
and (abs(color.red - fallback.red) < 0.01)
|
||||
and (abs(color.blue - fallback.blue) < 0.01)
|
||||
and (abs(color.alpha - fallback.alpha) < 0.01)
|
||||
):
|
||||
raise AssertionError(
|
||||
f"Color {color.to_string()} is similar to {fallback.toString()}"
|
||||
)
|
||||
|
||||
def test_get_colors(self):
|
||||
self._test_color_wont_fallback(Colors.get_accent_color, Colors.fallback_accent)
|
||||
self._test_color_wont_fallback(Colors.get_border_color, Colors.fallback_border)
|
||||
self._test_color_wont_fallback(
|
||||
Colors.get_background_color, Colors.fallback_background
|
||||
)
|
||||
self._test_color_wont_fallback(Colors.get_base_color, Colors.fallback_base)
|
||||
self._test_color_wont_fallback(Colors.get_font_color, Colors.fallback_font)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
1925
tests/system/gui/test_components.py
Normal file
1925
tests/system/gui/test_components.py
Normal file
File diff suppressed because it is too large
Load Diff
149
tests/system/gui/test_debounce.py
Normal file
149
tests/system/gui/test_debounce.py
Normal file
@ -0,0 +1,149 @@
|
||||
#!/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 time
|
||||
import unittest
|
||||
|
||||
import gi
|
||||
|
||||
|
||||
gi.require_version("Gdk", "3.0")
|
||||
gi.require_version("Gtk", "3.0")
|
||||
gi.require_version("GtkSource", "4")
|
||||
gi.require_version("GLib", "2.0")
|
||||
|
||||
from inputremapper.gui.utils import gtk_iteration, debounce, debounce_manager
|
||||
|
||||
from tests.lib.test_setup import test_setup
|
||||
|
||||
|
||||
@test_setup
|
||||
class TestDebounce(unittest.TestCase):
|
||||
def test_debounce(self):
|
||||
calls = 0
|
||||
|
||||
class A:
|
||||
@debounce(20)
|
||||
def foo(self):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
|
||||
# two methods with the same name don't confuse debounce
|
||||
class B:
|
||||
@debounce(20)
|
||||
def foo(self):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
|
||||
a = A()
|
||||
b = B()
|
||||
|
||||
self.assertEqual(calls, 0)
|
||||
|
||||
a.foo()
|
||||
gtk_iteration()
|
||||
self.assertEqual(calls, 0)
|
||||
|
||||
b.foo()
|
||||
gtk_iteration()
|
||||
self.assertEqual(calls, 0)
|
||||
|
||||
time.sleep(0.021)
|
||||
gtk_iteration()
|
||||
self.assertEqual(calls, 2)
|
||||
|
||||
a.foo()
|
||||
b.foo()
|
||||
a.foo()
|
||||
b.foo()
|
||||
gtk_iteration()
|
||||
self.assertEqual(calls, 2)
|
||||
|
||||
time.sleep(0.021)
|
||||
gtk_iteration()
|
||||
self.assertEqual(calls, 4)
|
||||
|
||||
def test_run_all_now(self):
|
||||
calls = 0
|
||||
|
||||
class A:
|
||||
@debounce(20)
|
||||
def foo(self):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
|
||||
a = A()
|
||||
a.foo()
|
||||
gtk_iteration()
|
||||
self.assertEqual(calls, 0)
|
||||
|
||||
debounce_manager.run_all_now()
|
||||
self.assertEqual(calls, 1)
|
||||
|
||||
# waiting for some time will not call it again
|
||||
time.sleep(0.021)
|
||||
gtk_iteration()
|
||||
self.assertEqual(calls, 1)
|
||||
|
||||
def test_stop_all(self):
|
||||
calls = 0
|
||||
|
||||
class A:
|
||||
@debounce(20)
|
||||
def foo(self):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
|
||||
a = A()
|
||||
a.foo()
|
||||
gtk_iteration()
|
||||
self.assertEqual(calls, 0)
|
||||
|
||||
debounce_manager.stop_all()
|
||||
|
||||
# waiting for some time will not call it
|
||||
time.sleep(0.021)
|
||||
gtk_iteration()
|
||||
self.assertEqual(calls, 0)
|
||||
|
||||
def test_stop(self):
|
||||
calls = 0
|
||||
|
||||
class A:
|
||||
@debounce(20)
|
||||
def foo(self):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
|
||||
a = A()
|
||||
a.foo()
|
||||
gtk_iteration()
|
||||
self.assertEqual(calls, 0)
|
||||
|
||||
debounce_manager.stop(a, a.foo)
|
||||
|
||||
# waiting for some time will not call it
|
||||
time.sleep(0.021)
|
||||
gtk_iteration()
|
||||
self.assertEqual(calls, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
137
tests/system/gui/test_groups.py
Normal file
137
tests/system/gui/test_groups.py
Normal file
@ -0,0 +1,137 @@
|
||||
#!/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 os
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import gi
|
||||
|
||||
from inputremapper.injection.global_uinputs import GlobalUInputs, UInput
|
||||
from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser
|
||||
|
||||
gi.require_version("Gdk", "3.0")
|
||||
gi.require_version("Gtk", "3.0")
|
||||
gi.require_version("GtkSource", "4")
|
||||
gi.require_version("GLib", "2.0")
|
||||
|
||||
from inputremapper.configs.global_config import GlobalConfig
|
||||
from inputremapper.gui.utils import gtk_iteration
|
||||
from inputremapper.daemon import Daemon
|
||||
|
||||
from tests.lib.test_setup import test_setup
|
||||
from tests.system.gui.gui_test_base import (
|
||||
launch,
|
||||
start_reader_service,
|
||||
clean_up_gui_test,
|
||||
)
|
||||
|
||||
|
||||
@test_setup
|
||||
class TestGroupsFromReaderService(unittest.TestCase):
|
||||
def patch_os_system(self):
|
||||
def os_system(cmd, original_os_system=os.system):
|
||||
# instead of running pkexec, fork instead. This will make
|
||||
# the reader-service aware of all the test patches
|
||||
if "pkexec input-remapper-control --command start-reader-service" in cmd:
|
||||
# don't start the reader-service just log that it was.
|
||||
self.reader_service_started()
|
||||
return 0
|
||||
|
||||
return original_os_system(cmd)
|
||||
|
||||
self.os_system_patch = patch.object(
|
||||
os,
|
||||
"system",
|
||||
os_system,
|
||||
)
|
||||
|
||||
# this is already part of the test. we need a bit of patching and hacking
|
||||
# because we want to discover the groups as early a possible, to reduce startup
|
||||
# time for the application
|
||||
self.os_system_patch.start()
|
||||
|
||||
def bootstrap_daemon(self):
|
||||
# The daemon gets fresh instances of everything, because as far as I remember
|
||||
# it runs in a separate process.
|
||||
global_config = GlobalConfig()
|
||||
global_uinputs = GlobalUInputs(UInput)
|
||||
mapping_parser = MappingParser(global_uinputs)
|
||||
|
||||
return Daemon(
|
||||
global_config,
|
||||
global_uinputs,
|
||||
mapping_parser,
|
||||
)
|
||||
|
||||
def patch_daemon(self):
|
||||
# don't try to connect, return an object instance of it instead
|
||||
self.daemon_connect_patch = patch.object(
|
||||
Daemon,
|
||||
"connect",
|
||||
lambda: self.bootstrap_daemon(),
|
||||
)
|
||||
self.daemon_connect_patch.start()
|
||||
|
||||
def setUp(self):
|
||||
self.reader_service_started = MagicMock()
|
||||
self.patch_os_system()
|
||||
self.patch_daemon()
|
||||
|
||||
(
|
||||
self.user_interface,
|
||||
self.controller,
|
||||
self.data_manager,
|
||||
self.message_broker,
|
||||
self.daemon,
|
||||
self.global_config,
|
||||
) = launch()
|
||||
|
||||
def tearDown(self):
|
||||
clean_up_gui_test(self)
|
||||
self.os_system_patch.stop()
|
||||
self.daemon_connect_patch.stop()
|
||||
|
||||
def test_knows_devices(self):
|
||||
# verify that it is working as expected. The gui doesn't have knowledge
|
||||
# of groups until the root-reader-service provides them
|
||||
self.data_manager._reader_client.groups.set_groups([])
|
||||
gtk_iteration()
|
||||
self.reader_service_started.assert_called()
|
||||
self.assertEqual(len(self.data_manager.get_group_keys()), 0)
|
||||
|
||||
# start the reader-service delayed
|
||||
start_reader_service()
|
||||
# perform some iterations so that the reader ends up reading from the pipes
|
||||
# which will make it receive devices.
|
||||
for _ in range(10):
|
||||
time.sleep(0.02)
|
||||
gtk_iteration()
|
||||
|
||||
self.assertIn("Foo Device 2", self.data_manager.get_group_keys())
|
||||
self.assertIn("Foo Device 2", self.data_manager.get_group_keys())
|
||||
self.assertIn("Bar Device", self.data_manager.get_group_keys())
|
||||
self.assertIn("gamepad", self.data_manager.get_group_keys())
|
||||
self.assertEqual(self.data_manager.active_group.name, "Foo Device")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
1619
tests/system/gui/test_gui.py
Normal file
1619
tests/system/gui/test_gui.py
Normal file
File diff suppressed because it is too large
Load Diff
113
tests/system/gui/test_user_interface.py
Normal file
113
tests/system/gui/test_user_interface.py
Normal file
@ -0,0 +1,113 @@
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import gi
|
||||
from evdev.ecodes import EV_KEY, KEY_A
|
||||
|
||||
gi.require_version("Gdk", "3.0")
|
||||
gi.require_version("Gtk", "3.0")
|
||||
gi.require_version("GLib", "2.0")
|
||||
gi.require_version("GtkSource", "4")
|
||||
from gi.repository import Gtk, Gdk, GLib
|
||||
|
||||
from inputremapper.gui.utils import gtk_iteration
|
||||
from inputremapper.gui.messages.message_broker import MessageBroker, MessageType
|
||||
from inputremapper.gui.user_interface import UserInterface
|
||||
from inputremapper.configs.mapping import MappingData
|
||||
from inputremapper.configs.input_config import InputCombination, InputConfig
|
||||
from tests.lib.test_setup import test_setup
|
||||
|
||||
|
||||
@test_setup
|
||||
class TestUserInterface(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.message_broker = MessageBroker()
|
||||
self.controller_mock = MagicMock()
|
||||
self.user_interface = UserInterface(self.message_broker, self.controller_mock)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
super().tearDown()
|
||||
self.message_broker.signal(MessageType.terminate)
|
||||
GLib.timeout_add(0, self.user_interface.window.destroy)
|
||||
GLib.timeout_add(0, Gtk.main_quit)
|
||||
Gtk.main()
|
||||
|
||||
def test_shortcut(self):
|
||||
mock = MagicMock()
|
||||
self.user_interface.shortcuts[Gdk.KEY_x] = mock
|
||||
|
||||
event = Gdk.Event()
|
||||
event.key.keyval = Gdk.KEY_x
|
||||
event.key.state = Gdk.ModifierType.SHIFT_MASK
|
||||
self.user_interface.window.emit("key-press-event", event)
|
||||
gtk_iteration()
|
||||
mock.assert_not_called()
|
||||
|
||||
event.key.state = Gdk.ModifierType.CONTROL_MASK
|
||||
self.user_interface.window.emit("key-press-event", event)
|
||||
gtk_iteration()
|
||||
mock.assert_called_once()
|
||||
|
||||
mock.reset_mock()
|
||||
event.key.keyval = Gdk.KEY_y
|
||||
self.user_interface.window.emit("key-press-event", event)
|
||||
gtk_iteration()
|
||||
mock.assert_not_called()
|
||||
|
||||
def test_connected_shortcuts(self):
|
||||
should_be_connected = {Gdk.KEY_q, Gdk.KEY_r, Gdk.KEY_Delete, Gdk.KEY_n}
|
||||
connected = set(self.user_interface.shortcuts.keys())
|
||||
self.assertEqual(connected, should_be_connected)
|
||||
|
||||
self.assertIs(
|
||||
self.user_interface.shortcuts[Gdk.KEY_q], self.controller_mock.close
|
||||
)
|
||||
self.assertIs(
|
||||
self.user_interface.shortcuts[Gdk.KEY_r],
|
||||
self.controller_mock.refresh_groups,
|
||||
)
|
||||
self.assertIs(
|
||||
self.user_interface.shortcuts[Gdk.KEY_Delete],
|
||||
self.controller_mock.stop_injecting,
|
||||
)
|
||||
|
||||
def test_connect_disconnect_shortcuts(self):
|
||||
mock = MagicMock()
|
||||
self.user_interface.shortcuts[Gdk.KEY_x] = mock
|
||||
|
||||
event = Gdk.Event()
|
||||
event.key.keyval = Gdk.KEY_x
|
||||
event.key.state = Gdk.ModifierType.CONTROL_MASK
|
||||
self.user_interface.disconnect_shortcuts()
|
||||
self.user_interface.window.emit("key-press-event", event)
|
||||
gtk_iteration()
|
||||
mock.assert_not_called()
|
||||
|
||||
self.user_interface.connect_shortcuts()
|
||||
gtk_iteration()
|
||||
self.user_interface.window.emit("key-press-event", event)
|
||||
gtk_iteration()
|
||||
mock.assert_called_once()
|
||||
|
||||
def test_combination_label_shows_combination(self):
|
||||
self.message_broker.publish(
|
||||
MappingData(
|
||||
input_combination=InputCombination(
|
||||
[InputConfig(type=EV_KEY, code=KEY_A)]
|
||||
),
|
||||
name="foo",
|
||||
)
|
||||
)
|
||||
gtk_iteration()
|
||||
label: Gtk.Label = self.user_interface.get("combination-label")
|
||||
self.assertEqual(label.get_text(), "a")
|
||||
self.assertEqual(label.get_opacity(), 1)
|
||||
|
||||
def test_combination_label_shows_text_when_empty_mapping(self):
|
||||
self.message_broker.publish(MappingData())
|
||||
gtk_iteration()
|
||||
label: Gtk.Label = self.user_interface.get("combination-label")
|
||||
self.assertEqual(label.get_text(), "no input configured")
|
||||
|
||||
# 0.5 != 0.501960..., for whatever reason this number is all screwed up
|
||||
self.assertAlmostEqual(label.get_opacity(), 0.5, delta=0.1)
|
||||
Reference in New Issue
Block a user