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:
2026-06-18 00:47:12 +02:00
parent 1d790a78fb
commit 0ab77d2a64
257 changed files with 51859 additions and 0 deletions

View File

View 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()

View 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()

View 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()

File diff suppressed because it is too large Load Diff

View 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()

View 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

File diff suppressed because it is too large Load Diff

View 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)