input-remapper/tests/integration/test_gui.py
jonasBoss 3637204bff
Frontend Refactor (#375)
* Tests for the GuiEventHandler

* Implement GuiEventHandler

* tests for data manager

* Implemented data_manager

* Remove Ellipsis from type hint

* workaround for old pydantic version

* workaround for old pydantic version

* some more tests for data_manager

* Updated Data Manager

* move DeviceSelection to its own class

* Data Manager no longer listens for events

* Moved PresetSelection to its own class

* MappingListBox and SelectionLable Listen to the EventHandler

* DataManager no longer creates its own data objects in the init

* removed global reader object

* Changed UI startup

* created backend Interface

* event_handler debug logs show function which emit a event

* some cleanup

* added target selector to components

* created code editor component

* adapted autocompletion & some cleanup

* black

* connected some buttons to the event_handler

* tests for data_manager newest_preset and group

* cleanup presets and test_presets

* migrated confirm delete dialog

* backend tests

* controller tests

* add python3-gi to ci

* more dependencies

* and more ...

* Github-Actions workaround

remove this commit

* not so many permission denyed errors in test.yml

* Fix #404 (hopefully)

* revert Github-Actions workaround

* More tests

* event_handler allows for event supression

* more tests

* WIP Implement Key recording

* Start and Stop Injection

* context no longer stores preset

* restructured the RelToBtnHandler

* Simplified read_loop

* Implement async iterator for ipc.pipe

* multiple event actions

* helper now implements mapping handlers to read inputs all with async

* updated and simplified reader 

the helper uses the mapping handlers, so the reader now can be much simpler

* Fixed race condition in tests

* implemented DataBus

* Fixed a UIMapping bug where the last_error would not be deleted

* added a immutable variant of the UIMapping

* updated data_manager to use data_bus

* Uptdated tests to use the DataBus

* Gui uses DataBus

* removed EventHandler

* Renamed controller methods

* Implemented recording toggle

* implemented StatusBar

* Sending validation errors to status bar

* sending injection status to status bar

* proper preset renaming

* implemented copy preset in the data manager

* implemented copy_preset in controller

* fixed a bug where a wron selection lable would update

* no longer send invalid data over the bus, if the preset or group changes

* Implement create and delete mapping

* Allow for frontend specific mapping defaults

* implemented autoload toggle

* cleanup user_interface

* removed editor

* Docstings renaming and ordering of methods

* more simplifications to user_interface

* integrated backend into data_manager

* removed active preset

* transformation tests

* controller tests

* fix missing uinputs in gui

* moved some tests and implemented basic tests for mapping handlers

* docstring reformatting

Co-authored-by: Tobi <proxima@sezanzeb.de>

* allow for empty groups

* docstring

* fixed TestGroupFromHelper

* some work on integration tests

* test for annoying import error in tests

* testing if test_user_interface works

* I feel lucky

* not so lucky

* some more tests

* fixed but where the group_key was used as folder name

* Fixed a bug where state=NO_GRAB would never be read from the injector

* allow to stop the recorder

* working on integration tests

* integration tests

* fixed more integration tests

* updated coveragerc

* no longer attempt to record keys when injecting

* event_reader cleans up not finished tasks

* More integration tests

* All tests pass

* renamed data_bus

* WIP fixing typing issues

* more typing fixes

* added keyboard+mouse device to tests

* cleanup imports

* new read loop because the evdev async read loop can not be cancelled

* Added field to modify mapping name

* created tests for components

* even more component tests

* do component tests need a screen?

* apparently they do :_(

* created release_input switch

* Don't record relative axis when movement is slow

* show delete dialog above main window

* wip basic dialog to edit combination

* some gui changes to the combination-editor

* Simple implementation of CombinationListbox

* renamed attach_to_events method and mark as private

* shorter str() for UInputsData

* moved logic to generate readable event string from combination to event

* new mapping parameter force release timeout

this helps with the helper when recording multiple relative axis at once

* make it possible to rearange the event_combination

* more work on the combination editor

* tests for DataManager.load_event

* simplyfied test_controller

* more controller tests

* Implement input threshold in gui

* greater range for time dependent unit test

* implemented a output-axis selector

* data_manager now provides injector state

* black

* mypy

* Updated confirm cancel dialog

* created release timeout input

* implemented transformation graph

* Added sliders for gain, expo and deadzone

* fix bug where the system_mapping was overridden in each injector thread

* updated slider settings

* removed debug statement

* explicitly checking output code against None (0 is a valid code)

* usage

* Allow for multiple axis to be activated by same button

* readme

* only warn about not implemented mapping-handler

don't fail to create event-pipelines

* More accurate event names

* Allow removal of single events from the input-combination

* rename callback to notify_callback

* rename event message to selected_event

* made read_continuisly private

* typing for autocompletion

* docstrings for message_broker messages

* make components methods and propreties private

* gui spacings

* removed eval

* make some controller functions private

* move status message generation from data_manager to controller

* parse mapping errors in controller for more helpful messages

* remove system_mapping from code editor

* More component tests

* more tests

* mypy

* make grab_devices less greedy (partial mitigation for #435)

only grab one device if there are multiple which can satisfy the same mapping

* accumulate more values in test

* docstrings

* Updated status messages

* comments, docstrings, imports

Co-authored-by: Tobi <proxima@sezanzeb.de>
2022-07-23 10:53:41 +02:00

1878 lines
68 KiB
Python

#!/usr/bin/python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 sezanzeb <proxima@sezanzeb.de>
#
# 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/>.
# the tests file needs to be imported first to make sure patches are loaded
from contextlib import contextmanager
from typing import Tuple, List
from inputremapper.exceptions import DataManagementError
from tests.test import (
get_project_root,
logger,
tmp,
push_events,
new_event,
spy,
cleanup,
uinput_write_history_pipe,
MAX_ABS,
EVENT_READ_TIMEOUT,
MIN_ABS,
get_ui_mapping,
prepare_presets,
)
import sys
import time
import atexit
import os
import unittest
import multiprocessing
import evdev
from evdev.ecodes import (
EV_KEY,
EV_ABS,
KEY_LEFTSHIFT,
KEY_A,
KEY_Q,
ABS_RX,
EV_REL,
REL_X,
ABS_X,
)
import json
from unittest.mock import patch, MagicMock, call
from importlib.util import spec_from_loader, module_from_spec
from importlib.machinery import SourceFileLoader
import gi
from inputremapper.input_event import InputEvent
gi.require_version("Gtk", "3.0")
gi.require_version("GLib", "2.0")
gi.require_version("GtkSource", "4")
from gi.repository import Gtk, GLib, Gdk, GtkSource
from inputremapper.configs.system_mapping import system_mapping, XMODMAP_FILENAME
from inputremapper.configs.mapping import UIMapping, Mapping
from inputremapper.configs.paths import CONFIG_PATH, get_preset_path, get_config_path
from inputremapper.configs.global_config import global_config, WHEEL, MOUSE, BUTTONS
from inputremapper.groups import _Groups
from inputremapper.gui.data_manager import DataManager
from inputremapper.gui.message_broker import (
MessageBroker,
MessageType,
StatusData,
CombinationRecorded,
)
from inputremapper.gui.components import SelectionLabel, SET_KEY_FIRST
from inputremapper.gui.reader import Reader
from inputremapper.gui.controller import Controller
from inputremapper.gui.helper import RootHelper
from inputremapper.gui.utils import gtk_iteration
from inputremapper.gui.user_interface import UserInterface
from inputremapper.injection.injector import RUNNING, FAILED, UNKNOWN, STOPPED
from inputremapper.event_combination import EventCombination
from inputremapper.daemon import Daemon, DaemonProxy
# 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(
argv=None,
) -> Tuple[UserInterface, Controller, DataManager, MessageBroker, DaemonProxy]:
"""Start input-remapper-gtk with the command line argument array argv."""
bin_path = os.path.join(get_project_root(), "bin", "input-remapper-gtk")
if not argv:
argv = ["-d"]
with patch.object(sys, "argv", [""] + [str(arg) for arg in argv]):
loader = SourceFileLoader("__main__", bin_path)
spec = spec_from_loader("__main__", loader)
module = module_from_spec(spec)
spec.loader.exec_module(module)
gtk_iteration()
# otherwise a new handler is added with each call to launch, which
# spams tons of garbage when all tests finish
atexit.unregister(module.stop)
return (
module.user_interface,
module.controller,
module.data_manager,
module.message_broker,
module.daemon,
)
@contextmanager
def patch_launch():
"""patch the launch function such that we don't connect to
the dbus and don't use pkexec to start the helper"""
original_connect = Daemon.connect
original_os_system = os.system
Daemon.connect = Daemon
def os_system(cmd):
# instead of running pkexec, fork instead. This will make
# the helper aware of all the test patches
if "pkexec input-remapper-control --command helper" in cmd:
multiprocessing.Process(target=RootHelper(_Groups()).run).start()
return 0
return original_os_system(cmd)
os.system = os_system
yield
os.system = original_os_system
Daemon.connect = original_connect
def clean_up_integration(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
class TestGroupsFromHelper(unittest.TestCase):
def setUp(self):
# don't try to connect, return an object instance of it instead
self.original_connect = Daemon.connect
Daemon.connect = Daemon
# 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.original_os_system = os.system
self.helper_started = MagicMock()
def os_system(cmd):
# instead of running pkexec, fork instead. This will make
# the helper aware of all the test patches
if "pkexec input-remapper-control --command helper" in cmd:
self.helper_started() # don't start the helper just log that it was.
return 0
return self.original_os_system(cmd)
os.system = os_system
(
self.user_interface,
self.controller,
self.data_manager,
self.message_broker,
self.daemon,
) = launch()
def tearDown(self):
clean_up_integration(self)
os.system = self.original_os_system
Daemon.connect = self.original_connect
def test_knows_devices(self):
# verify that it is working as expected. The gui doesn't have knowledge
# of groups until the root-helper provides them
self.data_manager._reader.groups.set_groups([])
gtk_iteration()
self.helper_started.assert_called()
self.assertEqual(len(self.data_manager.get_group_keys()), 0)
# start the helper delayed
multiprocessing.Process(target=RootHelper(_Groups()).run).start()
# 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")
class PatchedConfirmDelete:
def __init__(self, user_interface: UserInterface, response=Gtk.ResponseType.ACCEPT):
self.response = response
self.user_interface = user_interface
self.patch = None
def _confirm_delete_run_patch(self):
"""A patch for the deletion confirmation that briefly shows the dialog."""
confirm_cancel_dialog = self.user_interface.confirm_cancel_dialog
# the emitted signal causes the dialog to close
GLib.timeout_add(
100,
lambda: confirm_cancel_dialog.emit("response", self.response),
)
Gtk.MessageDialog.run(confirm_cancel_dialog) # don't recursively call the patch
return self.response
def __enter__(self):
self.patch = patch.object(
self.user_interface.get("confirm-cancel"),
"run",
self._confirm_delete_run_patch,
)
self.patch.__enter__()
def __exit__(self, *args, **kwargs):
self.patch.__exit__(*args, **kwargs)
class GuiTestBase(unittest.TestCase):
def setUp(self):
prepare_presets()
with patch_launch():
(
self.user_interface,
self.controller,
self.data_manager,
self.message_broker,
self.daemon,
) = launch()
get = self.user_interface.get
self.device_selection: Gtk.ComboBox = 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.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.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("apply_system_layout")
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.grab_fails = False
def grab(_):
if self.grab_fails:
raise OSError()
evdev.InputDevice.grab = grab
global_config._save_config()
self.throttle()
self.assertIsNotNone(self.data_manager.active_group)
self.assertIsNotNone(self.data_manager.active_preset)
def tearDown(self):
clean_up_integration(self)
self.throttle()
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, iterations=10):
"""Give GTK some time 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(iterations):
gtk_iteration()
time.sleep(0.002)
def activate_recording_toggle(self):
logger.info("Activating the recording toggle")
self.recording_toggle.set_active(True)
gtk_iteration()
def disable_recording_toggle(self):
logger.info("Deactivating the recording toggle")
self.recording_toggle.set_active(False)
gtk_iteration()
# should happen automatically:
self.assertFalse(self.recording_toggle.get_active())
def set_focus(self, widget):
logger.info("Focusing %s", widget)
self.user_interface.window.set_focus(widget)
self.throttle()
def get_selection_labels(self) -> List[SelectionLabel]:
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: Mapping = None):
self.controller.create_mapping()
self.controller.load_mapping(EventCombination.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()
class TestGui(GuiTestBase):
"""For tests that use the window.
Try to modify the configuration only by calling functions of the window.
"""
def test_can_start(self):
self.assertIsNotNone(self.user_interface)
self.assertTrue(self.user_interface.window.get_visible())
def assert_gui_clean(self):
selection_labels = self.selection_label_listbox.get_children()
self.assertEqual(len(selection_labels), 0)
self.assertEqual(len(self.data_manager.active_preset), 0)
self.assertEqual(self.preset_selection.get_active_id(), "new preset")
self.assertEqual(self.recording_toggle.get_label(), "Record Input")
self.assertEqual(self.get_unfiltered_symbol_input_text(), SET_KEY_FIRST)
def test_initial_state(self):
self.assertEqual(self.data_manager.active_group.key, "Foo Device")
self.assertEqual(self.device_selection.get_active_id(), "Foo Device")
self.assertEqual(self.data_manager.active_preset.name, "preset3")
self.assertEqual(self.preset_selection.get_active_id(), "preset3")
self.assertFalse(self.data_manager.get_autoload())
self.assertFalse(self.autoload_toggle.get_active())
self.assertEqual(
self.selection_label_listbox.get_selected_row().combination, ((1, 5, 1),)
)
self.assertEqual(
self.data_manager.active_mapping.event_combination, ((1, 5, 1),)
)
self.assertEqual(self.selection_label_listbox.get_selected_row().name, "4")
self.assertIsNone(self.data_manager.active_mapping.name)
self.assertTrue(self.data_manager.active_mapping.is_valid())
self.assertTrue(self.data_manager.active_preset.is_valid())
# todo
def test_set_autoload_refreshes_service_config(self):
self.assertFalse(self.data_manager.get_autoload())
with spy(self.daemon, "set_config_dir") as set_config_dir:
self.autoload_toggle.set_active(True)
gtk_iteration()
set_config_dir.assert_called_once()
self.assertTrue(self.data_manager.get_autoload())
def test_autoload_sets_correctly(self):
self.assertFalse(self.data_manager.get_autoload())
self.assertFalse(self.autoload_toggle.get_active())
self.autoload_toggle.set_active(True)
gtk_iteration()
self.assertTrue(self.data_manager.get_autoload())
self.assertTrue(self.autoload_toggle.get_active())
self.autoload_toggle.set_active(False)
gtk_iteration()
self.assertFalse(self.data_manager.get_autoload())
self.assertFalse(self.autoload_toggle.get_active())
def test_autoload_is_set_when_changing_preset(self):
self.assertFalse(self.data_manager.get_autoload())
self.assertFalse(self.autoload_toggle.get_active())
self.device_selection.set_active_id("Foo Device 2")
self.preset_selection.set_active_id("preset2")
gtk_iteration()
self.assertTrue(self.data_manager.get_autoload())
self.assertTrue(self.autoload_toggle.get_active())
def test_only_one_autoload_per_group(self):
self.assertFalse(self.data_manager.get_autoload())
self.assertFalse(self.autoload_toggle.get_active())
self.device_selection.set_active_id("Foo Device 2")
self.preset_selection.set_active_id("preset2")
gtk_iteration()
self.assertTrue(self.data_manager.get_autoload())
self.assertTrue(self.autoload_toggle.get_active())
self.preset_selection.set_active_id("preset3")
gtk_iteration()
self.autoload_toggle.set_active(True)
gtk_iteration()
self.preset_selection.set_active_id("preset2")
gtk_iteration()
self.assertFalse(self.data_manager.get_autoload())
self.assertFalse(self.autoload_toggle.get_active())
def test_each_device_can_have_autoload(self):
self.autoload_toggle.set_active(True)
gtk_iteration()
self.assertTrue(self.data_manager.get_autoload())
self.assertTrue(self.autoload_toggle.get_active())
self.device_selection.set_active_id("Foo Device 2")
gtk_iteration()
self.autoload_toggle.set_active(True)
gtk_iteration()
self.assertTrue(self.data_manager.get_autoload())
self.assertTrue(self.autoload_toggle.get_active())
self.device_selection.set_active_id("Foo Device")
gtk_iteration()
self.assertTrue(self.data_manager.get_autoload())
self.assertTrue(self.autoload_toggle.get_active())
def test_select_device_without_preset(self):
# creates a new empty preset when no preset exists for the device
self.device_selection.set_active_id("Bar Device")
self.assertEqual(self.preset_selection.get_active_id(), "new preset")
self.assertEqual(len(self.data_manager.active_preset), 0)
# it creates the file for that right away. It may have been possible
# to write it such that it doesn't (its empty anyway), but it does,
# so use that to test it in more detail.
path = get_preset_path("Bar Device", "new preset")
self.assertTrue(os.path.exists(path))
with open(path, "r") as file:
self.assertEqual(file.read(), "")
def test_recording_toggle_labels(self):
self.assertEqual(self.recording_toggle.get_label(), "Record Input")
self.recording_toggle.set_active(True)
gtk_iteration()
self.assertEqual(self.recording_toggle.get_label(), "Recording ...")
self.recording_toggle.set_active(False)
gtk_iteration()
self.assertEqual(self.recording_toggle.get_label(), "Record Input")
def test_recording_label_updates_on_recording_finished(self):
self.assertEqual(self.recording_toggle.get_label(), "Record Input")
self.recording_toggle.set_active(True)
gtk_iteration()
self.assertEqual(self.recording_toggle.get_label(), "Recording ...")
self.message_broker.signal(MessageType.recording_finished)
gtk_iteration()
self.assertEqual(self.recording_toggle.get_label(), "Record Input")
self.assertFalse(self.recording_toggle.get_active())
def test_events_from_helper_arrive(self):
# load a device with more capabilities
self.controller.load_group("Foo Device 2")
gtk_iteration()
mock1 = MagicMock()
mock2 = MagicMock()
self.message_broker.subscribe(MessageType.combination_recorded, mock1)
self.message_broker.subscribe(MessageType.recording_finished, mock2)
self.recording_toggle.set_active(True)
gtk_iteration()
push_events(
"Foo Device 2",
[InputEvent.from_string("1,30,1"), InputEvent.from_string("1,31,1")],
)
self.throttle(20)
mock1.assert_has_calls(
(
call(CombinationRecorded(EventCombination.from_string("1,30,1"))),
call(
CombinationRecorded(EventCombination.from_string("1,30,1+1,31,1"))
),
),
any_order=False,
)
self.assertEqual(mock1.call_count, 2)
mock2.assert_not_called()
push_events("Foo Device 2", [InputEvent.from_string("1,31,0")])
self.throttle(20)
self.assertEqual(mock1.call_count, 2)
mock2.assert_not_called()
push_events("Foo Device 2", [InputEvent.from_string("1,30,0")])
self.throttle(20)
self.assertEqual(mock1.call_count, 2)
mock2.assert_called_once()
self.assertFalse(self.recording_toggle.get_active())
def test_cannot_create_duplicate_event_combination(self):
# load a device with more capabilities
self.controller.load_group("Foo Device 2")
gtk_iteration()
# update the combination of the active mapping
self.controller.start_key_recording()
push_events(
"Foo Device 2",
[InputEvent.from_string("1,30,1"), InputEvent.from_string("1,30,0")],
)
self.throttle(20)
self.assertEqual(
self.data_manager.active_mapping.event_combination,
EventCombination.from_string("1,30,1"),
)
# create a new mapping
self.controller.create_mapping()
gtk_iteration()
self.assertEqual(
self.data_manager.active_mapping.event_combination,
EventCombination.empty_combination(),
)
# try to recorde the same combination
self.controller.start_key_recording()
push_events(
"Foo Device 2",
[InputEvent.from_string("1,30,1"), InputEvent.from_string("1,30,0")],
)
self.throttle(20)
# should still be the empty mapping
self.assertEqual(
self.data_manager.active_mapping.event_combination,
EventCombination.empty_combination(),
)
# try to recorde a different combination
self.controller.start_key_recording()
push_events("Foo Device 2", [InputEvent.from_string("1,30,1")])
self.throttle(20)
# nothing changed yet, as we got the duplicate combination
self.assertEqual(
self.data_manager.active_mapping.event_combination,
EventCombination.empty_combination(),
)
push_events("Foo Device 2", [InputEvent.from_string("1,31,1")])
self.throttle(20)
# now the combination is different
self.assertEqual(
self.data_manager.active_mapping.event_combination,
EventCombination.from_string("1,30,1+1,31,1"),
)
# let's make the combination even longer
push_events("Foo Device 2", [InputEvent.from_string("1,32,1")])
self.throttle(20)
self.assertEqual(
self.data_manager.active_mapping.event_combination,
EventCombination.from_string("1,30,1+1,31,1+1,32,1"),
)
# make sure we stop recording by releasing all keys
push_events(
"Foo Device 2",
[
InputEvent.from_string("1,31,0"),
InputEvent.from_string("1,30,0"),
InputEvent.from_string("1,32,0"),
],
)
self.throttle(20)
# sending a combination update now should not do anything
self.message_broker.send(
CombinationRecorded(EventCombination.from_string("1,35,1"))
)
gtk_iteration()
self.assertEqual(
self.data_manager.active_mapping.event_combination,
EventCombination.from_string("1,30,1+1,31,1+1,32,1"),
)
def test_create_simple_mapping(self):
# 1. create a mapping
self.create_mapping_btn.clicked()
gtk_iteration()
self.assertEqual(
self.selection_label_listbox.get_selected_row().combination,
EventCombination.empty_combination(),
)
self.assertEqual(
self.data_manager.active_mapping.event_combination,
EventCombination.empty_combination(),
)
self.assertEqual(
self.selection_label_listbox.get_selected_row().name, "Empty Mapping"
)
self.assertIsNone(self.data_manager.active_mapping.name)
# there are now 2 mappings
self.assertEqual(len(self.selection_label_listbox.get_children()), 2)
self.assertEqual(len(self.data_manager.active_preset), 2)
# 2. recorde a combination for that mapping
self.recording_toggle.set_active(True)
gtk_iteration()
push_events("Foo Device", [InputEvent.from_string("1,30,1")])
self.throttle(20)
push_events("Foo Device", [InputEvent.from_string("1,30,0")])
self.throttle(20)
# check the event_combination
self.assertEqual(
self.selection_label_listbox.get_selected_row().combination,
EventCombination.from_string("1,30,1"),
)
self.assertEqual(
self.data_manager.active_mapping.event_combination,
EventCombination.from_string("1,30,1"),
)
self.assertEqual(self.selection_label_listbox.get_selected_row().name, "a")
self.assertIsNone(self.data_manager.active_mapping.name)
# 3. set the output symbol
self.code_editor.get_buffer().set_text("Shift_L")
gtk_iteration()
# the mapping and preset should be valid by now
self.assertTrue(self.data_manager.active_mapping.is_valid())
self.assertTrue(self.data_manager.active_preset.is_valid())
self.assertEqual(
self.data_manager.active_mapping,
Mapping(
event_combination="1,30,1",
output_symbol="Shift_L",
target_uinput="keyboard",
),
)
self.assertEqual(self.target_selection.get_active_id(), "keyboard")
buffer = self.code_editor.get_buffer()
self.assertEqual(
buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True),
"Shift_L",
)
self.assertEqual(
self.selection_label_listbox.get_selected_row().combination,
EventCombination.from_string("1,30,1"),
)
# 4. update target to mouse
self.target_selection.set_active_id("mouse")
gtk_iteration()
self.assertEqual(
self.data_manager.active_mapping,
Mapping(
event_combination="1,30,1",
output_symbol="Shift_L",
target_uinput="mouse",
),
)
def test_show_status(self):
self.message_broker.send(StatusData(0, "a" * 100))
gtk_iteration()
text = self.get_status_text()
self.assertIn("...", text)
self.message_broker.send(StatusData(0, "b"))
gtk_iteration()
text = self.get_status_text()
self.assertNotIn("...", text)
def test_hat_switch(self):
# load a device with more capabilities
self.controller.load_group("Foo Device 2")
gtk_iteration()
# it should be possible to add all of them
ev_1 = InputEvent.from_tuple((EV_ABS, evdev.ecodes.ABS_HAT0X, -1))
ev_2 = InputEvent.from_tuple((EV_ABS, evdev.ecodes.ABS_HAT0X, 1))
ev_3 = InputEvent.from_tuple((EV_ABS, evdev.ecodes.ABS_HAT0Y, -1))
ev_4 = InputEvent.from_tuple((EV_ABS, evdev.ecodes.ABS_HAT0Y, 1))
def add_mapping(event, symbol):
self.controller.create_mapping()
gtk_iteration()
self.controller.start_key_recording()
push_events("Foo Device 2", [event, event.modify(value=0)])
self.throttle(20)
gtk_iteration()
self.code_editor.get_buffer().set_text(symbol)
gtk_iteration()
add_mapping(ev_1, "a")
add_mapping(ev_2, "b")
add_mapping(ev_3, "c")
add_mapping(ev_4, "d")
self.assertEqual(
self.data_manager.active_preset.get_mapping(
EventCombination(ev_1)
).output_symbol,
"a",
)
self.assertEqual(
self.data_manager.active_preset.get_mapping(
EventCombination(ev_2)
).output_symbol,
"b",
)
self.assertEqual(
self.data_manager.active_preset.get_mapping(
EventCombination(ev_3)
).output_symbol,
"c",
)
self.assertEqual(
self.data_manager.active_preset.get_mapping(
EventCombination(ev_4)
).output_symbol,
"d",
)
def test_combination(self):
# load a device with more capabilities
self.controller.load_group("Foo Device 2")
gtk_iteration()
# it should be possible to write a combination combination
ev_1 = InputEvent.from_tuple((EV_KEY, evdev.ecodes.KEY_A, 1))
ev_2 = InputEvent.from_tuple((EV_ABS, evdev.ecodes.ABS_HAT0X, 1))
ev_3 = InputEvent.from_tuple((EV_KEY, evdev.ecodes.KEY_C, 1))
ev_4 = InputEvent.from_tuple((EV_ABS, evdev.ecodes.ABS_HAT0X, -1))
combination_1 = EventCombination((ev_1, ev_2, ev_3))
combination_2 = EventCombination((ev_2, ev_1, ev_3))
# same as 1, but different D-Pad direction
combination_3 = EventCombination((ev_1, ev_4, ev_3))
combination_4 = EventCombination((ev_4, ev_1, ev_3))
# same as 1, but the last combination is different
combination_5 = EventCombination((ev_1, ev_3, ev_2))
combination_6 = EventCombination((ev_3, ev_1, ev_2))
def add_mapping(combi: EventCombination, symbol):
self.controller.create_mapping()
gtk_iteration()
self.controller.start_key_recording()
push_events("Foo Device 2", [event for event in combi])
push_events("Foo Device 2", [event.modify(value=0) for event in combi])
self.throttle(20)
gtk_iteration()
self.code_editor.get_buffer().set_text(symbol)
gtk_iteration()
add_mapping(combination_1, "a")
self.assertEqual(
self.data_manager.active_preset.get_mapping(combination_1).output_symbol,
"a",
)
self.assertEqual(
self.data_manager.active_preset.get_mapping(combination_2).output_symbol,
"a",
)
self.assertIsNone(self.data_manager.active_preset.get_mapping(combination_3))
self.assertIsNone(self.data_manager.active_preset.get_mapping(combination_4))
self.assertIsNone(self.data_manager.active_preset.get_mapping(combination_5))
self.assertIsNone(self.data_manager.active_preset.get_mapping(combination_6))
# it won't write the same combination again, even if the
# first two events are in a different order
add_mapping(combination_2, "b")
self.assertEqual(
self.data_manager.active_preset.get_mapping(combination_1).output_symbol,
"a",
)
self.assertEqual(
self.data_manager.active_preset.get_mapping(combination_2).output_symbol,
"a",
)
self.assertIsNone(self.data_manager.active_preset.get_mapping(combination_3))
self.assertIsNone(self.data_manager.active_preset.get_mapping(combination_4))
self.assertIsNone(self.data_manager.active_preset.get_mapping(combination_5))
self.assertIsNone(self.data_manager.active_preset.get_mapping(combination_6))
add_mapping(combination_3, "c")
self.assertEqual(
self.data_manager.active_preset.get_mapping(combination_1).output_symbol,
"a",
)
self.assertEqual(
self.data_manager.active_preset.get_mapping(combination_2).output_symbol,
"a",
)
self.assertEqual(
self.data_manager.active_preset.get_mapping(combination_3).output_symbol,
"c",
)
self.assertEqual(
self.data_manager.active_preset.get_mapping(combination_4).output_symbol,
"c",
)
self.assertIsNone(self.data_manager.active_preset.get_mapping(combination_5))
self.assertIsNone(self.data_manager.active_preset.get_mapping(combination_6))
# same as with combination_2, the existing combination_3 blocks
# combination_4 because they have the same keys and end in the
# same key.
add_mapping(combination_4, "d")
self.assertEqual(
self.data_manager.active_preset.get_mapping(combination_1).output_symbol,
"a",
)
self.assertEqual(
self.data_manager.active_preset.get_mapping(combination_2).output_symbol,
"a",
)
self.assertEqual(
self.data_manager.active_preset.get_mapping(combination_3).output_symbol,
"c",
)
self.assertEqual(
self.data_manager.active_preset.get_mapping(combination_4).output_symbol,
"c",
)
self.assertIsNone(self.data_manager.active_preset.get_mapping(combination_5))
self.assertIsNone(self.data_manager.active_preset.get_mapping(combination_6))
add_mapping(combination_5, "e")
self.assertEqual(
self.data_manager.active_preset.get_mapping(combination_1).output_symbol,
"a",
)
self.assertEqual(
self.data_manager.active_preset.get_mapping(combination_2).output_symbol,
"a",
)
self.assertEqual(
self.data_manager.active_preset.get_mapping(combination_3).output_symbol,
"c",
)
self.assertEqual(
self.data_manager.active_preset.get_mapping(combination_4).output_symbol,
"c",
)
self.assertEqual(
self.data_manager.active_preset.get_mapping(combination_5).output_symbol,
"e",
)
self.assertEqual(
self.data_manager.active_preset.get_mapping(combination_6).output_symbol,
"e",
)
error_icon = self.user_interface.get("error_status_icon")
warning_icon = self.user_interface.get("warning_status_icon")
self.assertFalse(error_icon.get_visible())
self.assertFalse(warning_icon.get_visible())
def test_only_one_empty_mapping_possible(self):
self.assertEqual(
self.selection_label_listbox.get_selected_row().combination,
EventCombination.from_string("1,5,1"),
)
self.assertEqual(len(self.selection_label_listbox.get_children()), 1)
self.assertEqual(len(self.data_manager.active_preset), 1)
self.create_mapping_btn.clicked()
gtk_iteration()
self.assertEqual(
self.selection_label_listbox.get_selected_row().combination,
EventCombination.empty_combination(),
)
self.assertEqual(len(self.selection_label_listbox.get_children()), 2)
self.assertEqual(len(self.data_manager.active_preset), 2)
self.create_mapping_btn.clicked()
gtk_iteration()
self.assertEqual(len(self.selection_label_listbox.get_children()), 2)
self.assertEqual(len(self.data_manager.active_preset), 2)
def test_selection_labels_sort_alphabetically(self):
self.controller.load_preset("preset1")
# contains two mappings (1,1,1 -> b) and (1,2,1 -> a)
gtk_iteration()
# we expect (1,2,1 -> a) to be selected because "1" < "Escape"
self.assertEqual(self.data_manager.active_mapping.output_symbol, "a")
self.assertIs(
self.selection_label_listbox.get_row_at_index(0),
self.selection_label_listbox.get_selected_row(),
)
self.recording_toggle.set_active(True)
gtk_iteration()
self.message_broker.send(
CombinationRecorded(EventCombination((EV_KEY, KEY_Q, 1)))
)
gtk_iteration()
self.message_broker.signal(MessageType.recording_finished)
gtk_iteration()
# the combination and the order changed "Escape" < "q"
self.assertEqual(self.data_manager.active_mapping.output_symbol, "a")
self.assertIs(
self.selection_label_listbox.get_row_at_index(1),
self.selection_label_listbox.get_selected_row(),
)
def test_selection_labels_sort_empty_mapping_to_the_bottom(self):
# make sure we have a mapping which would sort to the bottom only
# considering alphanumeric sorting: "q" > "Empty Mapping"
self.controller.load_preset("preset1")
gtk_iteration()
self.recording_toggle.set_active(True)
gtk_iteration()
self.message_broker.send(
CombinationRecorded(EventCombination((EV_KEY, KEY_Q, 1)))
)
gtk_iteration()
self.message_broker.signal(MessageType.recording_finished)
gtk_iteration()
self.controller.create_mapping()
gtk_iteration()
row: SelectionLabel = self.selection_label_listbox.get_selected_row()
self.assertEqual(row.combination, EventCombination.empty_combination())
self.assertEqual(row.label.get_text(), "Empty Mapping")
self.assertIs(self.selection_label_listbox.get_row_at_index(2), row)
def test_select_mapping(self):
self.controller.load_preset("preset1")
# contains two mappings (1,1,1 -> b) and (1,2,1 -> a)
gtk_iteration()
# we expect (1,2,1 -> a) to be selected because "1" < "Escape"
self.assertEqual(self.data_manager.active_mapping.output_symbol, "a")
# select the second entry in the listbox
row = self.selection_label_listbox.get_row_at_index(1)
self.selection_label_listbox.select_row(row)
gtk_iteration()
self.assertEqual(self.data_manager.active_mapping.output_symbol, "b")
def test_selection_label_uses_name_if_available(self):
self.controller.load_preset("preset1")
gtk_iteration()
row: SelectionLabel = self.selection_label_listbox.get_selected_row()
self.assertEqual(row.label.get_text(), "1")
self.assertIs(row, self.selection_label_listbox.get_row_at_index(0))
self.controller.update_mapping(name="foo")
gtk_iteration()
self.assertEqual(row.label.get_text(), "foo")
self.assertIs(row, self.selection_label_listbox.get_row_at_index(1))
# Empty Mapping still sorts to the bottom
self.controller.create_mapping()
gtk_iteration()
row = self.selection_label_listbox.get_selected_row()
self.assertEqual(row.combination, EventCombination.empty_combination())
self.assertEqual(row.label.get_text(), "Empty Mapping")
self.assertIs(self.selection_label_listbox.get_row_at_index(2), row)
def test_fake_empty_mapping_does_not_sort_to_bottom(self):
"""If someone chooses to name a mapping "Empty Mapping"
it is not sorted to the bottom"""
self.controller.load_preset("preset1")
gtk_iteration()
self.controller.update_mapping(name="Empty Mapping")
self.throttle() # sorting seems to take a bit
# "Empty Mapping" < "Escape" so we still expect this to be the first row
row = self.selection_label_listbox.get_selected_row()
self.assertIs(row, self.selection_label_listbox.get_row_at_index(0))
# now create a real empty mapping
self.controller.create_mapping()
self.throttle()
# for some reason we no longer can use assertIs maybe a gtk bug?
# self.assertIs(row, self.selection_label_listbox.get_row_at_index(0))
# we expect the fake empty mapping in row 0 and the real one in row 2
self.selection_label_listbox.select_row(
self.selection_label_listbox.get_row_at_index(0)
)
gtk_iteration()
self.assertEqual(self.data_manager.active_mapping.name, "Empty Mapping")
self.assertEqual(self.data_manager.active_mapping.output_symbol, "a")
self.selection_label_listbox.select_row(
self.selection_label_listbox.get_row_at_index(2)
)
self.assertIsNone(self.data_manager.active_mapping.name)
self.assertEqual(
self.data_manager.active_mapping.event_combination,
EventCombination.empty_combination(),
)
def test_remove_mapping(self):
self.controller.load_preset("preset1")
gtk_iteration()
self.assertEqual(len(self.data_manager.active_preset), 2)
self.assertEqual(len(self.selection_label_listbox.get_children()), 2)
with PatchedConfirmDelete(self.user_interface):
self.delete_mapping_btn.clicked()
gtk_iteration()
self.assertEqual(len(self.data_manager.active_preset), 1)
self.assertEqual(len(self.selection_label_listbox.get_children()), 1)
def test_problematic_combination(self):
# load a device with more capabilities
self.controller.load_group("Foo Device 2")
gtk_iteration()
def add_mapping(combi: EventCombination, symbol):
self.controller.create_mapping()
gtk_iteration()
self.controller.start_key_recording()
push_events("Foo Device 2", [event for event in combi])
push_events("Foo Device 2", [event.modify(value=0) for event in combi])
self.throttle(20)
gtk_iteration()
self.code_editor.get_buffer().set_text(symbol)
gtk_iteration()
combination = EventCombination(((EV_KEY, KEY_LEFTSHIFT, 1), (EV_KEY, 82, 1)))
add_mapping(combination, "b")
text = self.get_status_text()
self.assertIn("shift", text)
error_icon = self.user_interface.get("error_status_icon")
warning_icon = self.user_interface.get("warning_status_icon")
self.assertFalse(error_icon.get_visible())
self.assertTrue(warning_icon.get_visible())
def test_rename_and_save(self):
# only a basic test, TestController and TestDataManager go more in detail
self.rename_input.set_text("foo")
self.rename_btn.clicked()
gtk_iteration()
preset_path = f"{CONFIG_PATH}/presets/Foo Device/foo.json"
self.assertTrue(os.path.exists(preset_path))
error_icon = self.user_interface.get("error_status_icon")
self.assertFalse(error_icon.get_visible())
def save():
raise PermissionError
with patch.object(self.data_manager.active_preset, "save", save):
self.code_editor.get_buffer().set_text("f")
gtk_iteration()
status = self.get_status_text()
self.assertIn("Permission denied", status)
with PatchedConfirmDelete(self.user_interface):
self.delete_preset_btn.clicked()
gtk_iteration()
self.assertFalse(os.path.exists(preset_path))
def test_check_for_unknown_symbols(self):
status = self.user_interface.get("status_bar")
error_icon = self.user_interface.get("error_status_icon")
warning_icon = self.user_interface.get("warning_status_icon")
self.controller.load_preset("preset1")
self.throttle()
self.controller.load_mapping(EventCombination.from_string("1,1,1"))
gtk_iteration()
self.controller.update_mapping(output_symbol="foo")
gtk_iteration()
self.controller.load_mapping(EventCombination.from_string("1,2,1"))
gtk_iteration()
self.controller.update_mapping(output_symbol="qux")
gtk_iteration()
tooltip = status.get_tooltip_text().lower()
self.assertIn("qux", tooltip)
self.assertTrue(error_icon.get_visible())
self.assertFalse(warning_icon.get_visible())
# it will still save it though
with open(get_preset_path("Foo Device", "preset1")) as f:
content = f.read()
self.assertIn("qux", content)
self.assertIn("foo", content)
self.controller.update_mapping(output_symbol="a")
gtk_iteration()
tooltip = status.get_tooltip_text().lower()
self.assertIn("foo", tooltip)
self.assertTrue(error_icon.get_visible())
self.assertFalse(warning_icon.get_visible())
self.controller.load_mapping(EventCombination.from_string("1,1,1"))
gtk_iteration()
self.controller.update_mapping(output_symbol="b")
gtk_iteration()
tooltip = status.get_tooltip_text()
self.assertIsNone(tooltip)
self.assertFalse(error_icon.get_visible())
self.assertFalse(warning_icon.get_visible())
def test_check_macro_syntax(self):
status = self.status_bar
error_icon = self.user_interface.get("error_status_icon")
warning_icon = self.user_interface.get("warning_status_icon")
self.code_editor.get_buffer().set_text("k(1))")
gtk_iteration()
tooltip = status.get_tooltip_text().lower()
self.assertIn("brackets", tooltip)
self.assertTrue(error_icon.get_visible())
self.assertFalse(warning_icon.get_visible())
self.code_editor.get_buffer().set_text("k(1)")
gtk_iteration()
tooltip = (status.get_tooltip_text() or "").lower()
self.assertNotIn("brackets", tooltip)
self.assertFalse(error_icon.get_visible())
self.assertFalse(warning_icon.get_visible())
self.assertEqual(
self.data_manager.active_mapping.output_symbol,
"k(1)",
)
def test_check_on_typing(self):
status = self.user_interface.get("status_bar")
error_icon = self.user_interface.get("error_status_icon")
warning_icon = self.user_interface.get("warning_status_icon")
tooltip = status.get_tooltip_text()
# nothing wrong yet
self.assertIsNone(tooltip)
# now change the mapping by typing into the field
buffer = self.code_editor.get_buffer()
buffer.set_text("sdfgkj()")
gtk_iteration()
# the mapping is immediately validated
tooltip = status.get_tooltip_text()
self.assertIn("Unknown function sdfgkj", tooltip)
self.assertTrue(error_icon.get_visible())
self.assertFalse(warning_icon.get_visible())
self.assertEqual(self.data_manager.active_mapping.output_symbol, "sdfgkj()")
def test_select_device(self):
# simple test to make sure we can switch between devices
# more detailed tests in TestController and TestDataManager
self.device_selection.set_active_id("Bar Device")
gtk_iteration()
entries = {entry[0] for entry in self.preset_selection.get_child().get_model()}
self.assertEqual(entries, {"new preset"})
self.device_selection.set_active_id("Foo Device")
gtk_iteration()
entries = {entry[0] for entry in self.preset_selection.get_child().get_model()}
self.assertEqual(entries, {"preset1", "preset2", "preset3"})
# make sure a preset and mapping was loaded
self.assertIsNotNone(self.data_manager.active_preset)
self.assertEqual(
self.data_manager.active_preset.name, self.preset_selection.get_active_id()
)
self.assertIsNotNone(self.data_manager.active_mapping)
self.assertEqual(
self.data_manager.active_mapping.event_combination,
self.selection_label_listbox.get_selected_row().combination,
)
def test_select_preset(self):
# simple test to make sure we can switch between presets
# more detailed tests in TestController and TestDataManager
self.device_selection.set_active_id("Foo Device 2")
gtk_iteration()
self.preset_selection.set_active_id("preset1")
gtk_iteration()
mappings = {
row.combination for row in self.selection_label_listbox.get_children()
}
self.assertEqual(
mappings,
{
EventCombination.from_string("1,1,1"),
EventCombination.from_string("1,2,1"),
},
)
self.assertFalse(self.autoload_toggle.get_active())
self.preset_selection.set_active_id("preset2")
gtk_iteration()
mappings = {
row.combination for row in self.selection_label_listbox.get_children()
}
self.assertEqual(
mappings,
{
EventCombination.from_string("1,3,1"),
EventCombination.from_string("1,4,1"),
},
)
self.assertTrue(self.autoload_toggle.get_active())
def test_copy_preset(self):
# simple tests to ensure it works
# more detailed tests in TestController and TestDataManager
# check the initial state
entries = {entry[0] for entry in self.preset_selection.get_child().get_model()}
self.assertEqual(entries, {"preset1", "preset2", "preset3"})
self.assertEqual(self.preset_selection.get_active_id(), "preset3")
self.copy_preset_btn.clicked()
gtk_iteration()
entries = {entry[0] for entry in self.preset_selection.get_child().get_model()}
self.assertEqual(entries, {"preset1", "preset2", "preset3", "preset3 copy"})
self.assertEqual(self.preset_selection.get_active_id(), "preset3 copy")
self.copy_preset_btn.clicked()
gtk_iteration()
entries = {entry[0] for entry in self.preset_selection.get_child().get_model()}
self.assertEqual(
entries, {"preset1", "preset2", "preset3", "preset3 copy", "preset3 copy 2"}
)
def test_wont_start(self):
def wait():
"""Wait for the injector process to finish doing stuff."""
for _ in range(10):
time.sleep(0.1)
gtk_iteration()
if "Starting" not in self.get_status_text():
return
error_icon = self.user_interface.get("error_status_icon")
self.controller.load_group("Bar Device")
# empty
self.start_injector_btn.clicked()
gtk_iteration()
wait()
text = self.get_status_text()
self.assertIn("add keys", text)
self.assertTrue(error_icon.get_visible())
self.assertNotEqual(self.daemon.get_state("Bar Device"), RUNNING)
# device grabbing fails
self.controller.load_group("Foo Device 2")
gtk_iteration()
for i in range(2):
# just pressing apply again will overwrite the previous error
self.grab_fails = True
self.start_injector_btn.clicked()
gtk_iteration()
text = self.get_status_text()
# it takes a little bit of time
self.assertIn("Starting injection", text)
self.assertFalse(error_icon.get_visible())
wait()
text = self.get_status_text()
self.assertIn("not grabbed", text)
self.assertTrue(error_icon.get_visible())
self.assertNotEqual(self.daemon.get_state("Foo Device 2"), RUNNING)
# this time work properly
self.grab_fails = False
self.start_injector_btn.clicked()
gtk_iteration()
text = self.get_status_text()
self.assertIn("Starting injection", text)
self.assertFalse(error_icon.get_visible())
wait()
text = self.get_status_text()
self.assertIn("Applied", text)
text = self.get_status_text()
self.assertNotIn("CTRL + DEL", text) # only shown if btn_left mapped
self.assertFalse(error_icon.get_visible())
self.assertEqual(self.daemon.get_state("Foo Device 2"), RUNNING)
def test_start_with_btn_left(self):
self.controller.load_group("Foo Device 2")
gtk_iteration()
self.controller.create_mapping()
gtk_iteration()
self.controller.update_mapping(
event_combination=EventCombination(InputEvent.btn_left()),
output_symbol="a",
)
gtk_iteration()
def wait():
"""Wait for the injector process to finish doing stuff."""
for _ in range(10):
time.sleep(0.1)
gtk_iteration()
if "Starting" not in self.get_status_text():
return
# first apply, shows btn_left warning
self.start_injector_btn.clicked()
gtk_iteration()
text = self.get_status_text()
self.assertIn("click", text)
self.assertEqual(self.daemon.get_state("Foo Device 2"), UNKNOWN)
# second apply, overwrites
self.start_injector_btn.clicked()
gtk_iteration()
wait()
self.assertEqual(self.daemon.get_state("Foo Device 2"), RUNNING)
text = self.get_status_text()
# because btn_left is mapped, shows help on how to stop
# injecting via the keyboard
self.assertIn("CTRL + DEL", text)
def test_cannot_record_keys(self):
self.controller.load_group("Foo Device 2")
self.assertNotEqual(self.data_manager.get_state(), RUNNING)
self.assertNotIn("Stop Injection", self.get_status_text())
self.recording_toggle.set_active(True)
gtk_iteration()
self.assertTrue(self.recording_toggle.get_active())
self.controller.stop_key_recording()
gtk_iteration()
self.assertFalse(self.recording_toggle.get_active())
self.start_injector_btn.clicked()
gtk_iteration()
# wait for the injector to start
for _ in range(10):
time.sleep(0.1)
gtk_iteration()
if "Starting" not in self.get_status_text():
break
self.assertEqual(self.data_manager.get_state(), RUNNING)
# the toggle button should reset itself shortly
self.recording_toggle.set_active(True)
gtk_iteration()
self.assertFalse(self.recording_toggle.get_active())
text = self.get_status_text()
self.assertIn("Stop Injection", text)
def test_start_injecting(self):
self.controller.load_group("Foo Device 2")
with spy(self.daemon, "set_config_dir") as spy1:
with spy(self.daemon, "start_injecting") as spy2:
self.start_injector_btn.clicked()
gtk_iteration()
# correctly uses group.key, not group.name
spy2.assert_called_once_with("Foo Device 2", "preset3")
spy1.assert_called_once_with(get_config_path())
for _ in range(10):
time.sleep(0.1)
gtk_iteration()
if self.data_manager.get_state() == RUNNING:
break
# fail here so we don't block forever
self.assertEqual(self.data_manager.get_state(), RUNNING)
# this is a stupid workaround for the bad test fixtures
# by switching the group we make sure that the helper no longer listens for
# events on "Foo Device 2" otherwise we would have two processes
# (helper and injector) reading the same pipe which can block this test
# indefinitely
self.controller.load_group("Foo Device")
gtk_iteration()
push_events(
"Foo Device 2",
[
new_event(evdev.events.EV_KEY, 5, 1),
new_event(evdev.events.EV_KEY, 5, 0),
],
)
event = uinput_write_history_pipe[0].recv()
self.assertEqual(event.type, evdev.events.EV_KEY)
self.assertEqual(event.code, KEY_A)
self.assertEqual(event.value, 1)
event = uinput_write_history_pipe[0].recv()
self.assertEqual(event.type, evdev.events.EV_KEY)
self.assertEqual(event.code, KEY_A)
self.assertEqual(event.value, 0)
# the input-remapper device will not be shown
self.controller.refresh_groups()
gtk_iteration()
for entry in self.device_selection.get_child().get_model():
# whichever attribute contains "input-remapper"
self.assertNotIn("input-remapper", "".join(entry))
def test_stop_injecting(self):
self.controller.load_group("Foo Device 2")
self.start_injector_btn.clicked()
gtk_iteration()
for _ in range(10):
time.sleep(0.1)
gtk_iteration()
if self.data_manager.get_state() == RUNNING:
break
# fail here so we don't block forever
self.assertEqual(self.data_manager.get_state(), RUNNING)
# stupid fixture workaround
self.controller.load_group("Foo Device")
gtk_iteration()
pipe = uinput_write_history_pipe[0]
self.assertFalse(pipe.poll())
push_events(
"Foo Device 2",
[
new_event(evdev.events.EV_KEY, 5, 1),
new_event(evdev.events.EV_KEY, 5, 0),
],
)
time.sleep(0.2)
self.assertTrue(pipe.poll())
while pipe.poll():
pipe.recv()
self.controller.load_group("Foo Device 2")
self.controller.stop_injecting()
gtk_iteration()
for _ in range(10):
time.sleep(0.1)
gtk_iteration()
if self.data_manager.get_state() == STOPPED:
break
self.assertEqual(self.data_manager.get_state(), STOPPED)
push_events(
"Foo Device 2",
[
new_event(evdev.events.EV_KEY, 5, 1),
new_event(evdev.events.EV_KEY, 5, 0),
],
)
time.sleep(0.2)
self.assertFalse(pipe.poll())
def test_delete_preset(self):
# as per test_initial_state we already have preset3 loaded
self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset3")))
with PatchedConfirmDelete(self.user_interface, Gtk.ResponseType.CANCEL):
self.delete_preset_btn.clicked()
gtk_iteration()
self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset3")))
self.assertEqual(self.data_manager.active_preset.name, "preset3")
self.assertEqual(self.data_manager.active_group.name, "Foo Device")
with PatchedConfirmDelete(self.user_interface):
self.delete_preset_btn.clicked()
gtk_iteration()
self.assertFalse(os.path.exists(get_preset_path("Foo Device", "preset3")))
self.assertEqual(self.data_manager.active_preset.name, "preset2")
self.assertEqual(self.data_manager.active_group.name, "Foo Device")
def test_refresh_groups(self):
# sanity check: preset3 should be the newest
self.assertEqual(self.preset_selection.get_active_id(), "preset3")
# select the older one
self.preset_selection.set_active_id("preset1")
gtk_iteration()
self.assertEqual(self.data_manager.active_preset.name, "preset1")
# add a device that doesn't exist to the dropdown
unknown_key = "key-1234"
self.device_selection.get_child().get_model().insert(
0, [unknown_key, None, "foo"]
)
self.controller.refresh_groups()
gtk_iteration()
self.throttle(100)
# the newest preset should be selected
self.assertEqual(self.controller.get_a_preset(), "preset3")
self.assertEqual(self.data_manager.active_preset.name, "preset3")
# the list contains correct entries
# and the non-existing entry should be removed
entries = [
tuple(entry) for entry in self.device_selection.get_child().get_model()
]
keys = [entry[0] for entry in self.device_selection.get_child().get_model()]
self.assertNotIn(unknown_key, keys)
self.assertIn("Foo Device", keys)
self.assertIn(("Foo Device", "input-keyboard", "Foo Device"), entries)
self.assertIn(("Foo Device 2", "input-gaming", "Foo Device 2"), entries)
self.assertIn(("Bar Device", "input-keyboard", "Bar Device"), entries)
self.assertIn(("gamepad", "input-gaming", "gamepad"), entries)
# it won't crash due to "list index out of range"
# when `types` is an empty list. Won't show an icon
self.data_manager._reader.groups.find(key="Foo Device 2").types = []
self.data_manager._reader.send_groups()
gtk_iteration()
self.assertIn(
("Foo Device 2", None, "Foo Device 2"),
[tuple(entry) for entry in self.device_selection.get_child().get_model()],
)
def test_shared_presets(self):
# devices with the same name (but different key because the key is
# unique) share the same presets.
# Those devices would usually be of the same model of keyboard for example
# Todo: move this to unit tests, there is no point in having the ui around
self.controller.load_group("Foo Device")
presets1 = self.data_manager.get_preset_names()
self.controller.load_group("Foo Device 2")
gtk_iteration()
presets2 = self.data_manager.get_preset_names()
self.controller.load_group("Bar Device")
gtk_iteration()
presets3 = self.data_manager.get_preset_names()
self.assertEqual(presets1, presets2)
self.assertNotEqual(presets1, presets3)
def test_delete_last_preset(self):
with PatchedConfirmDelete(self.user_interface):
# as per test_initial_state we already have preset3 loaded
self.delete_preset_btn.clicked()
gtk_iteration()
# the next newest preset should be loaded
self.assertEqual(self.data_manager.active_preset.name, "preset2")
self.delete_preset_btn.clicked()
gtk_iteration()
self.delete_preset_btn.clicked()
# the ui should be clean
self.assert_gui_clean()
device_path = f"{CONFIG_PATH}/presets/{self.data_manager.active_group.name}"
self.assertTrue(os.path.exists(f"{device_path}/new preset.json"))
self.delete_preset_btn.clicked()
gtk_iteration()
# deleting an empty preset als doesn't do weird stuff
self.assert_gui_clean()
device_path = f"{CONFIG_PATH}/presets/{self.data_manager.active_group.name}"
self.assertTrue(os.path.exists(f"{device_path}/new preset.json"))
def test_enable_disable_symbol_input(self):
# load a group without any presets
self.controller.load_group("Bar Device")
# should be disabled by default since no key is recorded yet
self.assertEqual(self.get_unfiltered_symbol_input_text(), SET_KEY_FIRST)
self.assertFalse(self.code_editor.get_sensitive())
# create a mapping
self.controller.create_mapping()
gtk_iteration()
# should still be disabled
self.assertEqual(self.get_unfiltered_symbol_input_text(), SET_KEY_FIRST)
self.assertFalse(self.code_editor.get_sensitive())
# enable it by sending a combination
self.controller.start_key_recording()
gtk_iteration()
push_events(
"Bar Device",
[
InputEvent.from_string("1,30,1"),
InputEvent.from_string("1,30,0"),
],
)
self.throttle(50) # give time for the input to arrive
self.assertEqual(self.get_unfiltered_symbol_input_text(), "")
self.assertTrue(self.code_editor.get_sensitive())
# disable it by deleting the mapping
with PatchedConfirmDelete(self.user_interface):
self.delete_mapping_btn.clicked()
gtk_iteration()
self.assertEqual(self.get_unfiltered_symbol_input_text(), SET_KEY_FIRST)
self.assertFalse(self.code_editor.get_sensitive())
class TestAutocompletion(GuiTestBase):
def press_key(self, keyval):
event = Gdk.EventKey()
event.keyval = keyval
self.user_interface.autocompletion.navigate(None, event)
def test_autocomplete_key(self):
self.controller.update_mapping(output_symbol="")
gtk_iteration()
self.set_focus(self.code_editor)
complete_key_name = "Test_Foo_Bar"
system_mapping.clear()
system_mapping._set(complete_key_name, 1)
system_mapping._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(100)
autocompletion = self.user_interface.autocompletion
self.assertTrue(autocompletion.visible)
self.press_key(Gdk.KEY_Down)
self.press_key(Gdk.KEY_Return)
gtk_iteration()
# the first suggestion should have been selected
modified_symbol = self.data_manager.active_mapping.output_symbol
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.code_editor
self.set_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.data_manager.active_mapping.output_symbol
self.assertEqual(modified_symbol, "key(KEY_A).\nrepeat")
def test_close_autocompletion(self):
self.controller.update_mapping(output_symbol="")
gtk_iteration()
source_view = self.code_editor
self.set_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.data_manager.active_mapping.output_symbol
self.assertEqual(symbol, "KEY_")
def test_writing_still_works(self):
self.controller.update_mapping(output_symbol="")
gtk_iteration()
source_view = self.code_editor
self.set_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.code_editor
self.set_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()