# # 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 . 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()