Files
InputRemapper/tests/unit/test_migrations.py
Blomios 9ef2c1439c feat(app-binding): lier les presets aux applications
Ajoute la configuration des associations application/preset, le service de suivi du focus, l'intégration GUI et les fichiers d'installation nécessaires.

Ajoute les tests unitaires ciblés des bindings, du service de focus, des migrations et des composants GUI associés.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 23:52:34 +02:00

793 lines
28 KiB
Python

#
# 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,
"app_binding_enabled": False,
"app_bindings": [],
},
)
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,
"app_binding_enabled": False,
"app_bindings": [],
},
)
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",
}
],
)
def _write_config(self, data):
config_file = os.path.join(PathUtils.config_path(), "config.json")
PathUtils.touch(config_file)
with open(config_file, "w") as file:
json.dump(data, file, indent=4)
return config_file
def _read_config(self, config_file):
with open(config_file, "r") as file:
return json.load(file)
def test_add_app_binding_config(self):
# an old config without the app-binding keys gets them added
config_file = self._write_config(
{"version": "2.2.1", "autoload": {"Foo Device 2": "preset2"}}
)
self.migrations.migrate()
config = self._read_config(config_file)
self.assertEqual(config["app_binding_enabled"], False)
self.assertEqual(config["app_bindings"], [])
# additive: existing keys are preserved
self.assertEqual(config["autoload"], {"Foo Device 2": "preset2"})
self.assertEqual(config["version"], VERSION)
def test_add_app_binding_config_preserves_existing(self):
# a config already carrying app-binding data must not be reset
existing_binding = {
"app_id": "firefox",
"match": "wm_class",
"presets": [{"group_key": "Foo Device", "preset": "preset1"}],
}
config_file = self._write_config(
{
"version": "2.2.1",
"autoload": {},
"app_binding_enabled": True,
"app_bindings": [existing_binding],
}
)
self.migrations.migrate()
config = self._read_config(config_file)
self.assertEqual(config["app_binding_enabled"], True)
self.assertEqual(config["app_bindings"], [existing_binding])
def test_add_app_binding_config_no_file(self):
# no config.json yet -> migration is a no-op and does not create one
config_file = os.path.join(PathUtils.config_path(), "config.json")
if os.path.exists(config_file):
os.remove(config_file)
self.migrations._add_app_binding_config()
self.assertFalse(os.path.exists(config_file))
if __name__ == "__main__":
unittest.main()