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