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

0
tests/__init__.py Normal file
View File

4
tests/__main__.py Normal file
View File

@ -0,0 +1,4 @@
import unittest
if __name__ == "__main__":
unittest.main()

0
tests/lib/__init__.py Normal file
View File

172
tests/lib/cleanup.py Normal file
View File

@ -0,0 +1,172 @@
#!/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/>.
from __future__ import annotations
import asyncio
import copy
import os
import shutil
import time
from pickle import UnpicklingError
import psutil
from tests.lib.constants import EVENT_READ_TIMEOUT
from tests.lib.fixtures import fixtures
from tests.lib.logger import logger
from tests.lib.patches import uinputs
from tests.lib.pipes import (
uinput_write_history_pipe,
uinput_write_history,
pending_events,
setup_pipe,
)
from tests.lib.tmp import tmp
# TODO on it. You don't need a framework for this by the way:
# don't import anything from input_remapper gloablly here, because some files execute
# code when imported, which can screw up patches. I wish we had a dependency injection
# framework that patches together the dependencies during runtime...
environ_copy = copy.deepcopy(os.environ)
def join_children():
"""Wait for child processes to exit. Stop them if it takes too long."""
this = psutil.Process(os.getpid())
i = 0
time.sleep(EVENT_READ_TIMEOUT)
children = this.children(recursive=True)
while len([c for c in children if c.status() != "zombie"]) > 0:
for child in children:
if i > 10:
child.kill()
logger.info("Killed pid %s because it didn't finish in time", child.pid)
children = this.children(recursive=True)
time.sleep(EVENT_READ_TIMEOUT)
i += 1
def clear_write_history():
"""Empty the history in preparation for the next test."""
while len(uinput_write_history) > 0:
uinput_write_history.pop()
while uinput_write_history_pipe[0].poll():
uinput_write_history_pipe[0].recv()
def quick_cleanup(log=True):
"""Reset the applications state."""
# TODO no:
# Reminder: before patches are applied in test.py, no inputremapper module
# may be imported. So tests.lib imports them just-in-time in functions instead.
from inputremapper.injection.macros.macro import macro_variables
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.gui.utils import debounce_manager
if log:
logger.info("Quick cleanup...")
debounce_manager.stop_all()
for device in list(pending_events.keys()):
try:
while pending_events[device][1].poll():
pending_events[device][1].recv()
except (UnpicklingError, EOFError):
pass
# setup new pipes for the next test
pending_events[device][1].close()
pending_events[device][0].close()
del pending_events[device]
setup_pipe(device)
try:
if asyncio.get_event_loop().is_running():
for task in asyncio.all_tasks():
task.cancel()
except RuntimeError:
# happens when the event loop disappears for magical reasons
# create a fresh event loop
asyncio.set_event_loop(asyncio.new_event_loop())
if macro_variables.process is not None and not macro_variables.process.is_alive():
# nothing should stop the process during runtime, if it has been started by
# the injector once
raise AssertionError("the SharedDict manager is not running anymore")
if macro_variables.process is not None:
macro_variables._stop()
join_children()
macro_variables.start()
if os.path.exists(tmp):
shutil.rmtree(tmp)
keyboard_layout.populate()
clear_write_history()
for name in list(uinputs.keys()):
del uinputs[name]
# for device in list(active_macros.keys()):
# del active_macros[device]
# for device in list(unreleased.keys()):
# del unreleased[device]
fixtures.reset()
os.environ.update(environ_copy)
for device in list(os.environ.keys()):
if device not in environ_copy:
del os.environ[device]
for _, pipe in pending_events.values():
assert not pipe.poll()
assert macro_variables.is_alive(1)
if log:
logger.info("Quick cleanup done")
def cleanup():
"""Reset the applications state.
Using this is slower, usually quick_cleanup() is sufficient.
"""
from inputremapper.groups import groups
logger.info("Cleanup...")
os.system("pkill -f input-remapper-service")
os.system("pkill -f input-remapper-control")
time.sleep(0.05)
quick_cleanup(log=False)
groups.refresh()
logger.info("Cleanup done")

28
tests/lib/constants.py Normal file
View File

@ -0,0 +1,28 @@
#!/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/>.
# give tests some time to test stuff while the process
# is still running
EVENT_READ_TIMEOUT = 0.01
# based on experience how much time passes at most until
# the reader-service starts receiving previously pushed events after a
# call to start_reading
START_READING_DELAY = 0.05

View File

@ -0,0 +1,36 @@
#!/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/>.
from __future__ import annotations
from tests.lib.fixtures import fixtures
from tests.lib.pipes import setup_pipe, close_pipe
def create_fixture_pipes():
# make sure those pipes exist before any process (the reader-service) gets forked,
# so that events can be pushed after the fork.
for _fixture in fixtures:
setup_pipe(_fixture)
def remove_fixture_pipes():
for _fixture in fixtures:
close_pipe(_fixture)

420
tests/lib/fixtures.py Normal file
View File

@ -0,0 +1,420 @@
#!/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/>.
from __future__ import annotations
import dataclasses
import json
import time
from hashlib import md5
from typing import Dict, Optional
import evdev
from inputremapper.configs.input_config import InputCombination
from inputremapper.configs.mapping import Mapping
from inputremapper.configs.paths import PathUtils
from inputremapper.configs.preset import Preset
from tests.lib.logger import logger
# input-remapper is only interested in devices that have EV_KEY, add some
# random other stuff to test that they are ignored.
phys_foo = "usb-0000:03:00.0-1/input2"
info_foo = evdev.device.DeviceInfo(1, 1, 1, 1)
keyboard_keys = sorted(evdev.ecodes.keys.keys())[:255]
@dataclasses.dataclass(frozen=True)
class Fixture:
path: str
capabilities: Dict = dataclasses.field(default_factory=dict)
name: str = "unset"
info: evdev.device.DeviceInfo = evdev.device.DeviceInfo(None, None, None, None)
phys: str = "unset"
group_key: Optional[str] = None
# for joysticks and such
min_abs: int = -(2**15)
max_abs: int = 2**15
# uniq is typically empty
uniq: str = ""
def __hash__(self):
return hash(self.path)
def get_device_hash(self):
s = str(self.capabilities) + self.name
device_hash = md5(s.encode()).hexdigest()
logger.info(
'Hash for fixture "%s" "%s": "%s"',
self.path,
self.name,
device_hash,
)
return device_hash
class _Fixtures:
"""contains all predefined Fixtures.
Can be extended with new Fixtures during runtime"""
dev_input_event1 = Fixture(
capabilities={
evdev.ecodes.EV_KEY: [evdev.ecodes.KEY_A],
},
phys="usb-0000:03:00.0-0/input1",
info=info_foo,
name="Foo Device",
path="/dev/input/event1",
)
# Another "Foo Device", which will get an incremented key.
# If possible write tests using this one, because name != key here and
# that would be important to test as well. Otherwise, the tests can't
# see if the groups correct attribute is used in functions and paths.
dev_input_event11 = Fixture(
capabilities={
evdev.ecodes.EV_KEY: [
evdev.ecodes.BTN_LEFT,
evdev.ecodes.BTN_TOOL_DOUBLETAP,
],
evdev.ecodes.EV_REL: [
evdev.ecodes.REL_X,
evdev.ecodes.REL_Y,
evdev.ecodes.REL_WHEEL,
evdev.ecodes.REL_HWHEEL,
],
},
phys=f"{phys_foo}/input2",
info=info_foo,
name="Foo Device foo",
group_key="Foo Device 2", # expected key
path="/dev/input/event11",
)
dev_input_event10 = Fixture(
capabilities={evdev.ecodes.EV_KEY: keyboard_keys},
phys=f"{phys_foo}/input3",
info=info_foo,
name="Foo Device",
group_key="Foo Device 2",
path="/dev/input/event10",
)
dev_input_event13 = Fixture(
capabilities={evdev.ecodes.EV_KEY: [], evdev.ecodes.EV_SYN: []},
phys=f"{phys_foo}/input1",
info=info_foo,
name="Foo Device",
group_key="Foo Device 2",
path="/dev/input/event13",
)
dev_input_event14 = Fixture(
capabilities={evdev.ecodes.EV_SYN: []},
phys=f"{phys_foo}/input0",
info=info_foo,
name="Foo Device qux",
group_key="Foo Device 2",
path="/dev/input/event14",
)
dev_input_event15 = Fixture(
capabilities={
evdev.ecodes.EV_SYN: [],
evdev.ecodes.EV_ABS: [
evdev.ecodes.ABS_X,
evdev.ecodes.ABS_Y,
evdev.ecodes.ABS_RX,
evdev.ecodes.ABS_RY,
evdev.ecodes.ABS_Z,
evdev.ecodes.ABS_RZ,
evdev.ecodes.ABS_HAT0X,
evdev.ecodes.ABS_HAT0Y,
],
evdev.ecodes.EV_KEY: [evdev.ecodes.BTN_A],
},
phys=f"{phys_foo}/input4",
info=info_foo,
name="Foo Device bar",
group_key="Foo Device 2",
path="/dev/input/event15",
)
# Bar Device
dev_input_event20 = Fixture(
capabilities={evdev.ecodes.EV_KEY: keyboard_keys},
phys="usb-0000:03:00.0-2/input1",
info=evdev.device.DeviceInfo(2, 1, 2, 1),
name="Bar Device",
path="/dev/input/event20",
)
dev_input_event30 = Fixture(
capabilities={
evdev.ecodes.EV_SYN: [],
evdev.ecodes.EV_ABS: [
evdev.ecodes.ABS_X,
evdev.ecodes.ABS_Y,
evdev.ecodes.ABS_RX,
evdev.ecodes.ABS_RY,
evdev.ecodes.ABS_Z,
evdev.ecodes.ABS_RZ,
evdev.ecodes.ABS_HAT0X,
evdev.ecodes.ABS_HAT0Y,
],
evdev.ecodes.EV_KEY: [
evdev.ecodes.BTN_A,
evdev.ecodes.BTN_B,
evdev.ecodes.BTN_X,
evdev.ecodes.BTN_Y,
],
},
phys="", # this is empty sometimes
info=evdev.device.DeviceInfo(3, 1, 3, 1),
name="gamepad",
path="/dev/input/event30",
)
# device that is completely ignored
dev_input_event31 = Fixture(
capabilities={evdev.ecodes.EV_SYN: []},
phys="usb-0000:03:00.0-4/input1",
info=evdev.device.DeviceInfo(4, 1, 4, 1),
name="Power Button",
path="/dev/input/event31",
)
# input-remapper devices are not displayed in the ui, some instance
# of input-remapper started injecting, apparently.
dev_input_event40 = Fixture(
capabilities={evdev.ecodes.EV_KEY: keyboard_keys},
phys="input-remapper/input1",
info=evdev.device.DeviceInfo(5, 1, 5, 1),
name="input-remapper Bar Device",
path="/dev/input/event40",
)
# denylisted
dev_input_event51 = Fixture(
capabilities={evdev.ecodes.EV_KEY: keyboard_keys},
phys="usb-0000:03:00.0-5/input1",
info=evdev.device.DeviceInfo(6, 1, 6, 1),
name="YuBiCofooYuBiKeYbar",
path="/dev/input/event51",
)
# name requires sanitation
dev_input_event52 = Fixture(
capabilities={evdev.ecodes.EV_KEY: keyboard_keys},
phys="usb-0000:03:00.0-3/input1",
info=evdev.device.DeviceInfo(2, 1, 2, 1),
name="Qux/[Device]?",
path="/dev/input/event52",
)
dev_input_event32 = Fixture(
capabilities={
evdev.ecodes.EV_SYN: [],
evdev.ecodes.EV_ABS: [
evdev.ecodes.ABS_X,
evdev.ecodes.ABS_Y,
evdev.ecodes.ABS_RX,
evdev.ecodes.ABS_RY,
evdev.ecodes.ABS_Z,
evdev.ecodes.ABS_RZ,
evdev.ecodes.ABS_HAT0X,
evdev.ecodes.ABS_HAT0Y,
],
evdev.ecodes.EV_KEY: [
evdev.ecodes.BTN_A,
evdev.ecodes.BTN_B,
evdev.ecodes.BTN_X,
evdev.ecodes.BTN_Y,
],
},
phys="", # this is empty sometimes
info=evdev.device.DeviceInfo(3, 1, 3, 1),
name="gamepad abs 0 to 256",
path="/dev/input/event32",
min_abs=0,
max_abs=256,
)
def __init__(self):
self.reset()
def reset(self) -> None:
self._iter = [
self.dev_input_event1,
self.dev_input_event11,
self.dev_input_event10,
self.dev_input_event13,
self.dev_input_event14,
self.dev_input_event15,
self.dev_input_event20,
self.dev_input_event30,
self.dev_input_event31,
self.dev_input_event40,
self.dev_input_event51,
self.dev_input_event52,
self.dev_input_event32,
]
def get_fixture(self, path: str) -> Fixture:
"""get a Fixture by it's unique /dev/input/eventX path"""
for fixture in self._iter:
if fixture.path == path:
return fixture
raise KeyError(f"Could not find fixture with path {path}")
def add_fixture(self, value: Fixture | dict) -> None:
if isinstance(value, Fixture):
value = value.__dict__
key = value["path"]
if isinstance(value, Fixture):
self._iter.append(value)
elif isinstance(value, dict):
self._iter.append(Fixture(**value))
def remove_fixture(self, path: str) -> None:
index = 0
for i, fixture in enumerate(self._iter):
if fixture.path == path:
index = i
del self._iter[index]
def __iter__(self):
return iter(self._iter)
def get_paths(self):
"""Get a list of all available device paths."""
paths = []
for fixture in self._iter:
paths.append(fixture.path)
return paths
def get(self, item) -> Optional[Fixture]:
try:
return self.get_fixture(item)
except KeyError:
return None
@property
def foo_device_1_1(self):
return self.get_fixture("/dev/input/event1")
@property
def foo_device_2_mouse(self):
return self.get_fixture("/dev/input/event11")
@property
def foo_device_2_keyboard(self):
return self.get_fixture("/dev/input/event10")
@property
def foo_device_2_13(self):
return self.get_fixture("/dev/input/event13")
@property
def foo_device_2_qux(self):
return self.get_fixture("/dev/input/event14")
@property
def foo_device_2_gamepad(self):
return self.get_fixture("/dev/input/event15")
@property
def bar_device(self):
return self.get_fixture("/dev/input/event20")
@property
def gamepad(self):
return self.get_fixture("/dev/input/event30")
@property
def power_button(self):
return self.get_fixture("/dev/input/event31")
@property
def input_remapper_bar_device(self):
return self.get_fixture("/dev/input/event40")
@property
def YuBiCofooYuBiKeYbar(self):
return self.get_fixture("/dev/input/event51")
@property
def QuxSlashDeviceQuestionmark(self):
return self.get_fixture("/dev/input/event52")
@property
def gamepad_abs_0_to_256(self):
return self.get_fixture("/dev/input/event32")
fixtures = _Fixtures()
def new_event(type, code, value, timestamp):
"""Create a new InputEvent.
Handy because of the annoying sec and usec arguments of the regular
evdev.InputEvent constructor.
Prefer using `InputEvent.key()`, `InputEvent.abs()`, `InputEvent.rel()` or just
`InputEvent(0, 0, 1234, 2345, 3456)`.
"""
from inputremapper.input_event import InputEvent
if timestamp is None:
timestamp = time.time()
sec = int(timestamp)
usec = timestamp % 1 * 1000000
event = InputEvent(sec, usec, type, code, value)
return event
def prepare_presets():
"""prepare a few presets for use in tests
"Foo Device 2/preset3" is the newest and "Foo Device 2/preset2" is set to autoload
"""
preset1 = Preset(PathUtils.get_preset_path("Foo Device", "preset1"))
preset1.add(
Mapping.from_combination(
InputCombination.from_tuples((1, 1)),
output_symbol="b",
)
)
preset1.add(Mapping.from_combination(InputCombination.from_tuples((1, 2))))
preset1.save()
time.sleep(0.1)
preset2 = Preset(PathUtils.get_preset_path("Foo Device", "preset2"))
preset2.add(Mapping.from_combination(InputCombination.from_tuples((1, 3))))
preset2.add(Mapping.from_combination(InputCombination.from_tuples((1, 4))))
preset2.save()
# make sure the timestamp of preset 3 is the newest,
# so that it will be automatically loaded by the GUI
time.sleep(0.1)
preset3 = Preset(PathUtils.get_preset_path("Foo Device", "preset3"))
preset3.add(Mapping.from_combination(InputCombination.from_tuples((1, 5))))
preset3.save()
with open(PathUtils.get_config_path("config.json"), "w") as file:
json.dump({"autoload": {"Foo Device 2": "preset2"}}, file, indent=4)
return preset1, preset2, preset3

View File

@ -0,0 +1,32 @@
#!/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/>.
from __future__ import annotations
import subprocess
def is_service_running():
"""Check if the daemon is running."""
try:
subprocess.check_output(["pgrep", "-f", "input-remapper-service"])
return True
except subprocess.CalledProcessError:
return False

54
tests/lib/logger.py Normal file
View File

@ -0,0 +1,54 @@
#!/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/>.
from __future__ import annotations
import sys
import traceback
import tracemalloc
import warnings
import logging
tracemalloc.start()
logger = logging.getLogger("input-remapper-test")
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("\033[90mTest: %(message)s\033[0m"))
logger.addHandler(handler)
logger.setLevel(logging.INFO)
def update_inputremapper_verbosity():
from inputremapper.logging.logger import logger
logger.update_verbosity(True)
def warn_with_traceback(message, category, filename, lineno, file=None, line=None):
log = file if hasattr(file, "write") else sys.stderr
traceback.print_stack(file=log)
log.write(warnings.formatwarning(message, category, filename, lineno, line))
def patch_warnings():
# show traceback
warnings.showwarning = warn_with_traceback
warnings.simplefilter("always")

413
tests/lib/patches.py Normal file
View File

@ -0,0 +1,413 @@
#!/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/>.
from __future__ import annotations
import asyncio
import copy
import os
import subprocess
import time
from pickle import UnpicklingError
from unittest.mock import patch
import atexit
import evdev
from inputremapper.utils import get_evdev_constant_name
from tests.lib.constants import EVENT_READ_TIMEOUT
from tests.lib.fixtures import Fixture, fixtures, new_event
from tests.lib.pipes import (
setup_pipe,
push_events,
uinput_write_history,
uinput_write_history_pipe,
pending_events,
)
from tests.lib.xmodmap import xmodmap
from tests.lib.tmp import tmp
from tests.lib.logger import logger
def patch_paths():
from inputremapper.user import UserUtils
return [
patch.object(UserUtils, "home", tmp),
patch.dict(os.environ, {"XDG_CONFIG_HOME": os.path.join(tmp, ".config")}),
]
class InputDevice:
# expose as existing attribute, otherwise the patch for
# evdev < 1.0.0 will crash the test
path = None
def __init__(self, path):
if path != "justdoit" and not fixtures.get(path):
# beware that fixtures keys and the path attribute of a fixture can
# theoretically be different. I don't know if this is the case right now
logger.error(
'path "%s" was not found in fixtures. available: %s',
path,
list(fixtures.get_paths()),
)
raise FileNotFoundError()
if path == "justdoit":
self._fixture = Fixture(path="justdoit")
else:
self._fixture = fixtures.get_fixture(path)
self.path = path
self.phys = self._fixture.phys
self.info = self._fixture.info
self.name = self._fixture.name
self.uniq = self._fixture.uniq
# this property exists only for test purposes and is not part of
# the original evdev.InputDevice class
self.group_key = self._fixture.group_key or self._fixture.name
# ensure a pipe exists to make this object act like
# it is reading events from a device
setup_pipe(self._fixture)
self.fd = pending_events[self._fixture][1].fileno()
def push_events(self, events):
push_events(self._fixture, events)
def fileno(self):
"""Compatibility to select.select."""
return self.fd
def log(self, key, msg):
logger.info('%s "%s" "%s" %s', msg, self.name, self.path, key)
def absinfo(self, *args):
raise Exception("Ubuntus version of evdev doesn't support .absinfo")
def grab(self):
logger.info("grab %s %s", self.name, self.path)
def ungrab(self):
logger.info("ungrab %s %s", self.name, self.path)
async def async_read_loop(self):
logger.info("starting read loop for %s", self.path)
new_frame = asyncio.Event()
asyncio.get_running_loop().add_reader(self.fd, new_frame.set)
while True:
await new_frame.wait()
new_frame.clear()
if not pending_events[self._fixture][1].poll():
# todo: why? why do we need this?
# sometimes this happens, as if a other process calls recv on
# the pipe
continue
event = pending_events[self._fixture][1].recv()
logger.info("got %s at %s", event, self.path)
yield event
def read(self):
# the patched fake InputDevice objects read anything pending from
# that group.
# To be realistic it would have to check if the provided
# element is in its capabilities.
if self.group_key not in pending_events:
self.log("no events to read", self.group_key)
return
# consume all of them
while pending_events[self._fixture][1].poll():
event = pending_events[self._fixture][1].recv()
self.log(event, "read")
yield event
time.sleep(EVENT_READ_TIMEOUT)
def read_loop(self):
"""Endless loop that yields events."""
while True:
event = pending_events[self._fixture][1].recv()
if event is not None:
self.log(event, "read_loop")
yield event
time.sleep(EVENT_READ_TIMEOUT)
def read_one(self):
"""Read one event or none if nothing available."""
if not pending_events.get(self._fixture):
return None
if not pending_events[self._fixture][1].poll():
return None
try:
event = pending_events[self._fixture][1].recv()
except (UnpicklingError, EOFError):
# failed in tests sometimes
return None
self.log(event, "read_one")
return event
def capabilities(self, absinfo=True, verbose=False):
result = copy.deepcopy(self._fixture.capabilities)
if absinfo and evdev.ecodes.EV_ABS in result:
absinfo_obj = evdev.AbsInfo(
value=None,
min=self._fixture.min_abs,
fuzz=None,
flat=None,
resolution=None,
max=self._fixture.max_abs,
)
ev_abs = []
for ev_code in result[evdev.ecodes.EV_ABS]:
if ev_code in range(0x10, 0x18): # ABS_HAT0X - ABS_HAT3Y
absinfo_obj = evdev.AbsInfo(
value=None,
min=-1,
fuzz=None,
flat=None,
resolution=None,
max=1,
)
ev_abs.append((ev_code, absinfo_obj))
result[evdev.ecodes.EV_ABS] = ev_abs
return result
def input_props(self):
return []
def leds(self):
return []
uinputs = {}
class UInputMock:
def __init__(
self, events=None, name="unnamed", phys="py-evdev-uinput", *args, **kwargs
):
self.fd = 0
self.write_count = 0
self.device = InputDevice("justdoit")
self.name = name
self.events = events
self.phys = phys
self.write_history = []
global uinputs
uinputs[name] = self
def capabilities(self, verbose=False, absinfo=True):
if absinfo or 3 not in self.events:
return self.events
else:
events = self.events.copy()
events[3] = [code for code, _ in self.events[3]]
return events
def write(self, type, code, value):
self.write_count += 1
event = new_event(type, code, value, time.time())
uinput_write_history.append(event)
uinput_write_history_pipe[1].send(event)
self.write_history.append(event)
logger.info(
'%s %s written to "%s"',
(type, code, value),
get_evdev_constant_name(type, code),
self.name,
)
def syn(self):
pass
def patch_evdev():
def list_devices():
return [fixture_.path for fixture_ in fixtures]
class PatchedInputEvent(evdev.InputEvent):
def __init__(self, sec, usec, type, code, value):
self.t = (type, code, value)
super().__init__(sec, usec, type, code, value)
def copy(self):
return PatchedInputEvent(
self.sec,
self.usec,
self.type,
self.code,
self.value,
)
return [
patch.object(evdev, "list_devices", list_devices),
patch.object(evdev, "InputDevice", InputDevice),
patch.object(evdev.UInput, "capabilities", UInputMock.capabilities),
patch.object(evdev.UInput, "write", UInputMock.write),
patch.object(evdev.UInput, "syn", UInputMock.syn),
patch.object(evdev.UInput, "__init__", UInputMock.__init__),
patch.object(evdev, "InputEvent", PatchedInputEvent),
]
def patch_events():
# improve logging of stuff
return patch.object(
evdev.InputEvent,
"__str__",
lambda self: (f"InputEvent{(self.type, self.code, self.value)}"),
)
def patch_os_system():
"""Avoid running pkexec."""
original_system = os.system
def system(command):
if "pkexec" in command:
# because it
# - will open a window for user input
# - has no knowledge of the fixtures and patches
raise Exception("Write patches to avoid running pkexec stuff")
return original_system(command)
return patch.object(os, "system", system)
def patch_atexit_register():
"""Avoid adding tons of redundant atexit handlers that we don't need anyway.
Otherwise we get lots of logs at the end of gui tests that bury the test result.
"""
return patch.object(atexit, "register")
def patch_check_output():
"""Xmodmap -pke should always return a fixed set of symbols.
On some installations the `xmodmap` command might be missig completely,
which would break the tests.
"""
original_check_output = subprocess.check_output
def check_output(command, *args, **kwargs):
if "xmodmap" in command and "-pke" in command:
return xmodmap
return original_check_output(command, *args, **kwargs)
return patch.object(subprocess, "check_output", check_output)
def patch_regrab_timeout():
# no need for a high number in tests
from inputremapper.injection.injector import Injector
return patch.object(Injector, "regrab_timeout", 0.05)
def is_running_patch():
logger.info("is_running is patched to always return True")
return True
def patch_is_running():
from inputremapper.gui.reader_service import ReaderService
return patch.object(ReaderService, "is_running", is_running_patch)
def patch_enable_all_logs():
from inputremapper.logging.logger import Logger
return patch.object(Logger, "analog_log_threshold", 0)
class FakeDaemonProxy:
def __init__(self):
self.calls = {
"stop_injecting": [],
"get_state": [],
"start_injecting": [],
"stop_all": 0,
"set_config_dir": [],
"autoload": 0,
"autoload_single": [],
"hello": [],
"quit": 0,
}
def stop_injecting(self, group_key: str) -> None:
self.calls["stop_injecting"].append(group_key)
def get_state(self, group_key: str):
from inputremapper.injection.injector import InjectorState
self.calls["get_state"].append(group_key)
return InjectorState.STOPPED
def start_injecting(self, group_key: str, preset: str) -> bool:
self.calls["start_injecting"].append((group_key, preset))
return True
def stop_all(self) -> None:
self.calls["stop_all"] += 1
def set_config_dir(self, config_dir: str) -> None:
self.calls["set_config_dir"].append(config_dir)
def autoload(self) -> None:
self.calls["autoload"] += 1
def autoload_single(self, group_key: str) -> None:
self.calls["autoload_single"].append(group_key)
def hello(self, out: str) -> str:
self.calls["hello"].append(out)
return out
def quit(self):
self.calls["quit"] += 1
def create_patches():
return [
# Sketchy, they only work because the whole modules are imported, instead of
# importing `check_output` and `system` from the module.
*patch_evdev(),
patch_os_system(),
patch_atexit_register(),
patch_check_output(),
# Those are comfortably wrapped in a class, and are therefore easy to patch
*patch_paths(),
patch_regrab_timeout(),
patch_is_running(),
patch_events(),
patch_enable_all_logs(),
]

96
tests/lib/pipes.py Normal file
View File

@ -0,0 +1,96 @@
#!/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/>.
"""Reading events from fixtures, making fixtures act like they are sending events."""
from __future__ import annotations
import multiprocessing
from multiprocessing.connection import Connection
from typing import Dict, Tuple
from tests.lib.fixtures import Fixture
from tests.lib.logger import logger
uinput_write_history = []
# for tests that makes the injector create its processes
uinput_write_history_pipe = multiprocessing.Pipe()
pending_events: Dict[Fixture, Tuple[Connection, Connection]] = {}
def read_write_history_pipe():
"""Convert the write history from the pipe to some easier to manage list."""
history = []
while uinput_write_history_pipe[0].poll():
event = uinput_write_history_pipe[0].recv()
history.append((event.type, event.code, event.value))
return history
def setup_pipe(fixture: Fixture):
"""Create a pipe that can be used to send events to the reader-service,
which in turn will be sent to the reader-client
"""
if pending_events.get(fixture) is None:
pending_events[fixture] = multiprocessing.Pipe()
def close_pipe(fixture: Fixture):
if fixture in pending_events:
pipe1, pipe2 = pending_events[fixture]
pipe1.close()
pipe2.close()
del pending_events[fixture]
def get_events():
"""Get all events written by the injector."""
return uinput_write_history
def push_event(fixture: Fixture, event, force: bool = False):
"""Make a device act like it is reading events from evdev.
push_event is like hitting a key on a keyboard for stuff that reads from
evdev.InputDevice (which is patched in test.py to work that way)
Parameters
----------
fixture
For example 'Foo Device'
event
The InputEvent to send
force
don't check if the event is in fixture.capabilities
"""
setup_pipe(fixture)
if not force and (
not fixture.capabilities.get(event.type)
or event.code not in fixture.capabilities[event.type]
):
raise AssertionError(f"Fixture {fixture.path} cannot send {event}")
logger.info("Simulating %s for %s", event, fixture.path)
pending_events[fixture][0].send(event)
def push_events(fixture: Fixture, events, force=False):
"""Push multiple events."""
for event in events:
push_event(fixture, event, force)

26
tests/lib/spy.py Normal file
View File

@ -0,0 +1,26 @@
#!/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/>.
from unittest.mock import patch
def spy(obj, name):
"""Convenient wrapper for patch.object(..., ..., wraps=...)."""
return patch.object(obj, name, wraps=obj.__getattribute__(name))

109
tests/lib/test_setup.py Normal file
View File

@ -0,0 +1,109 @@
#!/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/>.
from __future__ import annotations
import os
import tracemalloc
from tests.lib.cleanup import cleanup, quick_cleanup
from tests.lib.fixture_pipes import create_fixture_pipes, remove_fixture_pipes
from tests.lib.is_service_running import is_service_running
from tests.lib.logger import update_inputremapper_verbosity, logger
from tests.lib.patches import create_patches
def test_setup(cls):
"""A class decorator to
- apply the patches to all tests
- check if the deamon is already running
- create pipes to send events to the reader service
- reset stuff automatically
"""
original_setUp = cls.setUp
original_tearDown = cls.tearDown
original_setUpClass = cls.setUpClass
original_tearDownClass = cls.tearDownClass
tracemalloc.start()
os.environ["UNITTEST"] = "1"
update_inputremapper_verbosity()
patches = create_patches()
def resetPatches():
# In case some patches carry a state (I don't remember, idk), stop and start
# them from scratch
for patch in patches:
patch.stop()
for patch in patches:
patch.start()
def setUpClass():
logger.info("setUpClass %s", cls)
if is_service_running():
# let tests control daemon existance
raise Exception("Expected the service not to be running already.")
create_fixture_pipes()
# I don't know. Somehow tearDownClass is called before the test, so lets
# make sure the patches are started already when the class is set up, so that
# an unpatched `prepare_all` doesn't take ages to finish, and doesn't do funky
# stuff with the real evdev.
resetPatches()
original_setUpClass()
def tearDownClass():
logger.info("tearDownClass %s", cls)
original_tearDownClass()
remove_fixture_pipes()
# Do the more thorough cleanup only after all tests of classes, because it
# slows tests down. If this is required after each test, call it in your
# tearDown method.
cleanup()
def setUp(self):
logger.info("setUp %s", cls)
resetPatches()
original_setUp(self)
def tearDown(self):
logger.info("tearDown %s", cls)
original_tearDown(self)
quick_cleanup()
resetPatches()
cls.setUp = setUp
cls.tearDown = tearDown
cls.setUpClass = setUpClass
cls.tearDownClass = tearDownClass
return cls

30
tests/lib/tmp.py Normal file
View File

@ -0,0 +1,30 @@
#!/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/>.
from __future__ import annotations
import tempfile
from tests.lib.logger import logger
# When it gets garbage collected it cleans up the temporary directory so it needs to
# stay reachable while the tests are ran.
temporary_directory = tempfile.TemporaryDirectory(prefix="input-remapper-test")
tmp = temporary_directory.name
logger.info('tmp at "%s"', tmp)

128
tests/lib/xmodmap.py Normal file
View File

@ -0,0 +1,128 @@
#!/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/>.
xmodmap = (
b"keycode 8 =\nkeycode 9 = Escape NoSymbol Escape\nkeycode 10 = 1 exclam 1 exclam onesuperior exclamdown ones"
b"uperior\nkeycode 11 = 2 quotedbl 2 quotedbl twosuperior oneeighth twosuperior\nkeycode 12 = 3 section 3 sectio"
b"n threesuperior sterling threesuperior\nkeycode 13 = 4 dollar 4 dollar onequarter currency onequarter\nkeycode "
b" 14 = 5 percent 5 percent onehalf threeeighths onehalf\nkeycode 15 = 6 ampersand 6 ampersand notsign fiveeighth"
b"s notsign\nkeycode 16 = 7 slash 7 slash braceleft seveneighths braceleft\nkeycode 17 = 8 parenleft 8 parenleft"
b" bracketleft trademark bracketleft\nkeycode 18 = 9 parenright 9 parenright bracketright plusminus bracketright"
b"\nkeycode 19 = 0 equal 0 equal braceright degree braceright\nkeycode 20 = ssharp question ssharp question back"
b"slash questiondown U1E9E\nkeycode 21 = dead_acute dead_grave dead_acute dead_grave dead_cedilla dead_ogonek dea"
b"d_cedilla\nkeycode 22 = BackSpace BackSpace BackSpace BackSpace\nkeycode 23 = Tab ISO_Left_Tab Tab ISO_Left_Ta"
b"b\nkeycode 24 = q Q q Q at Greek_OMEGA at\nkeycode 25 = w W w W lstroke Lstroke lstroke\nkeycode 26 = e E e E"
b" EuroSign EuroSign EuroSign\nkeycode 27 = r R r R paragraph registered paragraph\nkeycode 28 = t T t T tslash "
b"Tslash tslash\nkeycode 29 = z Z z Z leftarrow yen leftarrow\nkeycode 30 = u U u U downarrow uparrow downarrow"
b"\nkeycode 31 = i I i I rightarrow idotless rightarrow\nkeycode 32 = o O o O oslash Oslash oslash\nkeycode 33 "
b"= p P p P thorn THORN thorn\nkeycode 34 = udiaeresis Udiaeresis udiaeresis Udiaeresis dead_diaeresis dead_above"
b"ring dead_diaeresis\nkeycode 35 = plus asterisk plus asterisk asciitilde macron asciitilde\nkeycode 36 = Retur"
b"n NoSymbol Return\nkeycode 37 = Control_L NoSymbol Control_L\nkeycode 38 = a A a A ae AE ae\nkeycode 39 = s S"
b" s S U017F U1E9E U017F\nkeycode 40 = d D d D eth ETH eth\nkeycode 41 = f F f F dstroke ordfeminine dstroke\nke"
b"ycode 42 = g G g G eng ENG eng\nkeycode 43 = h H h H hstroke Hstroke hstroke\nkeycode 44 = j J j J dead_below"
b"dot dead_abovedot dead_belowdot\nkeycode 45 = k K k K kra ampersand kra\nkeycode 46 = l L l L lstroke Lstroke "
b"lstroke\nkeycode 47 = odiaeresis Odiaeresis odiaeresis Odiaeresis dead_doubleacute dead_belowdot dead_doubleacu"
b"te\nkeycode 48 = adiaeresis Adiaeresis adiaeresis Adiaeresis dead_circumflex dead_caron dead_circumflex\nkeycod"
b"e 49 = dead_circumflex degree dead_circumflex degree U2032 U2033 U2032\nkeycode 50 = Shift_L NoSymbol Shift_L"
b"\nkeycode 51 = numbersign apostrophe numbersign apostrophe rightsinglequotemark dead_breve rightsinglequotemark"
b"\nkeycode 52 = y Y y Y guillemotright U203A guillemotright\nkeycode 53 = x X x X guillemotleft U2039 guillemot"
b"left\nkeycode 54 = c C c C cent copyright cent\nkeycode 55 = v V v V doublelowquotemark singlelowquotemark dou"
b"blelowquotemark\nkeycode 56 = b B b B leftdoublequotemark leftsinglequotemark leftdoublequotemark\nkeycode 57 "
b"= n N n N rightdoublequotemark rightsinglequotemark rightdoublequotemark\nkeycode 58 = m M m M mu masculine mu"
b"\nkeycode 59 = comma semicolon comma semicolon periodcentered multiply periodcentered\nkeycode 60 = period col"
b"on period colon U2026 division U2026\nkeycode 61 = minus underscore minus underscore endash emdash endash\nkeyc"
b"ode 62 = Shift_R NoSymbol Shift_R\nkeycode 63 = KP_Multiply KP_Multiply KP_Multiply KP_Multiply KP_Multiply KP"
b"_Multiply XF86ClearGrab\nkeycode 64 = Alt_L Meta_L Alt_L Meta_L\nkeycode 65 = space NoSymbol space\nkeycode 6"
b"6 = Caps_Lock NoSymbol Caps_Lock\nkeycode 67 = F1 F1 F1 F1 F1 F1 XF86Switch_VT_1\nkeycode 68 = F2 F2 F2 F2 F2 "
b"F2 XF86Switch_VT_2\nkeycode 69 = F3 F3 F3 F3 F3 F3 XF86Switch_VT_3\nkeycode 70 = F4 F4 F4 F4 F4 F4 XF86Switch_"
b"VT_4\nkeycode 71 = F5 F5 F5 F5 F5 F5 XF86Switch_VT_5\nkeycode 72 = F6 F6 F6 F6 F6 F6 XF86Switch_VT_6\nkeycode "
b" 73 = F7 F7 F7 F7 F7 F7 XF86Switch_VT_7\nkeycode 74 = F8 F8 F8 F8 F8 F8 XF86Switch_VT_8\nkeycode 75 = F9 F9 F9"
b" F9 F9 F9 XF86Switch_VT_9\nkeycode 76 = F10 F10 F10 F10 F10 F10 XF86Switch_VT_10\nkeycode 77 = Num_Lock NoSymb"
b"ol Num_Lock\nkeycode 78 = Scroll_Lock NoSymbol Scroll_Lock\nkeycode 79 = KP_Home KP_7 KP_Home KP_7\nkeycode 8"
b"0 = KP_Up KP_8 KP_Up KP_8\nkeycode 81 = KP_Prior KP_9 KP_Prior KP_9\nkeycode 82 = KP_Subtract KP_Subtract KP_S"
b"ubtract KP_Subtract KP_Subtract KP_Subtract XF86Prev_VMode\nkeycode 83 = KP_Left KP_4 KP_Left KP_4\nkeycode 84"
b" = KP_Begin KP_5 KP_Begin KP_5\nkeycode 85 = KP_Right KP_6 KP_Right KP_6\nkeycode 86 = KP_Add KP_Add KP_Add KP"
b"_Add KP_Add KP_Add XF86Next_VMode\nkeycode 87 = KP_End KP_1 KP_End KP_1\nkeycode 88 = KP_Down KP_2 KP_Down KP_"
b"2\nkeycode 89 = KP_Next KP_3 KP_Next KP_3\nkeycode 90 = KP_Insert KP_0 KP_Insert KP_0\nkeycode 91 = KP_Delete"
b" KP_Separator KP_Delete KP_Separator\nkeycode 92 = ISO_Level3_Shift NoSymbol ISO_Level3_Shift\nkeycode 93 =\nk"
b"eycode 94 = less greater less greater bar dead_belowmacron bar\nkeycode 95 = F11 F11 F11 F11 F11 F11 XF86Switc"
b"h_VT_11\nkeycode 96 = F12 F12 F12 F12 F12 F12 XF86Switch_VT_12\nkeycode 97 =\nkeycode 98 = Katakana NoSymbol "
b"Katakana\nkeycode 99 = Hiragana NoSymbol Hiragana\nkeycode 100 = Henkan_Mode NoSymbol Henkan_Mode\nkeycode 101 "
b"= Hiragana_Katakana NoSymbol Hiragana_Katakana\nkeycode 102 = Muhenkan NoSymbol Muhenkan\nkeycode 103 =\nkeycode"
b" 104 = KP_Enter NoSymbol KP_Enter\nkeycode 105 = Control_R NoSymbol Control_R\nkeycode 106 = KP_Divide KP_Divide"
b" KP_Divide KP_Divide KP_Divide KP_Divide XF86Ungrab\nkeycode 107 = Print Sys_Req Print Sys_Req\nkeycode 108 = IS"
b"O_Level3_Shift NoSymbol ISO_Level3_Shift\nkeycode 109 = Linefeed NoSymbol Linefeed\nkeycode 110 = Home NoSymbol "
b"Home\nkeycode 111 = Up NoSymbol Up\nkeycode 112 = Prior NoSymbol Prior\nkeycode 113 = Left NoSymbol Left\nkeycod"
b"e 114 = Right NoSymbol Right\nkeycode 115 = End NoSymbol End\nkeycode 116 = Down NoSymbol Down\nkeycode 117 = Ne"
b"xt NoSymbol Next\nkeycode 118 = Insert NoSymbol Insert\nkeycode 119 = Delete NoSymbol Delete\nkeycode 120 =\nkey"
b"code 121 = XF86AudioMute NoSymbol XF86AudioMute\nkeycode 122 = XF86AudioLowerVolume NoSymbol XF86AudioLowerVolum"
b"e\nkeycode 123 = XF86AudioRaiseVolume NoSymbol XF86AudioRaiseVolume\nkeycode 124 = XF86PowerOff NoSymbol XF86Pow"
b"erOff\nkeycode 125 = KP_Equal NoSymbol KP_Equal\nkeycode 126 = plusminus NoSymbol plusminus\nkeycode 127 = Pause"
b" Break Pause Break\nkeycode 128 = XF86LaunchA NoSymbol XF86LaunchA\nkeycode 129 = KP_Decimal KP_Decimal KP_Decim"
b"al KP_Decimal\nkeycode 130 = Hangul NoSymbol Hangul\nkeycode 131 = Hangul_Hanja NoSymbol Hangul_Hanja\nkeycode 1"
b"32 =\nkeycode 133 = Super_L NoSymbol Super_L\nkeycode 134 = Super_R NoSymbol Super_R\nkeycode 135 = Menu NoSymbo"
b"l Menu\nkeycode 136 = Cancel NoSymbol Cancel\nkeycode 137 = Redo NoSymbol Redo\nkeycode 138 = SunProps NoSymbol "
b"SunProps\nkeycode 139 = Undo NoSymbol Undo\nkeycode 140 = SunFront NoSymbol SunFront\nkeycode 141 = XF86Copy NoS"
b"ymbol XF86Copy\nkeycode 142 = XF86Open NoSymbol XF86Open\nkeycode 143 = XF86Paste NoSymbol XF86Paste\nkeycode 14"
b"4 = Find NoSymbol Find\nkeycode 145 = XF86Cut NoSymbol XF86Cut\nkeycode 146 = Help NoSymbol Help\nkeycode 147 = "
b"XF86MenuKB NoSymbol XF86MenuKB\nkeycode 148 = XF86Calculator NoSymbol XF86Calculator\nkeycode 149 =\nkeycode 150"
b" = XF86Sleep NoSymbol XF86Sleep\nkeycode 151 = XF86WakeUp NoSymbol XF86WakeUp\nkeycode 152 = XF86Explorer NoSymb"
b"ol XF86Explorer\nkeycode 153 = XF86Send NoSymbol XF86Send\nkeycode 154 =\nkeycode 155 = XF86Xfer NoSymbol XF86Xf"
b"er\nkeycode 156 = XF86Launch1 NoSymbol XF86Launch1\nkeycode 157 = XF86Launch2 NoSymbol XF86Launch2\nkeycode 158 "
b"= XF86WWW NoSymbol XF86WWW\nkeycode 159 = XF86DOS NoSymbol XF86DOS\nkeycode 160 = XF86ScreenSaver NoSymbol XF86S"
b"creenSaver\nkeycode 161 = XF86RotateWindows NoSymbol XF86RotateWindows\nkeycode 162 = XF86TaskPane NoSymbol XF86"
b"TaskPane\nkeycode 163 = XF86Mail NoSymbol XF86Mail\nkeycode 164 = XF86Favorites NoSymbol XF86Favorites\nkeycode "
b"165 = XF86MyComputer NoSymbol XF86MyComputer\nkeycode 166 = XF86Back NoSymbol XF86Back\nkeycode 167 = XF86Forwar"
b"d NoSymbol XF86Forward\nkeycode 168 =\nkeycode 169 = XF86Eject NoSymbol XF86Eject\nkeycode 170 = XF86Eject XF86E"
b"ject XF86Eject XF86Eject\nkeycode 171 = XF86AudioNext NoSymbol XF86AudioNext\nkeycode 172 = XF86AudioPlay XF86Au"
b"dioPause XF86AudioPlay XF86AudioPause\nkeycode 173 = XF86AudioPrev NoSymbol XF86AudioPrev\nkeycode 174 = XF86Aud"
b"ioStop XF86Eject XF86AudioStop XF86Eject\nkeycode 175 = XF86AudioRecord NoSymbol XF86AudioRecord\nkeycode 176 = "
b"XF86AudioRewind NoSymbol XF86AudioRewind\nkeycode 177 = XF86Phone NoSymbol XF86Phone\nkeycode 178 =\nkeycode 179"
b" = XF86Tools NoSymbol XF86Tools\nkeycode 180 = XF86HomePage NoSymbol XF86HomePage\nkeycode 181 = XF86Reload NoSy"
b"mbol XF86Reload\nkeycode 182 = XF86Close NoSymbol XF86Close\nkeycode 183 =\nkeycode 184 =\nkeycode 185 = XF86Scr"
b"ollUp NoSymbol XF86ScrollUp\nkeycode 186 = XF86ScrollDown NoSymbol XF86ScrollDown\nkeycode 187 = parenleft NoSym"
b"bol parenleft\nkeycode 188 = parenright NoSymbol parenright\nkeycode 189 = XF86New NoSymbol XF86New\nkeycode 190"
b" = Redo NoSymbol Redo\nkeycode 191 = XF86Tools NoSymbol XF86Tools\nkeycode 192 = XF86Launch5 NoSymbol XF86Launch"
b"5\nkeycode 193 = XF86Launch6 NoSymbol XF86Launch6\nkeycode 194 = XF86Launch7 NoSymbol XF86Launch7\nkeycode 195 ="
b" XF86Launch8 NoSymbol XF86Launch8\nkeycode 196 = XF86Launch9 NoSymbol XF86Launch9\nkeycode 197 =\nkeycode 198 = "
b"XF86AudioMicMute NoSymbol XF86AudioMicMute\nkeycode 199 = XF86TouchpadToggle NoSymbol XF86TouchpadToggle\nkeycod"
b"e 200 = XF86TouchpadOn NoSymbol XF86TouchpadOn\nkeycode 201 = XF86TouchpadOff NoSymbol XF86TouchpadOff\nkeycode "
b"202 =\nkeycode 203 = Mode_switch NoSymbol Mode_switch\nkeycode 204 = NoSymbol Alt_L NoSymbol Alt_L\nkeycode 205 "
b"= NoSymbol Meta_L NoSymbol Meta_L\nkeycode 206 = NoSymbol Super_L NoSymbol Super_L\nkeycode 207 = NoSymbol Hyper"
b"_L NoSymbol Hyper_L\nkeycode 208 = XF86AudioPlay NoSymbol XF86AudioPlay\nkeycode 209 = XF86AudioPause NoSymbol X"
b"F86AudioPause\nkeycode 210 = XF86Launch3 NoSymbol XF86Launch3\nkeycode 211 = XF86Launch4 NoSymbol XF86Launch4\nk"
b"eycode 212 = XF86LaunchB NoSymbol XF86LaunchB\nkeycode 213 = XF86Suspend NoSymbol XF86Suspend\nkeycode 214 = XF8"
b"6Close NoSymbol XF86Close\nkeycode 215 = XF86AudioPlay NoSymbol XF86AudioPlay\nkeycode 216 = XF86AudioForward No"
b"Symbol XF86AudioForward\nkeycode 217 =\nkeycode 218 = Print NoSymbol Print\nkeycode 219 =\nkeycode 220 = XF86Web"
b"Cam NoSymbol XF86WebCam\nkeycode 221 = XF86AudioPreset NoSymbol XF86AudioPreset\nkeycode 222 =\nkeycode 223 = XF"
b"86Mail NoSymbol XF86Mail\nkeycode 224 = XF86Messenger NoSymbol XF86Messenger\nkeycode 225 = XF86Search NoSymbol "
b"XF86Search\nkeycode 226 = XF86Go NoSymbol XF86Go\nkeycode 227 = XF86Finance NoSymbol XF86Finance\nkeycode 228 = "
b"XF86Game NoSymbol XF86Game\nkeycode 229 = XF86Shop NoSymbol XF86Shop\nkeycode 230 =\nkeycode 231 = Cancel NoSymb"
b"ol Cancel\nkeycode 232 = XF86MonBrightnessDown NoSymbol XF86MonBrightnessDown\nkeycode 233 = XF86MonBrightnessUp"
b" NoSymbol XF86MonBrightnessUp\nkeycode 234 = XF86AudioMedia NoSymbol XF86AudioMedia\nkeycode 235 = XF86Display N"
b"oSymbol XF86Display\nkeycode 236 = XF86KbdLightOnOff NoSymbol XF86KbdLightOnOff\nkeycode 237 = XF86KbdBrightness"
b"Down NoSymbol XF86KbdBrightnessDown\nkeycode 238 = XF86KbdBrightnessUp NoSymbol XF86KbdBrightnessUp\nkeycode 239"
b" = XF86Send NoSymbol XF86Send\nkeycode 240 = XF86Reply NoSymbol XF86Reply\nkeycode 241 = XF86MailForward NoSymbo"
b"l XF86MailForward\nkeycode 242 = XF86Save NoSymbol XF86Save\nkeycode 243 = XF86Documents NoSymbol XF86Documents"
b"\nkeycode 244 = XF86Battery NoSymbol XF86Battery\nkeycode 245 = XF86Bluetooth NoSymbol XF86Bluetooth\nkeycode 24"
b"6 = XF86WLAN NoSymbol XF86WLAN\nkeycode 247 =\nkeycode 248 =\nkeycode 249 =\nkeycode 250 =\nkeycode 251 = XF86Mo"
b"nBrightnessCycle NoSymbol XF86MonBrightnessCycle\nkeycode 252 =\nkeycode 253 =\nkeycode 254 = XF86WWAN NoSymbol "
b"XF86WWAN\nkeycode 255 = XF86RFKill NoSymbol XF86RFKill\n"
)

8
tests/system/__init__.py Normal file
View File

@ -0,0 +1,8 @@
"""Tests that require linux system components to be running, that might not be."""
import gi
gi.require_version("Gdk", "3.0")
gi.require_version("Gtk", "3.0")
gi.require_version("GLib", "2.0")
gi.require_version("GtkSource", "4")

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)

85
tests/system/test_dbus.py Normal file
View File

@ -0,0 +1,85 @@
#!/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 multiprocessing
import os
import time
import unittest
from xml.etree import ElementTree as ET
import gi
from tests.lib.test_setup import is_service_running
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk
from inputremapper.daemon import Daemon, DAEMON
from tests.lib.test_setup import test_setup
def gtk_iteration():
"""Iterate while events are pending."""
while Gtk.events_pending():
Gtk.main_iteration()
@test_setup
class TestDBusDaemon(unittest.TestCase):
def setUp(self):
# You need to install input-remapper into your system in order for this test
# to work.
self.process = multiprocessing.Process(
target=os.system, args=("input-remapper-service -d",)
)
self.process.start()
time.sleep(1)
# should not use pkexec, but rather connect to the previously
# spawned process
self.interface = Daemon.connect()
def tearDown(self):
self.interface.stop_all()
os.system("pkill -f input-remapper-service")
for _ in range(10):
time.sleep(0.1)
if not is_service_running():
break
self.assertFalse(is_service_running())
def test_can_connect(self):
# Seriously what does this test even do? It checks if the correct interface
# name is set, apparently, which does not sound like "can_connect" at all.
# it's a remote dbus object
introspection_xml = self.interface.Introspect()
root = ET.fromstring(introspection_xml)
interfaces = [node.get("name") for node in root.findall(".//interface")]
self.assertIn(DAEMON.interface_name, interfaces)
self.assertFalse(isinstance(self.interface, Daemon))
self.assertEqual(self.interface.hello("foo"), "foo")
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,56 @@
#!/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
from inputremapper.injection.numlock import is_numlock_on, set_numlock, ensure_numlock
from tests.lib.test_setup import test_setup
@test_setup
class TestNumlock(unittest.TestCase):
def test_numlock(self):
before = is_numlock_on()
set_numlock(not before) # should change
self.assertEqual(not before, is_numlock_on())
@ensure_numlock
def wrapped_1():
set_numlock(not is_numlock_on())
@ensure_numlock
def wrapped_2():
pass
# should not change
wrapped_1()
self.assertEqual(not before, is_numlock_on())
wrapped_2()
self.assertEqual(not before, is_numlock_on())
# toggle one more time to restore the previous configuration
set_numlock(before)
self.assertEqual(before, is_numlock_on())
if __name__ == "__main__":
unittest.main()

1
tests/unit/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Tests that don't require a complete linux desktop."""

142
tests/unit/test_context.py Normal file
View File

@ -0,0 +1,142 @@
#!/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
from unittest.mock import patch
from evdev.ecodes import (
EV_REL,
EV_ABS,
ABS_X,
ABS_Y,
REL_WHEEL_HI_RES,
REL_HWHEEL_HI_RES,
)
from inputremapper.configs.input_config import InputCombination
from inputremapper.configs.mapping import Mapping
from inputremapper.configs.preset import Preset
from inputremapper.injection.context import Context
from inputremapper.injection.global_uinputs import GlobalUInputs, UInput
from inputremapper.injection.mapping_handlers.macro_handler import MacroHandler
from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser
from inputremapper.input_event import InputEvent
from tests.lib.test_setup import test_setup
@test_setup
class TestContext(unittest.TestCase):
def test_callbacks(self):
global_uinputs = GlobalUInputs(UInput)
mapping_parser = MappingParser(global_uinputs)
preset = Preset()
cfg = {
"input_combination": InputCombination.from_tuples((EV_ABS, ABS_X)),
"target_uinput": "mouse",
"output_type": EV_REL,
"output_code": REL_HWHEEL_HI_RES,
}
preset.add(Mapping(**cfg)) # abs x -> wheel
cfg["input_combination"] = InputCombination.from_tuples((EV_ABS, ABS_Y))
cfg["output_code"] = REL_WHEEL_HI_RES
preset.add(Mapping(**cfg)) # abs y -> wheel
preset.add(
Mapping.from_combination(
InputCombination.from_tuples((1, 31)), "keyboard", "key(a)"
)
)
preset.add(
Mapping.from_combination(
InputCombination.from_tuples((1, 32)), "keyboard", "b"
)
)
# overlapping combination for (1, 32, 1)
preset.add(
Mapping.from_combination(
InputCombination.from_tuples((1, 32), (1, 33), (1, 34)),
"keyboard",
"c",
)
)
# map abs x to key "b"
preset.add(
Mapping.from_combination(
InputCombination.from_tuples((EV_ABS, ABS_X, 20)),
"keyboard",
"d",
),
)
context = Context(preset, {}, {}, mapping_parser)
expected_num_callbacks = {
# ABS_X -> "d" and ABS_X -> wheel have the same type and code
InputEvent.abs(ABS_X, 1): 2,
InputEvent.abs(ABS_Y, 1): 1,
InputEvent.key(31, 1): 1,
# even though we have 2 mappings with this type and code, we only expect
# one callback because they both map to keys. We don't want to trigger two
# mappings with the same key press
InputEvent.key(32, 1): 1,
InputEvent.key(33, 1): 1,
InputEvent.key(34, 1): 1,
}
self.assertEqual(
set([event.input_match_hash for event in expected_num_callbacks.keys()]),
set(context._notify_callbacks.keys()),
)
for input_event, num_callbacks in expected_num_callbacks.items():
self.assertEqual(
num_callbacks,
len(context.get_notify_callbacks(input_event)),
)
# 7 unique input events in the preset
self.assertEqual(7, len(context._handlers))
def test_reset(self):
global_uinputs = GlobalUInputs(UInput)
mapping_parser = MappingParser(global_uinputs)
preset = Preset()
preset.add(
Mapping.from_combination(
InputCombination.from_tuples((1, 31)),
"keyboard",
"key(a)",
)
)
context = Context(preset, {}, {}, mapping_parser)
self.assertEqual(1, len(context._handlers))
with patch.object(MacroHandler, "reset") as reset_mock:
context.reset()
reset_mock.assert_called_once()
if __name__ == "__main__":
unittest.main()

407
tests/unit/test_control.py Normal file
View File

@ -0,0 +1,407 @@
#!/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/>.
"""Testing the input-remapper-control command"""
import collections
import os
import time
import unittest
from unittest.mock import patch, MagicMock
from inputremapper.bin.input_remapper_control import InputRemapperControlBin
from inputremapper.configs.global_config import GlobalConfig
from inputremapper.configs.migrations import Migrations
from inputremapper.configs.paths import PathUtils
from inputremapper.configs.preset import Preset
from inputremapper.daemon import Daemon
from inputremapper.groups import groups
from inputremapper.injection.global_uinputs import GlobalUInputs, FrontendUInput
from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser
from tests.lib.test_setup import test_setup
from tests.lib.tmp import tmp
options = collections.namedtuple(
"options",
["command", "config_dir", "preset", "device", "list_devices", "key_names", "debug"],
)
def remove_timeout_from_calls(method: str):
# Remove the timeout argument, which is used by dasbus but not actually
# passed to the Daemon class methods. Since we create the Daemon object directly
# for this test it would raise an exception otherwise, for example:
# TypeError: Daemon.autoload_single() got an unexpected keyword argument 'timeout'
def decorator(func):
def wrapped(*args, **kwargs):
original_method = getattr(Daemon, method)
with patch.object(
Daemon,
method,
lambda *args, timeout=0, **kwargs: original_method(*args, **kwargs),
):
return func(*args, **kwargs)
return wrapped
return decorator
@test_setup
class TestControl(unittest.TestCase):
def setUp(self):
self.global_config = GlobalConfig()
self.global_uinputs = GlobalUInputs(FrontendUInput)
self.migrations = Migrations(self.global_uinputs)
self.mapping_parser = MappingParser(self.global_uinputs)
self.input_remapper_control = InputRemapperControlBin(
self.global_config, self.migrations
)
@remove_timeout_from_calls("autoload")
@remove_timeout_from_calls("autoload_single")
def test_autoload(self):
device_keys = ["Foo Device 2", "Bar Device"]
groups_ = [groups.find(key=key) for key in device_keys]
presets = ["bar0", "bar", "bar2"]
paths = [
PathUtils.get_preset_path(groups_[0].name, presets[0]),
PathUtils.get_preset_path(groups_[1].name, presets[1]),
PathUtils.get_preset_path(groups_[1].name, presets[2]),
]
Preset(paths[0]).save()
Preset(paths[1]).save()
Preset(paths[2]).save()
daemon = Daemon(self.global_config, self.global_uinputs, self.mapping_parser)
self.input_remapper_control.set_daemon(daemon)
start_history = []
stop_counter = 0
# using an actual injector is not within the scope of this test
class Injector:
def stop_injecting(self, *args, **kwargs):
nonlocal stop_counter
stop_counter += 1
def start_injecting(device: str, preset: str):
print(f'\033[90mstart_injecting "{device}" "{preset}"\033[0m')
start_history.append((device, preset))
daemon.injectors[device] = Injector()
patch.object(daemon, "start_injecting", start_injecting).start()
self.global_config.set_autoload_preset(groups_[0].key, presets[0])
self.global_config.set_autoload_preset(groups_[1].key, presets[1])
self.input_remapper_control.communicate(
command="autoload",
config_dir=None,
preset=None,
device=None,
)
self.assertEqual(len(start_history), 2)
self.assertEqual(start_history[0], (groups_[0].key, presets[0]))
self.assertEqual(start_history[1], (groups_[1].key, presets[1]))
self.assertIn(groups_[0].key, daemon.injectors)
self.assertIn(groups_[1].key, daemon.injectors)
self.assertFalse(
daemon.autoload_history.may_autoload(groups_[0].key, presets[0])
)
self.assertFalse(
daemon.autoload_history.may_autoload(groups_[1].key, presets[1])
)
# calling autoload again doesn't load redundantly
self.input_remapper_control.communicate(
command="autoload",
config_dir=None,
preset=None,
device=None,
)
self.assertEqual(len(start_history), 2)
self.assertEqual(stop_counter, 0)
self.assertFalse(
daemon.autoload_history.may_autoload(groups_[0].key, presets[0])
)
self.assertFalse(
daemon.autoload_history.may_autoload(groups_[1].key, presets[1])
)
# unless the injection in question ist stopped
self.input_remapper_control.communicate(
command="stop",
config_dir=None,
preset=None,
device=groups_[0].key,
)
self.assertEqual(stop_counter, 1)
self.assertTrue(
daemon.autoload_history.may_autoload(groups_[0].key, presets[0])
)
self.assertFalse(
daemon.autoload_history.may_autoload(groups_[1].key, presets[1])
)
self.input_remapper_control.communicate(
command="autoload",
config_dir=None,
preset=None,
device=None,
)
self.assertEqual(len(start_history), 3)
self.assertEqual(start_history[2], (groups_[0].key, presets[0]))
self.assertFalse(
daemon.autoload_history.may_autoload(groups_[0].key, presets[0])
)
self.assertFalse(
daemon.autoload_history.may_autoload(groups_[1].key, presets[1])
)
# if a device name is passed, will only start injecting for that one
self.input_remapper_control.communicate(
command="stop-all",
config_dir=None,
preset=None,
device=None,
)
self.assertTrue(
daemon.autoload_history.may_autoload(groups_[0].key, presets[0])
)
self.assertTrue(
daemon.autoload_history.may_autoload(groups_[1].key, presets[1])
)
self.assertEqual(stop_counter, 3)
self.global_config.set_autoload_preset(groups_[1].key, presets[2])
self.input_remapper_control.communicate(
command="autoload",
config_dir=None,
preset=None,
device=groups_[1].key,
)
self.assertEqual(len(start_history), 4)
self.assertEqual(start_history[3], (groups_[1].key, presets[2]))
self.assertTrue(
daemon.autoload_history.may_autoload(groups_[0].key, presets[0])
)
self.assertFalse(
daemon.autoload_history.may_autoload(groups_[1].key, presets[2])
)
# autoloading for the same device again redundantly will not autoload
# again
self.input_remapper_control.communicate(
command="autoload",
config_dir=None,
preset=None,
device=groups_[1].key,
)
self.assertEqual(len(start_history), 4)
self.assertEqual(stop_counter, 3)
self.assertFalse(
daemon.autoload_history.may_autoload(groups_[1].key, presets[2])
)
# any other arbitrary preset may be autoloaded
self.assertTrue(daemon.autoload_history.may_autoload(groups_[1].key, "quuuux"))
# after 15 seconds it may be autoloaded again
daemon.autoload_history._autoload_history[groups_[1].key] = (
time.time() - 16,
presets[2],
)
self.assertTrue(
daemon.autoload_history.may_autoload(groups_[1].key, presets[2])
)
@remove_timeout_from_calls("autoload")
def test_autoload_other_path(self):
device_names = ["Foo Device", "Bar Device"]
groups_ = [groups.find(name=name) for name in device_names]
presets = ["bar123", "bar2"]
config_dir = os.path.join(tmp, "qux", "quux")
paths = [
os.path.join(config_dir, "presets", device_names[0], presets[0] + ".json"),
os.path.join(config_dir, "presets", device_names[1], presets[1] + ".json"),
]
Preset(paths[0]).save()
Preset(paths[1]).save()
daemon = Daemon(self.global_config, self.global_uinputs, self.mapping_parser)
self.input_remapper_control.set_daemon(daemon)
start_history = []
daemon.start_injecting = lambda *args: start_history.append(args)
self.global_config.path = os.path.join(config_dir, "config.json")
self.global_config.load_config()
self.global_config.set_autoload_preset(device_names[0], presets[0])
self.global_config.set_autoload_preset(device_names[1], presets[1])
self.input_remapper_control.communicate(
command="autoload",
config_dir=config_dir,
preset=None,
device=None,
)
self.assertEqual(len(start_history), 2)
self.assertEqual(start_history[0], (groups_[0].key, presets[0]))
self.assertEqual(start_history[1], (groups_[1].key, presets[1]))
def test_start_stop(self):
group = groups.find(key="Foo Device 2")
preset = "preset9"
daemon = Daemon(self.global_config, self.global_uinputs, self.mapping_parser)
self.input_remapper_control.set_daemon(daemon)
start_history = []
stop_history = []
stop_all_history = []
daemon.start_injecting = lambda *args: start_history.append(args)
daemon.stop_injecting = lambda *args: stop_history.append(args)
daemon.stop_all = lambda *args: stop_all_history.append(args)
self.input_remapper_control.communicate(
command="start",
config_dir=None,
preset=preset,
device=group.paths[0],
)
self.assertEqual(len(start_history), 1)
self.assertEqual(start_history[0], (group.key, preset))
self.input_remapper_control.communicate(
command="stop",
config_dir=None,
preset=None,
device=group.paths[1],
)
self.assertEqual(len(stop_history), 1)
# provided any of the groups paths as --device argument, figures out
# the correct group.key to use here
self.assertEqual(stop_history[0], (group.key,))
self.input_remapper_control.communicate(
command="stop-all",
config_dir=None,
preset=None,
device=None,
)
self.assertEqual(len(stop_all_history), 1)
self.assertEqual(stop_all_history[0], ())
@patch.object(Daemon, "quit")
def test_quit(self, quit_mock: MagicMock) -> None:
group = groups.find(key="Foo Device 2")
assert group is not None
preset = "preset9"
daemon = Daemon(self.global_config, self.global_uinputs, self.mapping_parser)
self.input_remapper_control.set_daemon(daemon)
self.input_remapper_control.communicate(
command="quit",
config_dir=None,
preset=preset,
device=group.paths[0],
)
quit_mock.assert_called_once()
def test_config_not_found(self):
key = "Foo Device 2"
path = "~/a/preset.json"
config_dir = "/foo/bar"
daemon = Daemon(self.global_config, self.global_uinputs, self.mapping_parser)
self.input_remapper_control.set_daemon(daemon)
start_history = []
stop_history = []
daemon.start_injecting = lambda *args: start_history.append(args)
daemon.stop_injecting = lambda *args: stop_history.append(args)
self.assertRaises(
SystemExit,
lambda: self.input_remapper_control.communicate(
command="start",
config_dir=config_dir,
preset=path,
device=key,
),
)
self.assertRaises(
SystemExit,
lambda: self.input_remapper_control.communicate(
command="stop",
config_dir=config_dir,
preset=None,
device=key,
),
)
def test_autoload_config_dir(self):
daemon = Daemon(self.global_config, self.global_uinputs, self.mapping_parser)
path = os.path.join(tmp, "foo")
os.makedirs(path)
with open(os.path.join(path, "config.json"), "w") as file:
file.write('{"autoload":{"foo": "bar"}}')
self.assertIsNone(self.global_config.get_autoload_preset("foo"))
daemon.set_config_dir(path)
# since daemon and this test share the same memory, the global_config
# object that this test can access will be modified
self.assertEqual(self.global_config.get_autoload_preset("foo"), "bar")
# passing a path that doesn't exist or a path that doesn't contain
# a config.json file won't do anything
os.makedirs(os.path.join(tmp, "bar"))
daemon.set_config_dir(os.path.join(tmp, "bar"))
self.assertEqual(self.global_config.get_autoload_preset("foo"), "bar")
daemon.set_config_dir(os.path.join(tmp, "qux"))
self.assertEqual(self.global_config.get_autoload_preset("foo"), "bar")
def test_internals_reader(self):
with patch.object(os, "system") as os_system_patch:
self.input_remapper_control.internals("start-reader-service", False)
os_system_patch.assert_called_once()
self.assertIn(
"input-remapper-reader-service", os_system_patch.call_args.args[0]
)
self.assertNotIn("-d", os_system_patch.call_args.args[0])
def test_internals_daemon(self):
with patch.object(os, "system") as os_system_patch:
self.input_remapper_control.internals("start-daemon", True)
os_system_patch.assert_called_once()
self.assertIn("input-remapper-service", os_system_patch.call_args.args[0])
self.assertIn("-d", os_system_patch.call_args.args[0])
if __name__ == "__main__":
unittest.main()

File diff suppressed because it is too large Load Diff

577
tests/unit/test_daemon.py Normal file
View File

@ -0,0 +1,577 @@
#!/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 json
import os
import time
import unittest
from unittest.mock import patch, MagicMock
import evdev
from evdev._ecodes import EV_ABS
from evdev.ecodes import EV_KEY, KEY_B, KEY_A, ABS_X, BTN_A, BTN_B
from inputremapper.configs.global_config import GlobalConfig
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.configs.mapping import Mapping
from inputremapper.configs.paths import PathUtils
from inputremapper.configs.preset import Preset
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.daemon import Daemon, DAEMON
from inputremapper.groups import groups
from inputremapper.injection.global_uinputs import GlobalUInputs, UInput
from inputremapper.injection.injector import InjectorState
from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser
from inputremapper.input_event import InputEvent
from tests.lib.cleanup import cleanup
from tests.lib.fixtures import Fixture
from tests.lib.fixtures import fixtures
from tests.lib.logger import logger
from tests.lib.pipes import push_events, uinput_write_history_pipe
from tests.lib.test_setup import test_setup, is_service_running
from tests.lib.tmp import tmp
@test_setup
class TestDaemon(unittest.TestCase):
new_fixture_path = "/dev/input/event9876"
def setUp(self):
self.daemon = None
self.global_config = GlobalConfig()
self.global_uinputs = GlobalUInputs(UInput)
PathUtils.mkdir(PathUtils.get_config_path())
self.global_config._save_config()
self.mapping_parser = MappingParser(self.global_uinputs)
# the daemon should be able to create them on demand:
self.global_uinputs.devices = {}
self.global_uinputs.is_service = True
def tearDown(self):
# avoid race conditions with other tests, daemon may run processes
if self.daemon is not None:
self.daemon.stop_all()
self.daemon = None
cleanup()
@patch.object(os, "system")
def test_connect(self, os_system_mock: MagicMock):
self.assertFalse(is_service_running())
# no daemon runs, should try to run it via pkexec instead.
# It fails due to the patch on os.system and therefore exits the process
self.assertRaises(SystemExit, Daemon.connect)
os_system_mock.assert_called_once()
self.assertIsNone(Daemon.connect(False))
# make the connect command work this time by acting like a connection is
# available:
set_config_dir_callcount = 0
class FakeConnection:
def set_config_dir(self, *_, **__):
nonlocal set_config_dir_callcount
set_config_dir_callcount += 1
def Introspect(self, timeout=0):
return ""
with patch.object(DAEMON, "get_proxy") as get_mock:
get_mock.return_value = FakeConnection()
self.assertIsInstance(Daemon.connect(), FakeConnection)
self.assertEqual(set_config_dir_callcount, 1)
self.assertIsInstance(Daemon.connect(False), FakeConnection)
self.assertEqual(set_config_dir_callcount, 2)
def test_daemon(self):
# remove the existing system mapping to force our own into it
if os.path.exists(PathUtils.get_config_path("xmodmap.json")):
os.remove(PathUtils.get_config_path("xmodmap.json"))
preset_name = "foo"
group = groups.find(name="gamepad")
# unrelated group that shouldn't be affected at all
group2 = groups.find(name="Bar Device")
preset = Preset(group.get_preset_path(preset_name))
preset.add(
Mapping.from_combination(
input_combination=InputCombination(
[InputConfig(type=EV_KEY, code=BTN_A)]
),
target_uinput="keyboard",
output_symbol="a",
)
)
preset.add(
Mapping.from_combination(
input_combination=InputCombination(
[InputConfig(type=EV_ABS, code=ABS_X, analog_threshold=-1)]
),
target_uinput="keyboard",
output_symbol="b",
)
)
preset.save()
self.global_config.set_autoload_preset(group.key, preset_name)
"""Injection 1"""
# should forward the event unchanged
push_events(
fixtures.gamepad,
[InputEvent.key(BTN_B, 1, fixtures.gamepad.get_device_hash())],
)
self.daemon = Daemon(
self.global_config,
self.global_uinputs,
self.mapping_parser,
)
self.assertFalse(uinput_write_history_pipe[0].poll())
# has been cleanedUp in setUp
self.assertNotIn("keyboard", self.global_uinputs.devices)
logger.info(f"start injector for {group.key}")
self.daemon.start_injecting(group.key, preset_name)
# created on demand
self.assertIn("keyboard", self.global_uinputs.devices)
self.assertNotIn("gamepad", self.global_uinputs.devices)
self.assertEqual(self.daemon.get_state(group.key), InjectorState.STARTING)
self.assertEqual(self.daemon.get_state(group2.key), InjectorState.UNKNOWN)
event = uinput_write_history_pipe[0].recv()
self.assertEqual(self.daemon.get_state(group.key), InjectorState.RUNNING)
self.assertEqual(event.type, EV_KEY)
self.assertEqual(event.code, BTN_B)
self.assertEqual(event.value, 1)
logger.info(f"stopping injector for {group.key}")
self.daemon.stop_injecting(group.key)
time.sleep(0.2)
self.assertEqual(self.daemon.get_state(group.key), InjectorState.STOPPED)
try:
self.assertFalse(uinput_write_history_pipe[0].poll())
except AssertionError:
print("Unexpected", uinput_write_history_pipe[0].recv())
# possibly a duplicate write!
raise
"""Injection 2"""
logger.info(f"start injector for {group.key}")
self.daemon.start_injecting(group.key, preset_name)
time.sleep(0.1)
# -1234 will be classified as -1 by the injector
push_events(
fixtures.gamepad,
[InputEvent.abs(ABS_X, -1234, fixtures.gamepad.get_device_hash())],
)
time.sleep(0.1)
self.assertTrue(uinput_write_history_pipe[0].poll())
# the written key is a key-down event, not the original
# event value of -1234
event = uinput_write_history_pipe[0].recv()
self.assertEqual(event.type, EV_KEY)
self.assertEqual(event.code, KEY_B)
self.assertEqual(event.value, 1)
def test_refresh_on_start(self):
if os.path.exists(PathUtils.get_config_path("xmodmap.json")):
os.remove(PathUtils.get_config_path("xmodmap.json"))
preset_name = "foo"
key_code = 9
group_name = "9876 name"
# expected key of the group
group_key = group_name
group = groups.find(name=group_name)
# this test only makes sense if this device is unknown yet
self.assertIsNone(group)
keyboard_layout.clear()
keyboard_layout._set("a", KEY_A)
preset = Preset(PathUtils.get_preset_path(group_name, preset_name))
preset.add(
Mapping.from_combination(
InputCombination([InputConfig(type=EV_KEY, code=key_code)]),
"keyboard",
"a",
)
)
# make the daemon load the file instead
with open(PathUtils.get_config_path("xmodmap.json"), "w") as file:
json.dump(keyboard_layout._mapping, file, indent=4)
keyboard_layout.clear()
preset.save()
self.global_config.set_autoload_preset(group_key, preset_name)
self.daemon = Daemon(
self.global_config,
self.global_uinputs,
self.mapping_parser,
)
# make sure the devices are populated
groups.refresh()
# the daemon is supposed to find this device by calling refresh
fixture = Fixture(
capabilities={evdev.ecodes.EV_KEY: [key_code]},
phys="9876 phys",
info=evdev.device.DeviceInfo(4, 5, 6, 7),
name=group_name,
path=self.new_fixture_path,
)
fixtures.add_fixture(fixture)
push_events(fixture, [InputEvent.key(key_code, 1, fixture.get_device_hash())])
self.daemon.start_injecting(group_key, preset_name)
# test if the injector called groups.refresh successfully
group = groups.find(key=group_key)
self.assertEqual(group.name, group_name)
self.assertEqual(group.key, group_key)
time.sleep(0.1)
self.assertTrue(uinput_write_history_pipe[0].poll())
event = uinput_write_history_pipe[0].recv()
self.assertEqual(event, (EV_KEY, KEY_A, 1))
self.daemon.stop_injecting(group_key)
time.sleep(0.2)
self.assertEqual(self.daemon.get_state(group_key), InjectorState.STOPPED)
def test_refresh_for_unknown_key(self):
device_9876 = "9876 name"
# this test only makes sense if this device is unknown yet
self.assertIsNone(groups.find(name=device_9876))
self.daemon = Daemon(
self.global_config,
self.global_uinputs,
self.mapping_parser,
)
# make sure the devices are populated
groups.refresh()
self.daemon.refresh()
fixtures.add_fixture(
Fixture(
capabilities={evdev.ecodes.EV_KEY: [evdev.ecodes.KEY_A]},
phys="9876 phys",
info=evdev.device.DeviceInfo(4, 5, 6, 7),
name=device_9876,
path=self.new_fixture_path,
)
)
self.daemon._autoload("25v7j9q4vtj")
# this is unknown, so the daemon will scan the devices again
# test if the injector called groups.refresh successfully
self.assertIsNotNone(groups.find(name=device_9876))
def test_xmodmap_file(self):
"""Create a custom xmodmap file, expect the daemon to read keycodes from it."""
from_keycode = evdev.ecodes.KEY_A
target = "keyboard"
to_name = "q"
to_keycode = 100
name = "Bar Device"
preset_name = "foo"
group = groups.find(name=name)
config_dir = os.path.join(tmp, "foo")
path = os.path.join(config_dir, "presets", name, f"{preset_name}.json")
preset = Preset(path)
preset.add(
Mapping.from_combination(
InputCombination([InputConfig(type=EV_KEY, code=from_keycode)]),
target,
to_name,
)
)
preset.save()
keyboard_layout.clear()
push_events(
fixtures.bar_device,
[
InputEvent.key(
from_keycode,
1,
origin_hash=fixtures.bar_device.get_device_hash(),
)
],
)
# an existing config file is needed otherwise set_config_dir refuses
# to use the directory
config_path = os.path.join(config_dir, "config.json")
self.global_config.path = config_path
self.global_config._save_config()
# finally, create the xmodmap file
xmodmap_path = os.path.join(config_dir, "xmodmap.json")
with open(xmodmap_path, "w") as file:
file.write(f'{{"{to_name}":{to_keycode}}}')
# test setup complete
self.daemon = Daemon(
self.global_config,
self.global_uinputs,
self.mapping_parser,
)
self.daemon.set_config_dir(config_dir)
self.daemon.start_injecting(group.key, preset_name)
time.sleep(0.1)
self.assertTrue(uinput_write_history_pipe[0].poll())
event = uinput_write_history_pipe[0].recv()
self.assertEqual(event.type, EV_KEY)
self.assertEqual(event.code, to_keycode)
self.assertEqual(event.value, 1)
def test_start_stop(self):
group_key = "Qux/[Device]?"
group = groups.find(key=group_key)
preset_name = "preset8"
daemon = Daemon(
self.global_config,
self.global_uinputs,
self.mapping_parser,
)
self.daemon = daemon
pereset = Preset(group.get_preset_path(preset_name))
pereset.add(
Mapping.from_combination(
InputCombination([InputConfig(type=EV_KEY, code=KEY_A)]),
"keyboard",
"a",
)
)
pereset.save()
# start
daemon.start_injecting(group_key, preset_name)
# explicit start, not autoload, so the history stays empty
self.assertNotIn(group_key, daemon.autoload_history._autoload_history)
self.assertTrue(daemon.autoload_history.may_autoload(group_key, preset_name))
# path got translated to the device name
self.assertIn(group_key, daemon.injectors)
# start again
previous_injector = daemon.injectors[group_key]
self.assertNotEqual(previous_injector.get_state(), InjectorState.STOPPED)
daemon.start_injecting(group_key, preset_name)
self.assertNotIn(group_key, daemon.autoload_history._autoload_history)
self.assertTrue(daemon.autoload_history.may_autoload(group_key, preset_name))
self.assertIn(group_key, daemon.injectors)
time.sleep(0.2)
self.assertEqual(previous_injector.get_state(), InjectorState.STOPPED)
# a different injetor is now running
self.assertNotEqual(previous_injector, daemon.injectors[group_key])
self.assertNotEqual(
daemon.injectors[group_key].get_state(), InjectorState.STOPPED
)
# trying to inject a non existing preset keeps the previous inejction
# alive
injector = daemon.injectors[group_key]
daemon.start_injecting(group_key, "qux")
self.assertEqual(injector, daemon.injectors[group_key])
self.assertNotEqual(
daemon.injectors[group_key].get_state(), InjectorState.STOPPED
)
# trying to start injecting for an unknown device also just does
# nothing
daemon.start_injecting("quux", "qux")
self.assertNotEqual(
daemon.injectors[group_key].get_state(), InjectorState.STOPPED
)
# after all that stuff autoload_history is still unharmed
self.assertNotIn(group_key, daemon.autoload_history._autoload_history)
self.assertTrue(daemon.autoload_history.may_autoload(group_key, preset_name))
# stop
daemon.stop_injecting(group_key)
time.sleep(0.2)
self.assertNotIn(group_key, daemon.autoload_history._autoload_history)
self.assertEqual(daemon.injectors[group_key].get_state(), InjectorState.STOPPED)
self.assertTrue(daemon.autoload_history.may_autoload(group_key, preset_name))
def test_autoload(self):
preset_name = "preset7"
group_key = "Qux/[Device]?"
group = groups.find(key=group_key)
daemon = Daemon(self.global_config, self.global_uinputs, self.mapping_parser)
self.daemon = daemon
preset = Preset(group.get_preset_path(preset_name))
preset.add(
Mapping.from_combination(
InputCombination([InputConfig(type=EV_KEY, code=KEY_A)]),
"keyboard",
"a",
)
)
preset.save()
# no autoloading is configured yet
self.daemon._autoload(group_key)
self.assertNotIn(group_key, daemon.autoload_history._autoload_history)
self.assertTrue(daemon.autoload_history.may_autoload(group_key, preset_name))
self.global_config.set_autoload_preset(group_key, preset_name)
len_before = len(self.daemon.autoload_history._autoload_history)
# now autoloading is configured, so it will autoload
self.daemon._autoload(group_key)
len_after = len(self.daemon.autoload_history._autoload_history)
self.assertEqual(
daemon.autoload_history._autoload_history[group_key][1],
preset_name,
)
self.assertFalse(daemon.autoload_history.may_autoload(group_key, preset_name))
injector = daemon.injectors[group_key]
self.assertEqual(len_before + 1, len_after)
# calling duplicate get_autoload does nothing
self.daemon._autoload(group_key)
self.assertEqual(
daemon.autoload_history._autoload_history[group_key][1],
preset_name,
)
self.assertEqual(injector, daemon.injectors[group_key])
self.assertFalse(daemon.autoload_history.may_autoload(group_key, preset_name))
# explicit start_injecting clears the autoload history
self.daemon.start_injecting(group_key, preset_name)
self.assertTrue(daemon.autoload_history.may_autoload(group_key, preset_name))
# calling autoload for (yet) unknown devices does nothing
len_before = len(self.daemon.autoload_history._autoload_history)
self.daemon._autoload("unknown-key-1234")
len_after = len(self.daemon.autoload_history._autoload_history)
self.assertEqual(len_before, len_after)
# autoloading input-remapper devices does nothing
len_before = len(self.daemon.autoload_history._autoload_history)
self.daemon.autoload_single("Bar Device")
len_after = len(self.daemon.autoload_history._autoload_history)
self.assertEqual(len_before, len_after)
def test_autoload_2(self):
self.daemon = Daemon(
self.global_config,
self.global_uinputs,
self.mapping_parser,
)
history = self.daemon.autoload_history._autoload_history
# existing device
preset_name = "preset7"
group = groups.find(key="Foo Device 2")
preset = Preset(group.get_preset_path(preset_name))
preset.add(
Mapping.from_combination(
InputCombination([InputConfig(type=3, code=2, analog_threshold=1)]),
"keyboard",
"a",
)
)
preset.save()
self.global_config.set_autoload_preset(group.key, preset_name)
# ignored, won't cause problems:
self.global_config.set_autoload_preset("non-existant-key", "foo")
self.daemon.autoload()
self.assertEqual(len(history), 1)
self.assertEqual(history[group.key][1], preset_name)
def test_autoload_3(self):
# based on a bug
preset_name = "preset7"
group = groups.find(key="Foo Device 2")
preset = Preset(group.get_preset_path(preset_name))
preset.add(
Mapping.from_combination(
InputCombination([InputConfig(type=3, code=2, analog_threshold=1)]),
"keyboard",
"a",
)
)
preset.save()
self.global_config.set_autoload_preset(group.key, preset_name)
self.daemon = Daemon(
self.global_config,
self.global_uinputs,
self.mapping_parser,
)
groups.set_groups([]) # caused the bug
self.assertIsNone(groups.find(key="Foo Device 2"))
self.daemon.autoload()
# it should try to refresh the groups because all the
# group_keys are unknown at the moment
history = self.daemon.autoload_history._autoload_history
self.assertEqual(history[group.key][1], preset_name)
self.assertEqual(self.daemon.get_state(group.key), InjectorState.STARTING)
self.assertIsNotNone(groups.find(key="Foo Device 2"))
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,971 @@
#!/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 itertools import permutations
from typing import List
from unittest.mock import MagicMock, call
from inputremapper.configs.global_config import GlobalConfig
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.configs.mapping import UIMapping, MappingData
from inputremapper.configs.paths import PathUtils
from inputremapper.configs.preset import Preset
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.exceptions import DataManagementError
from inputremapper.groups import _Groups
from inputremapper.gui.data_manager import DataManager, DEFAULT_PRESET_NAME
from inputremapper.gui.messages.message_broker import (
MessageBroker,
MessageType,
)
from inputremapper.gui.messages.message_data import (
GroupData,
CombinationUpdate,
)
from inputremapper.gui.reader_client import ReaderClient
from inputremapper.injection.global_uinputs import GlobalUInputs, FrontendUInput
from tests.lib.fixtures import prepare_presets
from tests.lib.patches import FakeDaemonProxy
from tests.lib.test_setup import test_setup
class Listener:
def __init__(self):
self.calls: List = []
def __call__(self, data):
self.calls.append(data)
@test_setup
class TestDataManager(unittest.TestCase):
def setUp(self) -> None:
self.message_broker = MessageBroker()
self.reader = ReaderClient(self.message_broker, _Groups())
self.uinputs = GlobalUInputs(FrontendUInput)
self.uinputs.prepare_all()
self.global_config = GlobalConfig()
self.data_manager = DataManager(
self.message_broker,
self.global_config,
self.reader,
FakeDaemonProxy(),
self.uinputs,
keyboard_layout,
)
def test_load_group_provides_presets(self):
"""we should get all preset of a group, when loading it"""
prepare_presets()
response: List[GroupData] = []
def listener(data: GroupData):
response.append(data)
self.message_broker.subscribe(MessageType.group, listener)
self.data_manager.load_group("Foo Device 2")
for preset_name in response[0].presets:
self.assertIn(
preset_name,
(
"preset1",
"preset2",
"preset3",
),
)
self.assertEqual(response[0].group_key, "Foo Device 2")
def test_load_group_without_presets_provides_none(self):
"""We should get no presets when loading a group without presets."""
response: List[GroupData] = []
def listener(data: GroupData):
response.append(data)
self.message_broker.subscribe(MessageType.group, listener)
self.data_manager.load_group(group_key="Foo Device 2")
self.assertEqual(len(response[0].presets), 0)
def test_load_non_existing_group(self):
"""we should not be able to load an unknown group"""
with self.assertRaises(DataManagementError):
self.data_manager.load_group(group_key="Some Unknown Device")
def test_cannot_load_preset_without_group(self):
"""Loading a preset without a loaded group raises a DataManagementError."""
prepare_presets()
self.assertRaises(
DataManagementError,
self.data_manager.load_preset,
name="preset1",
)
def test_load_preset(self):
"""loading an existing preset should be possible"""
prepare_presets()
self.data_manager.load_group(group_key="Foo Device")
listener = Listener()
self.message_broker.subscribe(MessageType.preset, listener)
self.data_manager.load_preset(name="preset1")
mappings = listener.calls[0].mappings
preset_name = listener.calls[0].name
expected_preset = Preset(PathUtils.get_preset_path("Foo Device", "preset1"))
expected_preset.load()
expected_mappings = list(expected_preset)
self.assertEqual(preset_name, "preset1")
for mapping in expected_mappings:
self.assertIn(mapping, mappings)
def test_cannot_load_non_existing_preset(self):
"""Loading a non-existing preset should raise a KeyError."""
prepare_presets()
self.data_manager.load_group(group_key="Foo Device")
self.assertRaises(
FileNotFoundError,
self.data_manager.load_preset,
name="unknownPreset",
)
def test_save_preset(self):
"""Modified preses should be saved to the disc."""
prepare_presets()
# make sure the correct preset is loaded
self.data_manager.load_group(group_key="Foo Device")
self.data_manager.load_preset(name="preset1")
listener = Listener()
self.message_broker.subscribe(MessageType.mapping, listener)
self.data_manager.load_mapping(
combination=InputCombination([InputConfig(type=1, code=1)])
)
mapping: MappingData = listener.calls[0]
control_preset = Preset(PathUtils.get_preset_path("Foo Device", "preset1"))
control_preset.load()
self.assertEqual(
control_preset.get_mapping(
InputCombination([InputConfig(type=1, code=1)])
).output_symbol,
mapping.output_symbol,
)
# change the mapping provided with the mapping_changed event and save
self.data_manager.update_mapping(output_symbol="key(a)")
self.data_manager.save()
# reload the control_preset
control_preset.empty()
control_preset.load()
self.assertEqual(
control_preset.get_mapping(
InputCombination([InputConfig(type=1, code=1)])
).output_symbol,
"key(a)",
)
def test_copy_preset(self):
prepare_presets()
self.data_manager.load_group(group_key="Foo Device 2")
self.data_manager.load_preset(name="preset2")
listener = Listener()
self.message_broker.subscribe(MessageType.group, listener)
self.message_broker.subscribe(MessageType.preset, listener)
self.data_manager.copy_preset("foo")
# we expect the first data to be group data and the second
# one a preset data of the new copy
presets_in_group = [preset for preset in listener.calls[0].presets]
self.assertIn("preset2", presets_in_group)
self.assertIn("foo", presets_in_group)
self.assertEqual(listener.calls[1].name, "foo")
# this should pass without error:
self.data_manager.load_preset("preset2")
self.data_manager.copy_preset("preset2")
def test_cannot_copy_preset(self):
prepare_presets()
self.assertRaises(
DataManagementError,
self.data_manager.copy_preset,
"foo",
)
self.data_manager.load_group("Foo Device 2")
self.assertRaises(
DataManagementError,
self.data_manager.copy_preset,
"foo",
)
def test_copy_preset_to_existing_name_raises_error(self):
prepare_presets()
self.data_manager.load_group(group_key="Foo Device 2")
self.data_manager.load_preset(name="preset2")
self.assertRaises(
ValueError,
self.data_manager.copy_preset,
"preset3",
)
def test_rename_preset(self):
"""should be able to rename a preset"""
prepare_presets()
self.data_manager.load_group(group_key="Foo Device 2")
self.data_manager.load_preset(name="preset2")
listener = Listener()
self.message_broker.subscribe(MessageType.group, listener)
self.message_broker.subscribe(MessageType.preset, listener)
self.data_manager.rename_preset(new_name="new preset")
# we expect the first data to be group data and the second
# one a preset data
presets_in_group = [preset for preset in listener.calls[0].presets]
self.assertNotIn("preset2", presets_in_group)
self.assertIn("new preset", presets_in_group)
self.assertEqual(listener.calls[1].name, "new preset")
# this should pass without error:
self.data_manager.load_preset(name="new preset")
self.data_manager.rename_preset(new_name="new preset")
def test_rename_preset_sets_autoload_correct(self):
"""when renaming a preset the autoload status should still be set correctly"""
prepare_presets()
self.data_manager.load_group(group_key="Foo Device 2")
listener = Listener()
self.message_broker.subscribe(MessageType.preset, listener)
self.data_manager.load_preset(name="preset2") # sends PresetData
# sends PresetData with updated name, e. e. should be equal
self.data_manager.rename_preset(new_name="foo")
self.assertEqual(listener.calls[0].autoload, listener.calls[1].autoload)
def test_cannot_rename_preset(self):
"""rename preset should raise a DataManagementError if a preset
with the new name already exists in the current group"""
prepare_presets()
self.data_manager.load_group(group_key="Foo Device 2")
self.data_manager.load_preset(name="preset2")
self.assertRaises(
ValueError,
self.data_manager.rename_preset,
new_name="preset3",
)
def test_cannot_rename_preset_without_preset(self):
prepare_presets()
self.assertRaises(
DataManagementError,
self.data_manager.rename_preset,
new_name="foo",
)
self.data_manager.load_group(group_key="Foo Device 2")
self.assertRaises(
DataManagementError,
self.data_manager.rename_preset,
new_name="foo",
)
def test_add_preset(self):
"""should be able to add a preset"""
prepare_presets()
self.data_manager.load_group(group_key="Foo Device 2")
listener = Listener()
self.message_broker.subscribe(MessageType.group, listener)
# should emit group_changed
self.data_manager.create_preset(name="new preset")
presets_in_group = [preset for preset in listener.calls[0].presets]
self.assertIn("preset2", presets_in_group)
self.assertIn("preset3", presets_in_group)
self.assertIn("new preset", presets_in_group)
def test_cannot_add_preset(self):
"""adding a preset with the same name as an already existing
preset (of the current group) should raise a DataManagementError"""
prepare_presets()
self.data_manager.load_group(group_key="Foo Device 2")
self.data_manager.load_preset(name="preset2")
self.assertRaises(
DataManagementError,
self.data_manager.create_preset,
name="preset3",
)
def test_cannot_add_preset_without_group(self):
self.assertRaises(
DataManagementError,
self.data_manager.create_preset,
name="foo",
)
def test_delete_preset(self):
"""should be able to delete the current preset"""
prepare_presets()
self.data_manager.load_group(group_key="Foo Device 2")
self.data_manager.load_preset(name="preset2")
listener = Listener()
self.message_broker.subscribe(MessageType.group, listener)
self.message_broker.subscribe(MessageType.preset, listener)
self.message_broker.subscribe(MessageType.mapping, listener)
# should emit only group_changed
self.data_manager.delete_preset()
presets_in_group = [preset for preset in listener.calls[0].presets]
self.assertEqual(len(presets_in_group), 2)
self.assertNotIn("preset2", presets_in_group)
self.assertEqual(len(listener.calls), 1)
def test_delete_preset_sanitized(self):
"""should be able to delete the current preset"""
Preset(PathUtils.get_preset_path("Qux/[Device]?", "bla")).save()
Preset(PathUtils.get_preset_path("Qux/[Device]?", "foo")).save()
self.assertTrue(
os.path.exists(PathUtils.get_preset_path("Qux/[Device]?", "bla"))
)
self.data_manager.load_group(group_key="Qux/[Device]?")
self.data_manager.load_preset(name="bla")
listener = Listener()
self.message_broker.subscribe(MessageType.group, listener)
self.message_broker.subscribe(MessageType.preset, listener)
self.message_broker.subscribe(MessageType.mapping, listener)
# should emit only group_changed
self.data_manager.delete_preset()
presets_in_group = [preset for preset in listener.calls[0].presets]
self.assertEqual(len(presets_in_group), 1)
self.assertNotIn("bla", presets_in_group)
self.assertIn("foo", presets_in_group)
self.assertEqual(len(listener.calls), 1)
self.assertFalse(
os.path.exists(PathUtils.get_preset_path("Qux/[Device]?", "bla"))
)
def test_load_mapping(self):
"""should be able to load a mapping"""
preset, _, _ = prepare_presets()
expected_mapping = preset.get_mapping(
InputCombination([InputConfig(type=1, code=1)])
)
self.data_manager.load_group(group_key="Foo Device")
self.data_manager.load_preset(name="preset1")
listener = Listener()
self.message_broker.subscribe(MessageType.mapping, listener)
self.data_manager.load_mapping(
combination=InputCombination([InputConfig(type=1, code=1)])
)
mapping = listener.calls[0]
self.assertEqual(mapping, expected_mapping)
def test_cannot_load_non_existing_mapping(self):
"""loading a mapping tha is not present in the preset should raise a KeyError"""
prepare_presets()
self.data_manager.load_group(group_key="Foo Device 2")
self.data_manager.load_preset(name="preset2")
self.assertRaises(
KeyError,
self.data_manager.load_mapping,
combination=InputCombination([InputConfig(type=1, code=1)]),
)
def test_cannot_load_mapping_without_preset(self):
"""loading a mapping if no preset is loaded
should raise an DataManagementError"""
prepare_presets()
self.assertRaises(
DataManagementError,
self.data_manager.load_mapping,
combination=InputCombination([InputConfig(type=1, code=1)]),
)
self.data_manager.load_group("Foo Device")
self.assertRaises(
DataManagementError,
self.data_manager.load_mapping,
combination=InputCombination([InputConfig(type=1, code=1)]),
)
def test_load_event(self):
prepare_presets()
mock = MagicMock()
self.message_broker.subscribe(MessageType.selected_event, mock)
self.data_manager.load_group("Foo Device")
self.data_manager.load_preset("preset1")
self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=1)]))
self.data_manager.load_input_config(InputConfig(type=1, code=1))
mock.assert_called_once_with(InputConfig(type=1, code=1))
self.assertEqual(
self.data_manager.active_input_config, InputConfig(type=1, code=1)
)
def test_cannot_load_event_when_mapping_not_set(self):
prepare_presets()
self.data_manager.load_group("Foo Device")
self.data_manager.load_preset("preset1")
with self.assertRaises(DataManagementError):
self.data_manager.load_input_config(InputConfig(type=1, code=1))
def test_cannot_load_event_when_not_in_mapping_combination(self):
prepare_presets()
self.data_manager.load_group("Foo Device")
self.data_manager.load_preset("preset1")
self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=1)]))
with self.assertRaises(ValueError):
self.data_manager.load_input_config(InputConfig(type=1, code=5))
def test_update_event(self):
prepare_presets()
self.data_manager.load_group("Foo Device")
self.data_manager.load_preset("preset1")
self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=1)]))
self.data_manager.load_input_config(InputConfig(type=1, code=1))
self.data_manager.update_input_config(InputConfig(type=1, code=5))
self.assertEqual(
self.data_manager.active_input_config, InputConfig(type=1, code=5)
)
def test_update_event_sends_messages(self):
prepare_presets()
self.data_manager.load_group("Foo Device")
self.data_manager.load_preset("preset1")
self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=1)]))
self.data_manager.load_input_config(InputConfig(type=1, code=1))
mock = MagicMock()
self.message_broker.subscribe(MessageType.selected_event, mock)
self.message_broker.subscribe(MessageType.combination_update, mock)
self.message_broker.subscribe(MessageType.mapping, mock)
self.data_manager.update_input_config(InputConfig(type=1, code=5))
expected = [
call(
CombinationUpdate(
InputCombination([InputConfig(type=1, code=1)]),
InputCombination([InputConfig(type=1, code=5)]),
)
),
call(self.data_manager.active_mapping.get_bus_message()),
call(InputConfig(type=1, code=5)),
]
mock.assert_has_calls(expected, any_order=False)
def test_cannot_update_event_when_resulting_combination_exists(self):
prepare_presets()
self.data_manager.load_group("Foo Device")
self.data_manager.load_preset("preset1")
self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=1)]))
self.data_manager.load_input_config(InputConfig(type=1, code=1))
with self.assertRaises(KeyError):
self.data_manager.update_input_config(InputConfig(type=1, code=2))
def test_cannot_update_event_when_not_loaded(self):
prepare_presets()
self.data_manager.load_group("Foo Device")
self.data_manager.load_preset("preset1")
self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=1)]))
with self.assertRaises(DataManagementError):
self.data_manager.update_input_config(InputConfig(type=1, code=2))
def test_update_mapping_emits_mapping_changed(self):
"""update mapping should emit a mapping_changed event"""
prepare_presets()
self.data_manager.load_group(group_key="Foo Device 2")
self.data_manager.load_preset(name="preset2")
self.data_manager.load_mapping(
combination=InputCombination([InputConfig(type=1, code=4)])
)
listener = Listener()
self.message_broker.subscribe(MessageType.mapping, listener)
self.data_manager.update_mapping(
name="foo",
output_symbol="f",
release_timeout=0.3,
)
response = listener.calls[0]
self.assertEqual(response.name, "foo")
self.assertEqual(response.output_symbol, "f")
self.assertEqual(response.release_timeout, 0.3)
def test_updated_mapping_can_be_saved(self):
"""make sure that updated changes can be saved"""
prepare_presets()
self.data_manager.load_group(group_key="Foo Device 2")
self.data_manager.load_preset(name="preset2")
self.data_manager.load_mapping(
combination=InputCombination([InputConfig(type=1, code=4)])
)
self.data_manager.update_mapping(
name="foo",
output_symbol="f",
release_timeout=0.3,
)
self.data_manager.save()
preset = Preset(PathUtils.get_preset_path("Foo Device", "preset2"), UIMapping)
preset.load()
mapping = preset.get_mapping(InputCombination([InputConfig(type=1, code=4)]))
self.assertEqual(mapping.format_name(), "foo")
self.assertEqual(mapping.output_symbol, "f")
self.assertEqual(mapping.release_timeout, 0.3)
def test_updated_mapping_saves_invalid_mapping(self):
"""make sure that updated changes can be saved even if they are not valid"""
prepare_presets()
self.data_manager.load_group(group_key="Foo Device 2")
self.data_manager.load_preset(name="preset2")
self.data_manager.load_mapping(
combination=InputCombination([InputConfig(type=1, code=4)])
)
self.data_manager.update_mapping(
output_symbol="bar", # not a macro and not a valid symbol
)
self.data_manager.save()
preset = Preset(PathUtils.get_preset_path("Foo Device", "preset2"), UIMapping)
preset.load()
mapping = preset.get_mapping(InputCombination([InputConfig(type=1, code=4)]))
self.assertIsNotNone(mapping.get_error())
self.assertEqual(mapping.output_symbol, "bar")
def test_update_mapping_combination_sends_massage(self):
prepare_presets()
self.data_manager.load_group(group_key="Foo Device 2")
self.data_manager.load_preset(name="preset2")
self.data_manager.load_mapping(
combination=InputCombination([InputConfig(type=1, code=4)])
)
listener = Listener()
self.message_broker.subscribe(MessageType.mapping, listener)
self.message_broker.subscribe(MessageType.combination_update, listener)
# we expect a message for combination update first, and then for mapping
self.data_manager.update_mapping(
input_combination=InputCombination(
InputCombination.from_tuples((1, 5), (1, 6))
)
)
self.assertEqual(listener.calls[0].message_type, MessageType.combination_update)
self.assertEqual(
listener.calls[0].old_combination,
InputCombination([InputConfig(type=1, code=4)]),
)
self.assertEqual(
listener.calls[0].new_combination,
InputCombination(InputCombination.from_tuples((1, 5), (1, 6))),
)
self.assertEqual(listener.calls[1].message_type, MessageType.mapping)
self.assertEqual(
listener.calls[1].input_combination,
InputCombination(InputCombination.from_tuples((1, 5), (1, 6))),
)
def test_cannot_update_mapping_combination(self):
"""updating a mapping with an already existing combination
should raise a KeyError"""
prepare_presets()
self.data_manager.load_group(group_key="Foo Device 2")
self.data_manager.load_preset(name="preset2")
self.data_manager.load_mapping(
combination=InputCombination([InputConfig(type=1, code=4)])
)
self.assertRaises(
KeyError,
self.data_manager.update_mapping,
input_combination=InputCombination([InputConfig(type=1, code=3)]),
)
def test_cannot_update_mapping(self):
"""updating a mapping should not be possible if the mapping was not loaded"""
prepare_presets()
self.assertRaises(
DataManagementError,
self.data_manager.update_mapping,
name="foo",
)
self.data_manager.load_group(group_key="Foo Device 2")
self.assertRaises(
DataManagementError,
self.data_manager.update_mapping,
name="foo",
)
self.data_manager.load_preset("preset2")
self.assertRaises(
DataManagementError,
self.data_manager.update_mapping,
name="foo",
)
def test_create_mapping(self):
"""should be able to add a mapping to the current preset"""
prepare_presets()
self.data_manager.load_group(group_key="Foo Device 2")
self.data_manager.load_preset(name="preset2")
listener = Listener()
self.message_broker.subscribe(MessageType.mapping, listener)
self.message_broker.subscribe(MessageType.preset, listener)
self.data_manager.create_mapping() # emits preset_changed
self.data_manager.load_mapping(combination=InputCombination.empty_combination())
self.assertEqual(listener.calls[0].name, "preset2")
self.assertEqual(len(listener.calls[0].mappings), 3)
self.assertEqual(listener.calls[1], UIMapping())
def test_cannot_create_mapping_without_preset(self):
"""adding a mapping if not preset is loaded
should raise an DataManagementError"""
prepare_presets()
self.assertRaises(DataManagementError, self.data_manager.create_mapping)
self.data_manager.load_group(group_key="Foo Device 2")
self.assertRaises(DataManagementError, self.data_manager.create_mapping)
def test_delete_mapping(self):
"""should be able to delete a mapping"""
prepare_presets()
old_preset = Preset(PathUtils.get_preset_path("Foo Device", "preset2"))
old_preset.load()
self.data_manager.load_group(group_key="Foo Device 2")
self.data_manager.load_preset(name="preset2")
self.data_manager.load_mapping(
combination=InputCombination([InputConfig(type=1, code=3)])
)
listener = Listener()
self.message_broker.subscribe(MessageType.preset, listener)
self.message_broker.subscribe(MessageType.mapping, listener)
self.data_manager.delete_mapping() # emits preset
self.data_manager.save()
deleted_mapping = old_preset.get_mapping(
InputCombination([InputConfig(type=1, code=3)])
)
mappings = listener.calls[0].mappings
preset_name = listener.calls[0].name
expected_preset = Preset(PathUtils.get_preset_path("Foo Device", "preset2"))
expected_preset.load()
expected_mappings = list(expected_preset)
self.assertEqual(preset_name, "preset2")
for mapping in expected_mappings:
self.assertIn(mapping, mappings)
self.assertNotIn(deleted_mapping, mappings)
def test_cannot_delete_mapping(self):
"""deleting a mapping should not be possible if the mapping was not loaded"""
prepare_presets()
self.assertRaises(DataManagementError, self.data_manager.delete_mapping)
self.data_manager.load_group(group_key="Foo Device 2")
self.assertRaises(DataManagementError, self.data_manager.delete_mapping)
self.data_manager.load_preset(name="preset2")
self.assertRaises(DataManagementError, self.data_manager.delete_mapping)
def test_set_autoload(self):
"""should be able to set the autoload status"""
prepare_presets()
self.data_manager.load_group(group_key="Foo Device")
listener = Listener()
self.message_broker.subscribe(MessageType.preset, listener)
self.data_manager.load_preset(name="preset1") # sends updated preset data
self.data_manager.set_autoload(True) # sends updated preset data
self.data_manager.set_autoload(False) # sends updated preset data
self.assertFalse(listener.calls[0].autoload)
self.assertTrue(listener.calls[1].autoload)
self.assertFalse(listener.calls[2].autoload)
def test_each_device_can_have_autoload(self):
prepare_presets()
self.data_manager.load_group("Foo Device 2")
self.data_manager.load_preset("preset1")
self.data_manager.set_autoload(True)
# switch to another device
self.data_manager.load_group("Foo Device")
self.data_manager.load_preset("preset1")
self.data_manager.set_autoload(True)
# now check that both are set to autoload
self.data_manager.load_group("Foo Device 2")
self.data_manager.load_preset("preset1")
self.assertTrue(self.data_manager.get_autoload())
self.data_manager.load_group("Foo Device")
self.data_manager.load_preset("preset1")
self.assertTrue(self.data_manager.get_autoload())
def test_cannot_set_autoload_without_preset(self):
prepare_presets()
self.assertRaises(
DataManagementError,
self.data_manager.set_autoload,
True,
)
self.data_manager.load_group(group_key="Foo Device 2")
self.assertRaises(
DataManagementError,
self.data_manager.set_autoload,
True,
)
def test_finds_newest_group(self):
Preset(PathUtils.get_preset_path("Foo Device", "preset 1")).save()
time.sleep(0.01)
Preset(PathUtils.get_preset_path("Bar Device", "preset 2")).save()
self.assertEqual(self.data_manager.get_newest_group_key(), "Bar Device")
def test_finds_newest_preset(self):
Preset(PathUtils.get_preset_path("Foo Device", "preset 1")).save()
time.sleep(0.01)
Preset(PathUtils.get_preset_path("Foo Device", "preset 2")).save()
self.data_manager.load_group("Foo Device")
self.assertEqual(self.data_manager.get_newest_preset_name(), "preset 2")
def test_newest_group_ignores_unknown_filetypes(self):
Preset(PathUtils.get_preset_path("Foo Device", "preset 1")).save()
time.sleep(0.01)
Preset(PathUtils.get_preset_path("Bar Device", "preset 2")).save()
# not a preset, ignore
time.sleep(0.01)
path = os.path.join(PathUtils.get_preset_path("Foo Device"), "picture.png")
os.mknod(path)
self.assertEqual(self.data_manager.get_newest_group_key(), "Bar Device")
def test_newest_preset_ignores_unknown_filetypes(self):
Preset(PathUtils.get_preset_path("Bar Device", "preset 1")).save()
time.sleep(0.01)
Preset(PathUtils.get_preset_path("Bar Device", "preset 2")).save()
time.sleep(0.01)
Preset(PathUtils.get_preset_path("Bar Device", "preset 3")).save()
# not a preset, ignore
time.sleep(0.01)
path = os.path.join(PathUtils.get_preset_path("Bar Device"), "picture.png")
os.mknod(path)
self.data_manager.load_group("Bar Device")
self.assertEqual(self.data_manager.get_newest_preset_name(), "preset 3")
def test_newest_group_ignores_unknown_groups(self):
Preset(PathUtils.get_preset_path("Bar Device", "preset 1")).save()
time.sleep(0.01)
# not a known group
Preset(PathUtils.get_preset_path("unknown_group", "preset 2")).save()
self.assertEqual(self.data_manager.get_newest_group_key(), "Bar Device")
def test_newest_group_and_preset_raises_file_not_found(self):
"""should raise file not found error when all preset folders are empty"""
self.assertRaises(FileNotFoundError, self.data_manager.get_newest_group_key)
os.makedirs(PathUtils.get_preset_path("Bar Device"))
self.assertRaises(FileNotFoundError, self.data_manager.get_newest_group_key)
self.data_manager.load_group("Bar Device")
self.assertRaises(FileNotFoundError, self.data_manager.get_newest_preset_name)
def test_newest_preset_raises_data_management_error(self):
"""should raise data management error without an active group"""
self.assertRaises(DataManagementError, self.data_manager.get_newest_preset_name)
def test_newest_preset_only_searches_active_group(self):
Preset(PathUtils.get_preset_path("Foo Device", "preset 1")).save()
time.sleep(0.01)
Preset(PathUtils.get_preset_path("Foo Device", "preset 3")).save()
time.sleep(0.01)
Preset(PathUtils.get_preset_path("Bar Device", "preset 2")).save()
self.data_manager.load_group("Foo Device")
self.assertEqual(self.data_manager.get_newest_preset_name(), "preset 3")
def test_available_preset_name_default(self):
self.data_manager.load_group("Foo Device")
self.assertEqual(
self.data_manager.get_available_preset_name(), DEFAULT_PRESET_NAME
)
def test_available_preset_name_adds_number_to_default(self):
Preset(PathUtils.get_preset_path("Foo Device", DEFAULT_PRESET_NAME)).save()
self.data_manager.load_group("Foo Device")
self.assertEqual(
self.data_manager.get_available_preset_name(), f"{DEFAULT_PRESET_NAME} 2"
)
def test_available_preset_name_returns_provided_name(self):
self.data_manager.load_group("Foo Device")
self.assertEqual(self.data_manager.get_available_preset_name("bar"), "bar")
def test_available_preset_name__adds_number_to_provided_name(self):
Preset(PathUtils.get_preset_path("Foo Device", "bar")).save()
self.data_manager.load_group("Foo Device")
self.assertEqual(self.data_manager.get_available_preset_name("bar"), "bar 2")
def test_available_preset_name_raises_data_management_error(self):
"""should raise DataManagementError when group is not set"""
self.assertRaises(
DataManagementError, self.data_manager.get_available_preset_name
)
def test_get_preset_names(self):
self.data_manager.load_group("Qux/[Device]?")
Preset(PathUtils.get_preset_path("Qux/[Device]?", "new preset")).save()
# get_preset_names uses glob, the special characters in the device name
# don't break it.
self.assertEqual(self.data_manager.get_preset_names(), ("new preset",))
def test_available_preset_name_sanitized(self):
self.data_manager.load_group("Qux/[Device]?")
self.assertEqual(
self.data_manager.get_available_preset_name(), DEFAULT_PRESET_NAME
)
Preset(PathUtils.get_preset_path("Qux/[Device]?", DEFAULT_PRESET_NAME)).save()
self.assertEqual(
self.data_manager.get_available_preset_name(), f"{DEFAULT_PRESET_NAME} 2"
)
Preset(PathUtils.get_preset_path("Qux/[Device]?", "foo")).save()
self.assertEqual(self.data_manager.get_available_preset_name("foo"), "foo 2")
def test_available_preset_name_increments_default(self):
Preset(PathUtils.get_preset_path("Foo Device", DEFAULT_PRESET_NAME)).save()
Preset(
PathUtils.get_preset_path("Foo Device", f"{DEFAULT_PRESET_NAME} 2")
).save()
Preset(
PathUtils.get_preset_path("Foo Device", f"{DEFAULT_PRESET_NAME} 3")
).save()
self.data_manager.load_group("Foo Device")
self.assertEqual(
self.data_manager.get_available_preset_name(), f"{DEFAULT_PRESET_NAME} 4"
)
def test_available_preset_name_increments_provided_name(self):
Preset(PathUtils.get_preset_path("Foo Device", "foo")).save()
Preset(PathUtils.get_preset_path("Foo Device", "foo 1")).save()
Preset(PathUtils.get_preset_path("Foo Device", "foo 2")).save()
self.data_manager.load_group("Foo Device")
self.assertEqual(self.data_manager.get_available_preset_name("foo 1"), "foo 3")
def test_should_publish_groups(self):
listener = Listener()
self.message_broker.subscribe(MessageType.groups, listener)
self.data_manager.publish_groups()
data = listener.calls[0]
# we expect a list of tuples with the group key and their device types
self.assertEqual(
data.groups,
{
"Foo Device": ["keyboard"],
"Foo Device 2": ["gamepad", "keyboard", "mouse"],
"Bar Device": ["keyboard"],
"gamepad": ["gamepad"],
"Qux/[Device]?": ["keyboard"],
},
)
def test_should_load_group(self):
prepare_presets()
listener = Listener()
self.message_broker.subscribe(MessageType.group, listener)
self.data_manager.load_group("Foo Device 2")
self.assertEqual(self.data_manager.active_group.key, "Foo Device 2")
data = (
GroupData("Foo Device 2", (p1, p2, p3))
for p1, p2, p3 in permutations(("preset3", "preset2", "preset1"))
)
self.assertIn(listener.calls[0], data)
def test_should_start_reading_active_group(self):
def f(*_):
raise AssertionError()
self.reader.set_group = f
self.assertRaises(AssertionError, self.data_manager.load_group, "Foo Device")
def test_should_send_uinputs(self):
listener = Listener()
self.message_broker.subscribe(MessageType.uinputs, listener)
self.data_manager.publish_uinputs()
data = listener.calls[0]
# we expect a list of tuples with the group key and their device types
self.assertEqual(
data.uinputs,
{
"gamepad": self.uinputs.get_uinput("gamepad").capabilities(),
"keyboard": self.uinputs.get_uinput("keyboard").capabilities(),
"mouse": self.uinputs.get_uinput("mouse").capabilities(),
"keyboard + mouse": self.uinputs.get_uinput(
"keyboard + mouse"
).capabilities(),
},
)
def test_cannot_stop_injecting_without_group(self):
self.assertRaises(DataManagementError, self.data_manager.stop_injecting)
def test_cannot_start_injecting_without_preset(self):
self.data_manager.load_group("Foo Device")
self.assertRaises(DataManagementError, self.data_manager.start_injecting)
def test_cannot_get_injector_state_without_group(self):
self.assertRaises(DataManagementError, self.data_manager.get_state)

View File

@ -0,0 +1,86 @@
#!/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 unittest
from typing import Iterable
import evdev
from inputremapper.configs.preset import Preset
from inputremapper.injection.context import Context
from inputremapper.injection.event_reader import EventReader
from inputremapper.injection.global_uinputs import GlobalUInputs, UInput
from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser
from inputremapper.input_event import InputEvent
from tests.lib.cleanup import cleanup
from tests.lib.logger import logger
from tests.lib.fixtures import Fixture
class EventPipelineTestBase(unittest.IsolatedAsyncioTestCase):
"""Test the event pipeline form event_reader to UInput."""
def setUp(self):
self.global_uinputs = GlobalUInputs(UInput)
self.global_uinputs.prepare_all()
self.mapping_parser = MappingParser(self.global_uinputs)
self.global_uinputs.is_service = True
self.global_uinputs.prepare_all()
self.forward_uinput = evdev.UInput(name="test-forward-uinput")
self.stop_event = asyncio.Event()
def tearDown(self) -> None:
cleanup()
async def asyncTearDown(self) -> None:
logger.info("setting stop_event for the reader")
self.stop_event.set()
await asyncio.sleep(0.5)
@staticmethod
async def send_events(events: Iterable[InputEvent], event_reader: EventReader):
for event in events:
logger.info("sending into event_pipeline: %s", event)
await event_reader.handle(event)
def create_event_reader(
self,
preset: Preset,
source: Fixture,
) -> EventReader:
"""Create and start an EventReader."""
context = Context(
preset,
source_devices={},
forward_devices={source.get_device_hash(): self.forward_uinput},
mapping_parser=self.mapping_parser,
)
reader = EventReader(
context,
evdev.InputDevice(source.path),
self.stop_event,
)
asyncio.ensure_future(reader.run())
return reader
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,145 @@
#!/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 unittest
from evdev.ecodes import (
EV_ABS,
ABS_X,
ABS_Y,
)
from inputremapper.configs.mapping import (
Mapping,
)
from inputremapper.configs.preset import Preset
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.input_event import InputEvent
from tests.lib.fixtures import fixtures
from tests.lib.test_setup import test_setup
from tests.unit.test_event_pipeline.event_pipeline_test_base import (
EventPipelineTestBase,
)
@test_setup
class TestAbsToAbs(EventPipelineTestBase):
async def test_abs_to_abs(self):
gain = 0.5
# left x to mouse x
input_config = InputConfig(type=EV_ABS, code=ABS_X)
mapping_config = {
"input_combination": InputCombination([input_config]).to_config(),
"target_uinput": "gamepad",
"output_type": EV_ABS,
"output_code": ABS_X,
"gain": gain,
"deadzone": 0,
}
mapping_1 = Mapping(**mapping_config)
preset = Preset()
preset.add(mapping_1)
input_config = InputConfig(type=EV_ABS, code=ABS_Y)
mapping_config["input_combination"] = InputCombination(
[input_config]
).to_config()
mapping_config["output_code"] = ABS_Y
mapping_2 = Mapping(**mapping_config)
preset.add(mapping_2)
x = fixtures.gamepad.max_abs
y = fixtures.gamepad.max_abs
event_reader = self.create_event_reader(preset, fixtures.gamepad)
await self.send_events(
[
InputEvent.abs(ABS_X, -x),
InputEvent.abs(ABS_Y, y),
],
event_reader,
)
await asyncio.sleep(0.2)
history = self.global_uinputs.get_uinput("gamepad").write_history
self.assertEqual(
history,
[
InputEvent.from_tuple((3, 0, fixtures.gamepad.min_abs / 2)),
InputEvent.from_tuple((3, 1, fixtures.gamepad.max_abs / 2)),
],
)
async def test_abs_to_abs_with_input_switch(self):
gain = 0.5
input_combination = InputCombination(
(
InputConfig(type=EV_ABS, code=0),
InputConfig(type=EV_ABS, code=1, analog_threshold=10),
)
)
# left x to mouse x
mapping_config = {
"input_combination": input_combination.to_config(),
"target_uinput": "gamepad",
"output_type": EV_ABS,
"output_code": ABS_X,
"gain": gain,
"deadzone": 0,
}
mapping_1 = Mapping(**mapping_config)
preset = Preset()
preset.add(mapping_1)
x = fixtures.gamepad.max_abs
y = fixtures.gamepad.max_abs
event_reader = self.create_event_reader(preset, fixtures.gamepad)
await self.send_events(
[
InputEvent.abs(ABS_X, -x // 5), # will not map
InputEvent.abs(ABS_X, -x), # will map later
# switch axis on sends initial position (previous event)
InputEvent.abs(ABS_Y, y),
InputEvent.abs(ABS_X, x), # normally mapped
InputEvent.abs(ABS_Y, y // 15), # off, re-centers axis
InputEvent.abs(ABS_X, -x // 5), # will not map
],
event_reader,
)
await asyncio.sleep(0.2)
history = self.global_uinputs.get_uinput("gamepad").write_history
self.assertEqual(
history,
[
InputEvent.from_tuple((3, 0, fixtures.gamepad.min_abs / 2)),
InputEvent.from_tuple((3, 0, fixtures.gamepad.max_abs / 2)),
InputEvent.from_tuple((3, 0, 0)),
],
)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,225 @@
#!/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
from evdev.ecodes import EV_KEY, EV_ABS, ABS_X, ABS_Z
from inputremapper.configs.mapping import (
Mapping,
)
from inputremapper.configs.preset import Preset
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.input_event import InputEvent
from tests.lib.logger import logger
from tests.lib.fixtures import fixtures
from tests.lib.test_setup import test_setup
from tests.unit.test_event_pipeline.event_pipeline_test_base import (
EventPipelineTestBase,
)
@test_setup
class TestAbsToBtn(EventPipelineTestBase):
async def test_abs_trigger_threshold_simple(self):
# at 30% map to a
mapping_1 = Mapping.from_combination(
InputCombination(
[InputConfig(type=EV_ABS, code=ABS_X, analog_threshold=30)]
),
output_symbol="a",
)
preset = Preset()
preset.add(mapping_1)
a_code = keyboard_layout.get("a")
event_reader = self.create_event_reader(preset, fixtures.gamepad)
# 50%, trigger a
await self.send_events(
[InputEvent.abs(ABS_X, fixtures.gamepad.max_abs // 2)],
event_reader,
)
keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history
self.assertEqual(len(keyboard_history), 1)
self.assertEqual(keyboard_history[0], (EV_KEY, a_code, 1))
self.assertNotIn((EV_KEY, a_code, 0), keyboard_history)
async def test_abs_z(self):
# Shoulder triggers (ABS_Z, ABS_RZ, ABS_GAS, ABS_BRAKE). Their center point
# is equal to the fully released point. They only have one direction.
# Triggers go from 0 to whatever value, so use the appropriate fixture.
# Yes this test setup is stupid, it should have different absinfos depending
# on the event.
fixture = fixtures.gamepad_abs_0_to_256
# at 30% map to a
mapping_1 = Mapping.from_combination(
InputCombination(
[InputConfig(type=EV_ABS, code=ABS_Z, analog_threshold=30)]
),
output_symbol="a",
)
# This mapping is impossible. There is no negative direction.
mapping_2 = Mapping.from_combination(
InputCombination(
[InputConfig(type=EV_ABS, code=ABS_Z, analog_threshold=-30)]
),
output_symbol="b",
)
preset = Preset()
preset.add(mapping_1)
preset.add(mapping_2)
a_code = keyboard_layout.get("a")
b_code = keyboard_layout.get("b")
event_reader = self.create_event_reader(preset, fixture)
max_abs = fixture.max_abs
# 50%, trigger a
await self.send_events(
[InputEvent.abs(ABS_Z, max_abs // 2)],
event_reader,
)
keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history
self.assertEqual(keyboard_history, [(EV_KEY, a_code, 1)])
# Lets slowly move it all the way into the other direction
assert fixture.min_abs == 0 # just for clarification here
await self.send_events(
[
InputEvent.abs(ABS_Z, max_abs // 3),
InputEvent.abs(ABS_Z, max_abs // 5),
InputEvent.abs(ABS_Z, max_abs // 10),
InputEvent.abs(ABS_Z, 0),
],
event_reader,
)
# The negative mapping (mapping_2, to b) was not triggered. It just released
# the a.
self.assertEqual(
keyboard_history,
[
(EV_KEY, a_code, 1),
(EV_KEY, a_code, 0),
],
)
async def test_abs_trigger_threshold(self):
"""Test that different activation points for abs_to_btn work correctly."""
forwarded_history = self.forward_uinput.write_history
# at 30% map to a
mapping_1 = Mapping.from_combination(
InputCombination(
[InputConfig(type=EV_ABS, code=ABS_X, analog_threshold=30)]
),
output_symbol="a",
)
# at 70% map to b
mapping_2 = Mapping.from_combination(
InputCombination(
[InputConfig(type=EV_ABS, code=ABS_X, analog_threshold=70)]
),
output_symbol="b",
)
preset = Preset()
preset.add(mapping_1)
preset.add(mapping_2)
a = keyboard_layout.get("a")
b = keyboard_layout.get("b")
event_reader = self.create_event_reader(preset, fixtures.gamepad)
logger.info("do nothing, then trigger a")
await self.send_events(
[
# -10%, do nothing
InputEvent.abs(ABS_X, fixtures.gamepad.min_abs // 10),
# 0%, do noting
InputEvent.abs(ABS_X, 0),
# 10%, do nothing
InputEvent.abs(ABS_X, fixtures.gamepad.max_abs // 10),
# 50%, trigger a
InputEvent.abs(ABS_X, fixtures.gamepad.max_abs // 2),
],
event_reader,
)
keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history
self.assertEqual(keyboard_history.count((EV_KEY, a, 1)), 1)
self.assertNotIn((EV_KEY, a, 0), keyboard_history)
self.assertNotIn((EV_KEY, b, 1), keyboard_history)
# the negative movements are not mapped, so the one event at -10% and its
# release should be forwarded instead
self.assertEqual(
forwarded_history,
[
(EV_ABS, ABS_X, fixtures.gamepad.min_abs // 10),
(EV_ABS, ABS_X, 0),
],
)
logger.info("trigger b, then release b")
await self.send_events(
[
# 80%, trigger b
InputEvent.abs(ABS_X, int(fixtures.gamepad.max_abs * 0.8)),
# 50%, release b
InputEvent.abs(ABS_X, fixtures.gamepad.max_abs // 2),
],
event_reader,
)
keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history
self.assertEqual(
keyboard_history,
[
(EV_KEY, a, 1),
(EV_KEY, b, 1),
(EV_KEY, b, 0),
],
)
# 0% release a
await event_reader.handle(InputEvent.abs(ABS_X, 0))
keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history
self.assertEqual(
keyboard_history,
[
(EV_KEY, a, 1),
(EV_KEY, b, 1),
(EV_KEY, b, 0),
(EV_KEY, a, 0),
],
)
# This didn't change. ABS_X of 0 should not be forwarded, because the joystick
# came from the mapped direction to 0. Instead, it maps to release a.
self.assertEqual(len(forwarded_history), 2)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,202 @@
#!/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 unittest
from evdev.ecodes import (
EV_ABS,
EV_REL,
ABS_X,
ABS_Y,
REL_X,
REL_Y,
REL_HWHEEL,
REL_WHEEL,
REL_WHEEL_HI_RES,
REL_HWHEEL_HI_RES,
)
from inputremapper.configs.mapping import (
Mapping,
REL_XY_SCALING,
DEFAULT_REL_RATE,
)
from inputremapper.configs.preset import Preset
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.input_event import InputEvent
from tests.lib.fixtures import fixtures
from tests.lib.test_setup import test_setup
from tests.unit.test_event_pipeline.event_pipeline_test_base import (
EventPipelineTestBase,
)
@test_setup
class TestAbsToRel(EventPipelineTestBase):
async def test_abs_to_rel(self):
"""Map gamepad EV_ABS events to EV_REL events."""
rel_rate = 60 # rate [Hz] at which events are produced
gain = 0.5 # halve the speed of the rel axis
# left x to mouse x
input_config = InputConfig(type=EV_ABS, code=ABS_X)
mapping_config = {
"input_combination": InputCombination([input_config]).to_config(),
"target_uinput": "mouse",
"output_type": EV_REL,
"output_code": REL_X,
"rel_rate": rel_rate,
"gain": gain,
"deadzone": 0,
}
mapping_1 = Mapping(**mapping_config)
preset = Preset()
preset.add(mapping_1)
# left y to mouse y
input_config = InputConfig(type=EV_ABS, code=ABS_Y)
mapping_config["input_combination"] = InputCombination(
[input_config]
).to_config()
mapping_config["output_code"] = REL_Y
mapping_2 = Mapping(**mapping_config)
preset.add(mapping_2)
# set input axis to 100% in order to move
# (gain * REL_XY_SCALING) pixel per event
x = fixtures.gamepad.max_abs
y = fixtures.gamepad.max_abs
event_reader = self.create_event_reader(preset, fixtures.gamepad)
await self.send_events(
[
InputEvent.abs(ABS_X, -x),
InputEvent.abs(ABS_Y, -y),
],
event_reader,
)
# wait a bit more for it to sum up
sleep = 0.5
await asyncio.sleep(sleep)
# stop it
await self.send_events(
[
InputEvent.abs(ABS_X, 0),
InputEvent.abs(ABS_Y, 0),
],
event_reader,
)
mouse_history = self.global_uinputs.get_uinput("mouse").write_history
if mouse_history[0].type == EV_ABS:
raise AssertionError(
"The injector probably just forwarded them unchanged"
# possibly in addition to writing mouse events
)
# This varies quite a lot depending on the machines performance.
# Face it, python is a bad choice for this.
self.assertAlmostEqual(len(mouse_history), rel_rate * sleep * 2, delta=10)
# those may be in arbitrary order
expected_value = -gain * REL_XY_SCALING * (rel_rate / DEFAULT_REL_RATE)
count_x = mouse_history.count((EV_REL, REL_X, expected_value))
count_y = mouse_history.count((EV_REL, REL_Y, expected_value))
self.assertGreater(count_x, 1)
self.assertGreater(count_y, 1)
# only those two types of events were written
self.assertEqual(len(mouse_history), count_x + count_y)
async def test_abs_to_wheel_hi_res_quirk(self):
"""When mapping to wheel events we always expect to see both,
REL_WHEEL and REL_WHEEL_HI_RES events with an accumulative value ratio of 1/120
"""
rel_rate = 60 # rate [Hz] at which events are produced
gain = 1
# left x to mouse x
input_config = InputConfig(type=EV_ABS, code=ABS_X)
mapping_config = {
"input_combination": InputCombination([input_config]).to_config(),
"target_uinput": "mouse",
"output_type": EV_REL,
"output_code": REL_WHEEL,
"rel_rate": rel_rate,
"gain": gain,
"deadzone": 0,
}
mapping_1 = Mapping(**mapping_config)
preset = Preset()
preset.add(mapping_1)
# left y to mouse y
input_config = InputConfig(type=EV_ABS, code=ABS_Y)
mapping_config["input_combination"] = InputCombination(
[input_config]
).to_config()
mapping_config["output_code"] = REL_HWHEEL_HI_RES
mapping_2 = Mapping(**mapping_config)
preset.add(mapping_2)
# set input axis to 100% in order to move
# speed*gain*rate=1*0.5*60 pixel per second
x = fixtures.gamepad.max_abs
y = fixtures.gamepad.max_abs
event_reader = self.create_event_reader(preset, fixtures.gamepad)
await self.send_events(
[
InputEvent.abs(ABS_X, x),
InputEvent.abs(ABS_Y, -y),
],
event_reader,
)
# wait a bit more for it to sum up
sleep = 0.8
await asyncio.sleep(sleep)
# stop it
await self.send_events(
[
InputEvent.abs(ABS_X, 0),
InputEvent.abs(ABS_Y, 0),
],
event_reader,
)
m_history = self.global_uinputs.get_uinput("mouse").write_history
rel_wheel = sum([event.value for event in m_history if event.code == REL_WHEEL])
rel_wheel_hi_res = sum(
[event.value for event in m_history if event.code == REL_WHEEL_HI_RES]
)
rel_hwheel = sum(
[event.value for event in m_history if event.code == REL_HWHEEL]
)
rel_hwheel_hi_res = sum(
[event.value for event in m_history if event.code == REL_HWHEEL_HI_RES]
)
self.assertAlmostEqual(rel_wheel, rel_wheel_hi_res / 120, places=0)
self.assertAlmostEqual(rel_hwheel, rel_hwheel_hi_res / 120, places=0)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,209 @@
#!/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 dataclasses
import functools
import itertools
import unittest
from typing import Iterable, List
from inputremapper.injection.mapping_handlers.axis_transform import Transformation
from tests.lib.test_setup import test_setup
@test_setup
class TestAxisTransformation(unittest.TestCase):
@dataclasses.dataclass
class InitArgs:
max_: int
min_: int
deadzone: float
gain: float
expo: float
def values(self):
return self.__dict__.values()
def get_init_args(
self,
max_=(255, 1000, 2**15),
min_=(50, 0, -255),
deadzone=(0, 0.5),
gain=(0.5, 1, 2),
expo=(-0.9, 0, 0.3),
) -> Iterable[InitArgs]:
for args in itertools.product(max_, min_, deadzone, gain, expo):
yield self.InitArgs(*args)
@staticmethod
def scale_to_range(min_, max_, x=(-1, -0.2, 0, 0.6, 1)) -> List[float]:
"""Scale values between -1 and 1 up, such that they are between min and max."""
half_range = (max_ - min_) / 2
return [float_x * half_range + min_ + half_range for float_x in x]
def test_scale_to_range(self):
"""Make sure scale_to_range will actually return the min and max values
(avoid "off by one" errors)"""
max_ = (255, 1000, 2**15)
min_ = (50, 0, -255)
for x1, x2 in itertools.product(min_, max_):
scaled = self.scale_to_range(x1, x2, (-1, 1))
self.assertEqual(scaled, [x1, x2])
def test_expo_symmetry(self):
"""Test that the transformation is symmetric for expo parameter
x = f(g(x)), if f._expo == - g._expo
with the following constraints:
min = -1, max = 1
gain = 1
deadzone = 0
we can remove the constraints for min, max and gain,
by scaling the values appropriately after each transformation
"""
for init_args in self.get_init_args(deadzone=(0,)):
f = Transformation(*init_args.values())
init_args.expo = -init_args.expo
g = Transformation(*init_args.values())
scale = functools.partial(
self.scale_to_range,
init_args.min_,
init_args.max_,
)
for x in scale():
y1 = g(x)
y1 = y1 / init_args.gain # remove the gain
y1 = scale((y1,))[0] # remove the min/max constraint
y2 = f(y1)
y2 = y2 / init_args.gain # remove the gain
y2 = scale((y2,))[0] # remove the min/max constraint
self.assertAlmostEqual(x, y2, msg=f"test expo symmetry for {init_args}")
def test_origin_symmetry(self):
"""Test that the transformation is symmetric to the origin_hash
f(x) = - f(-x)
within the constraints: min = -max
"""
for init_args in self.get_init_args():
init_args.min_ = -init_args.max_
f = Transformation(*init_args.values())
for x in self.scale_to_range(init_args.min_, init_args.max_):
self.assertAlmostEqual(
f(x),
-f(-x),
msg=f"test origin_hash symmetry at {x=} for {init_args}",
)
def test_gain(self):
"""Test that f(max) = gain and f(min) = -gain."""
for init_args in self.get_init_args():
f = Transformation(*init_args.values())
self.assertAlmostEqual(
f(init_args.max_),
init_args.gain,
msg=f"test gain for {init_args}",
)
self.assertAlmostEqual(
f(init_args.min_),
-init_args.gain,
msg=f"test gain for {init_args}",
)
def test_deadzone(self):
"""Test the Transfomation returns exactly 0 in the range of the deadzone."""
for init_args in self.get_init_args(deadzone=(0.1, 0.2, 0.9)):
f = Transformation(*init_args.values())
for x in self.scale_to_range(
init_args.min_,
init_args.max_,
x=(
init_args.deadzone * 0.999,
-init_args.deadzone * 0.999,
0.3 * init_args.deadzone,
0,
),
):
self.assertEqual(f(x), 0, msg=f"test deadzone at {x=} for {init_args}")
def test_continuity_near_deadzone(self):
"""Test that the Transfomation is continues (no sudden jump) next to the
deadzone"""
for init_args in self.get_init_args(deadzone=(0.1, 0.2, 0.9)):
f = Transformation(*init_args.values())
scale = functools.partial(
self.scale_to_range,
init_args.min_,
init_args.max_,
)
x = (
init_args.deadzone * 1.00001,
init_args.deadzone * 1.001,
-init_args.deadzone * 1.00001,
-init_args.deadzone * 1.001,
)
scaled_x = scale(x=x)
p1 = (x[0], f(scaled_x[0])) # first point right of deadzone
p2 = (x[1], f(scaled_x[1])) # second point right of deadzone
# calculate a linear function y = m * x + b from p1 and p2
m = (p1[1] - p2[1]) / (p1[0] - p2[0])
b = p1[1] - m * p1[0]
# the zero intersection of that function must be close to the
# edge of the deadzone
self.assertAlmostEqual(
-b / m,
init_args.deadzone,
places=5,
msg=f"test continuity at {init_args.deadzone} for {init_args}",
)
# same thing on the other side
p1 = (x[2], f(scaled_x[2]))
p2 = (x[3], f(scaled_x[3]))
m = (p1[1] - p2[1]) / (p1[0] - p2[0])
b = p1[1] - m * p1[0]
self.assertAlmostEqual(
-b / m,
-init_args.deadzone,
places=5,
msg=f"test continuity at {- init_args.deadzone} for {init_args}",
)
def test_expo_out_of_range(self):
f = Transformation(deadzone=0.1, min_=-20, max_=5, expo=1.3)
self.assertRaises(ValueError, f, 0)
f = Transformation(deadzone=0.1, min_=-20, max_=5, expo=-1.3)
self.assertRaises(ValueError, f, 0)
def test_returns_one_for_range_between_minus_and_plus_one(self):
for init_args in self.get_init_args(max_=(1,), min_=(-1,), gain=(1,)):
f = Transformation(*init_args.values())
self.assertEqual(f(1), 1)
self.assertEqual(f(-1), -1)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,597 @@
#!/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/>.
"""See TestEventPipeline for more tests."""
import asyncio
import unittest
from unittest.mock import MagicMock
import evdev
from evdev.ecodes import (
EV_KEY,
EV_ABS,
EV_REL,
ABS_X,
REL_X,
BTN_LEFT,
BTN_RIGHT,
KEY_A,
REL_Y,
REL_WHEEL,
)
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.configs.mapping import Mapping, DEFAULT_REL_RATE, KnownUinput
from inputremapper.injection.global_uinputs import GlobalUInputs, UInput
from inputremapper.injection.mapping_handlers.abs_to_abs_handler import AbsToAbsHandler
from inputremapper.injection.mapping_handlers.abs_to_btn_handler import AbsToBtnHandler
from inputremapper.injection.mapping_handlers.abs_to_rel_handler import AbsToRelHandler
from inputremapper.injection.mapping_handlers.axis_switch_handler import (
AxisSwitchHandler,
)
from inputremapper.injection.mapping_handlers.combination_handler import (
CombinationHandler,
)
from inputremapper.injection.mapping_handlers.hierarchy_handler import HierarchyHandler
from inputremapper.injection.mapping_handlers.key_handler import KeyHandler
from inputremapper.injection.mapping_handlers.macro_handler import MacroHandler
from inputremapper.injection.mapping_handlers.mapping_handler import (
MappingHandler,
)
from inputremapper.injection.mapping_handlers.rel_to_abs_handler import RelToAbsHandler
from inputremapper.injection.mapping_handlers.rel_to_btn_handler import RelToBtnHandler
from inputremapper.injection.mapping_handlers.rel_to_rel_handler import RelToRelHandler
from inputremapper.input_event import InputEvent, EventActions
from tests.lib.cleanup import cleanup
from tests.lib.fixtures import fixtures
from tests.lib.patches import InputDevice
from tests.lib.test_setup import test_setup
class BaseTests:
"""implements test that should pass on most mapping handlers
in special cases override specific tests.
"""
handler: MappingHandler
def setUp(self):
raise NotImplementedError
def tearDown(self) -> None:
cleanup()
def test_reset(self):
mock = MagicMock()
self.handler.set_sub_handler(mock)
self.handler.reset()
mock.reset.assert_called()
@test_setup
class TestAxisSwitchHandler(BaseTests, unittest.IsolatedAsyncioTestCase):
def setUp(self):
input_combination = InputCombination(
(
InputConfig(type=2, code=5),
InputConfig(type=1, code=3),
)
)
self.global_uinputs = GlobalUInputs(UInput)
self.global_uinputs.prepare_all()
self.handler = AxisSwitchHandler(
input_combination,
Mapping(
input_combination=input_combination.to_config(),
target_uinput="mouse",
output_type=2,
output_code=1,
),
MagicMock(),
self.global_uinputs,
)
@test_setup
class TestAbsToBtnHandler(BaseTests, unittest.IsolatedAsyncioTestCase):
def setUp(self):
input_combination = InputCombination(
[InputConfig(type=3, code=5, analog_threshold=10)]
)
self.global_uinputs = GlobalUInputs(UInput)
self.global_uinputs.prepare_all()
self.handler = AbsToBtnHandler(
input_combination,
Mapping(
input_combination=input_combination.to_config(),
target_uinput="mouse",
output_symbol="BTN_LEFT",
),
global_uinputs=self.global_uinputs,
)
@test_setup
class TestAbsToAbsHandler(BaseTests, unittest.IsolatedAsyncioTestCase):
def setUp(self):
input_combination = InputCombination([InputConfig(type=EV_ABS, code=ABS_X)])
self.global_uinputs = GlobalUInputs(UInput)
self.global_uinputs.prepare_all()
self.handler = AbsToAbsHandler(
input_combination,
Mapping(
input_combination=input_combination.to_config(),
target_uinput="gamepad",
output_type=EV_ABS,
output_code=ABS_X,
),
global_uinputs=self.global_uinputs,
)
async def test_reset(self):
self.handler.notify(
InputEvent(0, 0, EV_ABS, ABS_X, fixtures.foo_device_2_gamepad.max_abs),
source=InputDevice("/dev/input/event15"),
)
self.handler.reset()
history = self.global_uinputs.get_uinput("gamepad").write_history
self.assertEqual(
history,
[
InputEvent.from_tuple((3, 0, fixtures.foo_device_2_gamepad.max_abs)),
InputEvent.from_tuple((3, 0, 0)),
],
)
@test_setup
class TestRelToAbsHandler(BaseTests, unittest.IsolatedAsyncioTestCase):
def setUp(self):
input_combination = InputCombination([InputConfig(type=EV_REL, code=REL_X)])
self.global_uinputs = GlobalUInputs(UInput)
self.global_uinputs.prepare_all()
self.handler = RelToAbsHandler(
input_combination,
Mapping(
input_combination=input_combination.to_config(),
target_uinput="gamepad",
output_type=EV_ABS,
output_code=ABS_X,
),
self.global_uinputs,
)
async def test_reset(self):
self.handler.notify(
InputEvent(0, 0, EV_REL, REL_X, 123),
source=InputDevice("/dev/input/event15"),
)
self.handler.reset()
history = self.global_uinputs.get_uinput("gamepad").write_history
self.assertEqual(len(history), 2)
# something large, doesn't matter
self.assertGreater(history[0].value, fixtures.foo_device_2_gamepad.max_abs / 10)
# 0, because of the reset
self.assertEqual(history[1].value, 0)
async def test_rate_changes(self):
expected_rate = 100
# delta in usec
delta = 1000000 / expected_rate
self.handler.notify(
InputEvent(0, delta, EV_REL, REL_X, 100),
source=InputDevice("/dev/input/event15"),
)
self.handler.notify(
InputEvent(0, delta * 2, EV_REL, REL_X, 100),
source=InputDevice("/dev/input/event15"),
)
self.assertEqual(self.handler._observed_rate, expected_rate)
async def test_rate_stays(self):
# if two timestamps are equal, the rate stays at its previous value,
# in this case the default
self.handler.notify(
InputEvent(0, 50, EV_REL, REL_X, 100),
source=InputDevice("/dev/input/event15"),
)
self.handler.notify(
InputEvent(0, 50, EV_REL, REL_X, 100),
source=InputDevice("/dev/input/event15"),
)
self.assertEqual(self.handler._observed_rate, DEFAULT_REL_RATE)
@test_setup
class TestAbsToRelHandler(BaseTests, unittest.IsolatedAsyncioTestCase):
def setUp(self):
input_combination = InputCombination([InputConfig(type=EV_ABS, code=ABS_X)])
self.global_uinputs = GlobalUInputs(UInput)
self.global_uinputs.prepare_all()
self.handler = AbsToRelHandler(
input_combination,
Mapping(
input_combination=input_combination.to_config(),
target_uinput="mouse",
output_type=EV_REL,
output_code=REL_X,
),
self.global_uinputs,
)
async def test_reset(self):
self.handler.notify(
InputEvent(0, 0, EV_ABS, ABS_X, fixtures.foo_device_2_gamepad.max_abs),
source=InputDevice("/dev/input/event15"),
)
await asyncio.sleep(0.2)
self.handler.reset()
await asyncio.sleep(0.05)
count = self.global_uinputs.get_uinput("mouse").write_count
self.assertGreater(count, 6) # count should be 60*0.2 = 12
await asyncio.sleep(0.2)
self.assertEqual(count, self.global_uinputs.get_uinput("mouse").write_count)
@test_setup
class TestCombinationHandler(BaseTests, unittest.IsolatedAsyncioTestCase):
handler: CombinationHandler
def setUp(self):
mouse = fixtures.foo_device_2_mouse
self.mouse_hash = mouse.get_device_hash()
keyboard = fixtures.foo_device_2_keyboard
self.keyboard_hash = keyboard.get_device_hash()
gamepad = fixtures.gamepad
self.gamepad_hash = gamepad.get_device_hash()
input_combination = InputCombination(
(
InputConfig(
type=EV_REL,
code=5,
analog_threshold=10,
origin_hash=self.mouse_hash,
),
InputConfig(
type=EV_KEY,
code=3,
origin_hash=self.keyboard_hash,
),
InputConfig(
type=EV_KEY,
code=4,
origin_hash=self.gamepad_hash,
),
)
)
self.input_combination = input_combination
self.context_mock = MagicMock()
self.global_uinputs = GlobalUInputs(UInput)
self.global_uinputs.prepare_all()
self.handler = CombinationHandler(
input_combination,
Mapping(
input_combination=input_combination.to_config(),
target_uinput="mouse",
output_symbol="BTN_LEFT",
release_combination_keys=True,
),
self.context_mock,
global_uinputs=self.global_uinputs,
)
sub_handler_mock = MagicMock(MappingHandler)
self.handler.set_sub_handler(sub_handler_mock)
# insert our own test-uinput to see what is being written to it
self.uinputs = {
self.mouse_hash: evdev.UInput(),
self.keyboard_hash: evdev.UInput(),
self.gamepad_hash: evdev.UInput(),
}
self.context_mock.get_forward_uinput = lambda origin_hash: self.uinputs[
origin_hash
]
def test_forward_correctly(self):
# In the past, if a mapping has inputs from two different sub devices, it
# always failed to send the release events to the correct one.
# Nowadays, self._context.get_forward_uinput(origin_hash) is used to
# release them correctly.
# 1. trigger the combination
self.handler.notify(
InputEvent.rel(
code=self.input_combination[0].code,
value=1,
origin_hash=self.input_combination[0].origin_hash,
),
source=fixtures.foo_device_2_mouse,
)
self.handler.notify(
InputEvent.key(
code=self.input_combination[1].code,
value=1,
origin_hash=self.input_combination[1].origin_hash,
),
source=fixtures.foo_device_2_keyboard,
)
self.handler.notify(
InputEvent.key(
code=self.input_combination[2].code,
value=1,
origin_hash=self.input_combination[2].origin_hash,
),
source=fixtures.gamepad,
)
# 2. expect release events to be written to the correct devices, as indicated
# by the origin_hash of the InputConfigs
self.assertListEqual(
self.uinputs[self.mouse_hash].write_history,
[InputEvent.rel(self.input_combination[0].code, 0)],
)
self.assertListEqual(
self.uinputs[self.keyboard_hash].write_history,
[InputEvent.key(self.input_combination[1].code, 0)],
)
# We do not expect a release event for this, because there was no key-down
# event when the combination triggered.
# self.assertListEqual(
# self.uinputs[self.gamepad_hash].write_history,
# [InputEvent.key(self.input_combination[2].code, 0)],
# )
def test_no_forwards(self):
# if a combination is not triggered, nothing is released
# 1. inject any two events
self.handler.notify(
InputEvent.rel(
code=self.input_combination[0].code,
value=1,
origin_hash=self.input_combination[0].origin_hash,
),
source=fixtures.foo_device_2_mouse,
)
self.handler.notify(
InputEvent.key(
code=self.input_combination[1].code,
value=1,
origin_hash=self.input_combination[1].origin_hash,
),
source=fixtures.foo_device_2_keyboard,
)
# 2. expect no release events to be written
self.assertListEqual(self.uinputs[self.mouse_hash].write_history, [])
self.assertListEqual(self.uinputs[self.keyboard_hash].write_history, [])
@test_setup
class TestHierarchyHandler(BaseTests, unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.mock1 = MagicMock()
self.mock2 = MagicMock()
self.mock3 = MagicMock()
self.global_uinputs = GlobalUInputs(UInput)
self.global_uinputs.prepare_all()
self.handler = HierarchyHandler(
[self.mock1, self.mock2, self.mock3],
InputConfig(type=EV_KEY, code=KEY_A),
self.global_uinputs,
)
def test_reset(self):
self.handler.reset()
self.mock1.reset.assert_called()
self.mock2.reset.assert_called()
self.mock3.reset.assert_called()
@test_setup
class TestKeyHandler(BaseTests, unittest.IsolatedAsyncioTestCase):
def setUp(self):
input_combination = InputCombination(
(
InputConfig(type=2, code=0, analog_threshold=10),
InputConfig(type=1, code=3),
)
)
self.global_uinputs = GlobalUInputs(UInput)
self.global_uinputs.prepare_all()
self.handler = KeyHandler(
input_combination,
Mapping(
input_combination=input_combination.to_config(),
target_uinput="mouse",
output_symbol="BTN_LEFT",
),
self.global_uinputs,
)
def test_reset(self):
self.handler.notify(
InputEvent(0, 0, EV_REL, REL_X, 1, actions=(EventActions.as_key,)),
source=InputDevice("/dev/input/event11"),
)
history = self.global_uinputs.get_uinput("mouse").write_history
self.assertEqual(history[0], InputEvent.key(BTN_LEFT, 1))
self.assertEqual(len(history), 1)
self.handler.reset()
history = self.global_uinputs.get_uinput("mouse").write_history
self.assertEqual(history[1], InputEvent.key(BTN_LEFT, 0))
self.assertEqual(len(history), 2)
@test_setup
class TestMacroHandler(BaseTests, unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.input_combination = InputCombination(
(
InputConfig(type=2, code=0, analog_threshold=10),
InputConfig(type=1, code=3),
)
)
self.context_mock = MagicMock()
self.global_uinputs = GlobalUInputs(UInput)
self.global_uinputs.prepare_all()
self.set_handler(KnownUinput.KEYBOARD, "key(a)")
def set_handler(self, target_uinput: KnownUinput, macro: str):
self.handler = MacroHandler(
self.input_combination,
Mapping(
input_combination=self.input_combination.to_config(),
target_uinput=target_uinput,
output_symbol=macro,
),
context=self.context_mock,
global_uinputs=self.global_uinputs,
)
async def test_reset(self):
self.set_handler(KnownUinput.MOUSE, "hold_keys(BTN_LEFT, BTN_RIGHT)")
self.handler.notify(
InputEvent(0, 0, EV_REL, REL_X, 1, actions=(EventActions.as_key,)),
source=InputDevice("/dev/input/event11"),
)
await asyncio.sleep(0.1)
history = self.global_uinputs.get_uinput(KnownUinput.MOUSE).write_history
self.assertIn(InputEvent.key(BTN_LEFT, 1), history)
self.assertIn(InputEvent.key(BTN_RIGHT, 1), history)
self.assertEqual(len(history), 2)
self.handler.reset()
await asyncio.sleep(0.1)
history = self.global_uinputs.get_uinput(KnownUinput.MOUSE).write_history
self.assertIn(InputEvent.key(BTN_LEFT, 0), history[-2:])
self.assertIn(InputEvent.key(BTN_RIGHT, 0), history[-2:])
self.assertEqual(len(history), 4)
async def test_reset_output(self):
self.set_handler(KnownUinput.KEYBOARD, "key_down(a)")
history = self.global_uinputs.get_uinput(KnownUinput.KEYBOARD).write_history
self.handler.reset()
await asyncio.sleep(0.1)
self.assertEqual(len(history), 0)
self.handler.notify(
InputEvent(0, 0, EV_REL, REL_X, 1, actions=(EventActions.as_key,)),
source=InputDevice("/dev/input/event11"),
)
await asyncio.sleep(0.1)
self.assertEqual(len(history), 1)
self.assertIn(InputEvent.key(KEY_A, 1), history)
self.handler.reset()
await asyncio.sleep(0.1)
self.assertEqual(len(history), 2)
self.assertIn(InputEvent.key(KEY_A, 0), history)
@test_setup
class TestRelToBtnHandler(BaseTests, unittest.IsolatedAsyncioTestCase):
def setUp(self):
input_combination = InputCombination(
[InputConfig(type=2, code=0, analog_threshold=10)]
)
self.global_uinputs = GlobalUInputs(UInput)
self.global_uinputs.prepare_all()
self.handler = RelToBtnHandler(
input_combination,
Mapping(
input_combination=input_combination.to_config(),
target_uinput="mouse",
output_symbol="BTN_LEFT",
),
self.global_uinputs,
)
@test_setup
class TestRelToRelHanlder(BaseTests, unittest.IsolatedAsyncioTestCase):
handler: RelToRelHandler
def setUp(self):
input_combination = InputCombination([InputConfig(type=EV_REL, code=REL_X)])
self.global_uinputs = GlobalUInputs(UInput)
self.global_uinputs.prepare_all()
self.handler = RelToRelHandler(
input_combination,
Mapping(
input_combination=input_combination.to_config(),
output_type=EV_REL,
output_code=REL_Y,
output_value=20,
target_uinput="mouse",
),
self.global_uinputs,
)
def test_should_map(self):
self.assertTrue(
self.handler._should_map(
InputEvent(
0,
0,
EV_REL,
REL_X,
0,
)
)
)
self.assertFalse(
self.handler._should_map(
InputEvent(
0,
0,
EV_REL,
REL_WHEEL,
1,
)
)
)
def test_reset(self):
# nothing special has to happen here
pass

View File

@ -0,0 +1,239 @@
#!/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 unittest
from evdev.ecodes import (
EV_KEY,
EV_ABS,
EV_REL,
ABS_X,
ABS_Y,
REL_X,
REL_Y,
KEY_A,
)
from inputremapper.configs.mapping import (
Mapping,
REL_XY_SCALING,
DEFAULT_REL_RATE,
)
from inputremapper.configs.preset import Preset
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.input_event import InputEvent
from tests.lib.fixtures import fixtures
from tests.lib.test_setup import test_setup
from tests.unit.test_event_pipeline.event_pipeline_test_base import (
EventPipelineTestBase,
)
@test_setup
class TestRelToAbs(EventPipelineTestBase):
def setUp(self):
self.timestamp = 0
super().setUp()
def next_usec_time(self):
self.timestamp += 1000000 / DEFAULT_REL_RATE
return self.timestamp
async def test_rel_to_abs(self):
# first mapping
# left mouse x to abs x
gain = 0.5
cutoff = 2
input_combination = InputCombination([InputConfig(type=EV_REL, code=REL_X)])
mapping_config = {
"input_combination": input_combination.to_config(),
"target_uinput": "gamepad",
"output_type": EV_ABS,
"output_code": ABS_X,
"gain": gain,
"rel_to_abs_input_cutoff": cutoff,
"release_timeout": 0.5,
"deadzone": 0,
}
mapping_1 = Mapping(**mapping_config)
preset = Preset()
preset.add(mapping_1)
# second mapping
input_combination = InputCombination([InputConfig(type=EV_REL, code=REL_Y)])
mapping_config["input_combination"] = input_combination.to_config()
mapping_config["output_code"] = ABS_Y
mapping_2 = Mapping(**mapping_config)
preset.add(mapping_2)
event_reader = self.create_event_reader(preset, fixtures.gamepad)
next_time = self.next_usec_time()
await self.send_events(
[
InputEvent(0, next_time, EV_REL, REL_X, -int(REL_XY_SCALING * cutoff)),
InputEvent(0, next_time, EV_REL, REL_Y, int(REL_XY_SCALING * cutoff)),
],
event_reader,
)
await asyncio.sleep(0.1)
history = self.global_uinputs.get_uinput("gamepad").write_history
self.assertEqual(
history,
[
InputEvent.from_tuple((3, 0, fixtures.gamepad.min_abs / 2)),
InputEvent.from_tuple((3, 1, fixtures.gamepad.max_abs / 2)),
],
)
# send more events, then wait until the release timeout
next_time = self.next_usec_time()
await self.send_events(
[
InputEvent(0, next_time, EV_REL, REL_X, -int(REL_XY_SCALING)),
InputEvent(0, next_time, EV_REL, REL_Y, int(REL_XY_SCALING)),
],
event_reader,
)
await asyncio.sleep(0.7)
history = self.global_uinputs.get_uinput("gamepad").write_history
self.assertEqual(
history,
[
InputEvent.from_tuple((3, 0, fixtures.gamepad.min_abs / 2)),
InputEvent.from_tuple((3, 1, fixtures.gamepad.max_abs / 2)),
InputEvent.from_tuple((3, 0, fixtures.gamepad.min_abs / 4)),
InputEvent.from_tuple((3, 1, fixtures.gamepad.max_abs / 4)),
InputEvent.from_tuple((3, 0, 0)),
InputEvent.from_tuple((3, 1, 0)),
],
)
async def test_rel_to_abs_reset_multiple(self):
# Recenters correctly when triggering the mapping a second time.
# Could only be reproduced if a key input is part of the combination, that is
# released and pressed again.
# left mouse x to abs x
gain = 0.5
cutoff = 2
input_combination = InputCombination(
[
InputConfig(type=EV_KEY, code=KEY_A),
InputConfig(type=EV_REL, code=REL_X),
]
)
mapping_config = {
"input_combination": input_combination.to_config(),
"target_uinput": "gamepad",
"output_type": EV_ABS,
"output_code": ABS_X,
"gain": gain,
"rel_to_abs_input_cutoff": cutoff,
"release_timeout": 0.1,
"deadzone": 0,
}
mapping_1 = Mapping(**mapping_config)
preset = Preset()
preset.add(mapping_1)
event_reader = self.create_event_reader(preset, fixtures.gamepad)
for _ in range(3):
next_time = self.next_usec_time()
value = int(REL_XY_SCALING * cutoff)
await event_reader.handle(InputEvent(0, next_time, EV_KEY, KEY_A, 1))
await event_reader.handle(InputEvent(0, next_time, EV_REL, REL_X, value))
await asyncio.sleep(0.2)
history = self.global_uinputs.get_uinput("gamepad").write_history
self.assertIn(
InputEvent.from_tuple((3, 0, 0)),
history,
)
await event_reader.handle(InputEvent(0, next_time, EV_KEY, KEY_A, 0))
await asyncio.sleep(0.05)
self.global_uinputs.get_uinput("gamepad").write_history = []
async def test_rel_to_abs_with_input_switch(self):
# use 0 everywhere, because that will cause the handler to not update the rate,
# and we are able to test things without worrying about that at all
timestamp = 0
gain = 0.5
cutoff = 1
input_combination = InputCombination(
(
InputConfig(type=EV_REL, code=REL_X),
InputConfig(type=EV_REL, code=REL_Y, analog_threshold=10),
)
)
# left mouse x to x
mapping_config = {
"input_combination": input_combination.to_config(),
"target_uinput": "gamepad",
"output_type": EV_ABS,
"output_code": ABS_X,
"gain": gain,
"rel_to_abs_input_cutoff": cutoff,
"deadzone": 0,
}
mapping_1 = Mapping(**mapping_config)
preset = Preset()
preset.add(mapping_1)
event_reader = self.create_event_reader(preset, fixtures.gamepad)
# if the cutoff is higher, the test sends higher values to overcome the cutoff
await self.send_events(
[
# will not map
InputEvent(0, timestamp, EV_REL, REL_X, -REL_XY_SCALING / 4 * cutoff),
# switch axis on
InputEvent(0, timestamp, EV_REL, REL_Y, REL_XY_SCALING / 5 * cutoff),
# normally mapped
InputEvent(0, timestamp, EV_REL, REL_X, REL_XY_SCALING * cutoff),
# off, re-centers axis
InputEvent(0, timestamp, EV_REL, REL_Y, REL_XY_SCALING / 20 * cutoff),
# will not map
InputEvent(0, timestamp, EV_REL, REL_X, REL_XY_SCALING / 2 * cutoff),
],
event_reader,
)
await asyncio.sleep(0.2)
history = self.global_uinputs.get_uinput("gamepad").write_history
self.assertEqual(
history,
[
InputEvent.from_tuple((3, 0, fixtures.gamepad.max_abs / 2)),
InputEvent.from_tuple((3, 0, 0)),
],
)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,180 @@
#!/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 unittest
from evdev.ecodes import (
EV_KEY,
EV_REL,
REL_X,
REL_HWHEEL,
REL_WHEEL,
)
from inputremapper.configs.mapping import (
Mapping,
)
from inputremapper.configs.preset import Preset
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.input_event import InputEvent
from tests.lib.fixtures import fixtures
from tests.lib.test_setup import test_setup
from tests.unit.test_event_pipeline.event_pipeline_test_base import (
EventPipelineTestBase,
)
@test_setup
class TestRelToBtn(EventPipelineTestBase):
async def test_rel_to_btn(self):
"""Rel axis mapped to buttons are automatically released if no new rel event arrives."""
# map those two to stuff
w_up = (EV_REL, REL_WHEEL, -1)
hw_right = (EV_REL, REL_HWHEEL, 1)
# should be forwarded and present in the capabilities
hw_left = (EV_REL, REL_HWHEEL, -1)
keyboard_layout.clear()
code_b = 91
code_c = 92
keyboard_layout._set("b", code_b)
keyboard_layout._set("c", code_c)
# set a high release timeout to make sure the tests pass
release_timeout = 0.2
mapping_1 = Mapping.from_combination(
InputCombination(InputCombination.from_tuples(hw_right)), "keyboard", "k(b)"
)
mapping_2 = Mapping.from_combination(
InputCombination(InputCombination.from_tuples(w_up)), "keyboard", "c"
)
mapping_1.release_timeout = release_timeout
mapping_2.release_timeout = release_timeout
preset = Preset()
preset.add(mapping_1)
preset.add(mapping_2)
event_reader = self.create_event_reader(preset, fixtures.foo_device_2_mouse)
await self.send_events(
[InputEvent.from_tuple(hw_right), InputEvent.from_tuple(w_up)] * 5,
event_reader,
)
# wait less than the release timeout and send more events
await asyncio.sleep(release_timeout / 5)
await self.send_events(
[InputEvent.from_tuple(hw_right), InputEvent.from_tuple(w_up)] * 5
+ [InputEvent.from_tuple(hw_left)]
* 3, # one event will release hw_right, the others are forwarded
event_reader,
)
# wait more than the release_timeout to make sure all handlers finish
await asyncio.sleep(release_timeout * 1.2)
keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history
forwarded_history = self.forward_uinput.write_history
self.assertEqual(keyboard_history.count((EV_KEY, code_b, 1)), 1)
self.assertEqual(keyboard_history.count((EV_KEY, code_c, 1)), 1)
self.assertEqual(keyboard_history.count((EV_KEY, code_b, 0)), 1)
self.assertEqual(keyboard_history.count((EV_KEY, code_c, 0)), 1)
# the unmapped wheel direction
self.assertEqual(forwarded_history.count(hw_left), 2)
# the unmapped wheel won't get a debounced release command, it's
# forwarded as is
self.assertNotIn((EV_REL, REL_HWHEEL, 0), forwarded_history)
async def test_rel_trigger_threshold(self):
"""Test that different activation points for rel_to_btn work correctly."""
# at 5 map to a
mapping_1 = Mapping.from_combination(
InputCombination(
[InputConfig(type=EV_REL, code=REL_X, analog_threshold=5)]
),
output_symbol="a",
)
# at 15 map to b
mapping_2 = Mapping.from_combination(
InputCombination(
[InputConfig(type=EV_REL, code=REL_X, analog_threshold=15)]
),
output_symbol="b",
)
release_timeout = 0.2 # give some time to do assertions before the release
mapping_1.release_timeout = release_timeout
mapping_2.release_timeout = release_timeout
preset = Preset()
preset.add(mapping_1)
preset.add(mapping_2)
a = keyboard_layout.get("a")
b = keyboard_layout.get("b")
event_reader = self.create_event_reader(preset, fixtures.foo_device_2_mouse)
await self.send_events(
[
InputEvent.rel(REL_X, -5), # forward
InputEvent.rel(REL_X, 0), # forward
InputEvent.rel(REL_X, 3), # forward
InputEvent.rel(REL_X, 10), # trigger a
],
event_reader,
)
await asyncio.sleep(release_timeout * 1.5) # release a
keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history
self.assertEqual(keyboard_history, [(EV_KEY, a, 1), (EV_KEY, a, 0)])
self.assertEqual(keyboard_history.count((EV_KEY, a, 1)), 1)
self.assertEqual(keyboard_history.count((EV_KEY, a, 0)), 1)
self.assertNotIn((EV_KEY, b, 1), keyboard_history)
await self.send_events(
[
InputEvent.rel(REL_X, 10), # trigger a
InputEvent.rel(REL_X, 20), # trigger b
InputEvent.rel(REL_X, 10), # release b
],
event_reader,
)
keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history
self.assertEqual(keyboard_history.count((EV_KEY, a, 1)), 2)
self.assertEqual(keyboard_history.count((EV_KEY, b, 1)), 1)
self.assertEqual(keyboard_history.count((EV_KEY, b, 0)), 1)
self.assertEqual(keyboard_history.count((EV_KEY, a, 0)), 1)
await asyncio.sleep(release_timeout * 1.5) # release a
keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history
forwarded_history = self.forward_uinput.write_history
self.assertEqual(keyboard_history.count((EV_KEY, a, 0)), 2)
self.assertEqual(
forwarded_history,
[(EV_REL, REL_X, -5), (EV_REL, REL_X, 0), (EV_REL, REL_X, 3)],
)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,219 @@
#!/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
from evdev.ecodes import (
EV_REL,
REL_X,
REL_Y,
REL_HWHEEL,
REL_WHEEL,
REL_WHEEL_HI_RES,
REL_HWHEEL_HI_RES,
)
from inputremapper.configs.mapping import (
Mapping,
REL_XY_SCALING,
WHEEL_SCALING,
WHEEL_HI_RES_SCALING,
)
from inputremapper.configs.preset import Preset
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.input_event import InputEvent
from tests.lib.fixtures import fixtures
from tests.lib.test_setup import test_setup
from tests.unit.test_event_pipeline.event_pipeline_test_base import (
EventPipelineTestBase,
)
@test_setup
class TestRelToRel(EventPipelineTestBase):
async def _test(self, input_code, input_value, output_code, output_value, gain=1):
preset = Preset()
input_config = InputConfig(type=EV_REL, code=input_code)
mapping = Mapping(
input_combination=InputCombination([input_config]).to_config(),
target_uinput="mouse",
output_type=EV_REL,
output_code=output_code,
deadzone=0,
gain=gain,
)
preset.add(mapping)
event_reader = self.create_event_reader(preset, fixtures.gamepad)
await self.send_events(
[InputEvent(0, 0, EV_REL, input_code, input_value)],
event_reader,
)
history = self.global_uinputs.get_uinput("mouse").write_history
self.assertEqual(len(history), 1)
self.assertEqual(
history[0],
InputEvent(0, 0, EV_REL, output_code, output_value),
)
async def test_wheel_to_y(self):
await self._test(
input_code=REL_WHEEL,
input_value=2 * WHEEL_SCALING,
output_code=REL_Y,
output_value=2 * REL_XY_SCALING,
)
async def test_hi_res_wheel_to_y(self):
await self._test(
input_code=REL_WHEEL_HI_RES,
input_value=3 * WHEEL_HI_RES_SCALING,
output_code=REL_Y,
output_value=3 * REL_XY_SCALING,
)
async def test_x_to_hwheel(self):
# injects both hi_res and regular wheel events at the same time
input_code = REL_X
input_value = 100
output_code = REL_HWHEEL
gain = 2
output_value = int(input_value / REL_XY_SCALING * WHEEL_SCALING * gain)
output_value_hi_res = int(
input_value / REL_XY_SCALING * WHEEL_HI_RES_SCALING * gain
)
preset = Preset()
input_config = InputConfig(type=EV_REL, code=input_code)
mapping = Mapping(
input_combination=InputCombination([input_config]).to_config(),
target_uinput="mouse",
output_type=EV_REL,
output_code=output_code,
deadzone=0,
gain=gain,
)
preset.add(mapping)
event_reader = self.create_event_reader(preset, fixtures.gamepad)
await self.send_events(
[InputEvent(0, 0, EV_REL, input_code, input_value)],
event_reader,
)
history = self.global_uinputs.get_uinput("mouse").write_history
# injects both REL_WHEEL and REL_WHEEL_HI_RES events
self.assertEqual(len(history), 2)
self.assertEqual(
history[0],
InputEvent(
0,
0,
EV_REL,
REL_HWHEEL,
output_value,
),
)
self.assertEqual(
history[1],
InputEvent(
0,
0,
EV_REL,
REL_HWHEEL_HI_RES,
output_value_hi_res,
),
)
async def test_remainder(self):
preset = Preset()
history = self.global_uinputs.get_uinput("mouse").write_history
# REL_WHEEL_HI_RES to REL_Y
input_config = InputConfig(type=EV_REL, code=REL_WHEEL_HI_RES)
gain = 0.01
mapping = Mapping(
input_combination=InputCombination([input_config]).to_config(),
target_uinput="mouse",
output_type=EV_REL,
output_code=REL_Y,
deadzone=0,
gain=gain,
)
preset.add(mapping)
event_reader = self.create_event_reader(preset, fixtures.gamepad)
events_until_one_rel_y_written = int(
WHEEL_HI_RES_SCALING / REL_XY_SCALING / gain
)
# due to the low gain and low input value, it needs to be sent many times
# until one REL_Y event is written
await self.send_events(
[InputEvent(0, 0, EV_REL, REL_WHEEL_HI_RES, 1)]
* (events_until_one_rel_y_written - 1),
event_reader,
)
self.assertEqual(len(history), 0)
# write the final event that causes the input to accumulate to 1
# plus one extra event because of floating-point math
await self.send_events(
[InputEvent(0, 0, EV_REL, REL_WHEEL_HI_RES, 1)],
event_reader,
)
self.assertEqual(len(history), 1)
self.assertEqual(
history[0],
InputEvent(0, 0, EV_REL, REL_Y, 1),
)
# repeat it one more time to see if the remainder is reset correctly
await self.send_events(
[InputEvent(0, 0, EV_REL, REL_WHEEL_HI_RES, 1)]
* (events_until_one_rel_y_written - 1),
event_reader,
)
self.assertEqual(len(history), 1)
# the event that causes the second REL_Y to be written
# this should never need the one extra if the remainder is reset correctly
await self.send_events(
[InputEvent(0, 0, EV_REL, REL_WHEEL_HI_RES, 1)],
event_reader,
)
self.assertEqual(len(history), 2)
self.assertEqual(
history[1],
InputEvent(0, 0, EV_REL, REL_Y, 1),
)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,300 @@
#!/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 os
import unittest
from unittest.mock import MagicMock, patch
import evdev
from evdev.ecodes import (
EV_KEY,
EV_ABS,
BTN_A,
ABS_X,
ABS_Y,
ABS_RX,
ABS_RY,
EV_REL,
REL_X,
REL_Y,
REL_HWHEEL_HI_RES,
REL_WHEEL_HI_RES,
)
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.configs.mapping import Mapping
from inputremapper.configs.preset import Preset
from inputremapper.injection.context import Context
from inputremapper.injection.event_reader import EventReader
from inputremapper.injection.global_uinputs import GlobalUInputs, UInput
from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser
from inputremapper.input_event import InputEvent
from inputremapper.utils import get_device_hash
from tests.lib.fixtures import fixtures
from tests.lib.test_setup import test_setup
@test_setup
class TestEventReader(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.gamepad_source = evdev.InputDevice(fixtures.gamepad.path)
self.source_hash = fixtures.gamepad.get_device_hash()
self.global_uinputs = GlobalUInputs(UInput)
self.mapping_parser = MappingParser(self.global_uinputs)
self.stop_event = asyncio.Event()
self.preset = Preset()
self.forward_uinput = evdev.UInput(name="test-forward-uinput")
self.global_uinputs.is_service = True
self.global_uinputs.prepare_all()
async def setup(self, mapping):
"""Set a EventReader up for the test and run it in the background."""
context = Context(
mapping,
{},
{self.source_hash: self.forward_uinput},
self.mapping_parser,
)
context.uinput = evdev.UInput()
event_reader = EventReader(
context,
self.gamepad_source,
self.stop_event,
)
asyncio.ensure_future(event_reader.run())
await asyncio.sleep(0.1)
return context, event_reader
async def test_if_single_joystick_then(self):
# TODO: Move this somewhere more sensible
# Integration test style for if_single.
# won't care about the event, because the purpose is not set to BUTTON
code_a = keyboard_layout.get("a")
code_shift = keyboard_layout.get("KEY_LEFTSHIFT")
trigger = evdev.ecodes.BTN_A
self.preset.add(
Mapping.from_combination(
InputCombination(
[
InputConfig(
type=EV_KEY,
code=trigger,
origin_hash=fixtures.gamepad.get_device_hash(),
)
]
),
"keyboard",
"if_single(key(a), key(KEY_LEFTSHIFT))",
)
)
self.preset.add(
Mapping.from_combination(
InputCombination(
[
InputConfig(
type=EV_ABS,
code=ABS_Y,
analog_threshold=1,
origin_hash=fixtures.gamepad.get_device_hash(),
)
]
),
"keyboard",
"b",
),
)
# left x to mouse x
config = {
"input_combination": [
InputConfig(
type=EV_ABS,
code=ABS_X,
origin_hash=fixtures.gamepad.get_device_hash(),
)
],
"target_uinput": "mouse",
"output_type": EV_REL,
"output_code": REL_X,
}
self.preset.add(Mapping(**config))
# left y to mouse y
config["input_combination"] = [
InputConfig(
type=EV_ABS,
code=ABS_Y,
origin_hash=fixtures.gamepad.get_device_hash(),
)
]
config["output_code"] = REL_Y
self.preset.add(Mapping(**config))
# right x to wheel x
config["input_combination"] = [
InputConfig(
type=EV_ABS,
code=ABS_RX,
origin_hash=fixtures.gamepad.get_device_hash(),
)
]
config["output_code"] = REL_HWHEEL_HI_RES
self.preset.add(Mapping(**config))
# right y to wheel y
config["input_combination"] = [
InputConfig(
type=EV_ABS,
code=ABS_RY,
origin_hash=fixtures.gamepad.get_device_hash(),
)
]
config["output_code"] = REL_WHEEL_HI_RES
self.preset.add(Mapping(**config))
context, _ = await self.setup(self.preset)
gamepad_hash = get_device_hash(self.gamepad_source)
self.gamepad_source.push_events(
[
InputEvent.key(evdev.ecodes.BTN_Y, 0, gamepad_hash), # start the macro
InputEvent.key(trigger, 1, gamepad_hash), # start the macro
InputEvent.abs(ABS_Y, 10, gamepad_hash), # ignored
InputEvent.key(evdev.ecodes.BTN_B, 2, gamepad_hash), # ignored
InputEvent.key(evdev.ecodes.BTN_B, 0, gamepad_hash), # ignored
# release the trigger, which runs `then` of if_single
InputEvent.key(trigger, 0, gamepad_hash),
]
)
await asyncio.sleep(0.1)
self.stop_event.set() # stop the reader
history = self.global_uinputs.get_uinput("keyboard").write_history
self.assertIn((EV_KEY, code_a, 1), history)
self.assertIn((EV_KEY, code_a, 0), history)
self.assertNotIn((EV_KEY, code_shift, 1), history)
self.assertNotIn((EV_KEY, code_shift, 0), history)
# after if_single takes an action, the listener should have been removed
self.assertSetEqual(context.listeners, set())
async def test_if_single_joystick_under_threshold(self):
"""Triggers then because the joystick events value is too low."""
# TODO: Move this somewhere more sensible
code_a = keyboard_layout.get("a")
trigger = evdev.ecodes.BTN_A
self.preset.add(
Mapping.from_combination(
InputCombination(
[
InputConfig(
type=EV_KEY,
code=trigger,
origin_hash=fixtures.gamepad.get_device_hash(),
)
]
),
"keyboard",
"if_single(k(a), k(KEY_LEFTSHIFT))",
)
)
self.preset.add(
Mapping.from_combination(
InputCombination(
[
InputConfig(
type=EV_ABS,
code=ABS_Y,
analog_threshold=1,
origin_hash=fixtures.gamepad.get_device_hash(),
)
]
),
"keyboard",
"b",
),
)
# self.preset.set("gamepad.joystick.left_purpose", BUTTONS)
# self.preset.set("gamepad.joystick.right_purpose", BUTTONS)
context, _ = await self.setup(self.preset)
self.gamepad_source.push_events(
[
InputEvent.key(trigger, 1), # start the macro
InputEvent.abs(ABS_Y, 1), # ignored because value too low
InputEvent.key(trigger, 0), # stop, only way to trigger `then`
]
)
await asyncio.sleep(0.1)
self.assertEqual(len(context.listeners), 0)
history = self.global_uinputs.get_uinput("keyboard").write_history
# the key that triggered if_single should be injected after
# if_single had a chance to inject keys (if the macro is fast enough),
# so that if_single can inject a modifier to e.g. capitalize the
# triggering key. This is important for the space cadet shift
self.assertListEqual(
history,
[
(EV_KEY, code_a, 1),
(EV_KEY, code_a, 0),
],
)
@patch.object(Context, "reset")
async def test_reset_handlers_on_stop(self, reset_mock: MagicMock) -> None:
await self.setup(self.preset)
self.stop_event.set()
await asyncio.sleep(0.1)
reset_mock.assert_called_once()
@patch.object(Context, "reset")
@patch.object(os, "stat")
async def test_reset_handlers_after_unplugging(
self,
stat_mock: MagicMock,
reset_mock: MagicMock,
) -> None:
await self.setup(self.preset)
self.gamepad_source.push_events([InputEvent.key(BTN_A, 1)])
await asyncio.sleep(0.1)
reset_mock.assert_not_called()
# unplug the device
stat_mock().st_nlink = 0
# It seems that once a device is unplugged, asyncio stops waiting for new input
# from _source.fileno() or something. I didn't manage to replicate this
# behavior in tests using the fd of pending_events[fixtures.gamepad][0].fileno()
# and/or pending_events[fixtures.gamepad][1].fileno(). So instead, I push
# another event, so that the EventReader makes another iteration.
self.gamepad_source.push_events([InputEvent.key(BTN_A, 1)])
await asyncio.sleep(0.1)
reset_mock.assert_called_once()

View File

@ -0,0 +1,106 @@
#!/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 unittest
from inputremapper.configs.global_config import GlobalConfig
from tests.lib.test_setup import test_setup
@test_setup
class TestGlobalConfig(unittest.TestCase):
def test_autoload(self):
global_config = GlobalConfig()
self.assertEqual(len(global_config.iterate_autoload_presets()), 0)
self.assertFalse(global_config.is_autoloaded("d1", "a"))
self.assertFalse(global_config.is_autoloaded("d2", "b"))
self.assertEqual(global_config.get_autoload_preset("d1"), None)
self.assertEqual(global_config.get_autoload_preset("d2"), None)
global_config.set_autoload_preset("d1", "a")
self.assertEqual(len(global_config.iterate_autoload_presets()), 1)
self.assertTrue(global_config.is_autoloaded("d1", "a"))
self.assertFalse(global_config.is_autoloaded("d2", "b"))
global_config.set_autoload_preset("d2", "b")
self.assertEqual(len(global_config.iterate_autoload_presets()), 2)
self.assertTrue(global_config.is_autoloaded("d1", "a"))
self.assertTrue(global_config.is_autoloaded("d2", "b"))
self.assertEqual(global_config.get_autoload_preset("d1"), "a")
self.assertEqual(global_config.get_autoload_preset("d2"), "b")
global_config.set_autoload_preset("d2", "c")
self.assertEqual(len(global_config.iterate_autoload_presets()), 2)
self.assertTrue(global_config.is_autoloaded("d1", "a"))
self.assertFalse(global_config.is_autoloaded("d2", "b"))
self.assertTrue(global_config.is_autoloaded("d2", "c"))
self.assertEqual(global_config._config["autoload"]["d2"], "c")
self.assertListEqual(
list(global_config.iterate_autoload_presets()),
[("d1", "a"), ("d2", "c")],
)
global_config.set_autoload_preset("d2", None)
self.assertTrue(global_config.is_autoloaded("d1", "a"))
self.assertFalse(global_config.is_autoloaded("d2", "b"))
self.assertFalse(global_config.is_autoloaded("d2", "c"))
self.assertListEqual(
list(global_config.iterate_autoload_presets()),
[("d1", "a")],
)
self.assertEqual(global_config.get_autoload_preset("d1"), "a")
self.assertRaises(ValueError, global_config.is_autoloaded, "d1", None)
self.assertRaises(ValueError, global_config.is_autoloaded, None, "a")
def test_initial(self):
global_config = GlobalConfig()
# when loading for the first time, create a config file with
# the default values
self.assertFalse(os.path.exists(global_config.path))
global_config.load_config()
self.assertTrue(os.path.exists(global_config.path))
with open(global_config.path, "r") as file:
contents = file.read()
self.assertIn('"autoload": {}', contents)
def test_save_load(self):
global_config = GlobalConfig()
self.assertEqual(len(global_config.iterate_autoload_presets()), 0)
global_config.load_config()
self.assertEqual(len(global_config.iterate_autoload_presets()), 0)
global_config.set_autoload_preset("d1", "a")
global_config.set_autoload_preset("d2", "b")
global_config.load_config()
self.assertListEqual(
list(global_config.iterate_autoload_presets()),
[("d1", "a"), ("d2", "b")],
)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,100 @@
#!/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 evdev
from evdev.ecodes import (
KEY_A,
ABS_X,
)
from inputremapper.exceptions import EventNotHandled, UinputNotAvailable
from inputremapper.injection.global_uinputs import (
FrontendUInput,
GlobalUInputs,
UInput,
)
from inputremapper.input_event import InputEvent
from tests.lib.cleanup import cleanup
from tests.lib.test_setup import test_setup
@test_setup
class TestFrontendUinput(unittest.TestCase):
def setUp(self) -> None:
cleanup()
def test_init(self):
name = "foo"
capabilities = {1: [1, 2, 3], 2: [4, 5, 6]}
uinput_defaults = FrontendUInput()
uinput_custom = FrontendUInput(name=name, events=capabilities)
self.assertEqual(uinput_defaults.name, "py-evdev-uinput")
self.assertIsNone(uinput_defaults.capabilities())
self.assertEqual(uinput_custom.name, name)
self.assertEqual(uinput_custom.capabilities(), capabilities)
@test_setup
class TestGlobalUInputs(unittest.TestCase):
def setUp(self) -> None:
cleanup()
def test_iter(self):
global_uinputs = GlobalUInputs(FrontendUInput)
for uinput in global_uinputs:
self.assertIsInstance(uinput, evdev.UInput)
def test_write(self):
"""Test write and write failure
implicitly tests get_uinput and UInput.can_emit
"""
global_uinputs = GlobalUInputs(UInput)
global_uinputs.prepare_all()
ev_1 = InputEvent.key(KEY_A, 1)
ev_2 = InputEvent.abs(ABS_X, 10)
keyboard = global_uinputs.get_uinput("keyboard")
global_uinputs.write(ev_1.event_tuple, "keyboard")
self.assertEqual(keyboard.write_count, 1)
with self.assertRaises(EventNotHandled):
global_uinputs.write(ev_2.event_tuple, "keyboard")
with self.assertRaises(UinputNotAvailable):
global_uinputs.write(ev_1.event_tuple, "foo")
def test_creates_frontend_uinputs(self):
frontend_uinputs = GlobalUInputs(FrontendUInput)
frontend_uinputs.prepare_all()
uinput = frontend_uinputs.get_uinput("keyboard")
self.assertIsInstance(uinput, FrontendUInput)
def test_creates_backend_service_uinputs(self):
frontend_uinputs = GlobalUInputs(UInput)
frontend_uinputs.prepare_all()
uinput = frontend_uinputs.get_uinput("keyboard")
self.assertIsInstance(uinput, UInput)

417
tests/unit/test_groups.py Normal file
View File

@ -0,0 +1,417 @@
#!/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 json
import os
import unittest
import evdev
from inputremapper.configs.paths import PathUtils
from inputremapper.groups import (
_FindGroups,
groups,
classify,
DeviceType,
_Group,
is_inputremapper_device,
)
from tests.lib.fixtures import fixtures, keyboard_keys, Fixture
from tests.lib.test_setup import test_setup
class FakePipe:
groups = None
def send(self, groups):
self.groups = groups
@test_setup
class TestGroups(unittest.TestCase):
def test_group(self):
group = _Group(
paths=["/dev/a", "/dev/b", "/dev/c"],
names=["name_bar", "name_a", "name_foo"],
types=[DeviceType.MOUSE, DeviceType.KEYBOARD, DeviceType.UNKNOWN],
key="key",
)
self.assertEqual(group.name, "name_a")
self.assertEqual(group.key, "key")
self.assertEqual(
group.get_preset_path("preset1234"),
os.path.join(
PathUtils.config_path(),
"presets",
group.name,
"preset1234.json",
),
)
def test_find_groups(self):
pipe = FakePipe()
_FindGroups(pipe).run()
self.assertIsInstance(pipe.groups, str)
groups.loads(pipe.groups)
self.maxDiff = None
dump1 = groups.dumps()
dump2 = json.dumps(
[
json.dumps(
{
"paths": [
"/dev/input/event1",
],
"names": ["Foo Device"],
"types": [DeviceType.KEYBOARD],
"key": "Foo Device",
}
),
json.dumps(
{
"paths": [
"/dev/input/event10",
"/dev/input/event11",
"/dev/input/event13",
"/dev/input/event15",
],
"names": [
"Foo Device",
"Foo Device foo",
"Foo Device",
"Foo Device bar",
],
"types": [
DeviceType.GAMEPAD,
DeviceType.KEYBOARD,
DeviceType.MOUSE,
],
"key": "Foo Device 2",
}
),
json.dumps(
{
"paths": ["/dev/input/event20"],
"names": ["Bar Device"],
"types": [DeviceType.KEYBOARD],
"key": "Bar Device",
}
),
json.dumps(
{
"paths": [
"/dev/input/event30",
"/dev/input/event32",
],
"names": [
"gamepad",
"gamepad abs 0 to 256",
],
"types": [DeviceType.GAMEPAD],
"key": "gamepad",
}
),
json.dumps(
{
"paths": ["/dev/input/event52"],
"names": ["Qux/[Device]?"],
"types": [DeviceType.KEYBOARD],
"key": "Qux/[Device]?",
}
),
]
)
self.assertEqual(dump1, dump2)
groups2 = json.dumps([group.dumps() for group in groups.get_groups()])
self.assertEqual(pipe.groups, groups2)
def test_list_group_names(self):
self.assertListEqual(
groups.list_group_names(),
[
"Foo Device",
"Foo Device",
"Bar Device",
"gamepad",
"Qux/[Device]?",
],
)
def test_get_groups(self):
# by default no input-remapper devices are present
filtered = groups.get_groups()
keys = [group.key for group in filtered]
self.assertIn("Foo Device 2", keys)
self.assertNotIn("input-remapper Bar Device", keys)
def test_skip_inputremapper_phys_devices(self):
fixtures.add_fixture(
{
"name": "Logitech G Pro",
"phys": "input-remapper/usb-0000:0f:00.3-4.2/input2:1",
"info": evdev.DeviceInfo(3, 0x046D, 0x4079, 0x0111),
"capabilities": {
evdev.ecodes.EV_KEY: [evdev.ecodes.BTN_LEFT],
evdev.ecodes.EV_REL: [
evdev.ecodes.REL_X,
evdev.ecodes.REL_Y,
evdev.ecodes.REL_WHEEL,
],
},
"path": "/foo/bar",
}
)
groups.refresh()
self.assertIsNone(groups.find(path="/foo/bar"))
def test_is_inputremapper_device(self):
device = evdev.InputDevice("/dev/input/event40")
self.assertTrue(is_inputremapper_device(device))
def test_skip_camera(self):
fixtures.add_fixture(
{
"name": "camera",
"phys": "abcd1",
"info": evdev.DeviceInfo(1, 2, 3, 4),
"capabilities": {evdev.ecodes.EV_KEY: [evdev.ecodes.KEY_CAMERA]},
"path": "/foo/bar",
}
)
groups.refresh()
self.assertIsNone(groups.find(name="camera"))
self.assertIsNotNone(groups.find(name="gamepad"))
def test_device_with_only_ev_abs(self):
# As Input Mapper can now map axes to buttons,
# a single EV_ABS device is valid for mapping.
fixtures.add_fixture(
{
"name": "qux",
"phys": "abcd2",
"info": evdev.DeviceInfo(1, 2, 3, 4),
"capabilities": {evdev.ecodes.EV_ABS: [evdev.ecodes.ABS_X]},
"path": "/foo/bar",
}
)
groups.refresh()
self.assertIsNotNone(groups.find(name="gamepad"))
self.assertIsNotNone(groups.find(name="qux"))
def test_device_with_no_capabilities(self):
fixtures.add_fixture(
{
"name": "nulcap",
"phys": "abcd3",
"info": evdev.DeviceInfo(1, 2, 3, 4),
"capabilities": {},
"path": "/foo/bar",
}
)
groups.refresh()
self.assertIsNotNone(groups.find(name="gamepad"))
self.assertIsNone(groups.find(name="nulcap"))
def test_duplicate_device(self):
fixtures.add_fixture(
{
"capabilities": {evdev.ecodes.EV_KEY: keyboard_keys},
"phys": "usb-0000:03:00.0-3/input1",
"info": evdev.device.DeviceInfo(2, 1, 2, 1),
"name": "Foo Device",
"path": "/dev/input/event100",
}
)
groups.refresh()
group1 = groups.find(key="Foo Device")
group2 = groups.find(key="Foo Device 2")
group3 = groups.find(key="Foo Device 3")
self.assertIn("/dev/input/event1", group1.paths)
self.assertIn("/dev/input/event10", group2.paths)
self.assertIn("/dev/input/event100", group3.paths)
self.assertEqual(group1.key, "Foo Device")
self.assertEqual(group2.key, "Foo Device 2")
self.assertEqual(group3.key, "Foo Device 3")
self.assertEqual(group1.name, "Foo Device")
self.assertEqual(group2.name, "Foo Device")
self.assertEqual(group3.name, "Foo Device")
# Bug reproduction: Unplugging a device and plugging it back in makes it appear
# earlier in the detected devices, giving it the first group, causing it to
# steal other devices injections on autoload.
# Make sure the first fixture is earlier in the list than the third fixture.
# This is the starting situation, everything is still fine.
paths = fixtures.get_paths()
self.assertLess(
paths.index("/dev/input/event1"),
paths.index("/dev/input/event100"),
)
# Remove the first group, and add it back in
backup = fixtures.get_fixture("/dev/input/event1")
fixtures.remove_fixture("/dev/input/event1")
fixtures.add_fixture(backup)
# Now the order should be messed up (This is acceptable here, as long as the
# groups end up in the correct original order. This check only exists to make
# sure the test would still be able to reproduce the bug. The order being
# messed up is a prerequisite to reproduce the bug)
paths = fixtures.get_paths()
self.assertGreater(
paths.index("/dev/input/event1"),
paths.index("/dev/input/event100"),
)
# refreshing groups should still end up giving each device the same group as
# before.
groups.refresh()
group1 = groups.find(key="Foo Device")
group2 = groups.find(key="Foo Device 2")
group3 = groups.find(key="Foo Device 3")
self.assertIn("/dev/input/event1", group1.paths)
self.assertIn("/dev/input/event10", group2.paths)
self.assertIn("/dev/input/event100", group3.paths)
self.assertEqual(group1.key, "Foo Device")
self.assertEqual(group2.key, "Foo Device 2")
self.assertEqual(group3.key, "Foo Device 3")
self.assertEqual(group1.name, "Foo Device")
self.assertEqual(group2.name, "Foo Device")
self.assertEqual(group3.name, "Foo Device")
def test_classify(self):
# properly detects if the device is a gamepad
EV_ABS = evdev.ecodes.EV_ABS
EV_KEY = evdev.ecodes.EV_KEY
EV_REL = evdev.ecodes.EV_REL
class FakeDevice:
def __init__(self, capabilities):
self.c = capabilities
def capabilities(self, absinfo):
assert not absinfo
return self.c
"""Gamepads"""
self.assertEqual(
classify(
FakeDevice(
{
EV_ABS: [evdev.ecodes.ABS_X, evdev.ecodes.ABS_Y],
EV_KEY: [evdev.ecodes.BTN_A],
}
)
),
DeviceType.GAMEPAD,
)
"""Mice"""
self.assertEqual(
classify(
FakeDevice(
{
EV_REL: [
evdev.ecodes.REL_X,
evdev.ecodes.REL_Y,
evdev.ecodes.REL_WHEEL,
],
EV_KEY: [evdev.ecodes.BTN_LEFT],
}
)
),
DeviceType.MOUSE,
)
"""Keyboard"""
self.assertEqual(
classify(FakeDevice({EV_KEY: [evdev.ecodes.KEY_A]})), DeviceType.KEYBOARD
)
"""Touchpads"""
self.assertEqual(
classify(
FakeDevice(
{
EV_KEY: [evdev.ecodes.KEY_A],
EV_ABS: [evdev.ecodes.ABS_MT_POSITION_X],
}
)
),
DeviceType.TOUCHPAD,
)
"""Graphics tablets"""
self.assertEqual(
classify(
FakeDevice(
{
EV_ABS: [evdev.ecodes.ABS_X, evdev.ecodes.ABS_Y],
EV_KEY: [evdev.ecodes.BTN_STYLUS],
}
)
),
DeviceType.GRAPHICS_TABLET,
)
"""Weird combos"""
self.assertEqual(
classify(
FakeDevice(
{
EV_ABS: [evdev.ecodes.ABS_X, evdev.ecodes.ABS_Y],
EV_KEY: [evdev.ecodes.KEY_1],
}
)
),
DeviceType.UNKNOWN,
)
self.assertEqual(
classify(
FakeDevice({EV_ABS: [evdev.ecodes.ABS_X], EV_KEY: [evdev.ecodes.BTN_A]})
),
DeviceType.UNKNOWN,
)
self.assertEqual(
classify(FakeDevice({EV_KEY: [evdev.ecodes.BTN_A]})), DeviceType.UNKNOWN
)
self.assertEqual(
classify(FakeDevice({EV_ABS: [evdev.ecodes.ABS_X]})), DeviceType.UNKNOWN
)
if __name__ == "__main__":
unittest.main()

711
tests/unit/test_injector.py Normal file
View File

@ -0,0 +1,711 @@
#!/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/>.
from inputremapper.injection.global_uinputs import GlobalUInputs, UInput
from inputremapper.injection.macros.parse import Parser
from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser
try:
from pydantic.v1 import ValidationError
except ImportError:
from pydantic import ValidationError
import time
import unittest
from unittest import mock
import evdev
from evdev.ecodes import (
EV_REL,
EV_KEY,
EV_ABS,
ABS_HAT0X,
KEY_A,
REL_HWHEEL,
BTN_A,
ABS_X,
ABS_VOLUME,
)
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.configs.mapping import Mapping
from inputremapper.configs.preset import Preset
from inputremapper.configs.keyboard_layout import (
keyboard_layout,
DISABLE_CODE,
DISABLE_NAME,
)
from inputremapper.groups import groups, classify, DeviceType
from inputremapper.injection.context import Context
from inputremapper.injection.injector import (
Injector,
is_in_capabilities,
InjectorState,
get_udev_name,
get_forward_name,
get_forward_phys,
)
from inputremapper.injection.numlock import is_numlock_on
from inputremapper.input_event import InputEvent
from tests.lib.constants import EVENT_READ_TIMEOUT
from tests.lib.fixtures import fixtures
from tests.lib.fixtures import keyboard_keys
from tests.lib.patches import uinputs
from tests.lib.pipes import read_write_history_pipe, push_events
from tests.lib.pipes import uinput_write_history_pipe
from tests.lib.test_setup import test_setup
def wait_for_uinput_write():
start = time.time()
if not uinput_write_history_pipe[0].poll(timeout=10):
raise AssertionError("No event written within 10 seconds")
return float(time.time() - start)
@test_setup
class TestInjector(unittest.IsolatedAsyncioTestCase):
new_gamepad_path = "/dev/input/event100"
@classmethod
def setUpClass(cls):
cls.injector = None
cls.grab = evdev.InputDevice.grab
def setUp(self):
self.failed = 0
self.make_it_fail = 2
self.global_uinputs = GlobalUInputs(UInput)
self.global_uinputs.prepare_all()
self.mapping_parser = MappingParser(self.global_uinputs)
def grab_fail_twice(_):
if self.failed < self.make_it_fail:
self.failed += 1
raise OSError()
evdev.InputDevice.grab = grab_fail_twice
def tearDown(self):
if self.injector is not None and self.injector.is_alive():
self.injector.stop_injecting()
time.sleep(0.2)
self.assertIn(
self.injector.get_state(),
(InjectorState.STOPPED, InjectorState.ERROR, InjectorState.NO_GRAB),
)
self.injector = None
evdev.InputDevice.grab = self.grab
def initialize_injector(self, group, preset: Preset):
self.injector = Injector(group, preset, self.mapping_parser)
self.injector._devices = self.injector.group.get_devices()
self.injector._update_preset()
def test_grab(self):
# path is from the fixtures
path = "/dev/input/event10"
preset = Preset()
preset.add(
Mapping.from_combination(
InputCombination([InputConfig(type=EV_KEY, code=10)]),
"keyboard",
"a",
)
)
self.injector = Injector(
groups.find(key="Foo Device 2"),
preset,
self.mapping_parser,
)
# this test needs to pass around all other constraints of
# _grab_device
self.injector.context = Context(preset, {}, {}, self.mapping_parser)
device = self.injector._grab_device(evdev.InputDevice(path))
gamepad = classify(device) == DeviceType.GAMEPAD
self.assertFalse(gamepad)
self.assertEqual(self.failed, 2)
# success on the third try
self.assertEqual(device.name, fixtures.get_fixture(path).name)
def test_fail_grab(self):
self.make_it_fail = 999
preset = Preset()
preset.add(
Mapping.from_combination(
InputCombination([InputConfig(type=EV_KEY, code=10)]),
"keyboard",
"a",
)
)
self.injector = Injector(
groups.find(key="Foo Device 2"),
preset,
self.mapping_parser,
)
path = "/dev/input/event10"
self.injector.context = Context(preset, {}, {}, self.mapping_parser)
device = self.injector._grab_device(evdev.InputDevice(path))
self.assertIsNone(device)
self.assertGreaterEqual(self.failed, 1)
self.assertEqual(self.injector.get_state(), InjectorState.UNKNOWN)
self.injector.start()
self.assertEqual(self.injector.get_state(), InjectorState.STARTING)
# since none can be grabbed, the process will terminate. But that
# actually takes quite some time.
time.sleep(self.injector.regrab_timeout * 12)
self.assertFalse(self.injector.is_alive())
self.assertEqual(self.injector.get_state(), InjectorState.NO_GRAB)
def test_grab_device_1(self):
device_hash = fixtures.gamepad.get_device_hash()
preset = Preset()
preset.add(
Mapping.from_combination(
InputCombination(
[
InputConfig(
type=EV_ABS,
code=ABS_HAT0X,
analog_threshold=1,
origin_hash=device_hash,
)
]
),
"keyboard",
"a",
),
)
self.initialize_injector(groups.find(name="gamepad"), preset)
self.injector.context = Context(preset, {}, {}, self.mapping_parser)
self.injector.group.paths = [
"/dev/input/event10",
"/dev/input/event30",
"/dev/input/event1234",
]
grabbed = self.injector._grab_devices()
self.assertEqual(len(grabbed), 1)
self.assertEqual(grabbed[device_hash].path, "/dev/input/event30")
def test_forward_gamepad_events(self):
device_hash = fixtures.gamepad.get_device_hash()
# forward abs joystick events
preset = Preset()
preset.add(
Mapping.from_combination(
input_combination=InputCombination(
[InputConfig(type=EV_KEY, code=BTN_A, origin_hash=device_hash)]
),
target_uinput="keyboard",
output_symbol="a",
),
)
self.initialize_injector(groups.find(name="gamepad"), preset)
self.injector.context = Context(preset, {}, {}, self.mapping_parser)
path = "/dev/input/event30"
devices = self.injector._grab_devices()
self.assertEqual(len(devices), 1)
self.assertEqual(devices[device_hash].path, path)
gamepad = classify(devices[device_hash]) == DeviceType.GAMEPAD
self.assertTrue(gamepad)
def test_skip_unused_device(self):
# skips a device because its capabilities are not used in the preset
preset = Preset()
preset.add(
Mapping.from_combination(
InputCombination([InputConfig(type=EV_KEY, code=10)]),
"keyboard",
"a",
)
)
self.initialize_injector(groups.find(key="Foo Device 2"), preset)
self.injector.context = Context(preset, {}, {}, self.mapping_parser)
# grabs only one device even though the group has 4 devices
devices = self.injector._grab_devices()
self.assertEqual(len(devices), 1)
self.assertEqual(self.failed, 2)
def test_skip_unknown_device(self):
preset = Preset()
preset.add(
Mapping.from_combination(
InputCombination([InputConfig(type=EV_KEY, code=1234)]),
"keyboard",
"a",
)
)
# skips a device because its capabilities are not used in the preset
self.initialize_injector(groups.find(key="Foo Device 2"), preset)
self.injector.context = Context(preset, {}, {}, self.mapping_parser)
devices = self.injector._grab_devices()
# skips the device alltogether, so no grab attempts fail
self.assertEqual(self.failed, 0)
self.assertEqual(devices, {})
def test_get_udev_name(self):
self.injector = Injector(
groups.find(key="Foo Device 2"),
Preset(),
self.mapping_parser,
)
suffix = "mapped"
prefix = "input-remapper"
expected = f'{prefix} {"a" * (80 - len(suffix) - len(prefix) - 2)} {suffix}'
self.assertEqual(len(expected), 80)
self.assertEqual(get_udev_name("a" * 100, suffix), expected)
self.injector.device = "abcd"
self.assertEqual(
get_udev_name("abcd", "forwarded"),
"input-remapper abcd forwarded",
)
def test_get_forward_phys(self):
path = "/dev/input/event11"
device = evdev.InputDevice(path)
self.assertEqual(
get_forward_phys(device),
"input-remapper/usb-0000:03:00.0-1/input2/input2",
)
def test_get_forward_name(self):
self.assertEqual(get_forward_name("a" * 100), "a" * 80)
self.assertEqual(get_forward_name("abcd"), "abcd")
@mock.patch("evdev.InputDevice.ungrab")
def test_capabilities_and_uinput_presence(self, ungrab_patch):
preset = Preset()
m1 = Mapping.from_combination(
InputCombination(
[
InputConfig(
type=EV_KEY,
code=KEY_A,
origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(),
)
]
),
"keyboard",
"c",
)
m2 = Mapping.from_combination(
InputCombination(
[
InputConfig(
type=EV_REL,
code=REL_HWHEEL,
analog_threshold=1,
origin_hash=fixtures.foo_device_2_mouse.get_device_hash(),
)
]
),
"keyboard",
"key(b)",
)
preset.add(m1)
preset.add(m2)
self.injector = Injector(
groups.find(key="Foo Device 2"),
preset,
self.mapping_parser,
)
self.injector.stop_injecting()
self.injector.run()
self.assertEqual(
self.injector.preset.get_mapping(
InputCombination(
[
InputConfig(
type=EV_KEY,
code=KEY_A,
origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(),
)
]
)
),
m1,
)
self.assertEqual(
self.injector.preset.get_mapping(
InputCombination(
[
InputConfig(
type=EV_REL,
code=REL_HWHEEL,
analog_threshold=1,
origin_hash=fixtures.foo_device_2_mouse.get_device_hash(),
)
]
)
),
m2,
)
# reading and preventing original events from reaching the
# display server
forwarded_foo = uinputs.get("Foo Device foo")
forwarded = uinputs.get("Foo Device")
self.assertIsNotNone(forwarded_foo)
self.assertIsNotNone(forwarded)
# copies capabilities for all other forwarded devices
self.assertIn(EV_REL, forwarded_foo.capabilities())
self.assertIn(EV_KEY, forwarded.capabilities())
self.assertEqual(sorted(forwarded.capabilities()[EV_KEY]), keyboard_keys)
self.assertEqual(
forwarded_foo.phys, "input-remapper/usb-0000:03:00.0-1/input2/input2"
)
self.assertEqual(
forwarded.phys, "input-remapper/usb-0000:03:00.0-1/input2/input3"
)
self.assertEqual(ungrab_patch.call_count, 2)
def test_injector(self):
numlock_before = is_numlock_on()
# stuff the preset outputs
keyboard_layout.clear()
code_a = 100
code_q = 101
code_w = 102
keyboard_layout._set("a", code_a)
keyboard_layout._set("key_q", code_q)
keyboard_layout._set("w", code_w)
preset = Preset()
preset.add(
Mapping.from_combination(
InputCombination(
[
InputConfig(
type=EV_KEY,
code=8,
origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(),
),
InputConfig(
type=EV_KEY,
code=9,
origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(),
),
]
),
"keyboard",
"k(KEY_Q).k(w)",
)
)
preset.add(
Mapping.from_combination(
InputCombination(
[
InputConfig(
type=EV_ABS,
code=ABS_HAT0X,
analog_threshold=-1,
origin_hash=fixtures.foo_device_2_gamepad.get_device_hash(),
)
]
),
"keyboard",
"a",
)
)
# one mapping that is unknown in the keyboard_layout on purpose
input_b = 10
with self.assertRaises(ValidationError):
preset.add(
Mapping.from_combination(
InputCombination(
[
InputConfig(
type=EV_KEY,
code=input_b,
origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(),
)
]
),
"keyboard",
"b",
)
)
self.injector = Injector(
groups.find(key="Foo Device 2"),
preset,
self.mapping_parser,
)
self.assertEqual(self.injector.get_state(), InjectorState.UNKNOWN)
self.injector.start()
self.assertEqual(self.injector.get_state(), InjectorState.STARTING)
uinput_write_history_pipe[0].poll(timeout=1)
self.assertEqual(self.injector.get_state(), InjectorState.RUNNING)
time.sleep(EVENT_READ_TIMEOUT * 10)
push_events(
fixtures.foo_device_2_keyboard,
[
# should execute a macro...
InputEvent.key(8, 1), # forwarded
InputEvent.key(9, 1), # triggers macro, not forwarding
# macro runs now and injects a few more keys
InputEvent.key(8, 0), # releases macro (needs to be forwarded as well)
InputEvent.key(9, 0), # not forwarded, just like the down-event
],
)
time.sleep(0.1) # give a chance that everything arrives in order
push_events(
fixtures.foo_device_2_gamepad,
[
# gamepad stuff. trigger a combination
InputEvent.abs(ABS_HAT0X, -1),
InputEvent.abs(ABS_HAT0X, 0),
],
)
time.sleep(0.1)
push_events(
fixtures.foo_device_2_keyboard,
[
# just pass those over without modifying
InputEvent.key(10, 1),
InputEvent.key(10, 0),
InputEvent(0, 0, 3124, 3564, 6542),
],
force=True,
)
# the injector needs time to process this
time.sleep(0.1)
# sending anything arbitrary does not stop the process
# (is_alive checked later after some time)
self.injector._msg_pipe[1].send(1234)
# convert the write history to some easier to manage list
history = read_write_history_pipe()
# 1 event before the combination was triggered
# 4 events for the macro
# 1 event for releasing the previous key-down event
# 2 for mapped keys
# 3 for forwarded events
self.assertEqual(len(history), 11)
# the first bit is ordered properly
self.assertEqual(history[0], (EV_KEY, 8, 1)) # forwarded
del history[0]
# since the macro takes a little bit of time to execute, its
# keystrokes are all over the place.
# just check if they are there and if so, remove them from the list.
# the macro itself
self.assertIn((EV_KEY, code_q, 1), history)
self.assertIn((EV_KEY, code_q, 0), history)
self.assertIn((EV_KEY, code_w, 1), history)
self.assertIn((EV_KEY, code_w, 0), history)
index_q_1 = history.index((EV_KEY, code_q, 1))
index_q_0 = history.index((EV_KEY, code_q, 0))
index_w_1 = history.index((EV_KEY, code_w, 1))
index_w_0 = history.index((EV_KEY, code_w, 0))
self.assertGreater(index_q_0, index_q_1)
self.assertGreater(index_w_1, index_q_0)
self.assertGreater(index_w_0, index_w_1)
del history[index_w_0]
del history[index_w_1]
del history[index_q_0]
del history[index_q_1]
# The rest should be in order now.
# First the released combination key which did not release the macro.
# The combination key which released the macro won't appear here, because
# it also didn't have a key-down event and therefore doesn't need to be
# released itself.
self.assertEqual(history[0], (EV_KEY, 8, 0))
# value should be 1, even if the input event was -1.
# Injected keycodes should always be either 0 or 1
self.assertEqual(history[1], (EV_KEY, code_a, 1))
self.assertEqual(history[2], (EV_KEY, code_a, 0))
self.assertEqual(history[3], (EV_KEY, input_b, 1))
self.assertEqual(history[4], (EV_KEY, input_b, 0))
self.assertEqual(history[5], (3124, 3564, 6542))
time.sleep(0.1)
self.assertTrue(self.injector.is_alive())
numlock_after = is_numlock_on()
self.assertEqual(numlock_before, numlock_after)
self.assertEqual(self.injector.get_state(), InjectorState.RUNNING)
def test_is_in_capabilities(self):
key = InputCombination(InputCombination.from_tuples((1, 2, 1)))
capabilities = {1: [9, 2, 5]}
self.assertTrue(is_in_capabilities(key, capabilities))
key = InputCombination(InputCombination.from_tuples((1, 2, 1), (1, 3, 1)))
capabilities = {1: [9, 2, 5]}
# only one of the codes of the combination is required.
# The goal is to make combinations= across those sub-devices possible,
# that make up one hardware device
self.assertTrue(is_in_capabilities(key, capabilities))
key = InputCombination(InputCombination.from_tuples((1, 2, 1), (1, 5, 1)))
capabilities = {1: [9, 2, 5]}
self.assertTrue(is_in_capabilities(key, capabilities))
@test_setup
class TestModifyCapabilities(unittest.TestCase):
def setUp(self):
self.global_uinputs = GlobalUInputs(UInput)
self.global_uinputs.prepare_all()
self.mapping_parser = MappingParser(self.global_uinputs)
class FakeDevice:
def __init__(self):
self._capabilities = {
evdev.ecodes.EV_SYN: [1, 2, 3],
evdev.ecodes.EV_FF: [1, 2, 3],
EV_ABS: [
(
1,
evdev.AbsInfo(
value=None,
min=None,
max=1234,
fuzz=None,
flat=None,
resolution=None,
),
),
(
2,
evdev.AbsInfo(
value=None,
min=50,
max=2345,
fuzz=None,
flat=None,
resolution=None,
),
),
3,
],
}
def capabilities(self, absinfo=False):
assert absinfo is True
return self._capabilities
preset = Preset()
preset.add(
Mapping.from_combination(
InputCombination([InputConfig(type=EV_KEY, code=80)]),
"keyboard",
"a",
)
)
preset.add(
Mapping.from_combination(
InputCombination([InputConfig(type=EV_KEY, code=81)]),
"keyboard",
DISABLE_NAME,
),
)
macro_code = "r(2, m(sHiFt_l, r(2, k(1).k(2))))"
macro = Parser.parse(macro_code, preset)
preset.add(
Mapping.from_combination(
InputCombination([InputConfig(type=EV_KEY, code=60)]),
"keyboard",
macro_code,
),
)
# going to be ignored, because EV_REL cannot be mapped, that's
# mouse movements.
preset.add(
Mapping.from_combination(
InputCombination(
[InputConfig(type=EV_REL, code=1234, analog_threshold=3)]
),
"keyboard",
"b",
),
)
self.a = keyboard_layout.get("a")
self.shift_l = keyboard_layout.get("ShIfT_L")
self.one = keyboard_layout.get(1)
self.two = keyboard_layout.get("2")
self.left = keyboard_layout.get("BtN_lEfT")
self.fake_device = FakeDevice()
self.preset = preset
self.macro = macro
def check_keys(self, capabilities):
"""No matter the configuration, EV_KEY will be mapped to EV_KEY."""
self.assertIn(EV_KEY, capabilities)
keys = capabilities[EV_KEY]
self.assertIn(self.a, keys)
self.assertIn(self.one, keys)
self.assertIn(self.two, keys)
self.assertIn(self.shift_l, keys)
self.assertNotIn(DISABLE_CODE, keys)
def test_copy_capabilities(self):
# I don't know what ABS_VOLUME is, for now I would like to just always
# remove it until somebody complains, since its presence broke stuff
self.injector = Injector(mock.Mock(), self.preset, self.mapping_parser)
self.fake_device._capabilities = {
EV_ABS: [ABS_VOLUME, (ABS_X, evdev.AbsInfo(0, 0, 500, 0, 0, 0))],
EV_KEY: [1, 2, 3],
EV_REL: [11, 12, 13],
evdev.ecodes.EV_SYN: [1],
evdev.ecodes.EV_FF: [2],
}
capabilities = self.injector._copy_capabilities(self.fake_device)
self.assertNotIn(ABS_VOLUME, capabilities[EV_ABS])
self.assertNotIn(evdev.ecodes.EV_SYN, capabilities)
self.assertNotIn(evdev.ecodes.EV_FF, capabilities)
self.assertListEqual(capabilities[EV_KEY], [1, 2, 3])
self.assertListEqual(capabilities[EV_REL], [11, 12, 13])
self.assertEqual(capabilities[EV_ABS][0][1].max, 500)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,559 @@
#!/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
from evdev.ecodes import (
EV_KEY,
EV_ABS,
EV_REL,
BTN_C,
BTN_B,
BTN_A,
BTN_LEFT,
BTN_MIDDLE,
BTN_RIGHT,
BTN_SIDE,
BTN_EXTRA,
BTN_FORWARD,
BTN_BACK,
BTN_TASK,
REL_X,
REL_Y,
REL_WHEEL,
REL_HWHEEL,
ABS_RY,
ABS_X,
ABS_HAT0Y,
ABS_HAT0X,
KEY_A,
KEY_LEFTSHIFT,
KEY_RIGHTALT,
KEY_LEFTCTRL,
)
from inputremapper.configs.input_config import InputCombination, InputConfig
from tests.lib.test_setup import test_setup
@test_setup
class TestInputConfig(unittest.TestCase):
def test_input_config(self):
test_cases = [
# basic test, nothing fancy here
{
"input": {
"type": EV_KEY,
"code": KEY_A,
"origin_hash": "foo",
},
"properties": {
"type": EV_KEY,
"code": KEY_A,
"origin_hash": "foo",
"input_match_hash": (EV_KEY, KEY_A, "foo"),
"defines_analog_input": False,
"type_and_code": (EV_KEY, KEY_A),
},
"methods": [
{
"name": "description",
"args": (),
"kwargs": {},
"return": "a",
},
{
"name": "__hash__",
"args": (),
"kwargs": {},
"return": hash((EV_KEY, KEY_A, "foo", None)),
},
],
},
# removes analog_threshold
{
"input": {
"type": EV_KEY,
"code": KEY_A,
"origin_hash": "foo",
"analog_threshold": 10,
},
"properties": {
"type": EV_KEY,
"code": KEY_A,
"origin_hash": "foo",
"analog_threshold": None,
"input_match_hash": (EV_KEY, KEY_A, "foo"),
"defines_analog_input": False,
"type_and_code": (EV_KEY, KEY_A),
},
"methods": [
{
"name": "description",
"args": (),
"kwargs": {},
"return": "a",
},
{
"name": "__hash__",
"args": (),
"kwargs": {},
"return": hash((EV_KEY, KEY_A, "foo", None)),
},
],
},
# abs to btn
{
"input": {
"type": EV_ABS,
"code": ABS_X,
"origin_hash": "foo",
"analog_threshold": 10,
},
"properties": {
"type": EV_ABS,
"code": ABS_X,
"origin_hash": "foo",
"analog_threshold": 10,
"input_match_hash": (EV_ABS, ABS_X, "foo"),
"defines_analog_input": False,
"type_and_code": (EV_ABS, ABS_X),
},
"methods": [
{
"name": "description",
"args": (),
"kwargs": {},
"return": "Joystick-X Right 10%",
},
{
"name": "description",
"args": (),
"kwargs": {"exclude_threshold": True},
"return": "Joystick-X Right",
},
{
"name": "description",
"args": (),
"kwargs": {
"exclude_threshold": True,
"exclude_direction": True,
},
"return": "Joystick-X",
},
{
"name": "__hash__",
"args": (),
"kwargs": {},
"return": hash((EV_ABS, ABS_X, "foo", 10)),
},
],
},
# abs to btn with d-pad
{
"input": {
"type": EV_ABS,
"code": ABS_HAT0Y,
"origin_hash": "foo",
"analog_threshold": 10,
},
"properties": {
"type": EV_ABS,
"code": ABS_HAT0Y,
"origin_hash": "foo",
"analog_threshold": 10,
"input_match_hash": (EV_ABS, ABS_HAT0Y, "foo"),
"defines_analog_input": False,
"type_and_code": (EV_ABS, ABS_HAT0Y),
},
"methods": [
{
"name": "description",
"args": (),
"kwargs": {},
"return": "DPad-Y Down 10%",
},
{
"name": "__hash__",
"args": (),
"kwargs": {},
"return": hash((EV_ABS, ABS_HAT0Y, "foo", 10)),
},
],
},
# rel to btn
{
"input": {
"type": EV_REL,
"code": REL_Y,
"origin_hash": "foo",
"analog_threshold": 10,
},
"properties": {
"type": EV_REL,
"code": REL_Y,
"origin_hash": "foo",
"analog_threshold": 10,
"input_match_hash": (EV_REL, REL_Y, "foo"),
"defines_analog_input": False,
"type_and_code": (EV_REL, REL_Y),
},
"methods": [
{
"name": "description",
"args": (),
"kwargs": {},
"return": "Y Down 10",
},
{
"name": "__hash__",
"args": (),
"kwargs": {},
"return": hash((EV_REL, REL_Y, "foo", 10)),
},
],
},
# abs as axis
{
"input": {
"type": EV_ABS,
"code": ABS_X,
"origin_hash": "foo",
"analog_threshold": 0,
},
"properties": {
"type": EV_ABS,
"code": ABS_X,
"origin_hash": "foo",
"analog_threshold": None,
"input_match_hash": (EV_ABS, ABS_X, "foo"),
"defines_analog_input": True,
"type_and_code": (EV_ABS, ABS_X),
},
"methods": [
{
"name": "description",
"args": (),
"kwargs": {},
"return": "Joystick-X",
},
{
"name": "description",
"args": (),
"kwargs": {
"exclude_threshold": True,
"exclude_direction": True,
},
"return": "Joystick-X",
},
{
"name": "__hash__",
"args": (),
"kwargs": {},
"return": hash((EV_ABS, ABS_X, "foo", None)),
},
],
},
# rel as axis
{
"input": {
"type": EV_REL,
"code": REL_WHEEL,
"origin_hash": "foo",
},
"properties": {
"type": EV_REL,
"code": REL_WHEEL,
"origin_hash": "foo",
"analog_threshold": None,
"input_match_hash": (EV_REL, REL_WHEEL, "foo"),
"defines_analog_input": True,
"type_and_code": (EV_REL, REL_WHEEL),
},
"methods": [
{
"name": "description",
"args": (),
"kwargs": {},
"return": "Wheel",
},
{
"name": "__hash__",
"args": (),
"kwargs": {},
"return": hash((EV_REL, REL_WHEEL, "foo", None)),
},
],
},
]
for test_case in test_cases:
input_config = InputConfig(**test_case["input"])
for property_, value in test_case["properties"].items():
self.assertEqual(
value,
getattr(input_config, property_),
f"property mismatch for input: {test_case['input']} "
f"property: {property_} expected value: {value}",
)
for method in test_case["methods"]:
self.assertEqual(
method["return"],
getattr(input_config, method["name"])(
*method["args"], **method["kwargs"]
),
f"wrong method return for input: {test_case['input']} "
f"method: {method}",
)
def test_is_immutable(self):
input_config = InputConfig(type=1, code=2)
with self.assertRaises(TypeError):
input_config.origin_hash = "foo"
@test_setup
class TestInputCombination(unittest.TestCase):
def test_eq(self):
a = InputCombination(
[
InputConfig(type=EV_REL, code=REL_X, value=1, origin_hash="1234"),
InputConfig(type=EV_KEY, code=KEY_A, value=1, origin_hash="abcd"),
]
)
b = InputCombination(
[
InputConfig(type=EV_REL, code=REL_X, value=1, origin_hash="1234"),
InputConfig(type=EV_KEY, code=KEY_A, value=1, origin_hash="abcd"),
]
)
self.assertEqual(a, b)
def test_not_eq(self):
a = InputCombination(
[
InputConfig(type=EV_REL, code=REL_X, value=1, origin_hash="2345"),
InputConfig(type=EV_KEY, code=KEY_A, value=1, origin_hash="bcde"),
]
)
b = InputCombination(
[
InputConfig(type=EV_REL, code=REL_X, value=1, origin_hash="1234"),
InputConfig(type=EV_KEY, code=KEY_A, value=1, origin_hash="abcd"),
]
)
self.assertNotEqual(a, b)
def test_can_be_used_as_dict_key(self):
dict_ = {
InputCombination(
[
InputConfig(type=EV_REL, code=REL_X, value=1, origin_hash="1234"),
InputConfig(type=EV_KEY, code=KEY_A, value=1, origin_hash="abcd"),
]
): "foo"
}
key = InputCombination(
[
InputConfig(type=EV_REL, code=REL_X, value=1, origin_hash="1234"),
InputConfig(type=EV_KEY, code=KEY_A, value=1, origin_hash="abcd"),
]
)
self.assertEqual(dict_.get(key), "foo")
def test_get_permutations(self):
key_1 = InputCombination(InputCombination.from_tuples((1, 3, 1)))
self.assertEqual(len(key_1.get_permutations()), 1)
self.assertEqual(key_1.get_permutations()[0], key_1)
key_2 = InputCombination(InputCombination.from_tuples((1, 3, 1), (1, 5, 1)))
self.assertEqual(len(key_2.get_permutations()), 1)
self.assertEqual(key_2.get_permutations()[0], key_2)
key_3 = InputCombination(
InputCombination.from_tuples((1, 3, 1), (1, 5, 1), (1, 7, 1))
)
self.assertEqual(len(key_3.get_permutations()), 2)
self.assertEqual(
key_3.get_permutations()[0],
InputCombination(
InputCombination.from_tuples((1, 3, 1), (1, 5, 1), (1, 7, 1))
),
)
self.assertEqual(
key_3.get_permutations()[1],
InputCombination(
InputCombination.from_tuples((1, 5, 1), (1, 3, 1), (1, 7, 1))
),
)
def test_is_problematic(self):
key_1 = InputCombination(
InputCombination.from_tuples((1, KEY_LEFTSHIFT, 1), (1, 5, 1))
)
self.assertTrue(key_1.is_problematic())
key_2 = InputCombination(
InputCombination.from_tuples((1, KEY_RIGHTALT, 1), (1, 5, 1))
)
self.assertTrue(key_2.is_problematic())
key_3 = InputCombination(
InputCombination.from_tuples((1, 3, 1), (1, KEY_LEFTCTRL, 1))
)
self.assertTrue(key_3.is_problematic())
key_4 = InputCombination(InputCombination.from_tuples((1, 3, 1)))
self.assertFalse(key_4.is_problematic())
key_5 = InputCombination(InputCombination.from_tuples((1, 3, 1), (1, 5, 1)))
self.assertFalse(key_5.is_problematic())
def test_init(self):
self.assertRaises(TypeError, lambda: InputCombination(1))
self.assertRaises(TypeError, lambda: InputCombination(None))
self.assertRaises(TypeError, lambda: InputCombination([1]))
self.assertRaises(TypeError, lambda: InputCombination((1,)))
self.assertRaises(TypeError, lambda: InputCombination((1, 2)))
self.assertRaises(TypeError, lambda: InputCombination("1"))
self.assertRaises(TypeError, lambda: InputCombination("(1,2,3)"))
self.assertRaises(
TypeError,
lambda: InputCombination(((1, 2, 3), (1, 2, 3), None)),
)
# those don't raise errors
InputCombination(({"type": 1, "code": 2}, {"type": 1, "code": 1}))
InputCombination(({"type": 1, "code": 2},))
InputCombination(({"type": "1", "code": "2"},))
InputCombination([InputConfig(type=1, code=2, analog_threshold=3)])
InputCombination(
(
{"type": 1, "code": 2},
{"type": "1", "code": "2"},
InputConfig(type=1, code=2),
)
)
def test_to_config(self):
c1 = InputCombination([InputConfig(type=1, code=2, analog_threshold=3)])
c2 = InputCombination(
(
InputConfig(type=1, code=2, analog_threshold=3),
InputConfig(type=4, code=5, analog_threshold=6),
)
)
# analog_threshold is removed for key events
self.assertEqual(c1.to_config(), ({"type": 1, "code": 2},))
self.assertEqual(
c2.to_config(),
({"type": 1, "code": 2}, {"type": 4, "code": 5, "analog_threshold": 6}),
)
def test_beautify(self):
# not an integration test, but I have all the selection_label tests here already
self.assert_beautify_single(EV_KEY, KEY_A, 1, "a")
self.assert_beautify_single(EV_KEY, KEY_A, 1, "a")
self.assert_beautify_single(EV_ABS, ABS_HAT0Y, -1, "DPad-Y Up")
self.assert_beautify_single(EV_KEY, BTN_A, 1, "Button A")
self.assert_beautify_single(EV_KEY, 1234, 1, "unknown (1, 1234)")
self.assert_beautify_single(EV_ABS, ABS_HAT0X, -1, "DPad-X Left")
self.assert_beautify_single(EV_ABS, ABS_HAT0Y, -1, "DPad-Y Up")
self.assert_beautify_single(EV_KEY, BTN_A, 1, "Button A")
self.assert_beautify_single(EV_ABS, ABS_X, 1, "Joystick-X Right")
self.assert_beautify_single(EV_ABS, ABS_RY, 1, "Joystick-RY Down")
self.assert_beautify_single(EV_REL, REL_HWHEEL, 1, "Wheel Right")
self.assert_beautify_single(EV_REL, REL_WHEEL, -1, "Wheel Down")
# region "mouse buttons"
self.assert_beautify_single(EV_KEY, BTN_LEFT, 1, "Mouse Button LEFT")
self.assert_beautify_single(EV_KEY, BTN_MIDDLE, 1, "Mouse Button MIDDLE")
self.assert_beautify_single(EV_KEY, BTN_RIGHT, 1, "Mouse Button RIGHT")
self.assert_beautify_single(EV_KEY, BTN_SIDE, 1, "Mouse Button 4")
self.assert_beautify_single(EV_KEY, BTN_EXTRA, 1, "Mouse Button 5")
self.assert_beautify_single(EV_KEY, BTN_FORWARD, 1, "Mouse Button 6")
self.assert_beautify_single(EV_KEY, BTN_BACK, 1, "Mouse Button 7")
self.assert_beautify_single(EV_KEY, BTN_TASK, 1, "Mouse Button 8")
# Mouse buttons 9+ do not have an evdev name, and so must be expressed
# as an offset from BTN_LEFT AKA "Button 1". Subtract 1 for base index
# so that `btn_mouse_base + 1` would be "Button 1".
btn_mouse_base: int = BTN_LEFT - 1
self.assert_beautify_single(EV_KEY, btn_mouse_base + 9, 1, "Mouse Button 9")
self.assert_beautify_single(EV_KEY, btn_mouse_base + 10, 1, "Mouse Button 10")
# endregion "mouse buttons"
# combinations
self.assertEqual(
InputCombination(
InputCombination.from_tuples(
(EV_KEY, BTN_A, 1),
(EV_KEY, BTN_B, 1),
(EV_KEY, BTN_C, 1),
),
).beautify(),
"Button A + Button B + Button C",
)
def test_find_analog_input_config(self):
analog_input = InputConfig(type=EV_REL, code=REL_X)
combination = InputCombination(
(
InputConfig(type=EV_KEY, code=BTN_MIDDLE),
InputConfig(type=EV_REL, code=REL_Y, analog_threshold=1),
analog_input,
)
)
self.assertIsNone(combination.find_analog_input_config(type_=EV_ABS))
self.assertEqual(
combination.find_analog_input_config(type_=EV_REL), analog_input
)
self.assertEqual(combination.find_analog_input_config(), analog_input)
combination = InputCombination(
(
InputConfig(type=EV_REL, code=REL_X, analog_threshold=1),
InputConfig(type=EV_KEY, code=BTN_MIDDLE),
)
)
self.assertIsNone(combination.find_analog_input_config(type_=EV_ABS))
self.assertIsNone(combination.find_analog_input_config(type_=EV_REL))
self.assertIsNone(combination.find_analog_input_config())
# region helper methods
def assert_beautify_single(self, type_, code, direction, expected_beautified_name):
"""
Assert that the beautified name of the event tuple `(type_, code,
direction)` matches the `expected_beautified_name`.
"""
self.assertEqual(
InputCombination(
InputCombination.from_tuples((type_, code, direction))
).beautify(),
expected_beautified_name,
)
# endregion
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,132 @@
#!/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 evdev
from dataclasses import FrozenInstanceError
from inputremapper.input_event import InputEvent
from tests.lib.test_setup import test_setup
@test_setup
class TestInputEvent(unittest.TestCase):
def test_from_event(self):
e1 = InputEvent.from_event(evdev.InputEvent(1, 2, 3, 4, 5))
e2 = InputEvent.from_event(e1)
self.assertEqual(e1, e2)
self.assertEqual(e1.sec, 1)
self.assertEqual(e1.usec, 2)
self.assertEqual(e1.type, 3)
self.assertEqual(e1.code, 4)
self.assertEqual(e1.value, 5)
self.assertEqual(e1.sec, e2.sec)
self.assertEqual(e1.usec, e2.usec)
self.assertEqual(e1.type, e2.type)
self.assertEqual(e1.code, e2.code)
self.assertEqual(e1.value, e2.value)
self.assertRaises(TypeError, InputEvent.from_event, "1,2,3")
def test_from_event_tuple(self):
t1 = (1, 2, 3)
t2 = (1, "2", 3)
t3 = (1, 2, 3, 4, 5)
t4 = (1, "b", 3)
e1 = InputEvent.from_tuple(t1)
e2 = InputEvent.from_tuple(t2)
self.assertEqual(e1, e2)
self.assertEqual(e1.sec, 0)
self.assertEqual(e1.usec, 0)
self.assertEqual(e1.type, 1)
self.assertEqual(e1.code, 2)
self.assertEqual(e1.value, 3)
def test_properties(self):
e1 = InputEvent.from_tuple((evdev.ecodes.EV_KEY, evdev.ecodes.BTN_LEFT, 1))
self.assertEqual(
e1.event_tuple,
(evdev.ecodes.EV_KEY, evdev.ecodes.BTN_LEFT, 1),
)
self.assertEqual(e1.type_and_code, (evdev.ecodes.EV_KEY, evdev.ecodes.BTN_LEFT))
with self.assertRaises(
FrozenInstanceError
): # would be TypeError on a slotted class
e1.event_tuple = (1, 2, 3)
with self.assertRaises(
FrozenInstanceError
): # would be TypeError on a slotted class
e1.type_and_code = (1, 2)
with self.assertRaises(FrozenInstanceError):
e1.value = 5
def test_modify(self):
e1 = InputEvent(1, 2, 3, 4, 5)
e2 = e1.modify(value=6)
e3 = e1.modify(sec=0, usec=0, type_=0, code=0, value=0)
self.assertNotEqual(e1, e2)
self.assertEqual(e1.sec, e2.sec)
self.assertEqual(e1.usec, e2.usec)
self.assertEqual(e1.type, e2.type)
self.assertEqual(e1.code, e2.code)
self.assertNotEqual(e1.value, e2.value)
self.assertEqual(e3.sec, 0)
self.assertEqual(e3.usec, 0)
self.assertEqual(e3.type, 0)
self.assertEqual(e3.code, 0)
self.assertEqual(e3.value, 0)
def test_is_wheel_event(self):
input_event_x = InputEvent(
0,
0,
evdev.ecodes.EV_REL,
evdev.ecodes.REL_X,
1,
)
self.assertFalse(input_event_x.is_wheel_event)
self.assertFalse(input_event_x.is_wheel_hi_res_event)
input_event_wheel = InputEvent(
0,
0,
evdev.ecodes.EV_REL,
evdev.ecodes.REL_WHEEL,
1,
)
self.assertTrue(input_event_wheel.is_wheel_event)
self.assertFalse(input_event_wheel.is_wheel_hi_res_event)
input_event_wheel_hi_res = InputEvent(
0,
0,
evdev.ecodes.EV_REL,
evdev.ecodes.REL_WHEEL_HI_RES,
1,
)
self.assertFalse(input_event_wheel_hi_res.is_wheel_event)
self.assertTrue(input_event_wheel_hi_res.is_wheel_hi_res_event)

208
tests/unit/test_ipc.py Normal file
View File

@ -0,0 +1,208 @@
#!/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 multiprocessing
import os
import select
import time
import unittest
from inputremapper.ipc.pipe import Pipe
from inputremapper.ipc.shared_dict import SharedDict
from inputremapper.ipc.socket import Server, Client, Base
from tests.lib.test_setup import test_setup
from tests.lib.tmp import tmp
@test_setup
class TestSharedDict(unittest.TestCase):
def setUp(self):
self.shared_dict = SharedDict()
self.shared_dict.start()
time.sleep(0.02)
def test_returns_none(self):
self.assertIsNone(self.shared_dict.get("a"))
self.assertIsNone(self.shared_dict["a"])
def test_set_get(self):
self.shared_dict["a"] = 3
self.assertEqual(self.shared_dict.get("a"), 3)
self.assertEqual(self.shared_dict["a"], 3)
@test_setup
class TestSocket(unittest.TestCase):
def test_socket(self):
def test(s1, s2):
self.assertEqual(s2.recv(), None)
s1.send(1)
self.assertTrue(s2.poll())
self.assertEqual(s2.recv(), 1)
self.assertFalse(s2.poll())
self.assertEqual(s2.recv(), None)
s1.send(2)
self.assertTrue(s2.poll())
s1.send(3)
self.assertTrue(s2.poll())
self.assertEqual(s2.recv(), 2)
self.assertTrue(s2.poll())
self.assertEqual(s2.recv(), 3)
self.assertFalse(s2.poll())
self.assertEqual(s2.recv(), None)
server = Server(os.path.join(tmp, "socket1"))
client = Client(os.path.join(tmp, "socket1"))
test(server, client)
client = Client(os.path.join(tmp, "socket2"))
server = Server(os.path.join(tmp, "socket2"))
test(client, server)
def test_not_connected_1(self):
# client discards old message, because it might have had a purpose
# for a different client and not for the current one
server = Server(os.path.join(tmp, "socket3"))
server.send(1)
client = Client(os.path.join(tmp, "socket3"))
server.send(2)
self.assertTrue(client.poll())
self.assertEqual(client.recv(), 2)
self.assertFalse(client.poll())
self.assertEqual(client.recv(), None)
def test_not_connected_2(self):
client = Client(os.path.join(tmp, "socket4"))
client.send(1)
server = Server(os.path.join(tmp, "socket4"))
client.send(2)
self.assertTrue(server.poll())
self.assertEqual(server.recv(), 2)
self.assertFalse(server.poll())
self.assertEqual(server.recv(), None)
def test_select(self):
"""Is compatible to select.select."""
server = Server(os.path.join(tmp, "socket6"))
client = Client(os.path.join(tmp, "socket6"))
server.send(1)
ready = select.select([client], [], [], 0)[0][0]
self.assertEqual(ready, client)
client.send(2)
ready = select.select([server], [], [], 0)[0][0]
self.assertEqual(ready, server)
def test_base_abstract(self):
self.assertRaises(NotImplementedError, lambda: Base("foo"))
self.assertRaises(NotImplementedError, lambda: Base.connect(None))
self.assertRaises(NotImplementedError, lambda: Base.reconnect(None))
self.assertRaises(NotImplementedError, lambda: Base.fileno(None))
@test_setup
class TestPipe(unittest.IsolatedAsyncioTestCase):
def test_pipe_single(self):
p1 = Pipe(os.path.join(tmp, "pipe"))
self.assertEqual(p1.recv(), None)
p1.send(1)
self.assertTrue(p1.poll())
self.assertEqual(p1.recv(), 1)
self.assertFalse(p1.poll())
self.assertEqual(p1.recv(), None)
p1.send(2)
self.assertTrue(p1.poll())
p1.send(3)
self.assertTrue(p1.poll())
self.assertEqual(p1.recv(), 2)
self.assertTrue(p1.poll())
self.assertEqual(p1.recv(), 3)
self.assertFalse(p1.poll())
self.assertEqual(p1.recv(), None)
def test_pipe_duo(self):
p1 = Pipe(os.path.join(tmp, "pipe"))
p2 = Pipe(os.path.join(tmp, "pipe"))
self.assertEqual(p2.recv(), None)
p1.send(1)
self.assertEqual(p2.recv(), 1)
self.assertEqual(p2.recv(), None)
p1.send(2)
p1.send(3)
self.assertEqual(p2.recv(), 2)
self.assertEqual(p2.recv(), 3)
self.assertEqual(p2.recv(), None)
async def test_async_for_loop(self):
p1 = Pipe(os.path.join(tmp, "pipe"))
iterator = p1.__aiter__()
p1.send(1)
self.assertEqual(await iterator.__anext__(), 1)
read_task = asyncio.Task(iterator.__anext__())
timeout_task = asyncio.Task(asyncio.sleep(1))
done, pending = await asyncio.wait(
(read_task, timeout_task), return_when=asyncio.FIRST_COMPLETED
)
self.assertIn(timeout_task, done)
self.assertIn(read_task, pending)
read_task.cancel()
async def test_async_for_loop_duo(self):
def writer():
p = Pipe(os.path.join(tmp, "pipe"))
for i in range(3):
p.send(i)
time.sleep(0.5)
for i in range(3):
p.send(i)
time.sleep(0.1)
p.send("stop now")
p1 = Pipe(os.path.join(tmp, "pipe"))
w_process = multiprocessing.Process(target=writer)
w_process.start()
messages = []
async for msg in p1:
messages.append(msg)
if msg == "stop now":
break
self.assertEqual(messages, [0, 1, 2, 0, 1, 2, "stop now"])
if __name__ == "__main__":
unittest.main()

151
tests/unit/test_logger.py Normal file
View File

@ -0,0 +1,151 @@
#!/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/>.
from tests.lib.tmp import tmp
import logging
import os
import shutil
import unittest
import evdev
from inputremapper.configs.paths import PathUtils
from inputremapper.logging.logger import (
logger,
ColorfulFormatter,
)
from tests.lib.test_setup import test_setup
def add_filehandler(log_path: str, debug: bool) -> None:
"""Start logging to a file."""
log_path = os.path.expanduser(log_path)
os.makedirs(os.path.dirname(log_path), exist_ok=True)
file_handler = logging.FileHandler(log_path)
file_handler.setFormatter(ColorfulFormatter(debug))
logger.addHandler(file_handler)
logger.info('Starting logging to "%s"', log_path)
@test_setup
class TestLogger(unittest.TestCase):
def tearDown(self):
logger.update_verbosity(debug=True)
# remove the file handler
logger.handlers = [
handler
for handler in logger.handlers
if not isinstance(logger.handlers, logging.FileHandler)
]
path = os.path.join(tmp, "logger-test")
PathUtils.remove(path)
def test_write(self):
uinput = evdev.UInput(name="foo")
path = os.path.join(tmp, "logger-test")
add_filehandler(path, False)
logger.write((evdev.ecodes.EV_KEY, evdev.ecodes.KEY_A, 1), uinput)
with open(path, "r") as f:
content = f.read()
self.assertIn(
'Writing (1, 30, 1) to "foo"',
content,
)
def test_log_info(self):
logger.update_verbosity(debug=False)
path = os.path.join(tmp, "logger-test")
add_filehandler(path, False)
logger.log_info()
with open(path, "r") as f:
content = f.read().lower()
self.assertIn("input-remapper", content)
def test_makes_path(self):
path = os.path.join(tmp, "logger-test")
if os.path.exists(path):
shutil.rmtree(path)
new_path = os.path.join(tmp, "logger-test", "a", "b", "c")
add_filehandler(new_path, False)
self.assertTrue(os.path.exists(new_path))
def test_debug(self):
path = os.path.join(tmp, "logger-test")
logger.update_verbosity(True)
add_filehandler(path, True)
logger.error("abc")
logger.warning("foo")
logger.info("123")
logger.debug("456")
logger.debug("789")
with open(path, "r") as f:
content = f.read().lower()
self.assertIn("logger.py", content)
self.assertIn("error", content)
self.assertIn("abc", content)
self.assertIn("warn", content)
self.assertIn("foo", content)
self.assertIn("info", content)
self.assertIn("123", content)
self.assertIn("debug", content)
self.assertIn("456", content)
self.assertIn("debug", content)
self.assertIn("789", content)
def test_default(self):
path = os.path.join(tmp, "logger-test")
logger.update_verbosity(debug=False)
add_filehandler(path, False)
logger.error("abc")
logger.warning("foo")
logger.info("123")
logger.debug("456")
logger.debug("789")
with open(path, "r") as f:
content = f.read().lower()
self.assertNotIn("logger.py", content)
self.assertNotIn("line", content)
self.assertIn("error", content)
self.assertIn("abc", content)
self.assertIn("warn", content)
self.assertIn("foo", content)
self.assertNotIn("info", content)
self.assertIn("123", content)
self.assertNotIn("debug", content)
self.assertNotIn("456", content)
self.assertNotIn("debug", content)
self.assertNotIn("789", content)
if __name__ == "__main__":
unittest.main()

View File

View File

@ -0,0 +1,120 @@
#!/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 unittest
from inputremapper.configs.preset import Preset
from inputremapper.configs.validation_errors import MacroError
from inputremapper.injection.context import Context
from inputremapper.injection.global_uinputs import GlobalUInputs, UInput
from inputremapper.injection.macros.macro import Macro, macro_variables
from inputremapper.injection.macros.parse import Parser
from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser
from tests.lib.fixtures import fixtures
from tests.lib.logger import logger
from tests.lib.patches import InputDevice
class MacroTestBase(unittest.IsolatedAsyncioTestCase):
@classmethod
def setUpClass(cls):
macro_variables.start()
def setUp(self):
self.result = []
self.global_uinputs = GlobalUInputs(UInput)
self.mapping_parser = MappingParser(self.global_uinputs)
try:
self.loop = asyncio.get_event_loop()
except RuntimeError:
# suddenly "There is no current event loop in thread 'MainThread'"
# errors started to appear
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)
self.source_device = InputDevice(fixtures.bar_device.path)
self.context = Context(
Preset(),
source_devices={fixtures.bar_device.get_device_hash(): self.source_device},
forward_devices={},
mapping_parser=self.mapping_parser,
)
def tearDown(self):
self.result = []
def handler(self, type_: int, code: int, value: int):
"""Where macros should write codes to."""
logger.info(f"macro wrote{(type_, code, value)}")
self.result.append((type_, code, value))
async def trigger_sequence(self, macro: Macro, event):
for listener in self.context.listeners:
asyncio.ensure_future(listener(event))
# this still might cause race conditions and the test to fail
await asyncio.sleep(0)
macro.press_trigger()
if macro.running:
return
asyncio.ensure_future(macro.run(self.handler))
async def release_sequence(self, macro: Macro, event):
for listener in self.context.listeners:
asyncio.ensure_future(listener(event))
# this still might cause race conditions and the test to fail
await asyncio.sleep(0)
macro.release_trigger()
def count_child_macros(self, macro) -> int:
count = 0
for task in macro.tasks:
count += len(task.child_macros)
for child_macro in task.child_macros:
count += self.count_child_macros(child_macro)
return count
def count_tasks(self, macro) -> int:
count = len(macro.tasks)
for task in macro.tasks:
for child_macro in task.child_macros:
count += self.count_tasks(child_macro)
return count
def expect_string_in_error(self, string: str, macro: str):
with self.assertRaises(MacroError) as cm:
Parser.parse(macro, self.context)
error = str(cm.exception)
self.assertIn(string, error)
class DummyMapping:
macro_key_sleep_ms = 10
rel_rate = 60
target_uinput = "keyboard + mouse"
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,71 @@
#!/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
from inputremapper.configs.validation_errors import MacroError
from inputremapper.injection.macros.macro import macro_variables
from inputremapper.injection.macros.parse import Parser
from tests.lib.test_setup import test_setup
from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase
@test_setup
class TestAdd(MacroTestBase):
async def test_add(self):
await Parser.parse("set(a, 1).add(a, 1)", self.context, DummyMapping).run(
self.handler
)
self.assertEqual(macro_variables.get("a"), 2)
await Parser.parse("set(b, 1).add(b, -1)", self.context, DummyMapping).run(
self.handler
)
self.assertEqual(macro_variables.get("b"), 0)
await Parser.parse("set(c, -1).add(c, 500)", self.context, DummyMapping).run(
self.handler
)
self.assertEqual(macro_variables.get("c"), 499)
await Parser.parse("add(d, 500)", self.context, DummyMapping).run(self.handler)
self.assertEqual(macro_variables.get("d"), 500)
async def test_add_invalid(self):
# For invalid input it should do nothing (except to log to the console)
await Parser.parse('set(e, "foo").add(e, 1)', self.context, DummyMapping).run(
self.handler
)
self.assertEqual(macro_variables.get("e"), "foo")
await Parser.parse('set(e, "2").add(e, 3)', self.context, DummyMapping).run(
self.handler
)
self.assertEqual(macro_variables.get("e"), "2")
async def test_raises_error(self):
Parser.parse("add(a, 1)", self.context) # no error
self.assertRaises(MacroError, Parser.parse, "add(a, b)", self.context)
self.assertRaises(MacroError, Parser.parse, 'add(a, "1")', self.context)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,187 @@
#!/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
from inputremapper.configs.validation_errors import (
MacroError,
)
from inputremapper.injection.macros.argument import Argument, ArgumentConfig
from inputremapper.injection.macros.macro import Macro, macro_variables
from inputremapper.injection.macros.raw_value import RawValue
from inputremapper.injection.macros.variable import Variable
from tests.lib.test_setup import test_setup
from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase
@test_setup
class TestArgument(MacroTestBase):
def test_resolve(self):
self.assertEqual(Variable("a", const=True).get_value(), "a")
self.assertEqual(Variable(1, const=True).get_value(), 1)
self.assertEqual(Variable(None, const=True).get_value(), None)
# $ is part of a custom string here
self.assertEqual(Variable('"$a"', const=True).get_value(), '"$a"')
self.assertEqual(Variable("'$a'", const=True).get_value(), "'$a'")
self.assertEqual(Variable("$a", const=True).get_value(), "$a")
variable = Variable("a", const=False)
self.assertEqual(variable.get_value(), None)
macro_variables["a"] = 1
self.assertEqual(variable.get_value(), 1)
def test_type_check(self):
def test(value, types, name, position):
argument = Argument(
ArgumentConfig(
types=types,
name=name,
position=position,
),
DummyMapping(),
)
argument.initialize_variable(RawValue(value=value))
return argument.get_value()
def test_variable(variable, types, name, position):
argument = Argument(
ArgumentConfig(
types=types,
name=name,
position=position,
),
DummyMapping(),
)
argument._variable = variable
return argument.get_value()
# allows params that can be cast to the target type
self.assertEqual(test("1", [str, None], "foo", 0), "1")
self.assertEqual(test("1.2", [str], "foo", 2), "1.2")
self.assertRaises(
MacroError,
lambda: test("1.2", [int], "foo", 3),
)
self.assertRaises(MacroError, lambda: test("a", [None], "foo", 0))
self.assertRaises(MacroError, lambda: test("a", [int], "foo", 1))
self.assertRaises(
MacroError,
lambda: test("a", [int, float], "foo", 2),
)
self.assertRaises(
MacroError,
lambda: test("a", [int, None], "foo", 3),
)
self.assertEqual(test("a", [int, float, None, str], "foo", 4), "a")
# variables are expected to be of the Variable type here, not a $string
self.assertRaises(
MacroError,
lambda: test("$a", [int], "foo", 4),
)
# We don't cast values that were explicitly set as strings back into numbers.
variable = Variable("a", const=False)
variable.set_value("5")
self.assertRaises(
MacroError,
lambda: test_variable(variable, [int], "foo", 4),
)
self.assertRaises(
MacroError,
lambda: test("a", [Macro], "foo", 0),
)
self.assertRaises(MacroError, lambda: test("1", [Macro], "foo", 0))
def test_validate_variable_name(self):
self.assertRaises(
MacroError,
lambda: Variable("1a", const=False).validate_variable_name(),
)
self.assertRaises(
MacroError,
lambda: Variable("$a", const=False).validate_variable_name(),
)
self.assertRaises(
MacroError,
lambda: Variable("a()", const=False).validate_variable_name(),
)
self.assertRaises(
MacroError,
lambda: Variable("1", const=False).validate_variable_name(),
)
self.assertRaises(
MacroError,
lambda: Variable("+", const=False).validate_variable_name(),
)
self.assertRaises(
MacroError,
lambda: Variable("-", const=False).validate_variable_name(),
)
self.assertRaises(
MacroError,
lambda: Variable("*", const=False).validate_variable_name(),
)
self.assertRaises(
MacroError,
lambda: Variable("a,b", const=False).validate_variable_name(),
)
self.assertRaises(
MacroError,
lambda: Variable("a,b", const=False).validate_variable_name(),
)
self.assertRaises(
MacroError,
lambda: Variable("#", const=False).validate_variable_name(),
)
self.assertRaises(
MacroError,
lambda: Variable(1, const=False).validate_variable_name(),
)
self.assertRaises(
MacroError,
lambda: Variable(None, const=False).validate_variable_name(),
)
self.assertRaises(
MacroError,
lambda: Variable([], const=False).validate_variable_name(),
)
self.assertRaises(
MacroError,
lambda: Variable((), const=False).validate_variable_name(),
)
# doesn't raise
Variable("a", const=False).validate_variable_name()
Variable("_a", const=False).validate_variable_name()
Variable("_A", const=False).validate_variable_name()
Variable("A", const=False).validate_variable_name()
Variable("Abcd", const=False).validate_variable_name()
Variable("Abcd_", const=False).validate_variable_name()
Variable("Abcd_1234", const=False).validate_variable_name()
Variable("Abcd1234_", const=False).validate_variable_name()
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,154 @@
#!/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
from inputremapper.configs.validation_errors import (
MacroError,
)
from inputremapper.injection.macros.argument import ArgumentConfig
from inputremapper.injection.macros.macro import macro_variables
from inputremapper.injection.macros.parse import Parser
from inputremapper.injection.macros.raw_value import RawValue
from inputremapper.injection.macros.task import Task
from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase
class TestDynamicTypes(MacroTestBase):
# "Dynamic" meaning const=False
async def test_set_type_int(self):
await Parser.parse(
"set(a, 1)",
self.context,
DummyMapping,
True,
).run(lambda *_, **__: None)
self.assertEqual(macro_variables.get("a"), 1)
# assertEqual(1.0, 1) passes, so check for the type to be sure:
self.assertIsInstance(macro_variables.get("a"), int)
async def test_set_type_float(self):
await Parser.parse(
"set(a, 2.2)",
self.context,
DummyMapping,
True,
).run(lambda *_, **__: None)
self.assertEqual(macro_variables.get("a"), 2.2)
async def test_set_type_str(self):
await Parser.parse(
'set(a, "3")',
self.context,
DummyMapping,
True,
).run(lambda *_, **__: None)
self.assertEqual(macro_variables.get("a"), "3")
def make_test_task(self, types):
# Make a new test task, with a different types array each time.
class TestTask(Task):
argument_configs = [
ArgumentConfig(
name="testvalue",
position=0,
types=types,
)
]
return TestTask(
[RawValue("$a")],
{},
self.context,
DummyMapping,
)
async def test_dynamic_int_parsing(self):
# set(a, 4) was used. Could be meant as an integer, or as a string
# (just like how key(KEY_A) doesn't require string quotes to be a string)
macro_variables["a"] = 4
test_task = self.make_test_task([str, int])
self.assertEqual(test_task.get_argument("testvalue").get_value(), 4)
test_task = self.make_test_task([int])
self.assertEqual(test_task.get_argument("testvalue").get_value(), 4)
# Now that ints are not allowed, it will be used as a string
test_task = self.make_test_task([str])
self.assertEqual(test_task.get_argument("testvalue").get_value(), "4")
async def test_dynamic_float_parsing(self):
# set(a, 5.5) was used.
macro_variables["a"] = 5.5
test_task = self.make_test_task([str, float])
self.assertEqual(test_task.get_argument("testvalue").get_value(), 5.5)
test_task = self.make_test_task([float])
self.assertEqual(test_task.get_argument("testvalue").get_value(), 5.5)
test_task = self.make_test_task([str])
self.assertEqual(test_task.get_argument("testvalue").get_value(), "5.5")
async def test_no_float_allowed(self):
# set(a, 6.6) was used.
macro_variables["a"] = 6.6
test_task = self.make_test_task([str, int])
self.assertEqual(test_task.get_argument("testvalue").get_value(), "6.6")
test_task = self.make_test_task([int])
self.assertRaises(
MacroError,
lambda: test_task.get_argument("testvalue").get_value(),
)
async def test_force_string_float(self):
# set(a, "7.7") was used. Since quotes are explicitly added, the variable is
# not intended to be used as a float.
macro_variables["a"] = "7.7"
test_task = self.make_test_task([str, float])
self.assertEqual(test_task.get_argument("testvalue").get_value(), "7.7")
test_task = self.make_test_task([float])
self.assertRaises(
MacroError,
lambda: test_task.get_argument("testvalue").get_value(),
)
async def test_force_string_int(self):
# set(a, "8") was used.
macro_variables["a"] = "8"
test_task = self.make_test_task([int, str])
self.assertEqual(test_task.get_argument("testvalue").get_value(), "8")
test_task = self.make_test_task([int])
self.assertRaises(
MacroError,
lambda: test_task.get_argument("testvalue").get_value(),
)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,67 @@
#!/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
from evdev.ecodes import (
EV_REL,
EV_KEY,
REL_X,
)
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.injection.macros.parse import Parser
from tests.lib.test_setup import test_setup
from tests.unit.test_macros.macro_test_base import MacroTestBase, DummyMapping
@test_setup
class TestEvent(MacroTestBase):
async def test_event_1(self):
macro = Parser.parse("e(EV_KEY, KEY_A, 1)", self.context, DummyMapping)
a_code = keyboard_layout.get("a")
await macro.run(self.handler)
self.assertListEqual(self.result, [(EV_KEY, a_code, 1)])
self.assertEqual(self.count_child_macros(macro), 0)
async def test_event_2(self):
macro = Parser.parse(
"repeat(1, event(type=5421, code=324, value=154))",
self.context,
DummyMapping,
)
code = 324
await macro.run(self.handler)
self.assertListEqual(self.result, [(5421, code, 154)])
self.assertEqual(self.count_child_macros(macro), 1)
async def test_event_mouse(self):
macro = Parser.parse("e(EV_REL, REL_X, 10)", self.context, DummyMapping)
await macro.run(self.handler)
self.assertListEqual(self.result, [(EV_REL, REL_X, 10)])
self.assertEqual(self.count_child_macros(macro), 0)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,191 @@
#!/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 unittest
from evdev.ecodes import EV_KEY
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.configs.validation_errors import MacroError
from inputremapper.injection.macros.parse import Parser
from tests.lib.test_setup import test_setup
from tests.unit.test_macros.macro_test_base import MacroTestBase, DummyMapping
@test_setup
class TestHold(MacroTestBase):
async def test_hold(self):
# repeats key(a) as long as the key is held down
macro = Parser.parse("key(1).hold(key(a)).key(3)", self.context, DummyMapping)
"""down"""
macro.press_trigger()
await asyncio.sleep(0.05)
self.assertTrue(macro.tasks[1].is_holding())
macro.press_trigger() # redundantly calling doesn't break anything
asyncio.ensure_future(macro.run(self.handler))
await asyncio.sleep(0.2)
self.assertTrue(macro.tasks[1].is_holding())
self.assertGreater(len(self.result), 2)
"""up"""
macro.release_trigger()
await asyncio.sleep(0.05)
self.assertFalse(macro.tasks[1].is_holding())
self.assertEqual(self.result[0], (EV_KEY, keyboard_layout.get("1"), 1))
self.assertEqual(self.result[-1], (EV_KEY, keyboard_layout.get("3"), 0))
code_a = keyboard_layout.get("a")
self.assertGreater(self.result.count((EV_KEY, code_a, 1)), 2)
self.assertEqual(self.count_child_macros(macro), 1)
self.assertEqual(self.count_tasks(macro), 4)
async def test_hold_failing_child(self):
# if a child macro fails, hold will not try to run it again.
# The exception is properly propagated through both `hold`s and the macro
# stops. If the code is broken, this test might enter an infinite loop.
macro = Parser.parse("hold(hold(key(a)))", self.context, DummyMapping)
class MyException(Exception):
pass
def f(*_):
raise MyException("foo")
macro.press_trigger()
with self.assertRaises(MyException):
await macro.run(f)
await asyncio.sleep(0.1)
self.assertFalse(macro.running)
async def test_dont_hold(self):
macro = Parser.parse("key(1).hold(key(a)).key(3)", self.context, DummyMapping)
asyncio.ensure_future(macro.run(self.handler))
await asyncio.sleep(0.2)
self.assertFalse(macro.tasks[1].is_holding())
# press_trigger was never called, so the macro completes right away
# and the child macro of hold is never called.
self.assertEqual(len(self.result), 4)
self.assertEqual(self.result[0], (EV_KEY, keyboard_layout.get("1"), 1))
self.assertEqual(self.result[-1], (EV_KEY, keyboard_layout.get("3"), 0))
self.assertEqual(self.count_child_macros(macro), 1)
self.assertEqual(self.count_tasks(macro), 4)
async def test_just_hold(self):
macro = Parser.parse("key(1).hold().key(3)", self.context, DummyMapping)
"""down"""
macro.press_trigger()
asyncio.ensure_future(macro.run(self.handler))
await asyncio.sleep(0.1)
self.assertTrue(macro.tasks[1].is_holding())
self.assertEqual(len(self.result), 2)
await asyncio.sleep(0.1)
# doesn't do fancy stuff, is blocking until the release
self.assertEqual(len(self.result), 2)
"""up"""
macro.release_trigger()
await asyncio.sleep(0.05)
self.assertFalse(macro.tasks[1].is_holding())
self.assertEqual(len(self.result), 4)
self.assertEqual(self.result[0], (EV_KEY, keyboard_layout.get("1"), 1))
self.assertEqual(self.result[-1], (EV_KEY, keyboard_layout.get("3"), 0))
self.assertEqual(self.count_child_macros(macro), 0)
self.assertEqual(self.count_tasks(macro), 3)
async def test_dont_just_hold(self):
macro = Parser.parse("key(1).hold().key(3)", self.context, DummyMapping)
asyncio.ensure_future(macro.run(self.handler))
await asyncio.sleep(0.1)
self.assertFalse(macro.tasks[1].is_holding())
# since press_trigger was never called it just does the macro
# completely
self.assertEqual(len(self.result), 4)
self.assertEqual(self.result[0], (EV_KEY, keyboard_layout.get("1"), 1))
self.assertEqual(self.result[-1], (EV_KEY, keyboard_layout.get("3"), 0))
self.assertEqual(self.count_child_macros(macro), 0)
async def test_hold_down(self):
# writes down and waits for the up event until the key is released
macro = Parser.parse("hold(a)", self.context, DummyMapping)
self.assertEqual(self.count_child_macros(macro), 0)
"""down"""
macro.press_trigger()
await asyncio.sleep(0.05)
self.assertTrue(macro.tasks[0].is_holding())
asyncio.ensure_future(macro.run(self.handler))
macro.press_trigger() # redundantly calling doesn't break anything
await asyncio.sleep(0.2)
self.assertTrue(macro.tasks[0].is_holding())
self.assertEqual(len(self.result), 1)
self.assertEqual(self.result[0], (EV_KEY, keyboard_layout.get("a"), 1))
"""up"""
macro.release_trigger()
await asyncio.sleep(0.05)
self.assertFalse(macro.tasks[0].is_holding())
self.assertEqual(len(self.result), 2)
self.assertEqual(self.result[0], (EV_KEY, keyboard_layout.get("a"), 1))
self.assertEqual(self.result[1], (EV_KEY, keyboard_layout.get("a"), 0))
async def test_hold_variable(self):
code_a = keyboard_layout.get("a")
macro = Parser.parse("set(foo, a).hold($foo)", self.context, DummyMapping)
await macro.run(self.handler)
self.assertListEqual(
self.result,
[
(EV_KEY, code_a, 1),
(EV_KEY, code_a, 0),
],
)
async def test_raises_error(self):
self.assertRaises(MacroError, Parser.parse, "h(1, 1)", self.context)
self.assertRaises(MacroError, Parser.parse, "h(hold(h(1, 1)))", self.context)
self.assertRaises(MacroError, Parser.parse, "hold(key(a)key(b))", self.context)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,130 @@
#!/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 unittest
from evdev.ecodes import EV_KEY
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.configs.validation_errors import MacroError
from inputremapper.injection.macros.parse import Parser
from tests.lib.test_setup import test_setup
from tests.unit.test_macros.macro_test_base import MacroTestBase, DummyMapping
@test_setup
class TestHoldKeys(MacroTestBase):
async def test_hold_keys(self):
macro = Parser.parse(
"set(foo, b).hold_keys(a, $foo, c)", self.context, DummyMapping
)
# press first
macro.press_trigger()
# then run, just like how it is going to happen during runtime
asyncio.ensure_future(macro.run(self.handler))
code_a = keyboard_layout.get("a")
code_b = keyboard_layout.get("b")
code_c = keyboard_layout.get("c")
await asyncio.sleep(0.2)
self.assertListEqual(
self.result,
[
(EV_KEY, code_a, 1),
(EV_KEY, code_b, 1),
(EV_KEY, code_c, 1),
],
)
macro.release_trigger()
await asyncio.sleep(0.2)
self.assertListEqual(
self.result,
[
(EV_KEY, code_a, 1),
(EV_KEY, code_b, 1),
(EV_KEY, code_c, 1),
(EV_KEY, code_c, 0),
(EV_KEY, code_b, 0),
(EV_KEY, code_a, 0),
],
)
async def test_hold_keys_broken(self):
# Won't run any of the keys when one of them is invalid
macro = Parser.parse(
"set(foo, broken).hold_keys(a, $foo, c)", self.context, DummyMapping
)
# press first
macro.press_trigger()
# then run, just like how it is going to happen during runtime
asyncio.ensure_future(macro.run(self.handler))
await asyncio.sleep(0.2)
self.assertListEqual(self.result, [])
macro.release_trigger()
await asyncio.sleep(0.2)
self.assertListEqual(self.result, [])
async def test_aldjfakl(self):
repeats = 5
macro = Parser.parse(
f"repeat({repeats}, key(k))",
self.context,
DummyMapping,
)
self.assertEqual(self.count_child_macros(macro), 1)
async def test_run_plus_syntax(self):
macro = Parser.parse("a + b + c + d", self.context, DummyMapping)
macro.press_trigger()
asyncio.ensure_future(macro.run(self.handler))
await asyncio.sleep(0.2)
self.assertTrue(macro.tasks[0].is_holding())
# starting from the left, presses each one down
self.assertEqual(self.result[0], (EV_KEY, keyboard_layout.get("a"), 1))
self.assertEqual(self.result[1], (EV_KEY, keyboard_layout.get("b"), 1))
self.assertEqual(self.result[2], (EV_KEY, keyboard_layout.get("c"), 1))
self.assertEqual(self.result[3], (EV_KEY, keyboard_layout.get("d"), 1))
# and then releases starting with the previously pressed key
macro.release_trigger()
await asyncio.sleep(0.2)
self.assertFalse(macro.tasks[0].is_holding())
self.assertEqual(self.result[4], (EV_KEY, keyboard_layout.get("d"), 0))
self.assertEqual(self.result[5], (EV_KEY, keyboard_layout.get("c"), 0))
self.assertEqual(self.result[6], (EV_KEY, keyboard_layout.get("b"), 0))
self.assertEqual(self.result[7], (EV_KEY, keyboard_layout.get("a"), 0))
async def test_raises_error(self):
self.assertRaises(
MacroError, Parser.parse, "hold_keys(a, broken, b)", self.context
)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,215 @@
#!/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 multiprocessing
import unittest
from evdev.ecodes import (
EV_KEY,
)
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.configs.validation_errors import MacroError
from inputremapper.injection.macros.macro import macro_variables
from inputremapper.injection.macros.parse import Parser
from tests.lib.logger import logger
from tests.lib.test_setup import test_setup
from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase
@test_setup
class TestIfEq(MacroTestBase):
async def test_if_eq(self):
"""new version of ifeq"""
code_a = keyboard_layout.get("a")
code_b = keyboard_layout.get("b")
a_press = [(EV_KEY, code_a, 1), (EV_KEY, code_a, 0)]
b_press = [(EV_KEY, code_b, 1), (EV_KEY, code_b, 0)]
async def test(macro, expected):
"""Run the macro and compare the injections with an expectation."""
logger.info("Testing %s", macro)
# cleanup
macro_variables._clear()
self.assertIsNone(macro_variables.get("a"))
self.result.clear()
# test
macro = Parser.parse(macro, self.context, DummyMapping)
await macro.run(self.handler)
self.assertListEqual(self.result, expected)
await test("if_eq(1, 1, key(a), key(b))", a_press)
await test("if_eq(1, 2, key(a), key(b))", b_press)
await test("if_eq(value_1=1, value_2=1, then=key(a), else=key(b))", a_press)
await test('set(a, "foo").if_eq($a, "foo", key(a), key(b))', a_press)
await test('set(a, "foo").if_eq("foo", $a, key(a), key(b))', a_press)
await test('set(a, "foo").if_eq("foo", $a, , key(b))', [])
await test('set(a, "foo").if_eq("foo", $a, None, key(b))', [])
await test('set(a, "qux").if_eq("foo", $a, key(a), key(b))', b_press)
await test('set(a, "qux").if_eq($a, "foo", key(a), key(b))', b_press)
await test('set(a, "qux").if_eq($a, "foo", key(a), )', [])
await test('set(a, "x").set(b, "y").if_eq($b, $a, key(a), key(b))', b_press)
await test('set(a, "x").set(b, "y").if_eq($b, $a, key(a), )', [])
await test('set(a, "x").set(b, "y").if_eq($b, $a, key(a), None)', [])
await test('set(a, "x").set(b, "y").if_eq($b, $a, key(a), else=None)', [])
await test('set(a, "x").set(b, "x").if_eq($b, $a, key(a), key(b))', a_press)
await test('set(a, "x").set(b, "x").if_eq($b, $a, , key(b))', [])
await test("if_eq($q, $w, key(a), else=key(b))", a_press) # both None
await test("set(q, 1).if_eq($q, $w, key(a), else=key(b))", b_press)
await test("set(q, 1).set(w, 1).if_eq($q, $w, key(a), else=key(b))", a_press)
await test('set(q, " a b ").if_eq($q, " a b ", key(a), key(b))', a_press)
await test('if_eq("\t", "\n", key(a), key(b))', b_press)
# treats values in quotes as strings, not as code
await test('set(q, "$a").if_eq($q, "$a", key(a), key(b))', a_press)
await test('set(q, "a,b").if_eq("a,b", $q, key(a), key(b))', a_press)
await test('set(q, "c(1, 2)").if_eq("c(1, 2)", $q, key(a), key(b))', a_press)
await test('set(q, "c(1, 2)").if_eq("c(1, 2)", "$q", key(a), key(b))', b_press)
await test('if_eq("value_1=1", 1, key(a), key(b))', b_press)
# won't compare strings and int, be similar to python
await test('set(a, "1").if_eq($a, 1, key(a), key(b))', b_press)
await test('set(a, 1).if_eq($a, "1", key(a), key(b))', b_press)
async def test_if_eq_runs_multiprocessed(self):
"""ifeq on variables that have been set in other processes works."""
macro = Parser.parse(
"if_eq($foo, 3, key(a), key(b))", self.context, DummyMapping
)
code_a = keyboard_layout.get("a")
code_b = keyboard_layout.get("b")
self.assertEqual(self.count_child_macros(macro), 2)
def set_foo(value):
# will write foo = 2 into the shared dictionary of macros
macro_2 = Parser.parse(f"set(foo, {value})", self.context, DummyMapping)
loop = asyncio.new_event_loop()
loop.run_until_complete(macro_2.run(lambda: None))
"""foo is not 3"""
process = multiprocessing.Process(target=set_foo, args=(2,))
process.start()
process.join()
await macro.run(self.handler)
self.assertListEqual(self.result, [(EV_KEY, code_b, 1), (EV_KEY, code_b, 0)])
"""foo is 3"""
process = multiprocessing.Process(target=set_foo, args=(3,))
process.start()
process.join()
await macro.run(self.handler)
self.assertListEqual(
self.result,
[
(EV_KEY, code_b, 1),
(EV_KEY, code_b, 0),
(EV_KEY, code_a, 1),
(EV_KEY, code_a, 0),
],
)
async def test_raises_error(self):
Parser.parse("if_eq(2, $a, k(a),)", self.context) # no error
Parser.parse("if_eq(2, $a, , else=k(a))", self.context) # no error
self.assertRaises(MacroError, Parser.parse, "if_eq(2, $a, 1,)", self.context)
self.assertRaises(MacroError, Parser.parse, "if_eq(2, $a, , 2)", self.context)
self.expect_string_in_error("blub", "if_eq(2, $a, key(a), blub=a)")
class TestIfEqDeprecated(MacroTestBase):
async def test_ifeq_runs(self):
# deprecated ifeq function, but kept for compatibility reasons
macro = Parser.parse(
"set(foo, 2).ifeq(foo, 2, key(a), key(b))",
self.context,
DummyMapping,
)
code_a = keyboard_layout.get("a")
code_b = keyboard_layout.get("b")
await macro.run(self.handler)
self.assertListEqual(self.result, [(EV_KEY, code_a, 1), (EV_KEY, code_a, 0)])
self.assertEqual(self.count_child_macros(macro), 2)
async def test_ifeq_none(self):
code_a = keyboard_layout.get("a")
# first param None
macro = Parser.parse(
"set(foo, 2).ifeq(foo, 2, None, key(b))", self.context, DummyMapping
)
self.assertEqual(self.count_child_macros(macro), 1)
await macro.run(self.handler)
self.assertListEqual(self.result, [])
# second param None
self.result = []
macro = Parser.parse(
"set(foo, 2).ifeq(foo, 2, key(a), None)", self.context, DummyMapping
)
self.assertEqual(self.count_child_macros(macro), 1)
await macro.run(self.handler)
self.assertListEqual(self.result, [(EV_KEY, code_a, 1), (EV_KEY, code_a, 0)])
"""Old syntax, use None instead"""
# first param ""
self.result = []
macro = Parser.parse(
"set(foo, 2).ifeq(foo, 2, , key(b))", self.context, DummyMapping
)
self.assertEqual(self.count_child_macros(macro), 1)
await macro.run(self.handler)
self.assertListEqual(self.result, [])
# second param ""
self.result = []
macro = Parser.parse(
"set(foo, 2).ifeq(foo, 2, key(a), )", self.context, DummyMapping
)
self.assertEqual(self.count_child_macros(macro), 1)
await macro.run(self.handler)
self.assertListEqual(self.result, [(EV_KEY, code_a, 1), (EV_KEY, code_a, 0)])
async def test_ifeq_unknown_key(self):
macro = Parser.parse("ifeq(qux, 2, key(a), key(b))", self.context, DummyMapping)
code_a = keyboard_layout.get("a")
code_b = keyboard_layout.get("b")
await macro.run(self.handler)
self.assertListEqual(self.result, [(EV_KEY, code_b, 1), (EV_KEY, code_b, 0)])
self.assertEqual(self.count_child_macros(macro), 2)
async def test_raises_error(self):
Parser.parse("ifeq(a, 2, k(a),)", self.context) # no error
Parser.parse("ifeq(a, 2, , k(a))", self.context) # no error
Parser.parse("ifeq(a, 2, None, k(a))", self.context) # no error
self.assertRaises(MacroError, Parser.parse, "ifeq(a, 2, 1,)", self.context)
self.assertRaises(MacroError, Parser.parse, "ifeq(a, 2, , 2)", self.context)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,192 @@
#!/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 unittest
from evdev.ecodes import (
EV_KEY,
ABS_Y,
)
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.configs.validation_errors import MacroError
from inputremapper.injection.macros.parse import Parser
from inputremapper.input_event import InputEvent
from tests.lib.test_setup import test_setup
from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase
@test_setup
class TestIfSingle(MacroTestBase):
async def test_if_single(self):
macro = Parser.parse("if_single(key(x), key(y))", self.context, DummyMapping)
self.assertEqual(self.count_child_macros(macro), 2)
a = keyboard_layout.get("a")
x = keyboard_layout.get("x")
await self.trigger_sequence(macro, InputEvent.key(a, 1))
await asyncio.sleep(0.1)
await self.release_sequence(macro, InputEvent.key(a, 0))
# the key that triggered the macro is released
await asyncio.sleep(0.1)
self.assertListEqual(self.result, [(EV_KEY, x, 1), (EV_KEY, x, 0)])
self.assertFalse(macro.running)
async def test_if_single_ignores_releases(self):
# the timeout won't break the macro, everything happens well within that
# timeframe.
macro = Parser.parse(
"if_single(key(x), else=key(y), timeout=100000)",
self.context,
DummyMapping,
)
self.assertEqual(self.count_child_macros(macro), 2)
a = keyboard_layout.get("a")
b = keyboard_layout.get("b")
x = keyboard_layout.get("x")
y = keyboard_layout.get("y")
# pressing the macro key
await self.trigger_sequence(macro, InputEvent.key(a, 1))
await asyncio.sleep(0.05)
# if_single only looks out for newly pressed keys,
# it doesn't care if keys were released that have been
# pressed before if_single. This was decided because it is a lot
# less tricky and more fluently to use if you type fast
for listener in self.context.listeners:
asyncio.ensure_future(listener(InputEvent.key(b, 0)))
await asyncio.sleep(0.05)
self.assertListEqual(self.result, [])
# releasing the actual key triggers if_single
await asyncio.sleep(0.05)
await self.release_sequence(macro, InputEvent.key(a, 0))
await asyncio.sleep(0.05)
self.assertListEqual(self.result, [(EV_KEY, x, 1), (EV_KEY, x, 0)])
self.assertFalse(macro.running)
async def test_if_not_single(self):
# Will run the `else` macro if another key is pressed.
# Also works if if_single is a child macro, i.e. the event is passed to it
# from the outside macro correctly.
macro = Parser.parse(
"repeat(1, if_single(then=key(x), else=key(y)))",
self.context,
DummyMapping,
)
self.assertEqual(self.count_child_macros(macro), 3)
self.assertEqual(self.count_tasks(macro), 4)
a = keyboard_layout.get("a")
b = keyboard_layout.get("b")
x = keyboard_layout.get("x")
y = keyboard_layout.get("y")
# press the trigger key
await self.trigger_sequence(macro, InputEvent.key(a, 1))
await asyncio.sleep(0.1)
# press another key
for listener in self.context.listeners:
asyncio.ensure_future(listener(InputEvent.key(b, 1)))
await asyncio.sleep(0.1)
self.assertListEqual(self.result, [(EV_KEY, y, 1), (EV_KEY, y, 0)])
self.assertFalse(macro.running)
async def test_if_not_single_none(self):
macro = Parser.parse("if_single(key(x),)", self.context, DummyMapping)
self.assertEqual(self.count_child_macros(macro), 1)
a = keyboard_layout.get("a")
b = keyboard_layout.get("b")
x = keyboard_layout.get("x")
# press trigger key
await self.trigger_sequence(macro, InputEvent.key(a, 1))
await asyncio.sleep(0.1)
# press another key
for listener in self.context.listeners:
asyncio.ensure_future(listener(InputEvent.key(b, 1)))
await asyncio.sleep(0.1)
self.assertListEqual(self.result, [])
self.assertFalse(macro.running)
async def test_if_single_times_out(self):
macro = Parser.parse(
"set(t, 300).if_single(key(x), key(y), timeout=$t)",
self.context,
DummyMapping,
)
self.assertEqual(self.count_child_macros(macro), 2)
a = keyboard_layout.get("a")
y = keyboard_layout.get("y")
await self.trigger_sequence(macro, InputEvent.key(a, 1))
# no timeout yet
await asyncio.sleep(0.2)
self.assertListEqual(self.result, [])
self.assertTrue(macro.running)
# times out now
await asyncio.sleep(0.2)
self.assertListEqual(self.result, [(EV_KEY, y, 1), (EV_KEY, y, 0)])
self.assertFalse(macro.running)
async def test_if_single_ignores_joystick(self):
"""Triggers else + delayed_handle_keycode."""
# Integration test style for if_single.
# If a joystick that is mapped to a button is moved, if_single stops
macro = Parser.parse(
"if_single(k(a), k(KEY_LEFTSHIFT))", self.context, DummyMapping
)
code_shift = keyboard_layout.get("KEY_LEFTSHIFT")
code_a = keyboard_layout.get("a")
trigger = 1
await self.trigger_sequence(macro, InputEvent.key(trigger, 1))
await asyncio.sleep(0.1)
for listener in self.context.listeners:
asyncio.ensure_future(listener(InputEvent.abs(ABS_Y, 10)))
await asyncio.sleep(0.1)
await self.release_sequence(macro, InputEvent.key(trigger, 0))
await asyncio.sleep(0.1)
self.assertFalse(macro.running)
self.assertListEqual(self.result, [(EV_KEY, code_a, 1), (EV_KEY, code_a, 0)])
async def test_raises_error(self):
Parser.parse("if_single(k(a),)", self.context) # no error
self.assertRaises(MacroError, Parser.parse, "if_single(1,)", self.context)
self.assertRaises(MacroError, Parser.parse, "if_single(,1)", self.context)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,190 @@
#!/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 unittest
from evdev.ecodes import (
EV_KEY,
KEY_A,
KEY_B,
)
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.configs.validation_errors import MacroError
from inputremapper.injection.macros.parse import Parser
from tests.lib.test_setup import test_setup
from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase
@test_setup
class TestIfTap(MacroTestBase):
async def test_if_tap(self):
macro = Parser.parse("if_tap(key(x), key(y), 100)", self.context, DummyMapping)
self.assertEqual(self.count_child_macros(macro), 2)
x = keyboard_layout.get("x")
y = keyboard_layout.get("y")
# this is the regular routine of how a macro is started. the tigger is pressed
# already when the macro runs, and released during if_tap within the timeout.
macro.press_trigger()
asyncio.ensure_future(macro.run(self.handler))
await asyncio.sleep(0.05)
macro.release_trigger()
await asyncio.sleep(0.05)
self.assertListEqual(self.result, [(EV_KEY, x, 1), (EV_KEY, x, 0)])
self.assertFalse(macro.running)
async def test_if_tap_2(self):
# when the press arrives shortly after run.
# a tap will happen within the timeout even if the tigger is not pressed when
# it does into if_tap
macro = Parser.parse("if_tap(key(a), key(b), 100)", self.context, DummyMapping)
asyncio.ensure_future(macro.run(self.handler))
await asyncio.sleep(0.01)
macro.press_trigger()
await asyncio.sleep(0.01)
macro.release_trigger()
await asyncio.sleep(0.2)
self.assertListEqual(self.result, [(EV_KEY, KEY_A, 1), (EV_KEY, KEY_A, 0)])
self.assertFalse(macro.running)
self.result.clear()
async def test_if_double_tap(self):
macro = Parser.parse(
"if_tap(if_tap(key(a), key(b), 100), key(c), 100)",
self.context,
DummyMapping,
)
self.assertEqual(self.count_child_macros(macro), 4)
self.assertEqual(self.count_tasks(macro), 5)
asyncio.ensure_future(macro.run(self.handler))
# first tap
macro.press_trigger()
await asyncio.sleep(0.05)
macro.release_trigger()
# second tap
await asyncio.sleep(0.04)
macro.press_trigger()
await asyncio.sleep(0.04)
macro.release_trigger()
await asyncio.sleep(0.05)
self.assertListEqual(self.result, [(EV_KEY, KEY_A, 1), (EV_KEY, KEY_A, 0)])
self.assertFalse(macro.running)
self.result.clear()
"""If the second tap takes too long, runs else there"""
asyncio.ensure_future(macro.run(self.handler))
# first tap
macro.press_trigger()
await asyncio.sleep(0.05)
macro.release_trigger()
# second tap
await asyncio.sleep(0.06)
macro.press_trigger()
await asyncio.sleep(0.06)
macro.release_trigger()
await asyncio.sleep(0.05)
self.assertListEqual(self.result, [(EV_KEY, KEY_B, 1), (EV_KEY, KEY_B, 0)])
self.assertFalse(macro.running)
self.result.clear()
async def test_if_tap_none(self):
# first param none
macro = Parser.parse("if_tap(, key(y), 100)", self.context, DummyMapping)
self.assertEqual(self.count_child_macros(macro), 1)
y = keyboard_layout.get("y")
macro.press_trigger()
asyncio.ensure_future(macro.run(self.handler))
await asyncio.sleep(0.05)
macro.release_trigger()
await asyncio.sleep(0.05)
self.assertListEqual(self.result, [])
# second param none
macro = Parser.parse("if_tap(key(y), , 50)", self.context, DummyMapping)
self.assertEqual(self.count_child_macros(macro), 1)
y = keyboard_layout.get("y")
macro.press_trigger()
asyncio.ensure_future(macro.run(self.handler))
await asyncio.sleep(0.1)
macro.release_trigger()
await asyncio.sleep(0.05)
self.assertListEqual(self.result, [])
self.assertFalse(macro.running)
async def test_if_not_tap(self):
macro = Parser.parse("if_tap(key(x), key(y), 50)", self.context, DummyMapping)
self.assertEqual(self.count_child_macros(macro), 2)
y = keyboard_layout.get("y")
macro.press_trigger()
asyncio.ensure_future(macro.run(self.handler))
await asyncio.sleep(0.1)
macro.release_trigger()
await asyncio.sleep(0.05)
self.assertListEqual(self.result, [(EV_KEY, y, 1), (EV_KEY, y, 0)])
self.assertFalse(macro.running)
async def test_if_not_tap_named(self):
macro = Parser.parse(
"if_tap(key(x), key(y), timeout=50)", self.context, DummyMapping
)
self.assertEqual(self.count_child_macros(macro), 2)
x = keyboard_layout.get("x")
y = keyboard_layout.get("y")
macro.press_trigger()
asyncio.ensure_future(macro.run(self.handler))
await asyncio.sleep(0.1)
macro.release_trigger()
await asyncio.sleep(0.05)
self.assertListEqual(self.result, [(EV_KEY, y, 1), (EV_KEY, y, 0)])
self.assertFalse(macro.running)
async def test_raises_error(self):
Parser.parse("if_tap(, k(a), 1000)", self.context) # no error
Parser.parse("if_tap(, k(a), timeout=1000)", self.context) # no error
Parser.parse("if_tap(, k(a), $timeout)", self.context) # no error
Parser.parse("if_tap(, k(a), timeout=$t)", self.context) # no error
Parser.parse("if_tap(, key(a))", self.context) # no error
Parser.parse("if_tap(k(a),)", self.context) # no error
self.assertRaises(MacroError, Parser.parse, "if_tap(k(a), b)", self.context)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,123 @@
#!/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
from evdev.ecodes import EV_KEY
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.configs.validation_errors import (
MacroError,
SymbolNotAvailableInTargetError,
)
from inputremapper.injection.macros.parse import Parser
from tests.lib.test_setup import test_setup
from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase
@test_setup
class TestKey(MacroTestBase):
async def test_1(self):
macro = Parser.parse("key(1)", self.context, DummyMapping, True)
one_code = keyboard_layout.get("1")
await macro.run(self.handler)
self.assertListEqual(
self.result,
[(EV_KEY, one_code, 1), (EV_KEY, one_code, 0)],
)
self.assertEqual(self.count_child_macros(macro), 0)
async def test_named_parameter(self):
macro = Parser.parse("key(symbol=1)", self.context, DummyMapping, True)
one_code = keyboard_layout.get("1")
await macro.run(self.handler)
self.assertListEqual(
self.result,
[(EV_KEY, one_code, 1), (EV_KEY, one_code, 0)],
)
self.assertEqual(self.count_child_macros(macro), 0)
async def test_2(self):
macro = Parser.parse('key(1).key("KEY_A").key(3)', self.context, DummyMapping)
await macro.run(self.handler)
self.assertListEqual(
self.result,
[
(EV_KEY, keyboard_layout.get("1"), 1),
(EV_KEY, keyboard_layout.get("1"), 0),
(EV_KEY, keyboard_layout.get("a"), 1),
(EV_KEY, keyboard_layout.get("a"), 0),
(EV_KEY, keyboard_layout.get("3"), 1),
(EV_KEY, keyboard_layout.get("3"), 0),
],
)
self.assertEqual(self.count_child_macros(macro), 0)
async def test_key_down_up(self):
code_a = keyboard_layout.get("a")
code_b = keyboard_layout.get("b")
macro = Parser.parse(
"set(foo, b).key_down($foo).key_up($foo).key_up(a).key_down(a)",
self.context,
DummyMapping,
)
await macro.run(self.handler)
self.assertListEqual(
self.result,
[
(EV_KEY, code_b, 1),
(EV_KEY, code_b, 0),
(EV_KEY, code_a, 0),
(EV_KEY, code_a, 1),
],
)
async def test_raises_error(self):
Parser.parse("k(1).h(k(a)).k(3)", self.context) # No error
self.expect_string_in_error("bracket", "key((1)")
self.expect_string_in_error("bracket", "k(1))")
self.assertRaises(MacroError, Parser.parse, "k((1).k)", self.context)
self.assertRaises(MacroError, Parser.parse, "key(foo=a)", self.context)
self.assertRaises(
MacroError, Parser.parse, "key(symbol=a, foo=b)", self.context
)
self.assertRaises(MacroError, Parser.parse, "k()", self.context)
self.assertRaises(MacroError, Parser.parse, "key(invalidkey)", self.context)
self.assertRaises(MacroError, Parser.parse, 'key("invalidkey")', self.context)
Parser.parse("key(1)", self.context) # no error
self.assertRaises(MacroError, Parser.parse, "k(1, 1)", self.context)
Parser.parse("key($a)", self.context) # no error
self.assertRaises(MacroError, Parser.parse, "key(a)key(b)", self.context)
# wrong target for BTN_A
self.assertRaises(
SymbolNotAvailableInTargetError,
Parser.parse,
"key(BTN_A)",
self.context,
DummyMapping,
)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,124 @@
#!/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
from unittest.mock import patch
from evdev.ecodes import (
EV_KEY,
KEY_1,
KEY_2,
LED_CAPSL,
LED_NUML,
)
from inputremapper.injection.macros.parse import Parser
from tests.lib.test_setup import test_setup
from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase
@test_setup
class TestLeds(MacroTestBase):
async def test_if_capslock(self):
macro = Parser.parse(
"if_capslock(key(KEY_1), key(KEY_2))",
self.context,
DummyMapping,
True,
)
self.assertEqual(self.count_child_macros(macro), 2)
with patch.object(self.source_device, "leds", side_effect=lambda: [LED_NUML]):
await macro.run(self.handler)
self.assertListEqual(self.result, [(EV_KEY, KEY_2, 1), (EV_KEY, KEY_2, 0)])
with patch.object(self.source_device, "leds", side_effect=lambda: [LED_CAPSL]):
self.result = []
await macro.run(self.handler)
self.assertListEqual(self.result, [(EV_KEY, KEY_1, 1), (EV_KEY, KEY_1, 0)])
async def test_if_numlock(self):
macro = Parser.parse(
"if_numlock(key(KEY_1), key(KEY_2))",
self.context,
DummyMapping,
True,
)
self.assertEqual(self.count_child_macros(macro), 2)
with patch.object(self.source_device, "leds", side_effect=lambda: [LED_NUML]):
await macro.run(self.handler)
self.assertListEqual(self.result, [(EV_KEY, KEY_1, 1), (EV_KEY, KEY_1, 0)])
with patch.object(self.source_device, "leds", side_effect=lambda: [LED_CAPSL]):
self.result = []
await macro.run(self.handler)
self.assertListEqual(self.result, [(EV_KEY, KEY_2, 1), (EV_KEY, KEY_2, 0)])
async def test_if_numlock_no_else(self):
macro = Parser.parse(
"if_numlock(key(KEY_1))",
self.context,
DummyMapping,
True,
)
self.assertEqual(self.count_child_macros(macro), 1)
with patch.object(self.source_device, "leds", side_effect=lambda: [LED_CAPSL]):
await macro.run(self.handler)
self.assertListEqual(self.result, [])
with patch.object(self.source_device, "leds", side_effect=lambda: [LED_NUML]):
self.result = []
await macro.run(self.handler)
self.assertListEqual(self.result, [(EV_KEY, KEY_1, 1), (EV_KEY, KEY_1, 0)])
async def test_if_capslock_no_then(self):
macro = Parser.parse(
"if_capslock(None, key(KEY_1))",
self.context,
DummyMapping,
True,
)
self.assertEqual(self.count_child_macros(macro), 1)
with patch.object(self.source_device, "leds", side_effect=lambda: [LED_CAPSL]):
await macro.run(self.handler)
self.assertListEqual(self.result, [])
with patch.object(self.source_device, "leds", side_effect=lambda: [LED_NUML]):
self.result = []
await macro.run(self.handler)
self.assertListEqual(self.result, [(EV_KEY, KEY_1, 1), (EV_KEY, KEY_1, 0)])
async def test_raises_error(self):
Parser.parse("if_capslock(else=key(KEY_A))", self.context) # no error
Parser.parse("if_capslock(key(KEY_A), None)", self.context) # no error
Parser.parse("if_capslock(key(KEY_A))", self.context) # no error
Parser.parse("if_capslock(then=key(KEY_A))", self.context) # no error
Parser.parse("if_numlock(else=key(KEY_A))", self.context) # no error
Parser.parse("if_numlock(key(KEY_A), None)", self.context) # no error
Parser.parse("if_numlock(key(KEY_A))", self.context) # no error
Parser.parse("if_numlock(then=key(KEY_A))", self.context) # no error
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,167 @@
#!/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 time
import unittest
from evdev.ecodes import (
EV_KEY,
)
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.injection.macros.macro import Macro
from inputremapper.injection.macros.parse import Parser
from tests.lib.test_setup import test_setup
from tests.unit.test_macros.macro_test_base import MacroTestBase, DummyMapping
@test_setup
class TestMacros(MacroTestBase):
async def test_newlines(self):
macro = Parser.parse(
" repeat(2,\nkey(\nr ).key(minus\n )).key(m) ",
self.context,
DummyMapping,
)
r = keyboard_layout.get("r")
minus = keyboard_layout.get("minus")
m = keyboard_layout.get("m")
await macro.run(self.handler)
self.assertListEqual(
self.result,
[
(EV_KEY, r, 1),
(EV_KEY, r, 0),
(EV_KEY, minus, 1),
(EV_KEY, minus, 0),
(EV_KEY, r, 1),
(EV_KEY, r, 0),
(EV_KEY, minus, 1),
(EV_KEY, minus, 0),
(EV_KEY, m, 1),
(EV_KEY, m, 0),
],
)
self.assertEqual(self.count_child_macros(macro), 1)
self.assertEqual(self.count_tasks(macro), 4)
async def test_various(self):
start = time.time()
macro = Parser.parse(
"w(200).repeat(2,modify(w,\nrepeat(2,\tkey(BtN_LeFt))).w(10).key(k))",
self.context,
DummyMapping,
)
self.assertEqual(self.count_child_macros(macro), 3)
self.assertEqual(self.count_tasks(macro), 7)
w = keyboard_layout.get("w")
left = keyboard_layout.get("bTn_lEfT")
k = keyboard_layout.get("k")
await macro.run(self.handler)
num_pauses = 8 + 6 + 4
keystroke_time = num_pauses * DummyMapping.macro_key_sleep_ms
wait_time = 220
total_time = (keystroke_time + wait_time) / 1000
self.assertLess(time.time() - start, total_time * 1.2)
self.assertGreater(time.time() - start, total_time * 0.9)
expected = [(EV_KEY, w, 1)]
expected += [(EV_KEY, left, 1), (EV_KEY, left, 0)] * 2
expected += [(EV_KEY, w, 0)]
expected += [(EV_KEY, k, 1), (EV_KEY, k, 0)]
expected *= 2
self.assertListEqual(self.result, expected)
async def test_not_run(self):
# does nothing without .run
macro = Parser.parse("key(a).repeat(3, key(b))", self.context)
self.assertIsInstance(macro, Macro)
self.assertListEqual(self.result, [])
async def test_duplicate_run(self):
# it won't restart the macro, because that may screw up the
# internal state (in particular the _trigger_release_event).
# I actually don't know at all what kind of bugs that might produce,
# lets just avoid it. It might cause it to be held down forever.
a = keyboard_layout.get("a")
b = keyboard_layout.get("b")
c = keyboard_layout.get("c")
macro = Parser.parse(
"key(a).modify(b, hold()).key(c)", self.context, DummyMapping
)
asyncio.ensure_future(macro.run(self.handler))
self.assertFalse(macro.tasks[1].child_macros[0].tasks[0].is_holding())
asyncio.ensure_future(macro.run(self.handler)) # ignored
self.assertFalse(macro.tasks[1].child_macros[0].tasks[0].is_holding())
macro.press_trigger()
await asyncio.sleep(0.2)
self.assertTrue(macro.tasks[1].child_macros[0].tasks[0].is_holding())
asyncio.ensure_future(macro.run(self.handler)) # ignored
self.assertTrue(macro.tasks[1].child_macros[0].tasks[0].is_holding())
macro.release_trigger()
await asyncio.sleep(0.2)
self.assertFalse(macro.tasks[1].child_macros[0].tasks[0].is_holding())
expected = [
(EV_KEY, a, 1),
(EV_KEY, a, 0),
(EV_KEY, b, 1),
(EV_KEY, b, 0),
(EV_KEY, c, 1),
(EV_KEY, c, 0),
]
self.assertListEqual(self.result, expected)
"""not ignored, since previous run is over"""
asyncio.ensure_future(macro.run(self.handler))
macro.press_trigger()
await asyncio.sleep(0.2)
self.assertTrue(macro.tasks[1].child_macros[0].tasks[0].is_holding())
macro.release_trigger()
await asyncio.sleep(0.2)
self.assertFalse(macro.tasks[1].child_macros[0].tasks[0].is_holding())
expected = [
(EV_KEY, a, 1),
(EV_KEY, a, 0),
(EV_KEY, b, 1),
(EV_KEY, b, 0),
(EV_KEY, c, 1),
(EV_KEY, c, 0),
] * 2
self.assertListEqual(self.result, expected)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,385 @@
import asyncio
import time
import unittest
from unittest.mock import patch
import evdev
from evdev.ecodes import KEY_A, EV_KEY, KEY_B, KEY_LEFTSHIFT, KEY_C
from inputremapper.configs.input_config import InputConfig
from inputremapper.configs.mapping import Mapping
from inputremapper.configs.preset import Preset
from inputremapper.injection.context import Context
from inputremapper.injection.event_reader import EventReader
from inputremapper.injection.global_uinputs import GlobalUInputs, UInput
from inputremapper.injection.macros.parse import Parser
from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser
from inputremapper.input_event import InputEvent
from tests.lib.fixtures import fixtures
from tests.lib.patches import InputDevice
from tests.lib.pipes import uinput_write_history
from tests.lib.test_setup import test_setup
from tests.unit.test_macros.macro_test_base import MacroTestBase, DummyMapping
@test_setup
class TestModTapIntegration(unittest.IsolatedAsyncioTestCase):
# Testcases are from https://github.com/qmk/qmk_firmware/blob/78a0adfbb4d2c4e12f93f2a62ded0020d406243e/docs/tap_hold.md#nested-tap-abba-nested-tap
# This test-setup is a bit more involved, because I needed to modify the
# EventReader as well for this to work.
def setUp(self):
self.origin_hash = fixtures.bar_device.get_device_hash()
self.forward_uinput = evdev.UInput(name="test-forward-uinput")
self.source_device = InputDevice(fixtures.bar_device.path)
self.stop_event = asyncio.Event()
self.global_uinputs = GlobalUInputs(UInput)
self.global_uinputs.prepare_all()
self.target_uinput = self.global_uinputs.get_uinput("keyboard")
self.mapping_parser = MappingParser(self.global_uinputs)
self.mapping = Mapping.from_combination(
input_combination=[
InputConfig(
type=EV_KEY,
code=KEY_A,
origin_hash=self.origin_hash,
)
],
output_symbol="mod_tap(a, Shift_L)",
)
self.preset = Preset()
self.preset.add(self.mapping)
self.bootstrap_event_reader()
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)
def bootstrap_event_reader(self):
self.context = Context(
self.preset,
source_devices={self.origin_hash: self.source_device},
forward_devices={self.origin_hash: self.forward_uinput},
mapping_parser=self.mapping_parser,
)
self.event_reader = EventReader(
self.context,
self.source_device,
self.stop_event,
)
def write(self, type_, code, value):
self.target_uinput.write_event(InputEvent.from_tuple((type_, code, value)))
async def input(self, type_, code, value):
asyncio.ensure_future(
self.event_reader.handle(
InputEvent.from_tuple(
(
type_,
code,
value,
),
origin_hash=self.origin_hash,
)
)
)
# Make the main_loop iterate a bit for the event_reader to do its thing.
await asyncio.sleep(0)
async def test_distinct_taps_1(self):
await self.input(EV_KEY, KEY_A, 1)
await asyncio.sleep(0.190)
await self.input(EV_KEY, KEY_A, 0)
await asyncio.sleep(0.020) # exceeds tapping_term here
await self.input(EV_KEY, KEY_B, 1)
await asyncio.sleep(0.020)
await self.input(EV_KEY, KEY_B, 0)
self.assertEqual(
uinput_write_history,
[
InputEvent.from_tuple((EV_KEY, KEY_A, 1)),
InputEvent.from_tuple((EV_KEY, KEY_A, 0)),
InputEvent.from_tuple((EV_KEY, KEY_B, 1)),
InputEvent.from_tuple((EV_KEY, KEY_B, 0)),
],
)
self.assertEqual(
self.target_uinput.write_history,
[
InputEvent.from_tuple((EV_KEY, KEY_A, 1)),
InputEvent.from_tuple((EV_KEY, KEY_A, 0)),
],
)
self.assertEqual(
self.forward_uinput.write_history,
[
InputEvent.from_tuple((EV_KEY, KEY_B, 1)),
InputEvent.from_tuple((EV_KEY, KEY_B, 0)),
],
)
async def test_distinct_taps_2(self):
await self.input(EV_KEY, KEY_A, 1)
await asyncio.sleep(0.220) # exceeds tapping_term here
await self.input(EV_KEY, KEY_A, 0)
await asyncio.sleep(0.020)
await self.input(EV_KEY, KEY_B, 1)
await asyncio.sleep(0.020)
await self.input(EV_KEY, KEY_B, 0)
self.assertEqual(
uinput_write_history,
[
InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 1)),
InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 0)),
InputEvent.from_tuple((EV_KEY, KEY_B, 1)),
InputEvent.from_tuple((EV_KEY, KEY_B, 0)),
],
)
self.assertEqual(
self.target_uinput.write_history,
[
InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 1)),
InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 0)),
],
)
self.assertEqual(
self.forward_uinput.write_history,
[
InputEvent.from_tuple((EV_KEY, KEY_B, 1)),
InputEvent.from_tuple((EV_KEY, KEY_B, 0)),
],
)
async def test_nested_tap_1(self):
await self.input(EV_KEY, KEY_A, 1)
await asyncio.sleep(0.100)
self.assertEqual(uinput_write_history, [])
await self.input(EV_KEY, KEY_B, 1)
await asyncio.sleep(0.020)
self.assertEqual(uinput_write_history, [])
await self.input(EV_KEY, KEY_B, 0)
await asyncio.sleep(0.050)
self.assertEqual(uinput_write_history, [])
await self.input(EV_KEY, KEY_A, 0)
# everything happened within the tapping_term, so the modifier is not activated.
# "ab" should be written, in the exact order of the input.
await asyncio.sleep(0.040)
self.assertEqual(
uinput_write_history,
[
InputEvent.from_tuple((EV_KEY, KEY_A, 1)),
InputEvent.from_tuple((EV_KEY, KEY_B, 1)),
InputEvent.from_tuple((EV_KEY, KEY_B, 0)),
InputEvent.from_tuple((EV_KEY, KEY_A, 0)),
],
)
async def test_nested_tap_2(self):
await self.input(EV_KEY, KEY_A, 1)
await asyncio.sleep(0.100)
await self.input(EV_KEY, KEY_B, 1)
await asyncio.sleep(0.020)
await self.input(EV_KEY, KEY_B, 0)
await asyncio.sleep(0.100) # exceeds tapping_term here
await self.input(EV_KEY, KEY_A, 0)
await asyncio.sleep(0.020)
self.assertEqual(
uinput_write_history,
[
InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 1)),
InputEvent.from_tuple((EV_KEY, KEY_B, 1)),
InputEvent.from_tuple((EV_KEY, KEY_B, 0)),
InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 0)),
],
)
async def test_nested_tap_3(self):
await self.input(EV_KEY, KEY_A, 1)
await asyncio.sleep(0.220) # exceeds tapping_term here
await self.input(EV_KEY, KEY_B, 1)
await asyncio.sleep(0.020)
await self.input(EV_KEY, KEY_B, 0)
await asyncio.sleep(0.020)
await self.input(EV_KEY, KEY_A, 0)
await asyncio.sleep(0.020)
self.assertEqual(
uinput_write_history,
[
InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 1)),
InputEvent.from_tuple((EV_KEY, KEY_B, 1)),
InputEvent.from_tuple((EV_KEY, KEY_B, 0)),
InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 0)),
],
)
async def test_rolling_keys_1(self):
await self.input(EV_KEY, KEY_A, 1)
await asyncio.sleep(0.100)
await self.input(EV_KEY, KEY_B, 1)
await asyncio.sleep(0.020)
await self.input(EV_KEY, KEY_A, 0)
await asyncio.sleep(0.020)
await self.input(EV_KEY, KEY_B, 0)
# everything happened within the tapping_term, so the modifier is not activated.
# "ab" should be written, in the exact order of the input.
await asyncio.sleep(0.100)
self.assertEqual(
uinput_write_history,
[
InputEvent.from_tuple((EV_KEY, KEY_A, 1)),
InputEvent.from_tuple((EV_KEY, KEY_B, 1)),
InputEvent.from_tuple((EV_KEY, KEY_A, 0)),
InputEvent.from_tuple((EV_KEY, KEY_B, 0)),
],
)
async def test_rolling_keys_2(self):
await self.input(EV_KEY, KEY_A, 1)
await asyncio.sleep(0.100)
await self.input(EV_KEY, KEY_B, 1)
await asyncio.sleep(0.100) # exceeds tapping_term here
await self.input(EV_KEY, KEY_A, 0)
await asyncio.sleep(0.020)
await self.input(EV_KEY, KEY_B, 0)
await asyncio.sleep(0.020)
self.assertEqual(
uinput_write_history,
[
InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 1)),
InputEvent.from_tuple((EV_KEY, KEY_B, 1)),
InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 0)),
InputEvent.from_tuple((EV_KEY, KEY_B, 0)),
],
)
async def test_many_keys_correct_order_without_sleep(self):
self.mapping.macro_key_sleep_ms = 0
await self.many_keys_correct_order()
async def test_many_keys_correct_order_with_sleep(self):
self.mapping.macro_key_sleep_ms = 20
await self.many_keys_correct_order()
async def many_keys_correct_order(self):
await self.input(EV_KEY, KEY_A, 1)
# Send many events to the listener. It has to make all of them wait.
for i in range(30):
await self.input(EV_KEY, i, 1)
# exceed tapping_term. mod_tap will inject the modifier and replay all the
# previous events.
await asyncio.sleep(0.201)
# mod_tap is busy replaying events. While it does that, inject this
await self.input(EV_KEY, 100, 1)
start = time.time()
timeout = 2
while len(uinput_write_history) < 32 and (time.time() - start) < timeout:
# Wait for it to complete
await asyncio.sleep(0.1)
self.assertEqual(len(uinput_write_history), 32)
# Expect it to cleanly handle all events before injecting 100. Expect
# everything to be in the correct order.
self.assertEqual(
uinput_write_history,
[
InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 1)),
*[InputEvent.from_tuple((EV_KEY, i, 1)) for i in range(30)],
InputEvent.from_tuple((EV_KEY, 100, 1)),
],
)
async def test_mapped_second_key(self):
# Map b to c.
# While mod_tap is waiting for the timeout to happen, press b.
# We expect c to be written, because b goes through the handlers and
# gets mapped.
# The event_reader has to wait for listeners to complete for mod_tap to work, so
# that it hands them over to the other handlers when the time comes.
# That means however, that the event_readers loop blocks. Therefore, it was turned
# into a fire-and-forget kind of thing. When an event arrives, it just schedules
# asyncio to do that stuff later, and continues reading.
self.preset.add(
Mapping.from_combination(
input_combination=[
InputConfig(
type=EV_KEY,
code=KEY_B,
origin_hash=self.origin_hash,
)
],
output_symbol="c",
),
)
self.bootstrap_event_reader()
async def async_generator():
events = [
InputEvent(0, 0, EV_KEY, KEY_A, 1),
InputEvent(0, 0, EV_KEY, KEY_B, 1),
InputEvent(0, 0, EV_KEY, KEY_A, 0),
InputEvent(0, 0, EV_KEY, KEY_B, 0),
]
for event in events:
yield event
# Wait a bit. During runtime, events don't come in that quickly
# and the mod_tap macro needs some loop iterations until it adds
# the listener to the context.
await asyncio.sleep(0.010)
with patch.object(self.event_reader, "read_loop", async_generator):
await self.event_reader.run()
await asyncio.sleep(0.020)
self.assertIn(InputEvent(0, 0, EV_KEY, KEY_C, 1), uinput_write_history)
self.assertIn(InputEvent(0, 0, EV_KEY, KEY_C, 0), uinput_write_history)
self.assertIn(InputEvent(0, 0, EV_KEY, KEY_A, 1), uinput_write_history)
self.assertIn(InputEvent(0, 0, EV_KEY, KEY_A, 0), uinput_write_history)
self.assertNotIn(InputEvent(0, 0, EV_KEY, KEY_B, 1), uinput_write_history)
self.assertNotIn(InputEvent(0, 0, EV_KEY, KEY_B, 0), uinput_write_history)
@test_setup
class TestModTapUnit(MacroTestBase):
async def wait_for_timeout(self, macro):
macro = Parser.parse(macro, self.context, DummyMapping, True)
start = time.time()
# Awaiting macro.run will cause it to wait for the tapping_term.
# When it injects the modifier, release the trigger.
macro.press_trigger()
await macro.run(lambda *_, **__: macro.release_trigger())
return time.time() - start
async def test_tapping_term_configuration_default(self):
time_ = await self.wait_for_timeout("mod_tap(a, b)")
# + 2 times 10ms of keycode_pause
self.assertAlmostEqual(time_, 0.22, delta=0.02)
async def test_tapping_term_configuration_100(self):
time_ = await self.wait_for_timeout("mod_tap(a, b, 100)")
self.assertAlmostEqual(time_, 0.12, delta=0.02)
async def test_tapping_term_configuration_100_kwarg(self):
time_ = await self.wait_for_timeout("mod_tap(a, b, tapping_term=100)")
self.assertAlmostEqual(time_, 0.12, delta=0.02)

View File

@ -0,0 +1,62 @@
#!/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
from evdev.ecodes import EV_KEY
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.configs.validation_errors import MacroError
from inputremapper.injection.macros.parse import Parser
from tests.lib.test_setup import test_setup
from tests.unit.test_macros.macro_test_base import MacroTestBase, DummyMapping
@test_setup
class TestModify(MacroTestBase):
async def test_modify(self):
code_a = keyboard_layout.get("a")
code_b = keyboard_layout.get("b")
code_c = keyboard_layout.get("c")
macro = Parser.parse(
"set(foo, b).modify($foo, modify(a, key(c)))",
self.context,
DummyMapping,
)
await macro.run(self.handler)
self.assertListEqual(
self.result,
[
(EV_KEY, code_b, 1),
(EV_KEY, code_a, 1),
(EV_KEY, code_c, 1),
(EV_KEY, code_c, 0),
(EV_KEY, code_a, 0),
(EV_KEY, code_b, 0),
],
)
async def test_raises_error(self):
self.assertRaises(MacroError, Parser.parse, "modify(asdf, k(a))", self.context)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,246 @@
#!/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 unittest
from evdev._ecodes import (
REL_Y,
EV_REL,
REL_HWHEEL,
REL_HWHEEL_HI_RES,
REL_X,
REL_WHEEL,
REL_WHEEL_HI_RES,
KEY_A,
EV_KEY,
)
from inputremapper.configs.validation_errors import MacroError
from inputremapper.injection.macros.parse import Parser
from tests.lib.test_setup import test_setup
from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase
@test_setup
class TestMouse(MacroTestBase):
async def test_mouse_acceleration(self):
# There is a tiny float-rounding error that can break the test, therefore I use
# 0.09001 to make it more robust.
await self._run_macro("mouse(up, 10, 0.09001)", 0.1)
self.assertEqual(
[
(EV_REL, REL_Y, -2),
(EV_REL, REL_Y, -3),
(EV_REL, REL_Y, -4),
(EV_REL, REL_Y, -4),
(EV_REL, REL_Y, -5),
],
self.result,
)
async def test_rate(self):
# It should move 200 times per second by 1px, for 0.2 seconds.
rel_rate = 200
time = 0.2
speed = 1
expected_movement = time * rel_rate * speed
await self._run_macro(f"mouse(down, {speed})", time, rel_rate)
total_movement = sum(event[2] for event in self.result)
self.assertAlmostEqual(float(total_movement), expected_movement, delta=1)
async def test_slow_movement(self):
await self._run_macro("mouse(down, 0.1)", 0.2, 200)
total_movement = sum(event[2] for event in self.result)
self.assertAlmostEqual(total_movement, 4, delta=1)
async def test_mouse_xy_acceleration_1(self):
await self._run_macro("mouse_xy(2, -10, 0.09001)", 0.1)
self.assertEqual(
[
(EV_REL, REL_Y, -2),
(EV_REL, REL_Y, -3),
(EV_REL, REL_Y, -4),
(EV_REL, REL_Y, -4),
(EV_REL, REL_Y, -5),
],
self._get_y_movement(),
)
self.assertEqual(
[
(EV_REL, REL_X, 1),
(EV_REL, REL_X, 1),
(EV_REL, REL_X, 1),
],
self._get_x_movement(),
)
async def test_mouse_xy_acceleration_2(self):
await self._run_macro("mouse_xy(10, -2, 0.09001)", 0.1)
self.assertEqual(
[
(EV_REL, REL_Y, -1),
(EV_REL, REL_Y, -1),
(EV_REL, REL_Y, -1),
],
self._get_y_movement(),
)
self.assertEqual(
[
(EV_REL, REL_X, 2),
(EV_REL, REL_X, 3),
(EV_REL, REL_X, 4),
(EV_REL, REL_X, 4),
(EV_REL, REL_X, 5),
],
self._get_x_movement(),
)
async def test_mouse_xy_only_x(self):
await self._run_macro("mouse_xy(x=10, acceleration=1)", 0.1)
self.assertEqual([], self._get_y_movement())
self.assertEqual(
[
(EV_REL, REL_X, 10),
(EV_REL, REL_X, 10),
(EV_REL, REL_X, 10),
(EV_REL, REL_X, 10),
(EV_REL, REL_X, 10),
(EV_REL, REL_X, 10),
],
self._get_x_movement(),
)
async def test_mouse_xy_only_y(self):
await self._run_macro("mouse_xy(y=10)", 0.1)
self.assertEqual([], self._get_x_movement())
self.assertEqual(
[
(EV_REL, REL_Y, 10),
(EV_REL, REL_Y, 10),
(EV_REL, REL_Y, 10),
(EV_REL, REL_Y, 10),
(EV_REL, REL_Y, 10),
(EV_REL, REL_Y, 10),
],
self._get_y_movement(),
)
async def test_wheel_left(self):
wheel_speed = 60
sleep = 0.1
await self._run_macro(f"wheel(left, {wheel_speed})", sleep)
self.assertIn((EV_REL, REL_HWHEEL, 1), self.result)
self.assertIn((EV_REL, REL_HWHEEL_HI_RES, 60), self.result)
expected_num_hires_events = sleep * DummyMapping.rel_rate
expected_num_wheel_events = int(expected_num_hires_events / 120 * wheel_speed)
actual_num_wheel_events = self.result.count((EV_REL, REL_HWHEEL, 1))
actual_num_hires_events = self.result.count(
(
EV_REL,
REL_HWHEEL_HI_RES,
wheel_speed,
)
)
self.assertGreater(
actual_num_wheel_events,
expected_num_wheel_events * 0.9,
)
self.assertLess(
actual_num_wheel_events,
expected_num_wheel_events * 1.1,
)
self.assertGreater(
actual_num_hires_events,
expected_num_hires_events * 0.9,
)
self.assertLess(
actual_num_hires_events,
expected_num_hires_events * 1.1,
)
async def test_wheel_up(self):
await self._run_macro("wheel(up, 60)", 0.1)
self.assertIn((EV_REL, REL_WHEEL, 1), self.result)
self.assertIn((EV_REL, REL_WHEEL_HI_RES, 60), self.result)
async def test_wheel_down(self):
await self._run_macro("wheel(down, 60)", 0.1)
self.assertIn((EV_REL, REL_WHEEL, -1), self.result)
self.assertIn((EV_REL, REL_WHEEL_HI_RES, -60), self.result)
async def test_wheel_right(self):
await self._run_macro("wheel(right, 60)", 0.1)
self.assertIn((EV_REL, REL_HWHEEL, -1), self.result)
self.assertIn((EV_REL, REL_HWHEEL_HI_RES, -60), self.result)
async def test_mouse_releases(self):
await self._run_macro("mouse(down, 1).key(a)", 0.1)
self.assertEqual(self.result[-2:], [(EV_KEY, KEY_A, 1), (EV_KEY, KEY_A, 0)])
async def test_mouse_xy_releases(self):
await self._run_macro("mouse_xy(1, 1, 1).key(a)", 0.1)
self.assertEqual(self.result[-2:], [(EV_KEY, KEY_A, 1), (EV_KEY, KEY_A, 0)])
async def test_wheel_releases(self):
await self._run_macro("wheel(down, 1).key(a)", 0.1)
self.assertEqual(self.result[-2:], [(EV_KEY, KEY_A, 1), (EV_KEY, KEY_A, 0)])
async def test_raises_error(self):
Parser.parse("mouse(up, 3)", self.context) # no error
Parser.parse("mouse(up, speed=$a)", self.context) # no error
self.assertRaises(MacroError, Parser.parse, "mouse(3, up)", self.context)
Parser.parse("wheel(left, 3)", self.context) # no error
self.assertRaises(MacroError, Parser.parse, "wheel(3, left)", self.context)
def _get_x_movement(self):
return [event for event in self.result if event[1] == REL_X]
def _get_y_movement(self):
return [event for event in self.result if event[1] == REL_Y]
async def _run_macro(
self,
code: str,
time: float,
rel_rate: int = DummyMapping.rel_rate,
):
dummy_mapping = DummyMapping()
dummy_mapping.rel_rate = rel_rate
macro = Parser.parse(
code,
self.context,
dummy_mapping,
)
macro.press_trigger()
asyncio.ensure_future(macro.run(self.handler))
await asyncio.sleep(time)
self.assertTrue(macro.tasks[0].is_holding())
macro.release_trigger()
await asyncio.sleep(0.05)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,114 @@
#!/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 unittest
from evdev.ecodes import (
EV_KEY,
KEY_A,
KEY_B,
KEY_C,
KEY_D,
)
from inputremapper.injection.macros.parse import Parser
from tests.lib.test_setup import test_setup
from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase
@test_setup
class TestParallel(MacroTestBase):
async def test_1_child_macro(self):
macro = Parser.parse(
"parallel(key(a))",
self.context,
DummyMapping(),
True,
)
self.assertEqual(len(macro.tasks[0].child_macros), 1)
await macro.run(self.handler)
self.assertEqual(self.result, [(EV_KEY, KEY_A, 1), (EV_KEY, KEY_A, 0)])
async def test_4_child_macros(self):
macro = Parser.parse(
"parallel(key(a), key(b), key(c), key(d))",
self.context,
DummyMapping(),
True,
)
self.assertEqual(len(macro.tasks[0].child_macros), 4)
await macro.run(self.handler)
self.assertIn((EV_KEY, KEY_A, 0), self.result)
self.assertIn((EV_KEY, KEY_B, 0), self.result)
self.assertIn((EV_KEY, KEY_C, 0), self.result)
self.assertIn((EV_KEY, KEY_D, 0), self.result)
async def test_one_wait_takes_longer(self):
mapping = DummyMapping()
mapping.macro_key_sleep_ms = 0
macro = Parser.parse(
"parallel(wait(100), wait(10).key(b)).key(c)",
self.context,
mapping,
True,
)
asyncio.ensure_future(macro.run(self.handler))
await asyncio.sleep(0.06)
# The wait(10).key(b) macro is already done, but KEY_C is not yet injected
self.assertEqual(len(self.result), 2)
self.assertIn((EV_KEY, KEY_B, 1), self.result)
self.assertIn((EV_KEY, KEY_B, 0), self.result)
# Both need to complete for it to continue to key(c)
await asyncio.sleep(0.06)
self.assertEqual(len(self.result), 4)
self.assertIn((EV_KEY, KEY_C, 1), self.result)
self.assertIn((EV_KEY, KEY_C, 0), self.result)
async def test_parallel_hold(self):
mapping = DummyMapping()
mapping.macro_key_sleep_ms = 0
macro = Parser.parse(
"parallel(hold_keys(a), hold_keys(b)).key(c)",
self.context,
mapping,
True,
)
macro.press_trigger()
asyncio.ensure_future(macro.run(self.handler))
await asyncio.sleep(0.05)
self.assertIn((EV_KEY, KEY_A, 1), self.result)
self.assertIn((EV_KEY, KEY_B, 1), self.result)
self.assertEqual(len(self.result), 2)
macro.release_trigger()
await asyncio.sleep(0.05)
self.assertIn((EV_KEY, KEY_A, 0), self.result)
self.assertIn((EV_KEY, KEY_B, 0), self.result)
self.assertIn((EV_KEY, KEY_C, 1), self.result)
self.assertIn((EV_KEY, KEY_C, 0), self.result)
self.assertEqual(len(self.result), 6)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,346 @@
#!/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 re
import unittest
from evdev.ecodes import EV_KEY, KEY_A, KEY_B, KEY_C, KEY_E
from inputremapper.configs.validation_errors import (
MacroError,
)
from inputremapper.injection.macros.argument import Argument, ArgumentConfig
from inputremapper.injection.macros.parse import Parser
from inputremapper.injection.macros.raw_value import RawValue
from inputremapper.injection.macros.tasks.hold_keys import HoldKeysTask
from inputremapper.injection.macros.tasks.if_tap import IfTapTask
from inputremapper.injection.macros.tasks.key import KeyTask
from inputremapper.injection.macros.variable import Variable
from tests.lib.logger import logger
from tests.lib.test_setup import test_setup
from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase
@test_setup
class TestParsing(MacroTestBase):
def test_get_macro_argument_names(self):
self.assertEqual(
IfTapTask.get_macro_argument_names(),
["then", "else", "timeout"],
)
self.assertEqual(
HoldKeysTask.get_macro_argument_names(),
["*symbols"],
)
def test_get_num_parameters(self):
self.assertEqual(IfTapTask.get_num_parameters(), (0, 3))
self.assertEqual(KeyTask.get_num_parameters(), (1, 1))
self.assertEqual(HoldKeysTask.get_num_parameters(), (0, float("inf")))
def test_remove_whitespaces(self):
self.assertEqual(Parser.remove_whitespaces('foo"bar"foo'), 'foo"bar"foo')
self.assertEqual(Parser.remove_whitespaces('foo" bar"foo'), 'foo" bar"foo')
self.assertEqual(
Parser.remove_whitespaces('foo" bar"fo" "o'), 'foo" bar"fo" "o'
)
self.assertEqual(
Parser.remove_whitespaces(' fo o"\nba r "f\noo'), 'foo"\nba r "foo'
)
self.assertEqual(Parser.remove_whitespaces(' a " b " c " '), 'a" b "c" ')
self.assertEqual(Parser.remove_whitespaces('"""""""""'), '"""""""""')
self.assertEqual(Parser.remove_whitespaces('""""""""'), '""""""""')
self.assertEqual(Parser.remove_whitespaces(" "), "")
self.assertEqual(Parser.remove_whitespaces(' " '), '" ')
self.assertEqual(Parser.remove_whitespaces(' " " '), '" "')
self.assertEqual(Parser.remove_whitespaces("a# ##b", delimiter="##"), "a###b")
self.assertEqual(Parser.remove_whitespaces("a###b", delimiter="##"), "a###b")
self.assertEqual(Parser.remove_whitespaces("a## #b", delimiter="##"), "a## #b")
self.assertEqual(
Parser.remove_whitespaces("a## ##b", delimiter="##"), "a## ##b"
)
def test_remove_comments(self):
self.assertEqual(Parser.remove_comments("a#b"), "a")
self.assertEqual(Parser.remove_comments('"a#b"'), '"a#b"')
self.assertEqual(Parser.remove_comments('a"#"#b'), 'a"#"')
self.assertEqual(Parser.remove_comments('a"#""#"#b'), 'a"#""#"')
self.assertEqual(Parser.remove_comments('#a"#""#"#b'), "")
self.assertEqual(
re.sub(
r"\s",
"",
Parser.remove_comments(
"""
# a
b
# c
d
"""
),
),
"bd",
)
async def test_count_brackets(self):
self.assertEqual(Parser._count_brackets(""), 0)
self.assertEqual(Parser._count_brackets("()"), 2)
self.assertEqual(Parser._count_brackets("a()"), 3)
self.assertEqual(Parser._count_brackets("a(b)"), 4)
self.assertEqual(Parser._count_brackets("a(b())"), 6)
self.assertEqual(Parser._count_brackets("a(b(c))"), 7)
self.assertEqual(Parser._count_brackets("a(b(c))d"), 7)
self.assertEqual(Parser._count_brackets("a(b(c))d()"), 7)
def test_split_keyword_arg(self):
self.assertTupleEqual(Parser._split_keyword_arg("_A=b"), ("_A", "b"))
self.assertTupleEqual(Parser._split_keyword_arg("a_=1"), ("a_", "1"))
self.assertTupleEqual(
Parser._split_keyword_arg("a=repeat(2, KEY_A)"),
("a", "repeat(2, KEY_A)"),
)
self.assertTupleEqual(Parser._split_keyword_arg('a="=,#+."'), ("a", '"=,#+."'))
def test_is_this_a_macro(self):
self.assertTrue(Parser.is_this_a_macro("key(1)"))
self.assertTrue(Parser.is_this_a_macro("key(1).key(2)"))
self.assertTrue(Parser.is_this_a_macro("repeat(1, key(1).key(2))"))
self.assertFalse(Parser.is_this_a_macro("1"))
self.assertFalse(Parser.is_this_a_macro("key_kp1"))
self.assertFalse(Parser.is_this_a_macro("btn_left"))
self.assertFalse(Parser.is_this_a_macro("minus"))
self.assertFalse(Parser.is_this_a_macro("k"))
self.assertFalse(Parser.is_this_a_macro(1))
self.assertFalse(Parser.is_this_a_macro(None))
self.assertTrue(Parser.is_this_a_macro("a+b"))
self.assertTrue(Parser.is_this_a_macro("a+b+c"))
self.assertTrue(Parser.is_this_a_macro("a + b"))
self.assertTrue(Parser.is_this_a_macro("a + b + c"))
def test_handle_plus_syntax(self):
self.assertEqual(Parser.handle_plus_syntax("a + b"), "hold_keys(a,b)")
self.assertEqual(Parser.handle_plus_syntax("a + b + c"), "hold_keys(a,b,c)")
self.assertEqual(Parser.handle_plus_syntax(" a+b+c "), "hold_keys(a,b,c)")
# invalid. The last one with `key` should not have been a parameter
# of this function to begin with.
strings = ["+", "a+", "+b", "a\n+\n+\nb", "key(a + b)"]
for string in strings:
with self.assertRaises(MacroError):
logger.info('testing "%s"', string)
Parser.handle_plus_syntax(string)
self.assertEqual(Parser.handle_plus_syntax("a"), "a")
self.assertEqual(Parser.handle_plus_syntax("key(a)"), "key(a)")
self.assertEqual(Parser.handle_plus_syntax(""), "")
def test_parse_plus_syntax(self):
macro = Parser.parse("a + b")
self.assertEqual(macro.code, "hold_keys(a,b)")
# this is not erroneously recognized as "plus" syntax
macro = Parser.parse("key(a) # a + b")
self.assertEqual(macro.code, "key(a)")
async def test_extract_params(self):
# splits strings, doesn't try to understand their meaning yet
def expect(raw, expectation):
self.assertListEqual(Parser._extract_args(raw), expectation)
expect("a", ["a"])
expect("a,b", ["a", "b"])
expect("a,b,c", ["a", "b", "c"])
expect("key(a)", ["key(a)"])
expect("key(a).key(b), key(a)", ["key(a).key(b)", "key(a)"])
expect("key(a), key(a).key(b)", ["key(a)", "key(a).key(b)"])
expect(
'a("foo(1,2,3)", ",,,,,, "), , ""',
['a("foo(1,2,3)", ",,,,,, ")', "", '""'],
)
expect(
",1, ,b,x(,a(),).y().z(),,",
["", "1", "", "b", "x(,a(),).y().z()", "", ""],
)
expect("repeat(1, key(a))", ["repeat(1, key(a))"])
expect(
"repeat(1, key(a)), repeat(1, key(b))",
["repeat(1, key(a))", "repeat(1, key(b))"],
)
expect(
"repeat(1, key(a)), repeat(1, key(b)), repeat(1, key(c))",
["repeat(1, key(a))", "repeat(1, key(b))", "repeat(1, key(c))"],
)
# will be parsed as None
expect("", [""])
expect(",", ["", ""])
expect(",,", ["", "", ""])
async def test_parse_params(self):
def test(value, types):
argument = Argument(
ArgumentConfig(position=0, name="test", types=types),
DummyMapping,
)
argument.initialize_variable(RawValue(value=value))
return argument._variable
self.assertEqual(
test("", [None, int, float]),
Variable(None, const=True),
)
# strings. If it is wrapped in quotes, don't parse the contents
self.assertEqual(
test('"foo"', [str]),
Variable("foo", const=True),
)
self.assertEqual(
test('"\tf o o\n"', [str]),
Variable("\tf o o\n", const=True),
)
self.assertEqual(
test('"foo(a,b)"', [str]),
Variable("foo(a,b)", const=True),
)
self.assertEqual(
test('",,,()"', [str]),
Variable(",,,()", const=True),
)
# strings without quotes only work as long as there is no function call or
# anything. This is only really acceptable for constants like KEY_A and for
# variable names, which are not allowed to contain special characters that may
# have a meaning in the macro syntax.
self.assertEqual(
test("foo", [str]),
Variable("foo", const=True),
)
self.assertEqual(
test("", [str, None]),
Variable(None, const=True),
)
self.assertEqual(
test("", [str]),
Variable("", const=True),
)
self.assertEqual(
test("", [None]),
Variable(None, const=True),
)
self.assertEqual(
test("None", [None]),
Variable(None, const=True),
)
self.assertEqual(
test('"None"', [str]),
Variable("None", const=True),
)
self.assertEqual(
test("5", [int]),
Variable(5, const=True),
)
self.assertEqual(
test("5", [float, int]),
Variable(5, const=True),
)
self.assertEqual(
test("5.2", [int, float]),
Variable(5.2, const=True),
)
self.assertIsInstance(
test("$foo", [str]),
Variable,
)
self.assertEqual(
test("$foo", [str]),
Variable("foo", const=False),
)
async def test_string_not_a_macro(self):
# passing a string parameter. This is not a macro, even though
# it might look like it without the string quotes. Everything with
# explicit quotes around it has to be treated as a string.
self.assertRaises(MacroError, Parser.parse, '"modify(a, b)"', self.context)
async def test_multiline_macro_and_comments(self):
# the parser is not confused by the code in the comments and can use hashtags
# in strings in the actual code
comment = '# repeat(1,key(KEY_D)).set(a,"#b")'
macro = Parser.parse(
f"""
{comment}
key(KEY_A).{comment}
key(KEY_B). {comment}
repeat({comment}
1, {comment}
key(KEY_C){comment}
). {comment}
{comment}
set(a, "#").{comment}
if_eq($a, "#", key(KEY_E), key(KEY_F)) {comment}
{comment}
""",
self.context,
DummyMapping,
)
await macro.run(self.handler)
self.assertListEqual(
self.result,
[
(EV_KEY, KEY_A, 1),
(EV_KEY, KEY_A, 0),
(EV_KEY, KEY_B, 1),
(EV_KEY, KEY_B, 0),
(EV_KEY, KEY_C, 1),
(EV_KEY, KEY_C, 0),
(EV_KEY, KEY_E, 1),
(EV_KEY, KEY_E, 0),
],
)
async def test_child_macro_count(self):
# It correctly keeps track of child-macros for both positional and keyword-args
macro = Parser.parse(
"hold(macro=hold(hold())).repeat(1, macro=repeat(1, hold()))",
self.context,
DummyMapping,
True,
)
self.assertEqual(self.count_child_macros(macro), 4)
self.assertEqual(self.count_tasks(macro), 6)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,142 @@
#!/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
from evdev.ecodes import EV_KEY
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.configs.validation_errors import MacroError
from inputremapper.injection.macros.parse import Parser
from tests.lib.test_setup import test_setup
from tests.unit.test_macros.macro_test_base import MacroTestBase, DummyMapping
@test_setup
class TestRepeat(MacroTestBase):
async def test_1(self):
start = time.time()
repeats = 20
macro = Parser.parse(
f"repeat({repeats}, key(k)).repeat(1, key(k))",
self.context,
DummyMapping,
)
k_code = keyboard_layout.get("k")
await macro.run(self.handler)
keystroke_sleep = DummyMapping.macro_key_sleep_ms
sleep_time = 2 * repeats * keystroke_sleep / 1000
self.assertGreater(time.time() - start, sleep_time * 0.9)
# This test is rather lax, timing in github actions is slow
self.assertLess(time.time() - start, sleep_time * 1.4)
self.assertListEqual(
self.result,
[(EV_KEY, k_code, 1), (EV_KEY, k_code, 0)] * (repeats + 1),
)
self.assertEqual(self.count_child_macros(macro), 2)
self.assertEqual(len(macro.tasks[0].child_macros), 1)
self.assertEqual(len(macro.tasks[0].child_macros[0].tasks), 1)
self.assertEqual(len(macro.tasks[0].child_macros[0].tasks[0].child_macros), 0)
self.assertEqual(len(macro.tasks[1].child_macros), 1)
self.assertEqual(len(macro.tasks[1].child_macros[0].tasks), 1)
self.assertEqual(len(macro.tasks[1].child_macros[0].tasks[0].child_macros), 0)
async def test_2(self):
start = time.time()
macro = Parser.parse("repeat(3, key(m).w(100))", self.context, DummyMapping)
m_code = keyboard_layout.get("m")
await macro.run(self.handler)
keystroke_time = 6 * DummyMapping.macro_key_sleep_ms
total_time = keystroke_time + 300
total_time /= 1000
self.assertGreater(time.time() - start, total_time * 0.9)
self.assertLess(time.time() - start, total_time * 1.2)
self.assertListEqual(
self.result,
[
(EV_KEY, m_code, 1),
(EV_KEY, m_code, 0),
(EV_KEY, m_code, 1),
(EV_KEY, m_code, 0),
(EV_KEY, m_code, 1),
(EV_KEY, m_code, 0),
],
)
self.assertEqual(self.count_child_macros(macro), 1)
self.assertEqual(len(macro.tasks), 1)
self.assertEqual(len(macro.tasks[0].child_macros), 1)
self.assertEqual(len(macro.tasks[0].child_macros[0].tasks), 2)
self.assertEqual(len(macro.tasks[0].child_macros[0].tasks[0].child_macros), 0)
self.assertEqual(len(macro.tasks[0].child_macros[0].tasks[1].child_macros), 0)
async def test_not_an_int(self):
# the first parameter for `repeat` requires an integer, not "foo",
# which makes `repeat` throw
macro = Parser.parse(
'set(a, "foo").repeat($a, key(KEY_A)).key(KEY_B)',
self.context,
DummyMapping,
)
try:
await macro.run(self.handler)
except MacroError as e:
self.assertIn("foo", str(e))
self.assertFalse(macro.running)
# key(KEY_B) is not executed, the macro stops
self.assertListEqual(self.result, [])
async def test_raises_error(self):
self.assertRaises(MacroError, Parser.parse, "r(1)", self.context)
self.assertRaises(MacroError, Parser.parse, "repeat(a, k(1))", self.context)
Parser.parse("repeat($a, k(1))", self.context) # no error
Parser.parse("repeat(2, k(1))", self.context) # no error
self.assertRaises(MacroError, Parser.parse, 'repeat("2", k(1))', self.context)
self.assertRaises(MacroError, Parser.parse, "r(1, 1)", self.context)
self.assertRaises(MacroError, Parser.parse, "r(k(1), 1)", self.context)
Parser.parse("r(1, macro=k(1))", self.context) # no error
self.assertRaises(MacroError, Parser.parse, "r(a=1, b=k(1))", self.context)
self.assertRaises(
MacroError,
Parser.parse,
"r(repeats=1, macro=k(1), a=2)",
self.context,
)
self.assertRaises(
MacroError,
Parser.parse,
"r(repeats=1, macro=k(1), repeats=2)",
self.context,
)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,101 @@
#!/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
from evdev.ecodes import EV_KEY
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.configs.validation_errors import MacroError
from inputremapper.injection.macros.macro import macro_variables
from inputremapper.injection.macros.parse import Parser
from tests.lib.test_setup import test_setup
from tests.unit.test_macros.macro_test_base import MacroTestBase, DummyMapping
@test_setup
class TestSet(MacroTestBase):
async def test_set_key(self):
code_b = keyboard_layout.get("b")
macro = Parser.parse("set(foo, b).key($foo)", self.context, DummyMapping)
await macro.run(self.handler)
self.assertListEqual(
self.result,
[
(EV_KEY, code_b, 1),
(EV_KEY, code_b, 0),
],
)
async def test_int_is_explicit_string(self):
await Parser.parse(
'set( \t"b" \n, "1")',
self.context,
DummyMapping,
).run(self.handler)
self.assertEqual(macro_variables.get("b"), "1")
async def test_int_is_int(self):
await Parser.parse(
"set(a, 1)",
self.context,
DummyMapping,
).run(self.handler)
self.assertEqual(macro_variables.get("a"), 1)
async def test_none(self):
await Parser.parse(
"set(a, )",
self.context,
DummyMapping,
).run(self.handler)
self.assertEqual(macro_variables.get("a"), None)
async def test_set_case_sensitive_1(self):
await Parser.parse(
'set(a, "foo")',
self.context,
DummyMapping,
).run(self.handler)
self.assertEqual(macro_variables.get("a"), "foo")
self.assertEqual(macro_variables.get("A"), None)
async def test_set_case_sensitive_2(self):
await Parser.parse(
'set(A, "foo")',
self.context,
DummyMapping,
).run(self.handler)
self.assertEqual(macro_variables.get("A"), "foo")
self.assertEqual(macro_variables.get("a"), None)
async def test_raises_error(self):
self.assertRaises(MacroError, Parser.parse, "set($a, 1)", self.context)
self.assertRaises(MacroError, Parser.parse, "set(1, 2)", self.context)
self.assertRaises(MacroError, Parser.parse, "set(+, 2)", self.context)
self.assertRaises(MacroError, Parser.parse, "set(a(), 2)", self.context)
self.assertRaises(MacroError, Parser.parse, "set('b,c', 2)", self.context)
self.assertRaises(MacroError, Parser.parse, 'set("b,c", 2)', self.context)
Parser.parse("set(A, 2)", self.context) # no error
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,69 @@
#!/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 unittest
from evdev.ecodes import EV_KEY
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.injection.macros.parse import Parser
from tests.lib.test_setup import test_setup
from tests.unit.test_macros.macro_test_base import MacroTestBase, DummyMapping
@test_setup
class TestToggle(MacroTestBase):
async def test_toggle(self):
# repeats key(a) as long as macro is toggled
macro = Parser.parse("toggle(key(a))", self.context, DummyMapping)
code_a = keyboard_layout.get("a")
self.assertEqual(self.count_child_macros(macro), 1)
self.assertEqual(self.count_tasks(macro), 2)
# Start
macro.press_trigger()
macro.release_trigger()
asyncio.ensure_future(macro.run(self.handler))
await asyncio.sleep(0.1)
count_1 = self.result.count((EV_KEY, code_a, 1))
self.assertGreater(count_1, 2)
# Keeps writing more
await asyncio.sleep(0.1)
count_2 = self.result.count((EV_KEY, code_a, 1))
self.assertGreater(count_2, count_1)
# Stop
macro.press_trigger()
macro.release_trigger()
count_3 = self.result.count((EV_KEY, code_a, 1))
await asyncio.sleep(0.1)
count_4 = self.result.count((EV_KEY, code_a, 1))
# ensure that the macro has stopped
self.assertEqual(count_3, count_4)
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 time
import unittest
from inputremapper.configs.validation_errors import MacroError
from inputremapper.injection.macros.macro import Macro
from inputremapper.injection.macros.parse import Parser
from tests.lib.test_setup import test_setup
from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase
@test_setup
class TestWait(MacroTestBase):
async def assert_time_randomized(
self,
macro: Macro,
min_: float,
max_: float,
):
for _ in range(100):
start = time.time()
await macro.run(self.handler)
time_taken = time.time() - start
# Any of the runs should be within the defined range, to prove that they
# are indeed random.
if min_ < time_taken < max_:
return
raise AssertionError("`wait` was not randomized")
async def test_wait_1_core(self):
mapping = DummyMapping()
mapping.macro_key_sleep_ms = 0
macro = Parser.parse("repeat(5, wait(50))", self.context, mapping, True)
start = time.time()
await macro.run(self.handler)
time_per_iteration = (time.time() - start) / 5
self.assertLess(abs(time_per_iteration - 0.05), 0.005)
async def test_wait_2_ranged(self):
mapping = DummyMapping()
mapping.macro_key_sleep_ms = 0
macro = Parser.parse("wait(1, 100)", self.context, mapping, True)
await self.assert_time_randomized(macro, 0.02, 0.08)
async def test_wait_3_ranged_single_get(self):
mapping = DummyMapping()
mapping.macro_key_sleep_ms = 0
macro = Parser.parse("set(a, 100).wait(1, $a)", self.context, mapping, True)
await self.assert_time_randomized(macro, 0.02, 0.08)
async def test_wait_4_ranged_double_get(self):
mapping = DummyMapping()
mapping.macro_key_sleep_ms = 0
macro = Parser.parse(
"set(a, 1).set(b, 100).wait($a, $b)", self.context, mapping, True
)
await self.assert_time_randomized(macro, 0.02, 0.08)
async def test_raises_error(self):
Parser.parse("w(2)", self.context) # no error
self.assertRaises(MacroError, Parser.parse, "wait(a)", self.context)
if __name__ == "__main__":
unittest.main()

503
tests/unit/test_mapping.py Normal file
View File

@ -0,0 +1,503 @@
#!/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
from functools import partial
from evdev.ecodes import (
EV_REL,
REL_X,
EV_KEY,
REL_Y,
REL_WHEEL,
REL_WHEEL_HI_RES,
KEY_1,
KEY_ESC,
)
try:
from pydantic.v1 import ValidationError
except ImportError:
from pydantic import ValidationError
from inputremapper.configs.mapping import Mapping, UIMapping, MappingType
from inputremapper.configs.keyboard_layout import keyboard_layout, DISABLE_NAME
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.gui.messages.message_broker import MessageType
from tests.lib.test_setup import test_setup
@test_setup
class TestMapping(unittest.IsolatedAsyncioTestCase):
def test_init(self):
"""Test init and that defaults are set."""
cfg = {
"input_combination": [{"type": 1, "code": 2}],
"target_uinput": "keyboard",
"output_symbol": "a",
}
m = Mapping(**cfg)
self.assertEqual(
m.input_combination, InputCombination([InputConfig(type=1, code=2)])
)
self.assertEqual(m.target_uinput, "keyboard")
self.assertEqual(m.output_symbol, "a")
self.assertIsNone(m.output_code)
self.assertIsNone(m.output_type)
self.assertEqual(m.macro_key_sleep_ms, 0)
self.assertEqual(m.deadzone, 0.1)
self.assertEqual(m.gain, 1)
self.assertEqual(m.expo, 0)
self.assertEqual(m.rel_rate, 60)
self.assertEqual(m.rel_to_abs_input_cutoff, 2)
self.assertEqual(m.release_timeout, 0.05)
def test_is_wheel_output(self):
mapping = Mapping(
input_combination=InputCombination([InputConfig(type=EV_REL, code=REL_X)]),
target_uinput="keyboard",
output_type=EV_REL,
output_code=REL_Y,
)
self.assertFalse(mapping.is_wheel_output())
self.assertFalse(mapping.is_high_res_wheel_output())
mapping = Mapping(
input_combination=InputCombination([InputConfig(type=EV_REL, code=REL_X)]),
target_uinput="keyboard",
output_type=EV_REL,
output_code=REL_WHEEL,
)
self.assertTrue(mapping.is_wheel_output())
self.assertFalse(mapping.is_high_res_wheel_output())
mapping = Mapping(
input_combination=InputCombination([InputConfig(type=EV_REL, code=REL_X)]),
target_uinput="keyboard",
output_type=EV_REL,
output_code=REL_WHEEL_HI_RES,
)
self.assertFalse(mapping.is_wheel_output())
self.assertTrue(mapping.is_high_res_wheel_output())
def test_get_output_type_code(self):
cfg = {
"input_combination": [{"type": 1, "code": 2}],
"target_uinput": "keyboard",
"output_symbol": "a",
}
m = Mapping(**cfg)
a = keyboard_layout.get("a")
self.assertEqual(m.get_output_type_code(), (EV_KEY, a))
m.output_symbol = "key(a)"
self.assertIsNone(m.get_output_type_code())
cfg = {
"input_combination": [{"type": 1, "code": 2}, {"type": 3, "code": 1}],
"target_uinput": "keyboard",
"output_type": 2,
"output_code": 3,
}
m = Mapping(**cfg)
self.assertEqual(m.get_output_type_code(), (2, 3))
def test_strips_output_symbol(self):
cfg = {
"input_combination": [{"type": 1, "code": 2}],
"target_uinput": "keyboard",
"output_symbol": "\t a \n",
}
m = Mapping(**cfg)
a = keyboard_layout.get("a")
self.assertEqual(m.get_output_type_code(), (EV_KEY, a))
def test_combination_changed_callback(self):
cfg = {
"input_combination": [{"type": 1, "code": 1}],
"target_uinput": "keyboard",
"output_symbol": "a",
}
m = Mapping(**cfg)
arguments = []
def callback(*args):
arguments.append(tuple(args))
m.set_combination_changed_callback(callback)
m.input_combination = [{"type": 1, "code": 2}]
m.input_combination = [{"type": 1, "code": 3}]
# make sure a copy works as expected and keeps the callback
m2 = m.copy()
m2.input_combination = [{"type": 1, "code": 4}]
m2.remove_combination_changed_callback()
m.remove_combination_changed_callback()
m.input_combination = [{"type": 1, "code": 5}]
m2.input_combination = [{"type": 1, "code": 6}]
self.assertEqual(
arguments,
[
(
InputCombination([{"type": 1, "code": 2}]),
InputCombination([{"type": 1, "code": 1}]),
),
(
InputCombination([{"type": 1, "code": 3}]),
InputCombination([{"type": 1, "code": 2}]),
),
(
InputCombination([{"type": 1, "code": 4}]),
InputCombination([{"type": 1, "code": 3}]),
),
],
)
m.remove_combination_changed_callback()
def test_init_fails(self):
"""Test that the init fails with invalid data."""
test = partial(self.assertRaises, ValidationError, Mapping)
cfg = {
"input_combination": [{"type": 1, "code": 2}],
"target_uinput": "keyboard",
"output_symbol": "a",
}
Mapping(**cfg)
# missing output symbol
del cfg["output_symbol"]
test(**cfg)
cfg["output_code"] = KEY_ESC
test(**cfg)
cfg["output_type"] = EV_KEY
Mapping(**cfg)
# matching type, code and symbol. This cannot be done via the ui, and requires
# manual editing of the preset file.
a = keyboard_layout.get("a")
cfg["output_code"] = a
cfg["output_symbol"] = "a"
cfg["output_type"] = EV_KEY
Mapping(**cfg)
# macro
cfg["output_symbol"] = "key(a)"
test(**cfg)
cfg["output_symbol"] = "a"
Mapping(**cfg)
# mismatching type, code and symbol
cfg["output_symbol"] = "b"
test(**cfg)
del cfg["output_type"]
del cfg["output_code"]
Mapping(**cfg) # no error
# empty symbol string without type and code
cfg["output_symbol"] = ""
test(**cfg)
cfg["output_symbol"] = "a"
# missing target
del cfg["target_uinput"]
test(**cfg)
# unknown target
cfg["target_uinput"] = "foo"
test(**cfg)
cfg["target_uinput"] = "keyboard"
Mapping(**cfg)
# missing input_combination
del cfg["input_combination"]
test(**cfg)
cfg["input_combination"] = [{"type": 1, "code": 2}]
Mapping(**cfg)
# no macro and not a known symbol
cfg["output_symbol"] = "qux"
test(**cfg)
cfg["output_symbol"] = "key(a)"
Mapping(**cfg)
# invalid macro
cfg["output_symbol"] = "key('a')"
test(**cfg)
cfg["output_symbol"] = "a"
Mapping(**cfg)
# map axis but no output type and code given
cfg["input_combination"] = [{"type": 3, "code": 0}]
test(**cfg)
# output symbol=disable is allowed
cfg["output_symbol"] = DISABLE_NAME
Mapping(**cfg)
del cfg["output_symbol"]
cfg["output_code"] = 1
cfg["output_type"] = 3
Mapping(**cfg)
# empty symbol string is allowed when type and code is set
cfg["output_symbol"] = ""
Mapping(**cfg)
del cfg["output_symbol"]
# multiple axis as axis in event combination
cfg["input_combination"] = [{"type": 3, "code": 0}, {"type": 3, "code": 1}]
test(**cfg)
cfg["input_combination"] = [{"type": 3, "code": 0}]
Mapping(**cfg)
del cfg["output_type"]
del cfg["output_code"]
cfg["input_combination"] = [{"type": 1, "code": 2}]
cfg["output_symbol"] = "a"
Mapping(**cfg)
# map EV_ABS as key with trigger point out of range
cfg["input_combination"] = [{"type": 3, "code": 0, "analog_threshold": 100}]
test(**cfg)
cfg["input_combination"] = [{"type": 3, "code": 0, "analog_threshold": 99}]
Mapping(**cfg)
cfg["input_combination"] = [{"type": 3, "code": 0, "analog_threshold": -100}]
test(**cfg)
cfg["input_combination"] = [{"type": 3, "code": 0, "analog_threshold": -99}]
Mapping(**cfg)
cfg["input_combination"] = [{"type": 1, "code": 2}]
Mapping(**cfg)
# deadzone out of range
test(**cfg, deadzone=1.01)
test(**cfg, deadzone=-0.01)
Mapping(**cfg, deadzone=1)
Mapping(**cfg, deadzone=0)
# expo out of range
test(**cfg, expo=1.01)
test(**cfg, expo=-1.01)
Mapping(**cfg, expo=1)
Mapping(**cfg, expo=-1)
# negative rate
test(**cfg, rel_rate=-1)
test(**cfg, rel_rate=0)
Mapping(**cfg, rel_rate=1)
Mapping(**cfg, rel_rate=200)
# negative rel_to_abs_input_cutoff
test(**cfg, rel_to_abs_input_cutoff=-1)
test(**cfg, rel_to_abs_input_cutoff=0)
Mapping(**cfg, rel_to_abs_input_cutoff=1)
Mapping(**cfg, rel_to_abs_input_cutoff=3)
# negative release timeout
test(**cfg, release_timeout=-0.1)
test(**cfg, release_timeout=0)
Mapping(**cfg, release_timeout=0.05)
Mapping(**cfg, release_timeout=0.3)
# analog output but no analog input
cfg = {
"input_combination": [{"type": 3, "code": 1, "analog_threshold": -1}],
"target_uinput": "gamepad",
"output_type": 3,
"output_code": 1,
}
test(**cfg)
cfg["input_combination"] = [{"type": 2, "code": 1, "analog_threshold": -1}]
test(**cfg)
cfg["output_type"] = 2
test(**cfg)
cfg["input_combination"] = [{"type": 3, "code": 1, "analog_threshold": -1}]
test(**cfg)
def test_automatically_detects_mapping_type(self):
cfg = {
"input_combination": [{"type": 1, "code": 2}],
"target_uinput": "keyboard",
"output_symbol": "a",
}
self.assertEqual(Mapping(**cfg).mapping_type, MappingType.KEY_MACRO.value)
cfg = {
"input_combination": [{"type": 1, "code": 2}],
"target_uinput": "keyboard",
"output_type": EV_KEY,
"output_code": KEY_ESC,
}
self.assertEqual(Mapping(**cfg).mapping_type, MappingType.KEY_MACRO.value)
cfg = {
"input_combination": [{"type": EV_REL, "code": REL_X}],
"target_uinput": "keyboard",
"output_type": EV_REL,
"output_code": REL_X,
}
self.assertEqual(Mapping(**cfg).mapping_type, MappingType.ANALOG.value)
def test_revalidate_at_assignment(self):
cfg = {
"input_combination": [{"type": 1, "code": 1}],
"target_uinput": "keyboard",
"output_symbol": "a",
}
m = Mapping(**cfg)
test = partial(self.assertRaises, ValidationError, m.__setattr__)
# invalid input event
test("input_combination", "1,2,3,4")
# unknown target
test("target_uinput", "foo")
# invalid macro
test("output_symbol", "key()")
# we could do a lot more tests here but since pydantic uses the same validation
# code as for the initialization we only need to make sure that the
# assignment validation is active
def test_set_invalid_combination_with_callback(self):
cfg = {
"input_combination": [{"type": 1, "code": 1}],
"target_uinput": "keyboard",
"output_symbol": "a",
}
m = Mapping(**cfg)
m.set_combination_changed_callback(lambda *args: None)
self.assertRaises(ValidationError, m.__setattr__, "input_combination", "1,2")
m.input_combination = [{"type": 1, "code": 2}]
m.input_combination = [{"type": 1, "code": 2}]
def test_is_valid(self):
cfg = {
"input_combination": [{"type": 1, "code": 1}],
"target_uinput": "keyboard",
"output_symbol": "a",
}
m = Mapping(**cfg)
self.assertTrue(m.is_valid())
def test_wrong_target(self):
mapping = Mapping(
input_combination=[{"type": EV_KEY, "code": KEY_1}],
target_uinput="keyboard",
output_symbol="a",
)
mapping.set_combination_changed_callback(lambda *args: None)
self.assertRaisesRegex(
ValidationError,
# the error should mention
# - the symbol
# - the current incorrect target
# - the target that works for this symbol
".*BTN_A.*keyboard.*gamepad",
mapping.__setattr__,
"output_symbol",
"BTN_A",
)
def test_wrong_target_for_macro(self):
mapping = Mapping(
input_combination=[{"type": EV_KEY, "code": KEY_1}],
target_uinput="keyboard",
output_symbol="key(a)",
)
mapping.set_combination_changed_callback(lambda *args: None)
self.assertRaisesRegex(
ValidationError,
# the error should mention
# - the symbol
# - the current incorrect target
# - the target that works for this symbol
".*BTN_A.*keyboard.*gamepad",
mapping.__setattr__,
"output_symbol",
"key(BTN_A)",
)
@test_setup
class TestUIMapping(unittest.IsolatedAsyncioTestCase):
def test_init(self):
"""Should be able to initialize without throwing errors."""
UIMapping()
def test_is_valid(self):
"""Should be invalid at first and become valid once all data is provided."""
mapping = UIMapping()
self.assertFalse(mapping.is_valid())
mapping.input_combination = [{"type": EV_KEY, "code": KEY_1}]
mapping.output_symbol = "a"
self.assertFalse(mapping.is_valid())
mapping.target_uinput = "keyboard"
self.assertTrue(mapping.is_valid())
def test_updates_validation_error(self):
mapping = UIMapping()
self.assertGreaterEqual(len(mapping.get_error().errors()), 2)
mapping.input_combination = [{"type": EV_KEY, "code": KEY_1}]
mapping.output_symbol = "a"
self.assertIn(
"1 validation error for Mapping\ntarget_uinput",
str(mapping.get_error()),
)
mapping.target_uinput = "keyboard"
self.assertTrue(mapping.is_valid())
self.assertIsNone(mapping.get_error())
def test_copy_returns_ui_mapping(self):
"""Copy should also be a UIMapping with all the invalid data."""
mapping = UIMapping()
mapping_2 = mapping.copy()
self.assertIsInstance(mapping_2, UIMapping)
self.assertEqual(
mapping_2.input_combination, InputCombination.empty_combination()
)
self.assertIsNone(mapping_2.output_symbol)
def test_get_bus_massage(self):
mapping = UIMapping()
mapping_2 = mapping.get_bus_message()
self.assertEqual(mapping_2.message_type, MessageType.mapping)
with self.assertRaises(TypeError):
# the massage should be immutable
mapping_2.output_symbol = "a"
self.assertIsNone(mapping_2.output_symbol)
# the original should be not immutable
mapping.output_symbol = "a"
self.assertEqual(mapping.output_symbol, "a")
def test_has_input_defined(self):
mapping = UIMapping()
self.assertFalse(mapping.has_input_defined())
mapping.input_combination = InputCombination([InputConfig(type=EV_KEY, code=1)])
self.assertTrue(mapping.has_input_defined())
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,109 @@
#!/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
from dataclasses import dataclass
from inputremapper.gui.messages.message_broker import MessageBroker, MessageType, Signal
from tests.lib.test_setup import test_setup
class Listener:
def __init__(self):
self.calls = []
def __call__(self, data):
self.calls.append(data)
@dataclass
class Message:
message_type: MessageType
msg: str
@test_setup
class TestMessageBroker(unittest.TestCase):
def test_calls_listeners(self):
"""The correct Listeners get called"""
message_broker = MessageBroker()
listener = Listener()
message_broker.subscribe(MessageType.test1, listener)
message_broker.publish(Message(MessageType.test1, "foo"))
message_broker.publish(Message(MessageType.test2, "bar"))
self.assertEqual(listener.calls[0], Message(MessageType.test1, "foo"))
def test_unsubscribe(self):
message_broker = MessageBroker()
listener = Listener()
message_broker.subscribe(MessageType.test1, listener)
message_broker.publish(Message(MessageType.test1, "a"))
message_broker.unsubscribe(listener)
message_broker.publish(Message(MessageType.test1, "b"))
self.assertEqual(len(listener.calls), 1)
self.assertEqual(listener.calls[0], Message(MessageType.test1, "a"))
def test_unsubscribe_unknown_listener(self):
"""nothing happens if we unsubscribe an unknown listener"""
message_broker = MessageBroker()
listener1 = Listener()
listener2 = Listener()
message_broker.subscribe(MessageType.test1, listener1)
message_broker.unsubscribe(listener2)
message_broker.publish(Message(MessageType.test1, "a"))
self.assertEqual(listener1.calls[0], Message(MessageType.test1, "a"))
def test_preserves_order(self):
message_broker = MessageBroker()
calls = []
def listener1(_):
message_broker.publish(Message(MessageType.test2, "f"))
calls.append(1)
def listener2(_):
message_broker.publish(Message(MessageType.test2, "f"))
calls.append(2)
def listener3(_):
message_broker.publish(Message(MessageType.test2, "f"))
calls.append(3)
def listener4(_):
calls.append(4)
message_broker.subscribe(MessageType.test1, listener1)
message_broker.subscribe(MessageType.test1, listener2)
message_broker.subscribe(MessageType.test1, listener3)
message_broker.subscribe(MessageType.test2, listener4)
message_broker.publish(Message(MessageType.test1, ""))
first = calls[:3]
first.sort()
self.assertEqual([1, 2, 3], first)
self.assertEqual([4, 4, 4], calls[3:])
@test_setup
class TestSignal(unittest.TestCase):
def test_eq(self):
self.assertEqual(Signal(MessageType.uinputs), Signal(MessageType.uinputs))
self.assertNotEqual(Signal(MessageType.uinputs), Signal(MessageType.groups))
self.assertNotEqual(Signal(MessageType.uinputs), "Signal: MessageType.uinputs")

View File

@ -0,0 +1,726 @@
#
# 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 json
import os
import shutil
import unittest
from packaging import version
from evdev.ecodes import (
EV_KEY,
EV_ABS,
ABS_HAT0X,
ABS_X,
ABS_Y,
ABS_RX,
ABS_RY,
EV_REL,
REL_X,
REL_Y,
REL_WHEEL_HI_RES,
REL_HWHEEL_HI_RES,
)
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.configs.mapping import UIMapping
from inputremapper.configs.migrations import Migrations
from inputremapper.configs.paths import PathUtils
from inputremapper.configs.preset import Preset
from inputremapper.injection.global_uinputs import GlobalUInputs, UInput
from inputremapper.logging.logger import VERSION
from inputremapper.user import UserUtils
from tests.lib.test_setup import test_setup
from tests.lib.tmp import tmp
@test_setup
class TestMigrations(unittest.TestCase):
def setUp(self):
# some extra care to ensure those tests are not destroying actual presets
self.assertTrue(UserUtils.home.startswith("/tmp"))
self.assertTrue(PathUtils.config_path().startswith("/tmp"))
self.assertTrue(PathUtils.get_preset_path().startswith("/tmp"))
self.assertTrue(PathUtils.get_preset_path("foo", "bar").startswith("/tmp"))
self.assertTrue(PathUtils.get_config_path().startswith("/tmp"))
self.assertTrue(PathUtils.get_config_path("foo").startswith("/tmp"))
self.v1_dir = os.path.join(UserUtils.home, ".config", "input-remapper")
self.beta_dir = os.path.join(
UserUtils.home, ".config", "input-remapper", "beta_1.6.0-beta"
)
global_uinputs = GlobalUInputs(UInput)
global_uinputs.prepare_all()
self.migrations = Migrations(global_uinputs)
def test_migrate_suffix(self):
old = os.path.join(PathUtils.config_path(), "config")
new = os.path.join(PathUtils.config_path(), "config.json")
try:
os.remove(new)
except FileNotFoundError:
pass
PathUtils.touch(old)
with open(old, "w") as f:
f.write("{}")
self.migrations.migrate()
self.assertTrue(os.path.exists(new))
self.assertFalse(os.path.exists(old))
def test_rename_config(self):
old = os.path.join(UserUtils.home, ".config", "key-mapper")
new = PathUtils.config_path()
# we are not destroying our actual config files with this test
self.assertTrue(new.startswith(tmp), f'Expected "{new}" to start with "{tmp}"')
try:
shutil.rmtree(new)
except FileNotFoundError:
pass
old_config_json = os.path.join(old, "config.json")
PathUtils.touch(old_config_json)
with open(old_config_json, "w") as f:
f.write('{"foo":"bar"}')
self.migrations.migrate()
self.assertTrue(os.path.exists(new))
self.assertFalse(os.path.exists(old))
new_config_json = os.path.join(new, "config.json")
with open(new_config_json, "r") as f:
moved_config = json.loads(f.read())
self.assertEqual(moved_config["foo"], "bar")
def test_wont_migrate_suffix(self):
old = os.path.join(PathUtils.config_path(), "config")
new = os.path.join(PathUtils.config_path(), "config.json")
PathUtils.touch(new)
with open(new, "w") as f:
f.write("{}")
PathUtils.touch(old)
with open(old, "w") as f:
f.write("{}")
self.migrations.migrate()
self.assertTrue(os.path.exists(new))
self.assertTrue(os.path.exists(old))
def test_migrate_preset(self):
if os.path.exists(PathUtils.config_path()):
shutil.rmtree(PathUtils.config_path())
p1 = os.path.join(PathUtils.config_path(), "foo1", "bar1.json")
p2 = os.path.join(PathUtils.config_path(), "foo2", "bar2.json")
PathUtils.touch(p1)
PathUtils.touch(p2)
with open(p1, "w") as f:
f.write("{}")
with open(p2, "w") as f:
f.write("{}")
self.migrations.migrate()
self.assertFalse(
os.path.exists(os.path.join(PathUtils.config_path(), "foo1", "bar1.json"))
)
self.assertFalse(
os.path.exists(os.path.join(PathUtils.config_path(), "foo2", "bar2.json"))
)
self.assertTrue(
os.path.exists(
os.path.join(PathUtils.config_path(), "presets", "foo1", "bar1.json")
),
)
self.assertTrue(
os.path.exists(
os.path.join(PathUtils.config_path(), "presets", "foo2", "bar2.json")
),
)
def test_wont_migrate_preset(self):
if os.path.exists(PathUtils.config_path()):
shutil.rmtree(PathUtils.config_path())
p1 = os.path.join(PathUtils.config_path(), "foo1", "bar1.json")
p2 = os.path.join(PathUtils.config_path(), "foo2", "bar2.json")
PathUtils.touch(p1)
PathUtils.touch(p2)
with open(p1, "w") as f:
f.write("{}")
with open(p2, "w") as f:
f.write("{}")
# already migrated
PathUtils.mkdir(os.path.join(PathUtils.config_path(), "presets"))
self.migrations.migrate()
self.assertTrue(
os.path.exists(os.path.join(PathUtils.config_path(), "foo1", "bar1.json"))
)
self.assertTrue(
os.path.exists(os.path.join(PathUtils.config_path(), "foo2", "bar2.json"))
)
self.assertFalse(
os.path.exists(
os.path.join(PathUtils.config_path(), "presets", "foo1", "bar1.json")
),
)
self.assertFalse(
os.path.exists(
os.path.join(PathUtils.config_path(), "presets", "foo2", "bar2.json")
),
)
def test_migrate_mappings(self):
"""Test if mappings are migrated correctly
mappings like
{(type, code): symbol} or {(type, code, value): symbol} should migrate
to {InputCombination: {target: target, symbol: symbol, ...}}
"""
path = os.path.join(
PathUtils.config_path(), "presets", "Foo Device", "test.json"
)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as file:
json.dump(
{
"mapping": {
f"{EV_KEY},1": "a",
f"{EV_KEY}, 2, 1": "BTN_B", # can be mapped to "gamepad"
f"{EV_KEY}, 3, 1": "BTN_1", # can not be mapped
f"{EV_ABS},{ABS_HAT0X},-1": "b",
f"{EV_ABS},1,1+{EV_ABS},2,-1+{EV_ABS},3,1": "c",
f"{EV_KEY}, 4, 1": ("d", "keyboard"),
f"{EV_KEY}, 5, 1": ("e", "foo"), # unknown target
f"{EV_KEY}, 6, 1": ("key(a, b)", "keyboard"), # broken macro
# ignored because broken
"3,1,1,2": "e",
"3": "e",
",,+3,1,2": "g",
"": "h",
}
},
file,
)
self.migrations.migrate()
# use UIMapping to also load invalid mappings
preset = Preset(PathUtils.get_preset_path("Foo Device", "test"), UIMapping)
preset.load()
self.assertEqual(
preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=1)])),
UIMapping(
input_combination=InputCombination([InputConfig(type=EV_KEY, code=1)]),
target_uinput="keyboard",
output_symbol="a",
),
)
self.assertEqual(
preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=2)])),
UIMapping(
input_combination=InputCombination([InputConfig(type=EV_KEY, code=2)]),
target_uinput="gamepad",
output_symbol="BTN_B",
),
)
self.assertEqual(
preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=3)])),
UIMapping(
input_combination=InputCombination([InputConfig(type=EV_KEY, code=3)]),
target_uinput="keyboard",
output_symbol="BTN_1\n# Broken mapping:\n# No target can handle all specified keycodes",
),
)
self.assertEqual(
preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=4)])),
UIMapping(
input_combination=InputCombination([InputConfig(type=EV_KEY, code=4)]),
target_uinput="keyboard",
output_symbol="d",
),
)
self.assertEqual(
preset.get_mapping(
InputCombination(
[InputConfig(type=EV_ABS, code=ABS_HAT0X, analog_threshold=-1)]
)
),
UIMapping(
input_combination=InputCombination(
[InputConfig(type=EV_ABS, code=ABS_HAT0X, analog_threshold=-1)]
),
target_uinput="keyboard",
output_symbol="b",
),
)
self.assertEqual(
preset.get_mapping(
InputCombination(
InputCombination.from_tuples(
(EV_ABS, 1, 1), (EV_ABS, 2, -1), (EV_ABS, 3, 1)
)
),
),
UIMapping(
input_combination=InputCombination(
InputCombination.from_tuples(
(EV_ABS, 1, 1), (EV_ABS, 2, -1), (EV_ABS, 3, 1)
),
),
target_uinput="keyboard",
output_symbol="c",
),
)
self.assertEqual(
preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=5)])),
UIMapping(
input_combination=InputCombination([InputConfig(type=EV_KEY, code=5)]),
target_uinput="foo",
output_symbol="e",
),
)
self.assertEqual(
preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=6)])),
UIMapping(
input_combination=InputCombination([InputConfig(type=EV_KEY, code=6)]),
target_uinput="keyboard",
output_symbol="key(a, b)",
),
)
self.assertEqual(8, len(preset))
def test_migrate_otherwise(self):
path = os.path.join(
PathUtils.config_path(), "presets", "Foo Device", "test.json"
)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as file:
json.dump(
{
"mapping": {
f"{EV_KEY},1,1": ("otherwise + otherwise", "keyboard"),
f"{EV_KEY},2,1": ("bar($otherwise)", "keyboard"),
f"{EV_KEY},3,1": ("foo(otherwise=qux)", "keyboard"),
f"{EV_KEY},4,1": ("qux(otherwise).bar(otherwise = 1)", "foo"),
f"{EV_KEY},5,1": ("foo(otherwise1=2qux)", "keyboard"),
}
},
file,
)
self.migrations.migrate()
preset = Preset(PathUtils.get_preset_path("Foo Device", "test"), UIMapping)
preset.load()
self.assertEqual(
preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=1)])),
UIMapping(
input_combination=InputCombination([InputConfig(type=EV_KEY, code=1)]),
target_uinput="keyboard",
output_symbol="otherwise + otherwise",
),
)
self.assertEqual(
preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=2)])),
UIMapping(
input_combination=InputCombination([InputConfig(type=EV_KEY, code=2)]),
target_uinput="keyboard",
output_symbol="bar($otherwise)",
),
)
self.assertEqual(
preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=3)])),
UIMapping(
input_combination=InputCombination([InputConfig(type=EV_KEY, code=3)]),
target_uinput="keyboard",
output_symbol="foo(else=qux)",
),
)
self.assertEqual(
preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=4)])),
UIMapping(
input_combination=InputCombination([InputConfig(type=EV_KEY, code=4)]),
target_uinput="foo",
output_symbol="qux(otherwise).bar(else=1)",
),
)
self.assertEqual(
preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=5)])),
UIMapping(
input_combination=InputCombination([InputConfig(type=EV_KEY, code=5)]),
target_uinput="keyboard",
output_symbol="foo(otherwise1=2qux)",
),
)
def test_add_version(self):
path = os.path.join(PathUtils.config_path(), "config.json")
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as file:
file.write("{}")
self.migrations.migrate()
self.assertEqual(
version.parse(VERSION),
self.migrations.config_version(),
)
def test_update_version(self):
path = os.path.join(PathUtils.config_path(), "config.json")
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as file:
json.dump({"version": "0.1.0"}, file)
self.migrations.migrate()
self.assertEqual(
version.parse(VERSION),
self.migrations.config_version(),
)
def test_config_version(self):
path = os.path.join(PathUtils.config_path(), "config.json")
with open(path, "w") as file:
file.write("{}")
self.assertEqual("0.0.0", self.migrations.config_version().public)
try:
os.remove(path)
except FileNotFoundError:
pass
self.assertEqual("0.0.0", self.migrations.config_version().public)
def test_migrate_left_and_right_purpose(self):
path = os.path.join(
PathUtils.config_path(), "presets", "Foo Device", "test.json"
)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as file:
json.dump(
{
"gamepad": {
"joystick": {
"left_purpose": "mouse",
"right_purpose": "wheel",
"pointer_speed": 50,
"x_scroll_speed": 10,
"y_scroll_speed": 20,
}
}
},
file,
)
self.migrations.migrate()
preset = Preset(PathUtils.get_preset_path("Foo Device", "test"), UIMapping)
preset.load()
# 2 mappings for mouse
# 2 mappings for wheel
self.assertEqual(len(preset), 4)
self.assertEqual(
preset.get_mapping(
InputCombination([InputConfig(type=EV_ABS, code=ABS_X)])
),
UIMapping(
input_combination=InputCombination(
[InputConfig(type=EV_ABS, code=ABS_X)]
),
target_uinput="mouse",
output_type=EV_REL,
output_code=REL_X,
gain=50 / 100,
),
)
self.assertEqual(
preset.get_mapping(
InputCombination([InputConfig(type=EV_ABS, code=ABS_Y)])
),
UIMapping(
input_combination=InputCombination(
[InputConfig(type=EV_ABS, code=ABS_Y)]
),
target_uinput="mouse",
output_type=EV_REL,
output_code=REL_Y,
gain=50 / 100,
),
)
self.assertEqual(
preset.get_mapping(
InputCombination([InputConfig(type=EV_ABS, code=ABS_RX)])
),
UIMapping(
input_combination=InputCombination(
[InputConfig(type=EV_ABS, code=ABS_RX)]
),
target_uinput="mouse",
output_type=EV_REL,
output_code=REL_HWHEEL_HI_RES,
gain=10,
),
)
self.assertEqual(
preset.get_mapping(
InputCombination([InputConfig(type=EV_ABS, code=ABS_RY)])
),
UIMapping(
input_combination=InputCombination(
[InputConfig(type=EV_ABS, code=ABS_RY)]
),
target_uinput="mouse",
output_type=EV_REL,
output_code=REL_WHEEL_HI_RES,
gain=20,
),
)
def test_migrate_left_and_right_purpose2(self):
# same as above, but left and right is swapped
path = os.path.join(
PathUtils.config_path(), "presets", "Foo Device", "test.json"
)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as file:
json.dump(
{
"gamepad": {
"joystick": {
"right_purpose": "mouse",
"left_purpose": "wheel",
"pointer_speed": 50,
"x_scroll_speed": 10,
"y_scroll_speed": 20,
}
}
},
file,
)
self.migrations.migrate()
preset = Preset(PathUtils.get_preset_path("Foo Device", "test"), UIMapping)
preset.load()
# 2 mappings for mouse
# 2 mappings for wheel
self.assertEqual(len(preset), 4)
self.assertEqual(
preset.get_mapping(
InputCombination([InputConfig(type=EV_ABS, code=ABS_RX)])
),
UIMapping(
input_combination=InputCombination(
[InputConfig(type=EV_ABS, code=ABS_RX)]
),
target_uinput="mouse",
output_type=EV_REL,
output_code=REL_X,
gain=50 / 100,
),
)
self.assertEqual(
preset.get_mapping(
InputCombination([InputConfig(type=EV_ABS, code=ABS_RY)])
),
UIMapping(
input_combination=InputCombination(
[InputConfig(type=EV_ABS, code=ABS_RY)]
),
target_uinput="mouse",
output_type=EV_REL,
output_code=REL_Y,
gain=50 / 100,
),
)
self.assertEqual(
preset.get_mapping(
InputCombination([InputConfig(type=EV_ABS, code=ABS_X)])
),
UIMapping(
input_combination=InputCombination(
[InputConfig(type=EV_ABS, code=ABS_X)]
),
target_uinput="mouse",
output_type=EV_REL,
output_code=REL_HWHEEL_HI_RES,
gain=10,
),
)
self.assertEqual(
preset.get_mapping(
InputCombination([InputConfig(type=EV_ABS, code=ABS_Y)])
),
UIMapping(
input_combination=InputCombination(
[InputConfig(type=EV_ABS, code=ABS_Y)]
),
target_uinput="mouse",
output_type=EV_REL,
output_code=REL_WHEEL_HI_RES,
gain=20,
),
)
def _create_v1_setup(self):
"""Create all files needed to mimic an outdated v1 configuration."""
device_name = "device_name"
PathUtils.mkdir(os.path.join(self.v1_dir, "presets", device_name))
v1_config = {"autoload": {device_name: "foo"}, "version": "1.0"}
with open(os.path.join(self.v1_dir, "config.json"), "w") as file:
json.dump(v1_config, file)
# insert something outdated that will be migrated, to ensure the files are
# first copied and then migrated.
with open(
os.path.join(self.v1_dir, "presets", device_name, "foo.json"), "w"
) as file:
json.dump({"mapping": {f"{EV_KEY},1": "a"}}, file)
def _create_beta_setup(self):
"""Create all files needed to mimic a beta configuration."""
device_name = "device_name"
# same here, but a different contents to tell the difference
PathUtils.mkdir(os.path.join(self.beta_dir, "presets", device_name))
beta_config = {"autoload": {device_name: "bar"}, "version": "1.6"}
with open(os.path.join(self.beta_dir, "config.json"), "w") as file:
json.dump(beta_config, file)
with open(
os.path.join(self.beta_dir, "presets", device_name, "bar.json"), "w"
) as file:
json.dump(
[
{
"input_combination": [
{"type": EV_KEY, "code": 1},
],
"target_uinput": "keyboard",
"output_symbol": "b",
"mapping_type": "key_macro",
}
],
file,
)
def test_prioritize_v1_over_beta_configs(self):
# if both v1 and beta presets and config exist, migrate v1
PathUtils.remove(PathUtils.get_config_path())
device_name = "device_name"
self._create_v1_setup()
self._create_beta_setup()
self.assertFalse(os.path.exists(PathUtils.get_preset_path(device_name, "foo")))
self.assertFalse(os.path.exists(PathUtils.get_config_path("config.json")))
self.migrations.migrate()
self.assertTrue(os.path.exists(PathUtils.get_preset_path(device_name, "foo")))
self.assertTrue(os.path.exists(PathUtils.get_config_path("config.json")))
self.assertFalse(os.path.exists(PathUtils.get_preset_path(device_name, "bar")))
# expect all original files to still exist
self.assertTrue(os.path.join(self.v1_dir, "config.json"))
self.assertTrue(os.path.join(self.v1_dir, "presets", "foo.json"))
self.assertTrue(os.path.join(self.beta_dir, "config.json"))
self.assertTrue(os.path.join(self.beta_dir, "presets", "bar.json"))
# v1 configs should be in the v2 dir now, and migrated
with open(PathUtils.get_config_path("config.json"), "r") as f:
config_json = json.load(f)
self.assertDictEqual(
config_json, {"autoload": {device_name: "foo"}, "version": VERSION}
)
with open(PathUtils.get_preset_path(device_name, "foo.json"), "r") as f:
os.system(f'cat { PathUtils.get_preset_path(device_name, "foo.json") }')
preset_foo_json = json.load(f)
self.assertEqual(
preset_foo_json,
[
{
"input_combination": [
{"type": EV_KEY, "code": 1},
],
"target_uinput": "keyboard",
"output_symbol": "a",
"mapping_type": "key_macro",
}
],
)
def test_copy_over_beta_configs(self):
# same as test_prioritize_v1_over_beta_configs, but only create the beta
# directory without any v1 presets.
PathUtils.remove(PathUtils.get_config_path())
device_name = "device_name"
self._create_beta_setup()
self.assertFalse(os.path.exists(PathUtils.get_preset_path(device_name, "bar")))
self.assertFalse(os.path.exists(PathUtils.get_config_path("config.json")))
self.migrations.migrate()
self.assertTrue(os.path.exists(PathUtils.get_preset_path(device_name, "bar")))
self.assertTrue(os.path.exists(PathUtils.get_config_path("config.json")))
# expect all original files to still exist
self.assertTrue(os.path.join(self.beta_dir, "config.json"))
self.assertTrue(os.path.join(self.beta_dir, "presets", "bar.json"))
# beta configs should be in the v2 dir now
with open(PathUtils.get_config_path("config.json"), "r") as f:
config_json = json.load(f)
self.assertDictEqual(
config_json, {"autoload": {device_name: "bar"}, "version": VERSION}
)
with open(PathUtils.get_preset_path(device_name, "bar.json"), "r") as f:
os.system(f'cat { PathUtils.get_preset_path(device_name, "bar.json") }')
preset_foo_json = json.load(f)
self.assertEqual(
preset_foo_json,
[
{
"input_combination": [
{"type": EV_KEY, "code": 1},
],
"target_uinput": "keyboard",
"output_symbol": "b",
"mapping_type": "key_macro",
}
],
)
if __name__ == "__main__":
unittest.main()

73
tests/unit/test_paths.py Normal file
View File

@ -0,0 +1,73 @@
#!/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 tempfile
import unittest
from inputremapper.configs.paths import PathUtils
from tests.lib.test_setup import test_setup
from tests.lib.tmp import tmp
def _raise(error):
raise error
@test_setup
class TestPaths(unittest.TestCase):
def test_touch(self):
with tempfile.TemporaryDirectory() as local_tmp:
path_abcde = os.path.join(local_tmp, "a/b/c/d/e")
PathUtils.touch(path_abcde)
self.assertTrue(os.path.exists(path_abcde))
self.assertTrue(os.path.isfile(path_abcde))
self.assertRaises(
ValueError,
lambda: PathUtils.touch(os.path.join(local_tmp, "a/b/c/d/f/")),
)
def test_mkdir(self):
with tempfile.TemporaryDirectory() as local_tmp:
path_bcde = os.path.join(local_tmp, "b/c/d/e")
PathUtils.mkdir(path_bcde)
self.assertTrue(os.path.exists(path_bcde))
self.assertTrue(os.path.isdir(path_bcde))
def test_get_preset_path(self):
self.assertTrue(
PathUtils.get_preset_path().startswith(PathUtils.get_config_path())
)
self.assertTrue(PathUtils.get_preset_path().endswith("presets"))
self.assertTrue(PathUtils.get_preset_path("a").endswith("presets/a"))
self.assertTrue(
PathUtils.get_preset_path("a", "b").endswith("presets/a/b.json")
)
def test_get_config_path(self):
# might end with /beta_XXX
self.assertTrue(
PathUtils.get_config_path().startswith(f"{tmp}/.config/input-remapper")
)
self.assertTrue(PathUtils.get_config_path("a", "b").endswith("a/b"))
def test_split_all(self):
self.assertListEqual(PathUtils.split_all("a/b/c/d"), ["a", "b", "c", "d"])

486
tests/unit/test_preset.py Normal file
View File

@ -0,0 +1,486 @@
#!/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 unittest
from unittest.mock import patch
from evdev.ecodes import EV_KEY, EV_ABS
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.configs.mapping import Mapping
from inputremapper.configs.mapping import UIMapping
from inputremapper.configs.paths import PathUtils
from inputremapper.configs.preset import Preset
from tests.lib.test_setup import test_setup
@test_setup
class TestPreset(unittest.TestCase):
def setUp(self):
self.preset = Preset(PathUtils.get_preset_path("foo", "bar2"))
self.assertFalse(self.preset.has_unsaved_changes())
def test_is_mapped_multiple_times(self):
combination = InputCombination(
InputCombination.from_tuples((1, 1, 1), (2, 2, 2), (3, 3, 3), (4, 4, 4))
)
permutations = combination.get_permutations()
self.assertEqual(len(permutations), 6)
self.preset._mappings[permutations[0]] = Mapping(
input_combination=permutations[0],
target_uinput="keyboard",
output_symbol="a",
)
self.assertFalse(self.preset._is_mapped_multiple_times(permutations[2]))
self.preset._mappings[permutations[1]] = Mapping(
input_combination=permutations[1],
target_uinput="keyboard",
output_symbol="a",
)
self.assertTrue(self.preset._is_mapped_multiple_times(permutations[2]))
def test_has_unsaved_changes(self):
self.preset.path = PathUtils.get_preset_path("foo", "bar2")
self.preset.add(Mapping.from_combination())
self.assertTrue(self.preset.has_unsaved_changes())
self.preset.save()
self.assertFalse(self.preset.has_unsaved_changes())
self.preset.empty()
self.assertEqual(len(self.preset), 0)
# empty preset but non-empty file
self.assertTrue(self.preset.has_unsaved_changes())
# load again from the disc
self.preset.load()
self.assertEqual(
self.preset.get_mapping(InputCombination.empty_combination()),
Mapping.from_combination(),
)
self.assertFalse(self.preset.has_unsaved_changes())
# change the path to a non exiting file
self.preset.path = PathUtils.get_preset_path("bar", "foo")
# the preset has a mapping, the file has not
self.assertTrue(self.preset.has_unsaved_changes())
# change back to the original path
self.preset.path = PathUtils.get_preset_path("foo", "bar2")
# no difference between file and memory
self.assertFalse(self.preset.has_unsaved_changes())
# modify the mapping
mapping = self.preset.get_mapping(InputCombination.empty_combination())
mapping.gain = 0.5
self.assertTrue(self.preset.has_unsaved_changes())
self.preset.load()
self.preset.path = PathUtils.get_preset_path("bar", "foo")
self.preset.remove(Mapping.from_combination().input_combination)
# empty preset and empty file
self.assertFalse(self.preset.has_unsaved_changes())
self.preset.path = PathUtils.get_preset_path("foo", "bar2")
# empty preset, but non-empty file
self.assertTrue(self.preset.has_unsaved_changes())
self.preset.load()
self.assertEqual(len(self.preset), 1)
self.assertFalse(self.preset.has_unsaved_changes())
# delete the preset from the system:
self.preset.empty()
self.preset.save()
self.preset.load()
self.assertFalse(self.preset.has_unsaved_changes())
self.assertEqual(len(self.preset), 0)
def test_save_load(self):
one = InputConfig(type=EV_KEY, code=10)
two = InputConfig(type=EV_KEY, code=11)
three = InputConfig(type=EV_KEY, code=12)
self.preset.add(
Mapping.from_combination(InputCombination([one]), "keyboard", "1")
)
self.preset.add(
Mapping.from_combination(InputCombination([two]), "keyboard", "2")
)
self.preset.add(
Mapping.from_combination(InputCombination((two, three)), "keyboard", "3"),
)
self.preset.path = PathUtils.get_preset_path("Foo Device", "test")
self.preset.save()
path = os.path.join(
PathUtils.config_path(), "presets", "Foo Device", "test.json"
)
self.assertTrue(os.path.exists(path))
loaded = Preset(PathUtils.get_preset_path("Foo Device", "test"))
self.assertEqual(len(loaded), 0)
loaded.load()
self.assertEqual(len(loaded), 3)
self.assertRaises(TypeError, loaded.get_mapping, one)
self.assertEqual(
loaded.get_mapping(InputCombination([one])),
Mapping.from_combination(InputCombination([one]), "keyboard", "1"),
)
self.assertEqual(
loaded.get_mapping(InputCombination([two])),
Mapping.from_combination(InputCombination([two]), "keyboard", "2"),
)
self.assertEqual(
loaded.get_mapping(InputCombination([two, three])),
Mapping.from_combination(InputCombination([two, three]), "keyboard", "3"),
)
# load missing file
preset = Preset(PathUtils.get_config_path("missing_file.json"))
self.assertRaises(FileNotFoundError, preset.load)
def test_modify_mapping(self):
ev_1 = InputCombination([InputConfig(type=EV_KEY, code=1)])
ev_3 = InputCombination([InputConfig(type=EV_KEY, code=2)])
# only values between -99 and 99 are allowed as mapping for EV_ABS or EV_REL
ev_4 = InputCombination([InputConfig(type=EV_ABS, code=1, analog_threshold=99)])
# add the first mapping
self.preset.add(Mapping.from_combination(ev_1, "keyboard", "a"))
self.assertTrue(self.preset.has_unsaved_changes())
self.assertEqual(len(self.preset), 1)
# change ev_1 to ev_3 and change a to b
mapping = self.preset.get_mapping(ev_1)
mapping.input_combination = ev_3
mapping.output_symbol = "b"
self.assertIsNone(self.preset.get_mapping(ev_1))
self.assertEqual(
self.preset.get_mapping(ev_3),
Mapping.from_combination(ev_3, "keyboard", "b"),
)
self.assertEqual(len(self.preset), 1)
# add 4
self.preset.add(Mapping.from_combination(ev_4, "keyboard", "c"))
self.assertEqual(
self.preset.get_mapping(ev_3),
Mapping.from_combination(ev_3, "keyboard", "b"),
)
self.assertEqual(
self.preset.get_mapping(ev_4),
Mapping.from_combination(ev_4, "keyboard", "c"),
)
self.assertEqual(len(self.preset), 2)
# change the preset of 4 to d
mapping = self.preset.get_mapping(ev_4)
mapping.output_symbol = "d"
self.assertEqual(
self.preset.get_mapping(ev_4),
Mapping.from_combination(ev_4, "keyboard", "d"),
)
self.assertEqual(len(self.preset), 2)
# try to change combination of 4 to 3
mapping = self.preset.get_mapping(ev_4)
with self.assertRaises(KeyError):
mapping.input_combination = ev_3
self.assertEqual(
self.preset.get_mapping(ev_3),
Mapping.from_combination(ev_3, "keyboard", "b"),
)
self.assertEqual(
self.preset.get_mapping(ev_4),
Mapping.from_combination(ev_4, "keyboard", "d"),
)
self.assertEqual(len(self.preset), 2)
def test_avoids_redundant_saves(self):
with patch.object(self.preset, "has_unsaved_changes", lambda: False):
self.preset.path = PathUtils.get_preset_path("foo", "bar2")
self.preset.add(Mapping.from_combination())
self.preset.save()
with open(PathUtils.get_preset_path("foo", "bar2"), "r") as f:
content = f.read()
self.assertFalse(content)
def test_combinations(self):
ev_1 = InputConfig(type=EV_KEY, code=1, analog_threshold=111)
ev_2 = InputConfig(type=EV_KEY, code=1, analog_threshold=222)
ev_3 = InputConfig(type=EV_KEY, code=2, analog_threshold=111)
ev_4 = InputConfig(type=EV_ABS, code=1, analog_threshold=99)
combi_1 = InputCombination((ev_1, ev_2, ev_3))
combi_2 = InputCombination((ev_2, ev_1, ev_3))
combi_3 = InputCombination((ev_1, ev_2, ev_4))
self.preset.add(Mapping.from_combination(combi_1, "keyboard", "a"))
self.assertEqual(
self.preset.get_mapping(combi_1),
Mapping.from_combination(combi_1, "keyboard", "a"),
)
self.assertEqual(
self.preset.get_mapping(combi_2),
Mapping.from_combination(combi_1, "keyboard", "a"),
)
# since combi_1 and combi_2 are equivalent, this raises a KeyError
self.assertRaises(
KeyError,
self.preset.add,
Mapping.from_combination(combi_2, "keyboard", "b"),
)
self.assertEqual(
self.preset.get_mapping(combi_1),
Mapping.from_combination(combi_1, "keyboard", "a"),
)
self.assertEqual(
self.preset.get_mapping(combi_2),
Mapping.from_combination(combi_1, "keyboard", "a"),
)
self.preset.add(Mapping.from_combination(combi_3, "keyboard", "c"))
self.assertEqual(
self.preset.get_mapping(combi_1),
Mapping.from_combination(combi_1, "keyboard", "a"),
)
self.assertEqual(
self.preset.get_mapping(combi_2),
Mapping.from_combination(combi_1, "keyboard", "a"),
)
self.assertEqual(
self.preset.get_mapping(combi_3),
Mapping.from_combination(combi_3, "keyboard", "c"),
)
mapping = self.preset.get_mapping(combi_1)
mapping.output_symbol = "c"
with self.assertRaises(KeyError):
mapping.input_combination = combi_3
self.assertEqual(
self.preset.get_mapping(combi_1),
Mapping.from_combination(combi_1, "keyboard", "c"),
)
self.assertEqual(
self.preset.get_mapping(combi_2),
Mapping.from_combination(combi_1, "keyboard", "c"),
)
self.assertEqual(
self.preset.get_mapping(combi_3),
Mapping.from_combination(combi_3, "keyboard", "c"),
)
def test_remove(self):
# does nothing
ev_1 = InputCombination([InputConfig(type=EV_KEY, code=40)])
ev_2 = InputCombination([InputConfig(type=EV_KEY, code=30)])
ev_3 = InputCombination([InputConfig(type=EV_KEY, code=20)])
ev_4 = InputCombination([InputConfig(type=EV_KEY, code=10)])
self.assertRaises(TypeError, self.preset.remove, (EV_KEY, 10, 1))
self.preset.remove(ev_1)
self.assertFalse(self.preset.has_unsaved_changes())
self.assertEqual(len(self.preset), 0)
self.preset.add(Mapping.from_combination(input_combination=ev_1))
self.assertEqual(len(self.preset), 1)
self.preset.remove(ev_1)
self.assertEqual(len(self.preset), 0)
self.preset.add(Mapping.from_combination(ev_4, "keyboard", "KEY_KP1"))
self.assertTrue(self.preset.has_unsaved_changes())
self.preset.add(Mapping.from_combination(ev_3, "keyboard", "KEY_KP2"))
self.preset.add(Mapping.from_combination(ev_2, "keyboard", "KEY_KP3"))
self.assertEqual(len(self.preset), 3)
self.preset.remove(ev_3)
self.assertEqual(len(self.preset), 2)
self.assertEqual(
self.preset.get_mapping(ev_4),
Mapping.from_combination(ev_4, "keyboard", "KEY_KP1"),
)
self.assertIsNone(self.preset.get_mapping(ev_3))
self.assertEqual(
self.preset.get_mapping(ev_2),
Mapping.from_combination(ev_2, "keyboard", "KEY_KP3"),
)
def test_empty(self):
self.preset.add(
Mapping.from_combination(
InputCombination([InputConfig(type=EV_KEY, code=10)]),
"keyboard",
"1",
),
)
self.preset.add(
Mapping.from_combination(
InputCombination([InputConfig(type=EV_KEY, code=11)]),
"keyboard",
"2",
),
)
self.preset.add(
Mapping.from_combination(
InputCombination([InputConfig(type=EV_KEY, code=12)]),
"keyboard",
"3",
),
)
self.assertEqual(len(self.preset), 3)
self.preset.path = PathUtils.get_config_path("test.json")
self.preset.save()
self.assertFalse(self.preset.has_unsaved_changes())
self.preset.empty()
self.assertEqual(self.preset.path, PathUtils.get_config_path("test.json"))
self.assertTrue(self.preset.has_unsaved_changes())
self.assertEqual(len(self.preset), 0)
def test_clear(self):
self.preset.add(
Mapping.from_combination(
InputCombination([InputConfig(type=EV_KEY, code=10)]),
"keyboard",
"1",
),
)
self.preset.add(
Mapping.from_combination(
InputCombination([InputConfig(type=EV_KEY, code=11)]),
"keyboard",
"2",
),
)
self.preset.add(
Mapping.from_combination(
InputCombination([InputConfig(type=EV_KEY, code=12)]),
"keyboard",
"3",
),
)
self.assertEqual(len(self.preset), 3)
self.preset.path = PathUtils.get_config_path("test.json")
self.preset.save()
self.assertFalse(self.preset.has_unsaved_changes())
self.preset.clear()
self.assertFalse(self.preset.has_unsaved_changes())
self.assertIsNone(self.preset.path)
self.assertEqual(len(self.preset), 0)
def test_dangerously_mapped_btn_left(self):
# btn left is mapped
self.preset.add(
Mapping.from_combination(
InputCombination([InputConfig.btn_left()]),
"keyboard",
"1",
)
)
self.assertTrue(self.preset.dangerously_mapped_btn_left())
self.preset.add(
Mapping.from_combination(
InputCombination([InputConfig(type=EV_KEY, code=41)]),
"keyboard",
"2",
)
)
self.assertTrue(self.preset.dangerously_mapped_btn_left())
# another mapping maps to btn_left
self.preset.add(
Mapping.from_combination(
InputCombination([InputConfig(type=EV_KEY, code=42)]),
"mouse",
"btn_left",
)
)
self.assertFalse(self.preset.dangerously_mapped_btn_left())
mapping = self.preset.get_mapping(
InputCombination([InputConfig(type=EV_KEY, code=42)])
)
mapping.output_symbol = "BTN_Left"
self.assertFalse(self.preset.dangerously_mapped_btn_left())
mapping.target_uinput = "keyboard + mouse"
mapping.output_symbol = "3"
self.assertTrue(self.preset.dangerously_mapped_btn_left())
# btn_left is not mapped
self.preset.remove(InputCombination([InputConfig.btn_left()]))
self.assertFalse(self.preset.dangerously_mapped_btn_left())
def test_save_load_with_invalid_mappings(self):
ui_preset = Preset(
PathUtils.get_config_path("test.json"), mapping_factory=UIMapping
)
ui_preset.add(UIMapping())
self.assertFalse(ui_preset.is_valid())
# make the mapping valid
m = ui_preset.get_mapping(InputCombination.empty_combination())
m.output_symbol = "a"
m.target_uinput = "keyboard"
self.assertTrue(ui_preset.is_valid())
m2 = UIMapping(
input_combination=InputCombination([InputConfig(type=1, code=2)])
)
ui_preset.add(m2)
self.assertFalse(ui_preset.is_valid())
ui_preset.save()
# only the valid preset is loaded
preset = Preset(PathUtils.get_config_path("test.json"))
preset.load()
self.assertEqual(len(preset), 1)
a = preset.get_mapping(m.input_combination).dict()
b = m.dict()
a.pop("mapping_type")
b.pop("mapping_type")
self.assertEqual(a, b)
# self.assertEqual(preset.get_mapping(m.input_combination), m)
# both presets load
ui_preset.clear()
ui_preset.path = PathUtils.get_config_path("test.json")
ui_preset.load()
self.assertEqual(len(ui_preset), 2)
a = ui_preset.get_mapping(m.input_combination).dict()
b = m.dict()
a.pop("mapping_type")
b.pop("mapping_type")
self.assertEqual(a, b)
# self.assertEqual(ui_preset.get_mapping(m.input_combination), m)
self.assertEqual(ui_preset.get_mapping(m2.input_combination), m2)
if __name__ == "__main__":
unittest.main()

1052
tests/unit/test_reader.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,165 @@
#!/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 json
import os
import subprocess
import unittest
from unittest.mock import patch
from evdev.ecodes import BTN_LEFT, KEY_A
from inputremapper.configs.paths import PathUtils
from inputremapper.configs.keyboard_layout import KeyboardLayout, XMODMAP_FILENAME
from tests.lib.test_setup import test_setup
@test_setup
class TestSystemMapping(unittest.TestCase):
def test_update(self):
keyboard_layout = KeyboardLayout()
keyboard_layout.update({"foo1": 101, "bar1": 102})
keyboard_layout.update({"foo2": 201, "bar2": 202})
self.assertEqual(keyboard_layout.get("foo1"), 101)
self.assertEqual(keyboard_layout.get("bar2"), 202)
def test_xmodmap_file(self):
keyboard_layout = KeyboardLayout()
path = os.path.join(PathUtils.config_path(), XMODMAP_FILENAME)
os.remove(path)
keyboard_layout.populate()
self.assertTrue(os.path.exists(path))
with open(path, "r") as file:
content = json.load(file)
self.assertEqual(content["a"], KEY_A)
# only xmodmap stuff should be present
self.assertNotIn("key_a", content)
self.assertNotIn("KEY_A", content)
self.assertNotIn("disable", content)
def test_empty_xmodmap(self):
# if xmodmap returns nothing, don't write the file
empty_xmodmap = ""
class SubprocessMock:
def decode(self):
return empty_xmodmap
def check_output(*args, **kwargs):
return SubprocessMock()
with patch.object(subprocess, "check_output", check_output):
keyboard_layout = KeyboardLayout()
path = os.path.join(PathUtils.config_path(), XMODMAP_FILENAME)
os.remove(path)
keyboard_layout.populate()
self.assertFalse(os.path.exists(path))
def test_xmodmap_command_missing(self):
# if xmodmap is not installed, don't write the file
def check_output(*args, **kwargs):
raise FileNotFoundError
with patch.object(subprocess, "check_output", check_output):
keyboard_layout = KeyboardLayout()
path = os.path.join(PathUtils.config_path(), XMODMAP_FILENAME)
os.remove(path)
keyboard_layout.populate()
self.assertFalse(os.path.exists(path))
def test_correct_case(self):
keyboard_layout = KeyboardLayout()
keyboard_layout.clear()
keyboard_layout._set("A", 31)
keyboard_layout._set("a", 32)
keyboard_layout._set("abcd_B", 33)
self.assertEqual(keyboard_layout.correct_case("a"), "a")
self.assertEqual(keyboard_layout.correct_case("A"), "A")
self.assertEqual(keyboard_layout.correct_case("ABCD_b"), "abcd_B")
# unknown stuff is returned as is
self.assertEqual(keyboard_layout.correct_case("FOo"), "FOo")
self.assertEqual(keyboard_layout.get("A"), 31)
self.assertEqual(keyboard_layout.get("a"), 32)
self.assertEqual(keyboard_layout.get("ABCD_b"), 33)
self.assertEqual(keyboard_layout.get("abcd_B"), 33)
def test_keyboard_layout(self):
keyboard_layout = KeyboardLayout()
keyboard_layout.populate()
self.assertGreater(len(keyboard_layout._mapping), 100)
# this is case-insensitive
self.assertEqual(keyboard_layout.get("1"), 2)
self.assertEqual(keyboard_layout.get("KeY_1"), 2)
self.assertEqual(keyboard_layout.get("AlT_L"), 56)
self.assertEqual(keyboard_layout.get("KEy_LEFtALT"), 56)
self.assertEqual(keyboard_layout.get("kEY_LeFTSHIFT"), 42)
self.assertEqual(keyboard_layout.get("ShiFt_L"), 42)
self.assertEqual(keyboard_layout.get("BTN_left"), 272)
self.assertIsNotNone(keyboard_layout.get("KEY_KP4"))
self.assertEqual(keyboard_layout.get("KP_Left"), keyboard_layout.get("KEY_KP4"))
# this only lists the correct casing,
# includes linux constants and xmodmap symbols
names = keyboard_layout.list_names()
self.assertIn("2", names)
self.assertIn("c", names)
self.assertIn("KEY_3", names)
self.assertNotIn("key_3", names)
self.assertIn("KP_Down", names)
self.assertNotIn("kp_down", names)
names = keyboard_layout._mapping.keys()
self.assertIn("F4", names)
self.assertNotIn("f4", names)
self.assertIn("BTN_RIGHT", names)
self.assertNotIn("btn_right", names)
self.assertIn("KEY_KP7", names)
self.assertIn("KP_Home", names)
self.assertNotIn("kp_home", names)
self.assertEqual(keyboard_layout.get("disable"), -1)
def test_get_name_no_xmodmap(self):
# if xmodmap is not installed, uses the linux constant names
keyboard_layout = KeyboardLayout()
def check_output(*args, **kwargs):
raise FileNotFoundError
with patch.object(subprocess, "check_output", check_output):
keyboard_layout.populate()
self.assertEqual(keyboard_layout.get_name(KEY_A), "KEY_A")
# testing for BTN_LEFT is especially important, because
# `evdev.ecodes.BTN.get(code)` returns an array of ['BTN_LEFT', 'BTN_MOUSE']
self.assertEqual(keyboard_layout.get_name(BTN_LEFT), "BTN_LEFT")
if __name__ == "__main__":
unittest.main()

145
tests/unit/test_test.py Normal file
View File

@ -0,0 +1,145 @@
#!/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 multiprocessing
import os
import time
import unittest
import evdev
from evdev.ecodes import EV_ABS, EV_KEY
from inputremapper.groups import groups, _Groups
from inputremapper.gui.messages.message_broker import MessageBroker
from inputremapper.gui.reader_client import ReaderClient
from inputremapper.gui.reader_service import ReaderService
from inputremapper.injection.global_uinputs import UInput, GlobalUInputs
from inputremapper.input_event import InputEvent
from inputremapper.utils import get_device_hash
from tests.lib.cleanup import cleanup
from tests.lib.constants import EVENT_READ_TIMEOUT, START_READING_DELAY
from tests.lib.fixtures import fixtures
from tests.lib.logger import logger
from tests.lib.patches import InputDevice
from tests.lib.pipes import push_events
from tests.lib.test_setup import test_setup
@test_setup
class TestTest(unittest.TestCase):
def test_stubs(self):
self.assertIsNotNone(groups.find(key="Foo Device 2"))
def test_fake_capabilities(self):
device = InputDevice("/dev/input/event30")
capabilities = device.capabilities(absinfo=False)
self.assertIsInstance(capabilities, dict)
self.assertIsInstance(capabilities[EV_ABS], list)
self.assertIsInstance(capabilities[EV_ABS][0], int)
capabilities = device.capabilities()
self.assertIsInstance(capabilities, dict)
self.assertIsInstance(capabilities[EV_ABS], list)
self.assertIsInstance(capabilities[EV_ABS][0], tuple)
self.assertIsInstance(capabilities[EV_ABS][0][0], int)
self.assertIsInstance(capabilities[EV_ABS][0][1], evdev.AbsInfo)
self.assertIsInstance(capabilities[EV_ABS][0][1].max, int)
self.assertIsInstance(capabilities, dict)
self.assertIsInstance(capabilities[EV_KEY], list)
self.assertIsInstance(capabilities[EV_KEY][0], int)
def test_restore_fixtures(self):
fixtures.add_fixture({"name": "bla", "path": "/bar/dev"})
cleanup()
self.assertIsNone(fixtures.get("/bar/dev"))
self.assertIsNotNone(fixtures.get("/dev/input/event11"))
def test_restore_os_environ(self):
os.environ["foo"] = "bar"
del os.environ["USER"]
environ = os.environ
cleanup()
self.assertIn("USER", environ)
self.assertNotIn("foo", environ)
def test_push_events(self):
"""Test that push_event works properly between reader service and client.
Using push_events after the reader-service is already started should work,
as well as using push_event twice
"""
reader_client = ReaderClient(MessageBroker(), groups)
def create_reader_service():
# this will cause pending events to be copied over to the reader-service
# process
def start_reader_service():
# Create dependencies from scratch, because the reader-service runs
# in a different process
global_uinputs = GlobalUInputs(UInput)
reader_service = ReaderService(_Groups(), global_uinputs)
loop = asyncio.new_event_loop()
loop.run_until_complete(reader_service.run())
self.reader_service = multiprocessing.Process(target=start_reader_service)
self.reader_service.start()
time.sleep(0.1)
def wait_for_results():
# wait for the reader-service to send stuff
for _ in range(10):
time.sleep(EVENT_READ_TIMEOUT)
if reader_client._results_pipe.poll():
break
create_reader_service()
reader_client.set_group(groups.find(key="Foo Device 2"))
reader_client.start_recorder()
time.sleep(START_READING_DELAY)
event = InputEvent.key(102, 1)
push_events(fixtures.foo_device_2_keyboard, [event])
wait_for_results()
self.assertTrue(reader_client._results_pipe.poll())
reader_client._read()
self.assertFalse(reader_client._results_pipe.poll())
# can push more events to the reader-service that is inside a separate
# process, which end up being sent to the reader
event = InputEvent.key(102, 0)
logger.info("push_events")
push_events(fixtures.foo_device_2_keyboard, [event])
wait_for_results()
logger.info("assert")
self.assertTrue(reader_client._results_pipe.poll())
reader_client.terminate()
def test_device_hash_from_fixture_is_correct(self):
for fixture in fixtures:
self.assertEqual(
fixture.get_device_hash(), get_device_hash(InputDevice(fixture.path))
)
if __name__ == "__main__":
unittest.main()

62
tests/unit/test_user.py Normal file
View File

@ -0,0 +1,62 @@
#!/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 unittest
from unittest import mock
from inputremapper.user import UserUtils
from tests.lib.test_setup import test_setup
def _raise(error):
raise error
@test_setup
class TestUser(unittest.TestCase):
def test_get_user(self):
with mock.patch("os.getlogin", lambda: "foo"):
self.assertEqual(UserUtils.get_user(), "foo")
with mock.patch("os.getlogin", lambda: "root"):
self.assertEqual(UserUtils.get_user(), "root")
property_mock = mock.Mock()
property_mock.configure_mock(pw_name="quix")
with (
mock.patch("os.getlogin", lambda: _raise(OSError())),
mock.patch("pwd.getpwuid", return_value=property_mock),
):
os.environ["USER"] = "root"
os.environ["SUDO_USER"] = "qux"
self.assertEqual(UserUtils.get_user(), "qux")
os.environ["USER"] = "root"
del os.environ["SUDO_USER"]
os.environ["PKEXEC_UID"] = "1000"
self.assertNotEqual(UserUtils.get_user(), "root")
def test_get_home(self):
property_mock = mock.Mock()
property_mock.configure_mock(pw_dir="/custom/home/foo")
with mock.patch("pwd.getpwnam", return_value=property_mock):
self.assertEqual(UserUtils.get_home("foo"), "/custom/home/foo")

43
tests/unit/test_util.py Normal file
View File

@ -0,0 +1,43 @@
#!/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
from evdev._ecodes import EV_ABS, ABS_X, BTN_WEST, BTN_Y, EV_KEY, KEY_A
from inputremapper.utils import get_evdev_constant_name
from tests.lib.test_setup import test_setup
@test_setup
class TestUtil(unittest.TestCase):
def test_get_evdev_constant_name(self):
# BTN_WEST and BTN_Y both are code 308. I don't care which one is chosen
# in the return value, but it should return one of them without crashing.
self.assertEqual(get_evdev_constant_name(EV_KEY, BTN_Y), "BTN_WEST")
self.assertEqual(get_evdev_constant_name(EV_KEY, BTN_WEST), "BTN_WEST")
self.assertEqual(get_evdev_constant_name(123, KEY_A), "unknown")
self.assertEqual(get_evdev_constant_name(EV_KEY, 9999), "unknown")
self.assertEqual(get_evdev_constant_name(EV_KEY, KEY_A), "KEY_A")
self.assertEqual(get_evdev_constant_name(EV_ABS, ABS_X), "ABS_X")