summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPierre Le Marre <dev@wismill.eu>2024-08-08 06:15:25 +0200
committerPierre Le Marre <dev@wismill.eu>2024-08-19 18:46:24 +0200
commit127d08444152188cff5f43ed31786c2d4e6d8fc1 (patch)
treede0a9564d7bc7c6374d0e4c6092d861e8e78d6ea
parentc4f76b584fe10bc2037966a70efad9bc5b682a4a (diff)
rules: Add tests for layout compat rules
Part-of: <https://gitlab.freedesktop.org/xkeyboard-config/xkeyboard-config/-/merge_requests/728>
-rw-r--r--.gitlab-ci.yml3
-rw-r--r--meson.build7
-rw-r--r--tests/conftest.py14
-rw-r--r--tests/test_compat_rules.py176
-rw-r--r--tests/xkbcommon/__init__.py45
5 files changed, 245 insertions, 0 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 65d81e73..1c7da3d1 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -349,6 +349,9 @@ keymap_tests:
- export PYTHONPATH="$PWD/tests:$PYTHONPATH"
# Run the tests
- pytest --junitxml=results.xml
+ tests
+ --layout-compat-config rules/compat/layoutsMapping.lst
+ --layout-compat-config rules/compat/variantsMapping.lst
artifacts:
reports:
junit: results.xml
diff --git a/meson.build b/meson.build
index 776a6811..163f59c4 100644
--- a/meson.build
+++ b/meson.build
@@ -84,6 +84,13 @@ pytest = find_program('pytest-3', 'pytest', required: false)
enable_pytest = python.found() and pytest.found()
if enable_pytest
pytest_args = ['--verbose', '--log-level=DEBUG']
+ if get_option('compat-rules')
+ pytest_args += [
+ 'tests',
+ '--layout-compat-config', layout_mappings,
+ '--layout-compat-config', variant_mappings,
+ ]
+ endif
# use pytest xdist if available, it really speeds up the tests cases
optional_python_modules = ['xdist']
if pymod.find_installation('python3', modules: optional_python_modules, required: false).found()
diff --git a/tests/conftest.py b/tests/conftest.py
index 9befeb7a..027955c8 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,7 +1,11 @@
+# SPDX-License-Identifier: MIT
+
import os
import sys
from pathlib import Path
+import pytest
+
tests_dir = Path(__file__).parent.resolve()
sys.path.insert(0, str(tests_dir))
@@ -14,3 +18,13 @@ try:
except ImportError:
pass
+
+
+def pytest_addoption(parser: pytest.Parser):
+ parser.addoption(
+ "--layout-compat-config",
+ action="append",
+ default=[],
+ type=Path,
+ help="List of layout compatibility files",
+ )
diff --git a/tests/test_compat_rules.py b/tests/test_compat_rules.py
new file mode 100644
index 00000000..bff935c2
--- /dev/null
+++ b/tests/test_compat_rules.py
@@ -0,0 +1,176 @@
+# SPDX-License-Identifier: MIT
+
+from __future__ import annotations
+
+import functools
+import itertools
+import os
+import re
+from dataclasses import dataclass
+from pathlib import Path
+from typing import ClassVar, Generator, Optional, Union, TYPE_CHECKING
+
+if TYPE_CHECKING:
+ import builtins
+
+import pytest
+import xkbcommon
+
+
+@dataclass
+class RMLVO:
+ rules: str
+ model: Optional[str]
+ layout: Optional[str]
+ variant: Optional[str]
+ options: Optional[str]
+
+
+@functools.total_ordering
+@dataclass(frozen=True, order=False)
+class Layout:
+ PATTERN: ClassVar[re.Pattern[str]] = re.compile(
+ r"(?P<layout>[^(]+)\((?P<variant>[^)]+)\)"
+ )
+
+ layout: str
+ variant: str
+
+ @classmethod
+ def parse(cls, layout: str, variant: str = ""):
+ # parse a layout(variant) string
+ if match := cls.PATTERN.match(layout):
+ assert not variant
+ layout = match.group("layout")
+ variant = match.group("variant")
+ return cls(layout, variant)
+
+ def __str__(self):
+ if self.variant:
+ return "{}({})".format(self.layout, self.variant)
+ else:
+ return "{}".format(self.layout)
+
+ def __lt__(self, other):
+ """
+ Custom compare function in order to deal with missing variant.
+ """
+ if not isinstance(other, self.__class__):
+ return NotImplemented
+ elif self.layout == other.layout:
+ if self.variant == other.variant:
+ return False
+ # Handle missing variant
+ elif self.variant and not other.variant:
+ return True
+ # Handle missing variant
+ elif not self.variant and other.variant:
+ return False
+ else:
+ return self.variant < other.variant
+ else:
+ return self.layout < other.layout
+
+ @classmethod
+ def read_file(cls, path: Path) -> Generator[tuple[Layout, Layout], None, None]:
+ """Returns a list of two-layout tuples [(layout1, layout2), ...]"""
+
+ with path.open("rt", encoding="utf-8") as fd:
+ for line in fd:
+ # Remove optional comment
+ line = line.split("//")[0]
+ # Split on whitespaces
+ groups = tuple(line.split())
+ length = len(groups)
+ if length == 2:
+ l1 = Layout.parse(groups[0])
+ l2 = Layout.parse(groups[1])
+ elif length == 4:
+ l1 = Layout.parse(groups[0], groups[1])
+ l2 = Layout.parse(groups[2], groups[3])
+ else:
+ raise ValueError(f"Invalid line: {line}")
+ yield (l1, l2)
+
+
+def pytest_generate_tests(metafunc: pytest.Metafunc):
+ if "mapping" in metafunc.fixturenames:
+ if files := metafunc.config.getoption("layout_compat_config"):
+ # Read all files
+ mappings = tuple(
+ itertools.chain.from_iterable(map(Layout.read_file, files)),
+ )
+ else:
+ mappings = ()
+ metafunc.parametrize("mapping", mappings)
+
+
+@pytest.fixture(scope="session")
+def xkb_base():
+ """Get the xkeyboard-config directory from the environment."""
+ path = os.environ.get("XKB_CONFIG_ROOT")
+ if path:
+ return Path(path)
+ else:
+ raise ValueError("XKB_CONFIG_ROOT environment variable is not defined")
+
+
+def compile_keymap(
+ xkb_base: Path,
+ rules: str,
+ ls: tuple[Union[Layout, "builtins.ellipsis"], ...],
+ layout: Layout,
+) -> tuple[str, str, str]:
+ lsʹ: tuple[Layout, ...] = tuple(layout if x is Ellipsis else x for x in ls)
+ alias_layout = ",".join(l.layout for l in lsʹ)
+ alias_variant = ",".join(l.variant for l in lsʹ)
+ km = xkbcommon.ForeignKeymap(
+ xkb_base=xkb_base, rules=rules, layout=alias_layout, variant=alias_variant
+ )
+ return alias_layout, alias_variant, km.as_string()
+
+
+@pytest.mark.parametrize("rules", ("base", "evdev"))
+def test_compat_layout(xkb_base: Path, rules: str, mapping: tuple[Layout, Layout]):
+ alias = mapping[0]
+ target = mapping[1]
+ us = Layout("us", "")
+ fr = Layout("fr", "")
+ es = Layout("es", "")
+ configs = (
+ (...,),
+ (us, ...),
+ (..., us),
+ (us, fr, ...),
+ (us, fr, es, ...),
+ )
+ for ls in configs:
+ # Compile alias
+ alias_layout, alias_variant, alias_string = compile_keymap(
+ xkb_base, rules, ls, alias
+ )
+ assert alias_string != "", (rules, alias_layout, alias_variant)
+
+ # Compile target
+ target_layout, target_variant, target_string = compile_keymap(
+ xkb_base, rules, ls, target
+ )
+ assert target_string != "", (rules, target_layout, target_variant)
+
+ # [HACK]: fix keycodes aliases
+ if alias.layout == "de" and target.layout != "de":
+ alias_string = alias_string.replace(
+ "<LatZ> = <AD06>", "<LatY> = <AD06>"
+ )
+ alias_string = alias_string.replace(
+ "<LatY> = <AB01>", "<LatZ> = <AB01>"
+ )
+
+ # Keymap obtain using alias should be the same as if using its target directly
+ assert alias_string == target_string, (
+ rules,
+ alias_layout,
+ alias_variant,
+ target_layout,
+ target_variant,
+ )
diff --git a/tests/xkbcommon/__init__.py b/tests/xkbcommon/__init__.py
index 76775eea..4c9bc1b7 100644
--- a/tests/xkbcommon/__init__.py
+++ b/tests/xkbcommon/__init__.py
@@ -13,6 +13,7 @@ from ctypes import (
c_uint32,
cdll,
create_string_buffer,
+ string_at,
)
from ctypes.util import find_library
from enum import Enum, IntFlag
@@ -25,6 +26,15 @@ from typing import TYPE_CHECKING, Any, NamedTuple, Optional, TypeAlias
###############################################################################
+class allocated_c_char_p(c_char_p):
+ """
+ This class is used in place of c_char_p when we need to free it manually.
+ Python would convert c_char_p to a bytes string and we would not be able to free it.
+ """
+
+ pass
+
+
class xkb_context(Structure):
pass
@@ -144,6 +154,10 @@ xkbcommon.xkb_keymap_new_from_names.restype = POINTER(xkb_keymap)
xkbcommon.xkb_keymap_key_by_name.argtypes = [POINTER(xkb_keymap), c_char_p]
xkbcommon.xkb_keymap_key_by_name.restype = xkb_keycode_t
+xkbcommon.xkb_keymap_get_as_string.argtypes = [POINTER(xkb_keymap), xkb_keymap_format]
+# Note: should be c_char_p; see comment in the definiton of allocated_c_char_p.
+xkbcommon.xkb_keymap_get_as_string.restype = allocated_c_char_p
+
xkbcommon.xkb_state_new.argtypes = [POINTER(xkb_keymap)]
xkbcommon.xkb_state_new.restype = POINTER(xkb_state)
@@ -190,6 +204,11 @@ xkbcommon.xkb_keymap_num_leds.restype = xkb_led_index_t
xkbcommon.xkb_state_led_index_is_active.argtypes = [POINTER(xkb_state), xkb_led_index_t]
xkbcommon.xkb_state_led_index_is_active.restype = c_int
+if libc_path := find_library("c"):
+ libc = cdll.LoadLibrary(libc_path)
+else:
+ raise ValueError("Cannot import libc")
+
def load_keymap(
xkb_config_root: Path,
@@ -230,6 +249,13 @@ def unref_keymap(keymap: xkb_keymap_p) -> None:
xkbcommon.xkb_keymap_unref(keymap)
+def keymap_as_string(keymap: xkb_keymap_p) -> str:
+ p = xkbcommon.xkb_keymap_get_as_string(keymap, XKB_KEYMAP_FORMAT_TEXT_V1)
+ s = string_at(p).decode("utf-8")
+ libc.free(p)
+ return s
+
+
def new_state(keymap: xkb_keymap_p) -> xkb_state_p:
state = xkbcommon.xkb_state_new(keymap)
if not state:
@@ -423,6 +449,25 @@ class ForeignKeymap:
def __exit__(self, exc_type, exc_val, exc_tb):
unref_keymap(self._keymap)
+ def check(self) -> bool:
+ """
+ Test if keymap compiles with corresponding RMLVO config.
+ """
+ try:
+ with self:
+ return bool(self._keymap)
+ except ValueError:
+ return False
+
+ def as_string(self) -> str:
+ try:
+ with self as keymap:
+ if not bool(keymap):
+ return ""
+ return keymap_as_string(keymap)
+ except ValueError:
+ return ""
+
class State:
"""