diff --git a/tests/python/bl_keymap_validate.py b/tests/python/bl_keymap_validate.py index ce2022f6ae7..9d613fc90f4 100644 --- a/tests/python/bl_keymap_validate.py +++ b/tests/python/bl_keymap_validate.py @@ -16,6 +16,7 @@ This catches the following kinds of issues: - Unused keymaps (keymaps which are defined but not used anywhere). - Event values that don't make sense for the event type, e.g. An escape key could have the value "NORTH" instead of "PRESS". +- Identical key-map items. This works by taking the keymap data (before it's loaded into Blender), then comparing it with that same keymap after exporting and importing. @@ -29,14 +30,28 @@ NOTE: import types import typing +import os import contextlib -import bpy +import bpy # type: ignore # Useful for diffing the output to see what changed in context. # this writes keymaps into the current directory with `.orig.py` & `.rewrite.py` extensions. -WRITE_OUTPUT_DIR = None # "/tmp", defaults to the systems temp directory. +WRITE_OUTPUT_DIR = "/tmp/test" # "/tmp", defaults to the systems temp directory. +# For each preset, test all of these options. +# The key is the preset name, containing a sequence of (attribute, value) pairs to test. +# +# NOTE(@campbellbarton): only add these for preferences which impact multiple keys as exposing all preferences +# this way would create too many combinations making the tests take too long to complete. +PRESET_PREFS = { + "Blender": ( + (("select_mouse", 'LEFT'), ("use_alt_tool", False)), + (("select_mouse", 'LEFT'), ("use_alt_tool", True)), + (("select_mouse", 'RIGHT'), ("rmb_action", 'TWEAK')), + (("select_mouse", 'RIGHT'), ("rmb_action", 'FALLBACK_TOOL')), + ), +} # ----------------------------------------------------------------------------- # Generic Utilities @@ -153,14 +168,49 @@ def keymap_data_clean(keyconfig_data: typing.List, *, relaxed: bool) -> None: items[i] = item_op, item_event, None -def keyconfig_activate_and_extract_data(filepath: str, *, relaxed: bool) -> typing.List: +def keyconfig_config_as_filename_component(values: typing.Sequence[typing.Tuple[str, typing.Any]]): + """ + Takes a configuration, eg: + + [("select_mouse", 'LEFT'), ("rmb_action", 'TWEAK')] + + And returns a filename compatible path: + """ + from urllib.parse import quote + if not values: + return "" + + return "(" + quote( + ".".join([ + "-".join((str(key), str(val))) + for key, val in values + ]), + # Needed so forward slashes aren't included in the resulting name. + safe="", + ) + ")" + + +def keyconfig_activate_and_extract_data( + filepath: str, + *, + relaxed: bool, + config: typing.Sequence[typing.Tuple[str, typing.Any]], +) -> typing.List: """ Activate the key-map by filepath, return the key-config data (cleaned for comparison). """ - import bl_keymap_utils.io + import bl_keymap_utils.io # type: ignore + + if config: + bpy.ops.preferences.keyconfig_activate(filepath=filepath) + km_prefs = bpy.context.window_manager.keyconfigs.active.preferences + for attr, value in config: + setattr(km_prefs, attr, value) + with temp_fn_argument_extractor(bl_keymap_utils.io, "keyconfig_init_from_data") as args_collected: bpy.ops.preferences.keyconfig_activate(filepath=filepath) + # If called multiple times, something strange is happening. assert(len(args_collected) == 1) args, _kw = args_collected[0] @@ -169,6 +219,29 @@ def keyconfig_activate_and_extract_data(filepath: str, *, relaxed: bool) -> typi return keyconfig_data +def keyconfig_report_duplicates(keyconfig_data: typing.List) -> str: + """ + Return true if any of the key-maps have duplicate items. + + Duplicate items are reported so they can be resolved. + """ + error_text = [] + for km_idname, km_args, km_items_data in keyconfig_data: + items = tuple(km_items_data["items"]) + unique: typing.Dict[str, typing.List[int]] = {} + for i, (item_op, item_event, item_prop) in enumerate(items): + # Ensure stable order as `repr` will use order of definition. + item_event = {key: item_event[key] for key in sorted(item_event.keys())} + if item_prop is not None: + item_prop = {key: item_prop[key] for key in sorted(item_prop.keys())} + item_repr = repr((item_op, item_event, item_prop)) + unique.setdefault(item_repr, []).append(i) + for key, value in unique.items(): + if len(value) > 1: + error_text.append("\"%s\" %r indices %r for item %r" % (km_idname, km_args, value, key)) + return "\n".join(error_text) + + def main() -> None: import os import sys @@ -186,37 +259,61 @@ def main() -> None: for filepath in presets: name_only = os.path.splitext(os.path.basename(filepath))[0] - print("KeyMap Validate:", name_only, end=" ... ") + for config in PRESET_PREFS.get(name_only, ((),)): + name_only_with_config = name_only + keyconfig_config_as_filename_component(config) + print("KeyMap Validate:", name_only_with_config, end=" ... ") + data_orig = keyconfig_activate_and_extract_data( + filepath, + relaxed=relaxed, + config=config, + ) - data_orig = keyconfig_activate_and_extract_data(filepath, relaxed=relaxed) + with tempfile.TemporaryDirectory() as dir_temp: + filepath_temp = os.path.join( + dir_temp, + name_only_with_config + ".test" + ".py", + ) - with tempfile.TemporaryDirectory() as dir_temp: - filepath_temp = os.path.join(dir_temp, name_only + ".test.py") - bpy.ops.preferences.keyconfig_export(filepath=filepath_temp, all=True) - data_reimport = keyconfig_activate_and_extract_data(filepath_temp, relaxed=relaxed) + bpy.ops.preferences.keyconfig_export(filepath=filepath_temp, all=True) + data_reimport = keyconfig_activate_and_extract_data( + filepath_temp, + relaxed=relaxed, + # No configuration supported when loading exported key-maps. + config=(), + ) - # Comparing a pretty printed string tends to give more useful - # text output compared to the data-structure. Both will work. - if (cmp_message := report_humanly_readable_difference( - pprint.pformat(data_orig, indent=0, width=120), - pprint.pformat(data_reimport, indent=0, width=120), - )): - print("FAILED!") - sys.stdout.write(( - "Keymap %s has inconsistency on re-importing:\n" - " %r" - ) % (filepath, cmp_message)) - has_error = True - else: - print("OK!") + # Comparing a pretty printed string tends to give more useful + # text output compared to the data-structure. Both will work. + if (cmp_message := report_humanly_readable_difference( + pprint.pformat(data_orig, indent=0, width=120), + pprint.pformat(data_reimport, indent=0, width=120), + )): + error_text_consistency = "Keymap %s has inconsistency on re-importing." % cmp_message + else: + error_text_consistency = "" - if WRITE_OUTPUT_DIR: - name_only_temp = os.path.join(WRITE_OUTPUT_DIR, name_only) - print("Writing data to:", name_only_temp + ".*.py") - with open(name_only_temp + ".orig.py", 'w') as fh: - fh.write(pprint.pformat(data_orig, indent=0, width=120)) - with open(name_only_temp + ".rewrite.py", 'w') as fh: - fh.write(pprint.pformat(data_reimport, indent=0, width=120)) + # Perform an additional sanity check: + # That there are no identical key-map items. + error_text_duplicates = keyconfig_report_duplicates(data_orig) + + if error_text_consistency or error_text_duplicates: + print("FAILED!") + print("%r has errors!" % filepath) + if error_text_consistency: + print(error_text_consistency) + if error_text_duplicates: + print(error_text_duplicates) + else: + print("OK!") + + if WRITE_OUTPUT_DIR: + os.makedirs(WRITE_OUTPUT_DIR, exist_ok=True) + name_only_temp = os.path.join(WRITE_OUTPUT_DIR, name_only_with_config) + print("Writing data to:", name_only_temp + ".*.py") + with open(name_only_temp + ".orig.py", 'w') as fh: + fh.write(pprint.pformat(data_orig, indent=0, width=120)) + with open(name_only_temp + ".rewrite.py", 'w') as fh: + fh.write(pprint.pformat(data_reimport, indent=0, width=120)) if has_error: sys.exit(1)