test_rules_xml.py revision 46185892
146185892Smrg#!/usr/bin/env python3
246185892Smrg#
346185892Smrg# Call with pytest. Requires XKB_CONFIG_ROOT to be set
446185892Smrg
546185892Smrgimport os
646185892Smrgimport pytest
746185892Smrgfrom pathlib import Path
846185892Smrgimport xml.etree.ElementTree as ET
946185892Smrg
1046185892Smrg
1146185892Smrgdef _xkb_config_root():
1246185892Smrg    path = os.getenv('XKB_CONFIG_ROOT')
1346185892Smrg    assert path is not None, 'Environment variable XKB_CONFIG_ROOT must be set'
1446185892Smrg    print(f'Using XKB_CONFIG_ROOT={path}')
1546185892Smrg
1646185892Smrg    xkbpath = Path(path)
1746185892Smrg    assert (xkbpath / 'rules').exists(), f'{path} is not an XKB installation'
1846185892Smrg    return xkbpath
1946185892Smrg
2046185892Smrg
2146185892Smrg@pytest.fixture
2246185892Smrgdef xkb_config_root():
2346185892Smrg    return _xkb_config_root()
2446185892Smrg
2546185892Smrg
2646185892Smrgdef iterate_layouts_variants(rules_xml):
2746185892Smrg    '''
2846185892Smrg    Return an iterator of type (layout, variant) for each element in the XML
2946185892Smrg    file.
3046185892Smrg    '''
3146185892Smrg    tree = ET.parse(rules_xml)
3246185892Smrg    root = tree.getroot()
3346185892Smrg    for layout in root.iter('layout'):
3446185892Smrg        yield layout, None
3546185892Smrg
3646185892Smrg        for variant in layout.iter('variant'):
3746185892Smrg            yield layout, variant
3846185892Smrg
3946185892Smrgdef iterate_config_items(rules_xml):
4046185892Smrg    '''
4146185892Smrg    Return an iterator of configItem elements
4246185892Smrg    '''
4346185892Smrg    tree = ET.parse(rules_xml)
4446185892Smrg    root = tree.getroot()
4546185892Smrg    return root.iter('configItem')
4646185892Smrg
4746185892Smrg
4846185892Smrgdef pytest_generate_tests(metafunc):
4946185892Smrg    # for any test_foo function with an argument named rules_xml,
5046185892Smrg    # make it the list of XKB_CONFIG_ROOT/rules/*.xml files.
5146185892Smrg    if 'rules_xml' in metafunc.fixturenames:
5246185892Smrg        rules_xml = list(_xkb_config_root().glob('rules/*.xml'))
5346185892Smrg        assert rules_xml
5446185892Smrg        metafunc.parametrize('rules_xml', rules_xml)
5546185892Smrg    # for any test_foo function with an argument named layout,
5646185892Smrg    # make it a Layout wrapper class for all layout(variant) combinations
5746185892Smrg    elif 'layout' in metafunc.fixturenames:
5846185892Smrg        rules_xml = list(_xkb_config_root().glob('rules/*.xml'))
5946185892Smrg        assert rules_xml
6046185892Smrg        layouts = []
6146185892Smrg        for f in rules_xml:
6246185892Smrg            for l, v in iterate_layouts_variants(f):
6346185892Smrg                layouts.append(Layout(f, l, v))
6446185892Smrg        metafunc.parametrize('layout', layouts)
6546185892Smrg    elif 'config_item' in metafunc.fixturenames:
6646185892Smrg        rules_xml = list(_xkb_config_root().glob('rules/*.xml'))
6746185892Smrg        assert rules_xml
6846185892Smrg        config_items = []
6946185892Smrg        for f in rules_xml:
7046185892Smrg            for item in iterate_config_items(f):
7146185892Smrg                item = ConfigItem.from_elem(item)
7246185892Smrg                item.rulesfile = f
7346185892Smrg                config_items.append(item)
7446185892Smrg        metafunc.parametrize('config_item', config_items)
7546185892Smrg
7646185892Smrg
7746185892Smrg
7846185892Smrg
7946185892Smrgclass Layout:
8046185892Smrg    '''
8146185892Smrg    Wrapper class for layout/variants - both ConfigItems are available but
8246185892Smrg    the properties automatically pick the variant (if it exists) or the
8346185892Smrg    layout otherwise.
8446185892Smrg    '''
8546185892Smrg    def __init__(self, rulesfile, layout, variant=None):
8646185892Smrg        self.rulesfile = rulesfile
8746185892Smrg        self.layout = ConfigItem.from_elem(layout)
8846185892Smrg        self.variant = ConfigItem.from_elem(variant) if variant else None
8946185892Smrg        if variant:
9046185892Smrg            self.name = f"{self.layout.name}({self.variant.name})"
9146185892Smrg        else:
9246185892Smrg            self.name = f"{self.layout.name}"
9346185892Smrg
9446185892Smrg    @property
9546185892Smrg    def iso3166(self):
9646185892Smrg        if self.variant and self.variant.iso3166 is not None:
9746185892Smrg            return self.variant.iso3166 or []
9846185892Smrg        # inherit from parent
9946185892Smrg        return self.layout.iso3166 or []
10046185892Smrg
10146185892Smrg    @property
10246185892Smrg    def iso639(self):
10346185892Smrg        if self.variant and self.variant.iso639 is not None:
10446185892Smrg            return self.variant.iso639 or []
10546185892Smrg        # inherit from parent
10646185892Smrg        return self.layout.iso639 or []
10746185892Smrg
10846185892Smrg    @property
10946185892Smrg    def popularity(self):
11046185892Smrg        return self.variant.popularity if self.variant else self.layout.popularity
11146185892Smrg
11246185892Smrg    @property
11346185892Smrg    def shortDescription(self):
11446185892Smrg        if self.variant and self.variant.shortDescription:
11546185892Smrg            return self.variant.shortDescription
11646185892Smrg        return self.layout.shortDescription
11746185892Smrg
11846185892Smrg
11946185892Smrgdef prettyxml(element):
12046185892Smrg    return ET.tostring(element).decode('utf-8')
12146185892Smrg
12246185892Smrg
12346185892Smrgclass ConfigItem:
12446185892Smrg    def __init__(self, name, shortDescription=None, description=None):
12546185892Smrg        self.name = name
12646185892Smrg        self.shortDescription = shortDescription
12746185892Smrg        self.description = description
12846185892Smrg        self.iso639 = None
12946185892Smrg        self.iso3166 = None
13046185892Smrg        self.popularity = None
13146185892Smrg
13246185892Smrg    @classmethod
13346185892Smrg    def _fetch_subelement(cls, parent, name):
13446185892Smrg        sub_element = parent.findall(name)
13546185892Smrg        if sub_element is not None and len(sub_element) == 1:
13646185892Smrg            return sub_element[0]
13746185892Smrg        else:
13846185892Smrg            return None
13946185892Smrg
14046185892Smrg    @classmethod
14146185892Smrg    def _fetch_subelement_text(cls, parent, name):
14246185892Smrg        sub_element = parent.findall(name)
14346185892Smrg        return [e.text for e in sub_element]
14446185892Smrg
14546185892Smrg    @classmethod
14646185892Smrg    def _fetch_text(cls, parent, name):
14746185892Smrg        sub_element = cls._fetch_subelement(parent, name)
14846185892Smrg        if sub_element is None:
14946185892Smrg            return None
15046185892Smrg        return sub_element.text
15146185892Smrg
15246185892Smrg    @classmethod
15346185892Smrg    def from_elem(cls, elem):
15446185892Smrg        try:
15546185892Smrg            ci_element = elem if elem.tag == "configItem" else cls._fetch_subelement(elem, 'configItem')
15646185892Smrg            name = cls._fetch_text(ci_element, 'name')
15746185892Smrg            assert name is not None
15846185892Smrg            # shortDescription and description are optional
15946185892Smrg            sdesc = cls._fetch_text(ci_element, 'shortDescription')
16046185892Smrg            desc = cls._fetch_text(ci_element, 'description')
16146185892Smrg            ci = ConfigItem(name, sdesc, desc)
16246185892Smrg            ci.popularity = ci_element.attrib.get('popularity')
16346185892Smrg
16446185892Smrg            langlist = cls._fetch_subelement(ci_element, 'languageList')
16546185892Smrg            if langlist:
16646185892Smrg                ci.iso639 = cls._fetch_subelement_text(langlist, 'iso639Id')
16746185892Smrg
16846185892Smrg            countrylist = cls._fetch_subelement(ci_element, 'countryList')
16946185892Smrg            if countrylist:
17046185892Smrg                ci.iso3166 = cls._fetch_subelement_text(countrylist, 'iso3166Id')
17146185892Smrg
17246185892Smrg            return ci
17346185892Smrg        except AssertionError as e:
17446185892Smrg            endl = "\n"  # f{} cannot contain backslashes
17546185892Smrg            e.args = (f'\nFor element {prettyxml(elem)}\n{endl.join(e.args)}',)
17646185892Smrg            raise
17746185892Smrg
17846185892Smrg
17946185892Smrgdef test_duplicate_layouts(rules_xml):
18046185892Smrg    tree = ET.parse(rules_xml)
18146185892Smrg    root = tree.getroot()
18246185892Smrg    layouts = {}
18346185892Smrg    for layout in root.iter('layout'):
18446185892Smrg        ci = ConfigItem.from_elem(layout)
18546185892Smrg        assert ci.name not in layouts, f'Duplicate layout {ci.name}'
18646185892Smrg        layouts[ci.name] = True
18746185892Smrg
18846185892Smrg        variants = {}
18946185892Smrg        for variant in layout.iter('variant'):
19046185892Smrg            vci = ConfigItem.from_elem(variant)
19146185892Smrg            assert vci.name not in variants, \
19246185892Smrg                f'{rules_xml}: duplicate variant {ci.name}({vci.name}):\n{prettyxml(variant)}'
19346185892Smrg            variants[vci.name] = True
19446185892Smrg
19546185892Smrg
19646185892Smrgdef test_duplicate_models(rules_xml):
19746185892Smrg    tree = ET.parse(rules_xml)
19846185892Smrg    root = tree.getroot()
19946185892Smrg    models = {}
20046185892Smrg    for model in root.iter('model'):
20146185892Smrg        ci = ConfigItem.from_elem(model)
20246185892Smrg        assert ci.name not in models, f'Duplicate model {ci.name}'
20346185892Smrg        models[ci.name] = True
20446185892Smrg
20546185892Smrg
20646185892Smrgdef test_exotic(config_item):
20746185892Smrg    """All items in extras should be marked exotic"""
20846185892Smrg    if config_item.rulesfile.stem.endswith('extras'):
20946185892Smrg        assert config_item.popularity == "exotic", f"{config_item.rulesfile}: item {config_item.name} does not have popularity exotic"
21046185892Smrg    else:
21146185892Smrg        assert config_item.popularity != "exotic", f"{config_item.rulesfile}: item {config_item.name} has popularity exotic"
21246185892Smrg
21346185892Smrg
21446185892Smrgdef test_short_description(layout):
21546185892Smrg    assert layout.shortDescription, f'{layout.rulesfile}: layout {layout.name} missing shortDescription'
21646185892Smrg
21746185892Smrg
21846185892Smrgdef test_iso3166(layout):
21946185892Smrg    """Typically layouts should specify at least one country code"""
22046185892Smrg    pycountry = pytest.importorskip('pycountry')
22146185892Smrg    country_codes = [c.alpha_2 for c in pycountry.countries]
22246185892Smrg    expected_without_country = [
22346185892Smrg        "apl", "bqn", # programming
22446185892Smrg        "brai", # Braille not specific to any country
22546185892Smrg        "custom",
22646185892Smrg        "epo", # Esperanto not native to any country
22746185892Smrg        "trans", # international
22846185892Smrg        ]
22946185892Smrg
23046185892Smrg    for code in layout.iso3166:
23146185892Smrg        assert code in country_codes, \
23246185892Smrg            f'{layout.rulesfile}: unknown country code "{code}" in {layout.name}'
23346185892Smrg
23446185892Smrg    assert layout.iso3166 or layout.layout.name in expected_without_country, f"{layout.rulesfile}: layout {layout.name} has no countries associated"
23546185892Smrg
23646185892Smrg
23746185892Smrgdef test_iso639(layout):
23846185892Smrg    """Typically layouts should should specify at least one language code"""
23946185892Smrg    pycountry = pytest.importorskip('pycountry')
24046185892Smrg
24146185892Smrg    # A list of languages not in pycountry, so we need to special-case them
24246185892Smrg    special_langs = [
24346185892Smrg        'ber',  # Berber languages (collective), https://iso639-3.sil.org/code/ber
24446185892Smrg        'btb',  # Beti (Cameroon), https://iso639-3.sil.org/code/btb
24546185892Smrg        'fox',  # Formosan languages (collective), https://iso639-3.sil.org/code/fox
24646185892Smrg        'phi',  # Philippine languages (collective), https://iso639-3.sil.org/code/phi
24746185892Smrg        'ovd',  # Elfdalian, https://iso639-3.sil.org/code/ovd
24846185892Smrg    ]
24946185892Smrg    language_codes = [c.alpha_3 for c in pycountry.languages] + special_langs
25046185892Smrg    expected_without_language = ["brai", "custom", "trans"]
25146185892Smrg
25246185892Smrg    for code in layout.iso639:
25346185892Smrg        assert code in language_codes, \
25446185892Smrg            f'{layout.rulesfile}: unknown language code "{code}" in {layout.name}'
25546185892Smrg
25646185892Smrg    assert layout.iso639 or layout.layout.name in expected_without_language, f"{layout.rulesfile}: layout {layout.name} has no languages associated"
257