diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 57fdeea6e..699f04935 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -8,6 +8,6 @@ Closes # #### Before submitting this PR, please make sure you have: - [ ] included test cases for core behavior and edge cases in `tests` -- [ ] run `nosetests` and verified all tests pass -- [ ] run `black pyxform tests` to format code +- [ ] run `python -m unittest` and verified all tests pass +- [ ] run `ruff format pyxform tests` and `ruff check pyxform tests` to lint code - [ ] verified that any code or assets from external sources are properly credited in comments \ No newline at end of file diff --git a/pyxform/aliases.py b/pyxform/aliases.py index a0c416771..480cd2f40 100644 --- a/pyxform/aliases.py +++ b/pyxform/aliases.py @@ -73,7 +73,7 @@ "SMS Response": constants.SMS_RESPONSE, "compact_tag": "instance::odk:tag", # used for compact representation "Type": "type", - "List_name": "list_name", + "List_name": constants.LIST_NAME_U, # u"repeat_count": u"jr:count", duplicate key "read_only": "bind::readonly", "readonly": "bind::readonly", @@ -111,7 +111,7 @@ constants.ENTITIES_SAVETO: "bind::entities:saveto", } -entities_header = {"list_name": "dataset"} +entities_header = {constants.LIST_NAME_U: "dataset"} # Key is the pyxform internal name, Value is the name used in error/warning messages. TRANSLATABLE_SURVEY_COLUMNS = { @@ -135,7 +135,7 @@ } list_header = { "caption": constants.LABEL, - "list_name": constants.LIST_NAME, + constants.LIST_NAME_U: constants.LIST_NAME_S, "value": constants.NAME, "image": "media::image", "big-image": "media::big-image", diff --git a/pyxform/constants.py b/pyxform/constants.py index 0730350eb..3759cf128 100644 --- a/pyxform/constants.py +++ b/pyxform/constants.py @@ -67,7 +67,8 @@ CHOICES = "choices" # XLS Specific constants -LIST_NAME = "list name" +LIST_NAME_S = "list name" +LIST_NAME_U = "list_name" CASCADING_SELECT = "cascading_select" TABLE_LIST = "table-list" # hyphenated because it goes in appearance, and convention for appearance column is dashes FIELD_LIST = "field-list" @@ -155,3 +156,12 @@ class EntityColumns(StrEnum): "constraint", "calculate", ) +NSMAP = { + "xmlns": "http://www.w3.org/2002/xforms", + "xmlns:h": "http://www.w3.org/1999/xhtml", + "xmlns:ev": "http://www.w3.org/2001/xml-events", + "xmlns:xsd": "http://www.w3.org/2001/XMLSchema", + "xmlns:jr": "http://openrosa.org/javarosa", + "xmlns:orx": "http://openrosa.org/xforms", + "xmlns:odk": "http://www.opendatakit.org/xforms", +} diff --git a/pyxform/survey.py b/pyxform/survey.py index 22b237f29..2c1cc996a 100644 --- a/pyxform/survey.py +++ b/pyxform/survey.py @@ -11,7 +11,7 @@ from typing import Generator, Iterator, List, Optional, Tuple from pyxform import aliases, constants -from pyxform.constants import EXTERNAL_INSTANCE_EXTENSIONS +from pyxform.constants import EXTERNAL_INSTANCE_EXTENSIONS, NSMAP from pyxform.errors import PyXFormError, ValidationError from pyxform.external_instance import ExternalInstance from pyxform.instance import SurveyInstance @@ -23,7 +23,6 @@ BRACKETED_TAG_REGEX, LAST_SAVED_INSTANCE_NAME, LAST_SAVED_REGEX, - NSMAP, DetachableElement, PatchedText, get_languages_with_bad_tags, @@ -40,7 +39,7 @@ r"(instance\(.*\)\/root\/item\[.*?(\$\{.*\})\]\/.*?)\s" ) RE_PULLDATA = re.compile(r"(pulldata\s*\(\s*)(.*?),") -SEARCH_APPEARANCE_REGEX = re.compile(r"search\(.*?\)") +SEARCH_FUNCTION_REGEX = re.compile(r"search\(.*?\)") class InstanceInfo: @@ -216,7 +215,7 @@ class Survey(Section): "namespaces": str, constants.ENTITY_FEATURES: list, } - ) # yapf: disable + ) def validate(self): if self.id_string in [None, "None"]: @@ -385,7 +384,7 @@ def _validate_external_instances(instances) -> None: errors = [] for element, copies in seen.items(): if len(copies) > 1: - contexts = ", ".join(x.context for x in copies) + contexts = ", ".join(f"{x.context}({x.type})" for x in copies) errors.append( "Instance names must be unique within a form. " f"The name '{element}' was found {len(copies)} time(s), " @@ -655,34 +654,44 @@ def _add_to_nested_dict(self, dicty, path, value): dicty[path[0]] = {} self._add_to_nested_dict(dicty[path[0]], path[1:], value) - @staticmethod - def _redirect_is_search_itext(element: SurveyElement) -> None: + def _redirect_is_search_itext(self, element: SurveyElement) -> bool: """ - For selects using the "search()" appearance, redirect itext for in-line items. + For selects using the "search()" function, redirect itext for in-line items. - External selects from a "search" appearance alone don't work in Enketo. In Collect + External selects from a "search" function alone don't work in Enketo. In Collect they must have the "item" elements in the body, rather than in an "itemset". The "itemset" reference is cleared below, so that the element will get in-line items instead of an itemset reference to a secondary instance. The itext ref is passed to the options/choices so they can use the generated translations. This - accounts for questions with and without a "search()" appearance sharing choices. + accounts for questions with and without a "search()" function sharing choices. :param element: A select type question. - :return: None, the question/children are modified in-place. + :return: If True, the element uses the search function. """ try: is_search = bool( - SEARCH_APPEARANCE_REGEX.search( + SEARCH_FUNCTION_REGEX.search( element[constants.CONTROL][constants.APPEARANCE] ) ) except (KeyError, TypeError): is_search = False if is_search: + file_id, ext = os.path.splitext(element[constants.ITEMSET]) + if ext in EXTERNAL_INSTANCE_EXTENSIONS: + msg = ( + f"Question '{element[constants.NAME]}' is a select from file type, " + "using 'search()'. This combination is not supported. " + "Remove the 'search()' usage, or change the select type." + ) + raise PyXFormError(msg) + itemset = element[constants.ITEMSET] + self.choices.pop(itemset, None) element[constants.ITEMSET] = "" for i, opt in enumerate(element.get(constants.CHILDREN, [])): - opt["_choice_itext_id"] = f"{element['list_name']}-{i}" + opt["_choice_itext_id"] = f"{element[constants.LIST_NAME_U]}-{i}" + return is_search def _setup_translations(self): """ @@ -745,6 +754,8 @@ def get_choices(): self._add_to_nested_dict(self._translations, path, leaf_value) select_types = set(aliases.select.keys()) + search_lists = set() + non_search_lists = set() for element in self.iter_descendants(): itemset = element.get("itemset") if itemset is not None: @@ -753,7 +764,12 @@ def get_choices(): element._itemset_dyn_label = itemset in itemsets_has_dyn_label if element[constants.TYPE] in select_types: - self._redirect_is_search_itext(element=element) + select_ref = (element[constants.NAME], element[constants.LIST_NAME_U]) + if self._redirect_is_search_itext(element=element): + search_lists.add(select_ref) + else: + non_search_lists.add(select_ref) + # Skip creation of translations for choices in selects. The creation of these # translations is done above in this function. parent = element.get("parent") @@ -780,6 +796,20 @@ def get_choices(): } ) + for q_name, list_name in search_lists: + choice_refs = [f"'{q}'" for q, c in non_search_lists if c == list_name] + if len(choice_refs) > 0: + refs_str = ", ".join(choice_refs) + msg = ( + f"Question '{q_name}' uses 'search()', and its select type references" + f" the choice list name '{list_name}'. This choice list name is " + f"referenced by at least one other question that is not using " + f"'search()', which will not work: {refs_str}. Either 1) use " + f"'search()' for all questions using this choice list name, or 2) " + f"use a different choice list name for the question using 'search()'." + ) + raise PyXFormError(msg) + def _add_empty_translations(self): """ Adds translations so that every itext element has the same elements across every diff --git a/pyxform/survey_element.py b/pyxform/survey_element.py index 73394aeb2..490a44492 100644 --- a/pyxform/survey_element.py +++ b/pyxform/survey_element.py @@ -51,7 +51,7 @@ "autoplay": str, "flat": lambda: False, "action": str, - "list_name": str, + const.LIST_NAME_U: str, "trigger": str, } diff --git a/pyxform/utils.py b/pyxform/utils.py index 5c31260fe..d6f34f211 100644 --- a/pyxform/utils.py +++ b/pyxform/utils.py @@ -29,17 +29,6 @@ NODE_TYPE_TEXT = (Node.TEXT_NODE, Node.CDATA_SECTION_NODE) -NSMAP = { - "xmlns": "http://www.w3.org/2002/xforms", - "xmlns:h": "http://www.w3.org/1999/xhtml", - "xmlns:ev": "http://www.w3.org/2001/xml-events", - "xmlns:xsd": "http://www.w3.org/2001/XMLSchema", - "xmlns:jr": "http://openrosa.org/javarosa", - "xmlns:orx": "http://openrosa.org/xforms", - "xmlns:odk": "http://www.opendatakit.org/xforms", -} - - class DetachableElement(Element): """ Element classes are not meant to be instantiated directly. This diff --git a/pyxform/xform2json.py b/pyxform/xform2json.py index e6d458b83..caa72a5be 100644 --- a/pyxform/xform2json.py +++ b/pyxform/xform2json.py @@ -13,8 +13,8 @@ from defusedxml.ElementTree import ParseError, XMLParser, fromstring, parse from pyxform import builder +from pyxform.constants import NSMAP from pyxform.errors import PyXFormError -from pyxform.utils import NSMAP logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) diff --git a/pyxform/xls2json.py b/pyxform/xls2json.py index 3a019fb14..d4ea1a642 100644 --- a/pyxform/xls2json.py +++ b/pyxform/xls2json.py @@ -370,7 +370,7 @@ def add_choices_info_to_question( question["query"] = list_name elif choices.get(list_name): # Reference to list name for data dictionary tools (ilri/odktools). - question["list_name"] = list_name + question[constants.LIST_NAME_U] = list_name # Copy choices for data export tools (onaio/onadata). # TODO: could onadata use the list_name to look up the list for # export, instead of pyxform internally duplicating choices data? @@ -386,7 +386,7 @@ def add_choices_info_to_question( # Select from previous answers e.g. type = "select_one ${q1}". or bool(PYXFORM_REFERENCE_REGEX.search(list_name)) ): - question["list_name"] = list_name + question[constants.LIST_NAME_U] = list_name question[constants.CHOICES] = choices[list_name] @@ -529,7 +529,7 @@ def workbook_to_json( default_language=default_language, ) external_choices = group_dictionaries_by_key( - list_of_dicts=external_choices_sheet.data, key=constants.LIST_NAME + list_of_dicts=external_choices_sheet.data, key=constants.LIST_NAME_S ) # ########## Choices sheet ########## @@ -543,7 +543,7 @@ def workbook_to_json( default_language=default_language, ) combined_lists = group_dictionaries_by_key( - list_of_dicts=choices_sheet.data, key=constants.LIST_NAME + list_of_dicts=choices_sheet.data, key=constants.LIST_NAME_S ) # To combine the warning into one message, the check for missing choices translation # columns is run with Survey sheet below. @@ -654,7 +654,7 @@ def workbook_to_json( use_double_colons=True, ) osm_tags = group_dictionaries_by_key( - list_of_dicts=osm_sheet.data, key=constants.LIST_NAME + list_of_dicts=osm_sheet.data, key=constants.LIST_NAME_S ) # ################################# @@ -1025,13 +1025,13 @@ def workbook_to_json( child_list = [] new_json_dict[constants.CHILDREN] = child_list if control_type is constants.LOOP: - if not parse_dict.get("list_name"): + if not parse_dict.get(constants.LIST_NAME_U): # TODO: Perhaps warn and make repeat into a group? raise PyXFormError( ROW_FORMAT_STRING % row_number + " Repeat loop without list name." ) - list_name = parse_dict["list_name"] + list_name = parse_dict[constants.LIST_NAME_U] if list_name not in choices: raise PyXFormError( ROW_FORMAT_STRING % row_number @@ -1127,7 +1127,7 @@ def workbook_to_json( + " select one external is only meant for" " filtered selects." ) - list_name = parse_dict["list_name"] + list_name = parse_dict[constants.LIST_NAME_U] file_extension = os.path.splitext(list_name)[1] if ( select_type == constants.SELECT_ONE_EXTERNAL @@ -1323,8 +1323,8 @@ def workbook_to_json( new_dict = row.copy() new_dict["type"] = constants.OSM - if parse_dict.get("list_name") is not None: - tags = osm_tags.get(parse_dict.get("list_name")) + if parse_dict.get(constants.LIST_NAME_U) is not None: + tags = osm_tags.get(parse_dict.get(constants.LIST_NAME_U)) for tag in tags: if osm_tags.get(tag.get("name")): tag["choices"] = osm_tags.get(tag.get("name")) diff --git a/tests/example_xls/settings.xls b/tests/example_xls/settings.xls deleted file mode 100644 index 0d1c05a83..000000000 Binary files a/tests/example_xls/settings.xls and /dev/null differ diff --git a/tests/pyxform_test_case.py b/tests/pyxform_test_case.py index 31e433db1..835668bb5 100644 --- a/tests/pyxform_test_case.py +++ b/tests/pyxform_test_case.py @@ -14,8 +14,9 @@ # noinspection PyProtectedMember from lxml.etree import _Element from pyxform.builder import create_survey_element_from_dict +from pyxform.constants import NSMAP from pyxform.errors import PyXFormError -from pyxform.utils import NSMAP, coalesce +from pyxform.utils import coalesce from pyxform.validators.odk_validate import ODKValidateError, check_xform from pyxform.xls2json import workbook_to_json @@ -113,13 +114,17 @@ def _ss_structure_to_pyxform_survey( ): # using existing methods from the builder imported_survey_json = workbook_to_json( - workbook_dict=ss_structure, warnings=warnings + workbook_dict=ss_structure, form_name=name, warnings=warnings ) # ideally, when all these tests are working, this would be refactored as well survey = create_survey_element_from_dict(imported_survey_json) - survey.name = coalesce(name, "data") - survey.title = title - survey.id_string = id_string + # Due to str(name) in workbook_to_json + if survey.name is None or survey.name == "None": + survey.name = coalesce(name, "data") + if survey.title is None: + survey.title = title + if survey.id_string is None: + survey.id_string = id_string return survey diff --git a/tests/test_builder.py b/tests/test_builder.py index a230d46ea..209d15feb 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -545,7 +545,7 @@ def test_style_column(self): STRIP_NS_FROM_TAG_RE = re.compile(r"\{.+\}") def test_style_not_added_to_body_if_not_present(self): - survey = utils.create_survey_from_fixture("settings", filetype=FIXTURE_FILETYPE) + survey = utils.create_survey_from_fixture("widgets", filetype=FIXTURE_FILETYPE) xml = survey.to_xml() # find the body tag root_elm = ETree.fromstring(xml.encode("utf-8")) diff --git a/tests/test_custom_xml_namespaces.py b/tests/test_custom_xml_namespaces.py deleted file mode 100644 index 57933eb5f..000000000 --- a/tests/test_custom_xml_namespaces.py +++ /dev/null @@ -1,36 +0,0 @@ -""" -Test custom namespaces. -""" -from tests.pyxform_test_case import PyxformTestCase - -MD = """ -| survey | | | | -| | type | name | label | -| | note | q | Q | -| settings | | | | -| | namespaces | | | -| | esri="http://esri.com/xforms" enk="http://enketo.org/xforms" naf="http://nafundi.com/xforms" | | | -""" # nopep8 - - -class CustomXMLNamespacesTest(PyxformTestCase): - """ - Test custom namespaces. - """ - - def test_custom_xml_name_spaces(self): - # re: https://github.com/XLSForm/pyxform/issues/65 - self.assertPyxformXform( - name="custom_namespaces", - md=MD, - xml__contains=[ - 'xmlns="http://www.w3.org/2002/xforms"', - 'xmlns:h="http://www.w3.org/1999/xhtml"', - 'xmlns:jr="http://openrosa.org/javarosa"', - 'xmlns:orx="http://openrosa.org/xforms"', - 'xmlns:xsd="http://www.w3.org/2001/XMLSchema"', - 'xmlns:esri="http://esri.com/xforms"', - 'xmlns:enk="http://enketo.org/xforms"', - 'xmlns:naf="http://nafundi.com/xforms"', - ], - ) diff --git a/tests/test_form_name.py b/tests/test_form_name.py index 36df4edb0..090f643fb 100644 --- a/tests/test_form_name.py +++ b/tests/test_form_name.py @@ -4,7 +4,7 @@ from tests.pyxform_test_case import PyxformTestCase -class FormNameTest(PyxformTestCase): +class TestFormName(PyxformTestCase): def test_default_to_data_when_no_name(self): """ Test no form_name will default to survey name to 'data'. @@ -14,9 +14,6 @@ def test_default_to_data_when_no_name(self): | survey | | | | | | type | name | label | | | text | city | City Name | - | settings | | | | - | | id_string | name | - | | some-id | data | """, autoname=False, ) diff --git a/tests/test_search_function.py b/tests/test_search_function.py new file mode 100644 index 000000000..984f81296 --- /dev/null +++ b/tests/test_search_function.py @@ -0,0 +1,578 @@ +""" +Tests about the 'search()' function, which pulls data from CSV, optionally filtering it. + +Although both go in the 'appearance' column, 'search()' is not the same as 'search'. The +latter is a style which enables a choice filtering UI. +""" + +from tests.pyxform_test_case import PyxformTestCase +from tests.xpath_helpers.choices import xpc +from tests.xpath_helpers.questions import xpq + + +def xp_model_instance_with_csv_src_no_items(iid: str) -> str: + return f""" + /h:html/h:head/x:model/x:instance[ + @id='{iid}' and @src='jr://file-csv/{iid}.csv' and not(./x:root/x:item) + ] + """ + + +def xp_body_select_search_appearance( + qname: str, appearance: str = "search('my_file')" +) -> str: + """The select has a 'search' appearance attribute.""" + return f""" + /h:html/h:body/x:select1[ + @ref='/test_name/{qname}' + and @appearance="{appearance}" + ] + """ + + +def xp_body_select_config_choice_inline(qname: str, cvname: str, clname: str) -> str: + """The inline choice item has an inline (non-translated) label.""" + return f""" + /h:html/h:body/x:select1[@ref='/test_name/{qname}']/x:item[ + ./x:value/text()='{cvname}' + and ./x:label/text()='{clname}' + ] + """ + + +def xp_body_select_config_choice_itext(qname: str, cname: str, cvname: str) -> str: + """The inline choice item has an itext (translated) label.""" + return f""" + /h:html/h:body/x:select1[@ref='/test_name/{qname}']/x:item[ + ./x:value/text()='{cvname}' + and ./x:label[@ref="jr:itext('{cname}-0')"] + ] + """ + + +class TestTranslations(PyxformTestCase): + """ + Translations behaviour with the search() appearance. + + The search() appearance is a Collect-only feature, so ODK Validate is run for these + tests to try and ensure that the output will be accepted by Collect. In particular, + the search() appearance requires in-line (body) items for choices. + """ + + @classmethod + def setUpClass(cls) -> None: + cls.run_odk_validate = True + + def test_shared_choice_list(self): + """Should include translation for search() items, when sharing the choice list""" + md = """ + | survey | | | | | | + | | type | name | label::en | label::fr | appearance | + | | select_one c1 | q1 | Question 1 | Chose 1 | search('my_file') | + | | select_one c1 | q2 | Question 2 | Chose 2 | search('my_file', 'matches', 'filtercol', 'x1') | + | choices | | | | | + | | list_name | name | label::en | label::fr | + | | c1 | id | label_en | label_fr | + """ + self.assertPyxformXform( + md=md, + run_odk_validate=self.run_odk_validate, + xml__xpath_match=[ + xp_body_select_search_appearance("q1"), + xp_body_select_config_choice_itext("q1", "c1", "id"), + xp_body_select_search_appearance( + "q2", "search('my_file', 'matches', 'filtercol', 'x1')" + ), + xp_body_select_config_choice_itext("q2", "c1", "id"), + xpc.model_itext_choice_text_label_by_pos("en", "c1", ("label_en",)), + xpc.model_itext_choice_text_label_by_pos("fr", "c1", ("label_fr",)), + ], + xml__xpath_count=[(xpq.model_instance_exists("c1"), 0)], + ) + + def test_usage_with_other_selects(self): + """Should include translation for search() items, when used with other selects""" + md = """ + | survey | | | | | | + | | type | name | label::en | label::fr | appearance | + | | select_one c1 | q1 | Question 1 | Chose 1 | search('my_file') | + | | select_one c2 | q2 | Question 2 | Chose 2 | | + | choices | | | | | + | | list_name | name | label::en | label::fr | + | | c1 | id | label_en | label_fr | + | | c2 | na | la-e | la-f | + | | c2 | nb | lb-e | lb-f | + """ + self.assertPyxformXform( + md=md, + run_odk_validate=self.run_odk_validate, + xml__xpath_match=[ + xp_body_select_search_appearance("q1"), + xp_body_select_config_choice_itext("q1", "c1", "id"), + xpc.model_itext_choice_text_label_by_pos("en", "c1", ("label_en",)), + xpc.model_itext_choice_text_label_by_pos("fr", "c1", ("label_fr",)), + xpc.model_instance_choices_itext("c2", ("na", "nb")), + xpc.model_itext_choice_text_label_by_pos("en", "c2", ("la-e", "lb-e")), + xpc.model_itext_choice_text_label_by_pos("fr", "c2", ("la-f", "lb-f")), + ], + xml__xpath_count=[ + (xpq.model_instance_exists("c1"), 0), + ], + ) + + def test_usage_with_other_selects__invalid_list_reuse_by_non_search_question(self): + """By design, q2 won't pull data but the test is to document output behaviour.""" + md = """ + | survey | | | | | | + | | type | name | label::en | label::fr | appearance | + | | select_one c1 | q1 | Question 1 | Chose 1 | search('my_file') | + | | select_one c1 | q2 | Question 2 | Chose 2 | | + | choices | | | | + | | list_name | name | label | + | | c1 | id | label | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=["Question 'q1' uses 'search()',"], + ) + + def test_single_question_usage(self): + """Should include translation for search() items, edge case of single question""" + md = """ + | survey | | | | | | + | | type | name | label::en | label::fr | appearance | + | | select_one c1 | q1 | Question 1 | Chose 1 | search('my_file') | + | choices | | | | | + | | list_name | name | label::en | label::fr | + | | c1 | id | label_en | label_fr | + """ + self.assertPyxformXform( + md=md, + run_odk_validate=self.run_odk_validate, + xml__xpath_match=[ + xp_body_select_search_appearance("q1"), + xp_body_select_config_choice_itext("q1", "c1", "id"), + xpc.model_itext_choice_text_label_by_pos("en", "c1", ("label_en",)), + xpc.model_itext_choice_text_label_by_pos("fr", "c1", ("label_fr",)), + ], + xml__xpath_count=[ + (xpq.model_instance_exists("c1"), 0), + ], + ) + + def test_additional_static_choices(self): + """Should include translation for search() items, when adding static choices""" + md = """ + | survey | | | | | | + | | type | name | label::en | label::fr | appearance | + | | select_one c1 | q1 | Question 1 | Chose 1 | search('my_file') | + | choices | | | | | + | | list_name | name | label::en | label::fr | + | | c1 | id | label_en | label_fr | + | | c1 | 0 | l0-e | l0-f | + | | c1 | 1 | l1-e | l1-f | + """ + self.assertPyxformXform( + md=md, + run_odk_validate=self.run_odk_validate, + xml__xpath_match=[ + xp_body_select_search_appearance("q1"), + xp_body_select_config_choice_itext("q1", "c1", "id"), + xpc.model_itext_choice_text_label_by_pos( + "en", "c1", ("label_en", "l0-e", "l1-e") + ), + xpc.model_itext_choice_text_label_by_pos( + "fr", "c1", ("label_fr", "l0-f", "l1-f") + ), + ], + xml__xpath_count=[(xpq.model_instance_exists("c1"), 0)], + ) + + def test_name_clashes(self): + """Should include translation for search() items, avoids any name clashes.""" + md = """ + | survey | | | | | | + | | type | name | label::en | label::fr | appearance | + | | select_one c1-0 | c1-0 | Question 1 | Chose 1 | search('my_file') | + | choices | | | | | + | | list_name | name | label::en | label::fr | + | | c1-0 | id | label_en | label_fr | + """ + self.assertPyxformXform( + md=md, + run_odk_validate=self.run_odk_validate, + xml__xpath_match=[ + xp_body_select_search_appearance("c1-0"), + xp_body_select_config_choice_itext("c1-0", "c1-0", "id"), + xpc.model_itext_choice_text_label_by_pos("en", "c1-0", ("label_en",)), + xpc.model_itext_choice_text_label_by_pos("fr", "c1-0", ("label_fr",)), + ], + xml__xpath_count=[(xpq.model_instance_exists("c1"), 0)], + ) + + def test_search_and_select_xlsx(self): + """Test to replace the old XLSX-based test fixture, 'search_and_select.xlsx'""" + md = """ + | survey | | | | | + | | type | name | label | appearance | + | | select_one fruits | fruit | Choose a fruit | search('fruits') | + | | note | note_fruit | The fruit ${fruit} pulled from csv | | + | choices | | | | + | | list_name | name | label | + | | fruits | name_key | name | + """ + self.assertPyxformXform( + md=md, + run_odk_validate=self.run_odk_validate, + xml__xpath_match=[ + "/h:html/h:head/x:model[not(descendant::x:itext)]", + """ + /h:html/h:body/x:select1[ + @ref='/test_name/fruit' + and @appearance="search('fruits')" + ]/x:item[ + ./x:value/text()='name_key' + and ./x:label/text()='name' + ] + """, + xpq.model_instance_bind("fruit", "string"), + xpq.model_instance_bind("note_fruit", "string"), + "/h:html/h:body/x:input[@ref='/test_name/note_fruit']", + ], + xml__xpath_count=[(xpq.model_instance_exists("c1"), 0)], + ) + + +class TestSecondaryInstances(PyxformTestCase): + """ + Test behaviour of the search() appearance with other sources of secondary instances. + """ + + @classmethod + def setUpClass(cls) -> None: + cls.run_odk_validate = True + + def test_pulldata_same_file(self): + """Should generate pulldata instance, and search elements in the body only.""" + md = """ + | survey | | | | | | + | | type | name | label | appearance | calculation | + | | select_one s1 | q1 | Q1 | search('my_file') | | + | | calculate | p1 | | | pulldata('my_file', 'this', 'that', ${q1}) | + | choices | | | | + | | list_name | name | label | + | | s1 | na | la | + """ + self.assertPyxformXform( + md=md, + run_odk_validate=self.run_odk_validate, + xml__xpath_match=[ + xp_model_instance_with_csv_src_no_items("my_file"), + xpq.model_instance_bind_attr( + "p1", + "calculate", + "pulldata('my_file', 'this', 'that', /test_name/q1 )", + ), + xp_body_select_search_appearance("q1"), + xp_body_select_config_choice_inline("q1", "na", "la"), + ], + xml__xpath_count=[ + (xpq.model_instance_exists("s1"), 0), + ], + ) + + def test_pulldata_same_file__multiple_search(self): + """Should generate pulldata instance, and search elements in the body only.""" + md = """ + | survey | | | | | | + | | type | name | label | appearance | calculation | + | | select_one s1 | q1 | Q1 | search('my_file') | | + | | calculate | p1 | | | pulldata('my_file', 'this', 'that', ${q1}) | + | | select_one s1 | q2 | Q2 | search('my_file') | | + | choices | | | | + | | list_name | name | label | + | | s1 | na | la | + """ + self.assertPyxformXform( + md=md, + run_odk_validate=self.run_odk_validate, + xml__xpath_match=[ + xp_model_instance_with_csv_src_no_items("my_file"), + xpq.model_instance_bind_attr( + "p1", + "calculate", + "pulldata('my_file', 'this', 'that', /test_name/q1 )", + ), + xp_body_select_search_appearance("q1"), + xp_body_select_config_choice_inline("q1", "na", "la"), + xp_body_select_search_appearance("q2"), + xp_body_select_config_choice_inline("q2", "na", "la"), + ], + xml__xpath_count=[ + (xpq.model_instance_exists("s1"), 0), + ], + ) + + def test_pulldata_same_file__multiple_search__translation(self): + """Should generate pulldata instance, and search elements in the body and itext.""" + md = """ + | survey | | | | | | + | | type | name | label | appearance | calculation | + | | select_one s1 | q1 | Q1 | search('my_file') | | + | | calculate | p1 | | | pulldata('my_file', 'this', 'that', ${q1}) | + | | select_one s1 | q2 | Q2 | search('my_file') | | + | choices | | | | + | | list_name | name | label::en | + | | s1 | na | la | + """ + self.assertPyxformXform( + md=md, + run_odk_validate=self.run_odk_validate, + xml__xpath_match=[ + xp_model_instance_with_csv_src_no_items("my_file"), + xpq.model_instance_bind_attr( + "p1", + "calculate", + "pulldata('my_file', 'this', 'that', /test_name/q1 )", + ), + xp_body_select_search_appearance("q1"), + xpc.model_itext_choice_text_label_by_pos("en", "s1", ("la",)), + xp_body_select_config_choice_itext("q1", "s1", "na"), + xp_body_select_search_appearance("q2"), + xp_body_select_config_choice_itext("q2", "s1", "na"), + ], + xml__xpath_count=[ + (xpq.model_instance_exists("s1"), 0), + ], + ) + + def test_pulldata_same_file__multiple_search_and_pulldata(self): + """Should generate pulldata instances, and search elements in the body only.""" + md = """ + | survey | | | | | | + | | type | name | label | appearance | calculation | + | | select_one s1 | q1 | Q1 | search('my_file') | | + | | calculate | p1 | | | pulldata('my_file', 'this', 'that', ${q1}) | + | | select_one s1 | q2 | Q2 | search('my_file') | | + | | calculate | p2 | | | pulldata('my_file', 'this', 'that', ${q2}) | + | choices | | | | + | | list_name | name | label | + | | s1 | na | la | + """ + self.assertPyxformXform( + md=md, + run_odk_validate=self.run_odk_validate, + xml__xpath_match=[ + xp_model_instance_with_csv_src_no_items("my_file"), + xpq.model_instance_bind_attr( + "p1", + "calculate", + "pulldata('my_file', 'this', 'that', /test_name/q1 )", + ), + xpq.model_instance_bind_attr( + "p2", + "calculate", + "pulldata('my_file', 'this', 'that', /test_name/q2 )", + ), + xp_body_select_search_appearance("q1"), + xp_body_select_config_choice_inline("q1", "na", "la"), + xp_body_select_search_appearance("q2"), + xp_body_select_config_choice_inline("q2", "na", "la"), + ], + xml__xpath_count=[ + (xpq.model_instance_exists("s1"), 0), + ], + ) + + def test_pulldata_same_file__multiple_search__different_config(self): + """Should generate pulldata instance, and search elements in the body only.""" + md = """ + | survey | | | | | | + | | type | name | label | appearance | calculation | + | | select_one s1 | q1 | Q1 | search('my_file') | | + | | calculate | p1 | | | pulldata('my_file', 'this', 'that', ${q1}) | + | | select_one s2 | q2 | Q2 | search('my_file') | | + | choices | | | | + | | list_name | name | label | + | | s1 | na | la | + | | s2 | nb | lb | + """ + self.assertPyxformXform( + md=md, + run_odk_validate=self.run_odk_validate, + xml__xpath_match=[ + xp_model_instance_with_csv_src_no_items("my_file"), + xpq.model_instance_bind_attr( + "p1", + "calculate", + "pulldata('my_file', 'this', 'that', /test_name/q1 )", + ), + xp_body_select_search_appearance("q1"), + xp_body_select_config_choice_inline("q1", "na", "la"), + xp_body_select_search_appearance("q2"), + xp_body_select_config_choice_inline("q2", "nb", "lb"), + ], + xml__xpath_count=[ + (xpq.model_instance_exists("s1"), 0), + (xpq.model_instance_exists("s2"), 0), + ], + ) + + def test_pulldata_same_file__multiple_search__static_choice(self): + """Should generate pulldata instance, and search elements in the body only.""" + md = """ + | survey | | | | | | + | | type | name | label | appearance | calculation | + | | select_one s1 | q1 | Q1 | search('my_file') | | + | | calculate | p1 | | | pulldata('my_file', 'this', 'that', ${q1}) | + | | select_one s1 | q2 | Q2 | search('my_file') | | + | choices | | | | + | | list_name | name | label | + | | s1 | na | la | + | | s1 | 0 | lb | + """ + self.assertPyxformXform( + md=md, + run_odk_validate=self.run_odk_validate, + xml__xpath_match=[ + xp_model_instance_with_csv_src_no_items("my_file"), + xpq.model_instance_bind_attr( + "p1", + "calculate", + "pulldata('my_file', 'this', 'that', /test_name/q1 )", + ), + xp_body_select_search_appearance("q1"), + xp_body_select_config_choice_inline("q1", "na", "la"), + xp_body_select_config_choice_inline("q1", "0", "lb"), + xp_body_select_search_appearance("q2"), + xp_body_select_config_choice_inline("q2", "na", "la"), + xp_body_select_config_choice_inline("q2", "0", "lb"), + ], + xml__xpath_count=[ + (xpq.model_instance_exists("s1"), 0), + ], + ) + + def test_pulldata_same_file__constraint(self): + """Should generate pulldata instance, and search elements in the body only.""" + md = """ + | survey | | | | | | + | | type | name | label | appearance | constraint | + | | select_one s1 | q1 | Q1 | search('my_file') | pulldata('my_file', 'this', 'that', ${q1}) | + | choices | | | | + | | list_name | name | label | + | | s1 | na | la | + """ + self.assertPyxformXform( + md=md, + run_odk_validate=self.run_odk_validate, + xml__xpath_match=[ + xp_model_instance_with_csv_src_no_items("my_file"), + xpq.model_instance_bind_attr( + "q1", + "constraint", + "pulldata('my_file', 'this', 'that', /test_name/q1 )", + ), + xp_body_select_search_appearance("q1"), + xp_body_select_config_choice_inline("q1", "na", "la"), + ], + xml__xpath_count=[ + (xpq.model_instance_exists("s1"), 0), + ], + ) + + def test_xmlexternal_same_file(self): + """Should generate xml-external instance, and search elements in the body only.""" + md = """ + | survey | | | | | + | | type | name | label | appearance | + | | select_one s1 | q1 | Q1 | search('my_file') | + | | xml-external | my_file | | | + | choices | | | | + | | list_name | name | label | + | | s1 | na | la | + """ + self.assertPyxformXform( + md=md, + run_odk_validate=self.run_odk_validate, + xml__xpath_match=[ + """ + /h:html/h:head/x:model/x:instance[ + @id='my_file' and @src='jr://file/my_file.xml' and not(./x:root/x:item) + ] + """, + xp_body_select_search_appearance("q1"), + xp_body_select_config_choice_inline("q1", "na", "la"), + ], + xml__xpath_count=[ + (xpq.model_instance_exists("s1"), 0), + ], + ) + + def test_csvexternal_same_file(self): + """Should generate csv-external instance, and search elements in the body only.""" + md = """ + | survey | | | | | + | | type | name | label | appearance | + | | select_one s1 | q1 | Q1 | search('my_file') | + | | csv-external | my_file | | | + | choices | | | | + | | list_name | name | label | + | | s1 | na | la | + """ + self.assertPyxformXform( + md=md, + run_odk_validate=self.run_odk_validate, + xml__xpath_match=[ + xp_model_instance_with_csv_src_no_items("my_file"), + xp_body_select_search_appearance("q1"), + xp_body_select_config_choice_inline("q1", "na", "la"), + ], + xml__xpath_count=[ + (xpq.model_instance_exists("s1"), 0), + ], + ) + + def test_select_from_file__search_on_different_question(self): + """Should generate select-from-file instance, and search elements in the body only.""" + md = """ + | survey | | | | | + | | type | name | label | appearance | + | | select_one s1 | q1 | Q1 | search('my_file') | + | | select_one_from_file my_file.csv | q2 | Q2 | | + | choices | | | | + | | list_name | name | label | + | | s1 | na | la | + """ + self.assertPyxformXform( + md=md, + run_odk_validate=self.run_odk_validate, + xml__xpath_match=[ + xp_model_instance_with_csv_src_no_items("my_file"), + xp_body_select_search_appearance("q1"), + xp_body_select_config_choice_inline("q1", "na", "la"), + xpq.body_select1_itemset("q2"), + ], + xml__xpath_count=[ + (xpq.model_instance_exists("s1"), 0), + ], + ) + + def test_select_from_file__search_on_same_question(self): + """Should raise an error, since this combination is not supported.""" + md = """ + | survey | | | | | + | | type | name | label | appearance | + | | select_one_from_file my_file.csv | q1 | Q1 | search('my_file') | + | choices | | | | + | | list_name | name | label | + | | my_file | na | la | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ + "Question 'q1' is a select from file type, using 'search()'." + ], + ) diff --git a/tests/test_settings.py b/tests/test_settings.py index 6f28532b2..86fd70f99 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,12 +1,228 @@ from tests.pyxform_test_case import PyxformTestCase +from tests.xpath_helpers.choices import xpc +from tests.xpath_helpers.questions import xpq class TestSettings(PyxformTestCase): - def test_instance_xmlns__is_set__custom_xmlns(self): + """ + Test form settings. + + Use the documented setting name, even if it's an alias. + """ + + # + def test_form_title(self): + """Should find the title set in the XForm.""" + md = """ + | settings | + | | form_title | + | | My Form | + | survey | | | | + | | type | name | label | + | | text | q1 | hello | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=["/h:html/h:head/h:title[.='My Form']"], + ) + + def test_form_id(self): + """Should find the instance id set in the XForm.""" + md = """ + | settings | + | | form_id | + | | my_form | + | survey | | | | + | | type | name | label | + | | text | q1 | hello | + """ + self.assertPyxformXform( + md=md, + debug=True, + xml__xpath_match=[ + "/h:html/h:head/x:model/x:instance/x:test_name[@id='my_form']" + ], + ) + + def test_clean_text_values__yes(self): + """Should find clean_text_values=yes (default) collapses survey sheet whitespace.""" + md = """ + | survey | | | | | + | | type | name | label | calculation | + | | integer | q1 | Q1 | string-length('abc def') | + | | select_one c1 | q2 | Q2 | | + | | select_multiple c2 | q3 | Q3 | | + | choices | + | | list_name | name | label | + | | c1 | a b | c 1 | + | | c2 | b | c 2 | + | settings | | + | | clean_text_values | + | | yes | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpq.model_instance_bind_attr( + "q1", + "calculate", + "string-length('abc def')", + ), + xpc.model_instance_choices_label("c1", (("a b", "c 1"),)), + xpc.model_instance_choices_label("c2", (("b", "c 2"),)), + ], + ) + + def test_clean_text_values__no(self): + """Should find clean_text_values=no leaves survey sheet whitespace as-is.""" + md = """ + | survey | | | | | + | | type | name | label | calculation | + | | integer | q1 | Q1 | string-length('abc def') | + | | select_one c1 | q2 | Q2 | | + | | select_multiple c2 | q3 | Q3 | | + | choices | + | | list_name | name | label | + | | c1 | a b | c 1 | + | | c2 | b | c 2 | + | settings | | + | | clean_text_values | + | | no | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpq.model_instance_bind_attr( + "q1", + "calculate", + "string-length('abc def')", + ), + xpc.model_instance_choices_label("c1", (("a b", "c 1"),)), + xpc.model_instance_choices_label("c2", (("b", "c 2"),)), + ], + ) + + +class TestNamespaces(PyxformTestCase): + """ + Test namespaces, for the XForm and in relation to settings that can be namespaced. + """ + + def test_standard_namespaces(self): + """Should find the standard namespaces in the XForm output.""" + md = """ + | survey | | | | + | | type | name | label | + | | note | q | Q | + """ + # re: https://github.com/XLSForm/pyxform/issues/14 + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + "/h:html[namespace::*[name()='']='http://www.w3.org/2002/xforms']", + "/h:html[namespace::h='http://www.w3.org/1999/xhtml']", + "/h:html[namespace::jr='http://openrosa.org/javarosa']", + "/h:html[namespace::orx='http://openrosa.org/xforms']", + "/h:html[namespace::xsd='http://www.w3.org/2001/XMLSchema']", + ], + ) + + def test_custom_xml_namespaces(self): + """Should find any custom namespaces in the XForm.""" + md = """ + | settings | | + | | namespaces | + | | esri="http://esri.com/xforms" enk="http://enketo.org/xforms" naf="http://nafundi.com/xforms" | + | survey | | | | + | | type | name | label | + | | note | q | Q | + """ + # re: https://github.com/XLSForm/pyxform/issues/65 + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + "/h:html[namespace::*[name()='']='http://www.w3.org/2002/xforms']", + "/h:html[namespace::h='http://www.w3.org/1999/xhtml']", + "/h:html[namespace::jr='http://openrosa.org/javarosa']", + "/h:html[namespace::orx='http://openrosa.org/xforms']", + "/h:html[namespace::xsd='http://www.w3.org/2001/XMLSchema']", + "/h:html[namespace::esri='http://esri.com/xforms']", + "/h:html[namespace::enk='http://enketo.org/xforms']", + "/h:html[namespace::naf='http://nafundi.com/xforms']", + ], + ) + + def test_custom_namespaced_instance_attribute(self): + md = """ + | settings | | + | | namespaces | + | | ex="http://example.com/xforms" | + | survey | | | | | + | | type | name | label | instance::ex:duration | + | | trigger | my_trigger | T1 | 10 | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + "/h:html[namespace::ex='http://example.com/xforms']", + """ + /h:html/h:head/x:model/x:instance/x:test_name/x:my_trigger/@*[ + local-name()='duration' + and namespace-uri()='http://example.com/xforms' + and .='10' + ] + """, + ], + ) + + def test_instance_xmlns__is_set__custom_namespace(self): """Should find the instance_xmlns value in the instance xmlns attribute.""" md = """ | settings | - | | instance_xmlns | + | | instance_xmlns | + | | http://example.com/xforms | + | survey | | | | + | | type | name | label | + | | text | q1 | hello | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + """ + /h:html/h:head/x:model/x:instance/*[ + namespace-uri()='http://example.com/xforms' + and local-name()='test_name' + and @id='test_id' + ] + """ + ], + ) + + def test_instance_xmlns__not_set__xforms_namespace(self): + """Should find the XForms namespace for the instance element.""" + md = """ + | survey | | | | + | | type | name | label | + | | text | q1 | hello | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + """ + /h:html/h:head/x:model/x:instance/*[ + namespace-uri()='http://www.w3.org/2002/xforms' + and local-name()='test_name' + and @id='test_id' + ] + """ + ], + ) + + def test_primary_instance_attribute__xforms_namespace(self): + """Should find the instance attribute in the default namespace.""" + md = """ + | settings | + | | attribute::xyz | | | 1234 | | survey | | | | | | type | name | label | @@ -16,18 +232,45 @@ def test_instance_xmlns__is_set__custom_xmlns(self): md=md, xml__xpath_match=[ """ - /h:html/h:head/x:model/x:instance/*[ - local-name()='test_name' - and @id='test_id' - and namespace-uri()='1234' - ] + /h:html/h:head/x:model/x:instance/x:test_name/@*[ + namespace-uri()='' + and local-name()='xyz' + and .='1234' + ] """ ], ) - def test_instance_xmlns__not_set__default_xmlns(self): - """Should find the default xmlns for the instance element.""" + def test_primary_instance_attribute__custom_namespace(self): + """Should find the instance attribute in the custom namespace.""" md = """ + | settings | + | | attribute::ex:xyz | namespaces | + | | 1234 | ex="http://example.com/xforms" | + | survey | | | | + | | type | name | label | + | | text | q1 | hello | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + "/h:html[namespace::ex='http://example.com/xforms']", + """ + /h:html/h:head/x:model/x:instance/x:test_name/@*[ + namespace-uri()='http://example.com/xforms' + and local-name()='xyz' + and .='1234' + ] + """, + ], + ) + + def test_primary_instance_attribute__multiple(self): + """Should find the multiple instance attributes in the default namespace.""" + md = """ + | settings | + | | attribute::xyz | attribute::abc | + | | 1234 | 5678 | | survey | | | | | | type | name | label | | | text | q1 | hello | @@ -36,11 +279,18 @@ def test_instance_xmlns__not_set__default_xmlns(self): md=md, xml__xpath_match=[ """ - /h:html/h:head/x:model/x:instance/*[ - local-name()='test_name' - and @id='test_id' - and namespace-uri()='http://www.w3.org/2002/xforms' - ] + /h:html/h:head/x:model/x:instance/x:test_name/@*[ + namespace-uri()='' + and local-name()='xyz' + and .='1234' + ] + """, """ + /h:html/h:head/x:model/x:instance/x:test_name/@*[ + namespace-uri()='' + and local-name()='abc' + and .='5678' + ] + """, ], ) diff --git a/tests/test_settings_xls.py b/tests/test_settings_xls.py deleted file mode 100644 index de52795ee..000000000 --- a/tests/test_settings_xls.py +++ /dev/null @@ -1,62 +0,0 @@ -""" -Test settings sheet syntax. -""" - -from unittest import TestCase - -from pyxform.builder import create_survey_from_path -from pyxform.xls2json import SurveyReader - -from tests import utils - - -class SettingsTests(TestCase): - maxDiff = None - - def setUp(self): - self.path = utils.path_to_text_fixture("settings.xls") - - def test_survey_reader(self): - survey_reader = SurveyReader(self.path, default_name="settings") - expected_dict = { - "id_string": "new_id", - "sms_keyword": "new_id", - "default_language": "default", - "name": "settings", - "title": "My Survey", - "type": "survey", - "attribute": { - "my_number": "1234567890", - "my_string": "lor\xe9m ipsum", - }, - "children": [ - { - "name": "your_name", - "label": {"english": "What is your name?"}, - "type": "text", - }, - { - "name": "your_age", - "label": {"english": "How many years old are you?"}, - "type": "integer", - }, - { - "children": [ - { - "bind": {"jr:preload": "uid", "readonly": "true()"}, - "name": "instanceID", - "type": "calculate", - } - ], - "control": {"bodyless": True}, - "name": "meta", - "type": "group", - }, - ], - } - self.assertEqual(survey_reader.to_json_dict(), expected_dict) - - def test_settings(self): - survey = create_survey_from_path(self.path) - self.assertEqual(survey.id_string, "new_id") - self.assertEqual(survey.title, "My Survey") diff --git a/tests/test_translations.py b/tests/test_translations.py index 7b3f2a561..4da396205 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -1555,223 +1555,6 @@ def test_choice_name_containing_dash_output_itext(self): ) -class TestTranslationsSearchAppearance(PyxformTestCase): - """ - Translations behaviour with the search() appearance. - - The search() appearance is a Collect-only feature, so ODK Validate is run for these - tests to try and ensure that the output will be accepted by Collect. In particular, - the search() appearance requires in-line (body) items for choices. - """ - - @classmethod - def setUpClass(cls) -> None: - cls.run_odk_validate = True - - @staticmethod - def xpath_body_select_search_appearance( - qname: str, appearance: str = "search('my_file')" - ) -> str: - return f""" - /h:html/h:body/x:select1[ - @ref='/test_name/{qname}' - and @appearance="{appearance}" - ] - """ - - @staticmethod - def xpath_body_select_config_choice(qname: str, cname: str, cvname: str) -> str: - return f""" - /h:html/h:body/x:select1[@ref='/test_name/{qname}']/x:item[ - ./x:value/text()='{cvname}' - and ./x:label[@ref="jr:itext('{cname}-0')"] - ] - """ - - def test_shared_choice_list(self): - """Should include translation for search() items, when sharing the choice list""" - md = """ - | survey | | | | | | - | | type | name | label::en | label::fr | appearance | - | | select_one c1 | q1 | Question 1 | Chose 1 | search('my_file') | - | | select_one c1 | q2 | Question 2 | Chose 2 | search('my_file', 'matches', 'filtercol', 'x1') | - | choices | | | | | - | | list_name | name | label::en | label::fr | - | | c1 | id | label_en | label_fr | - """ - self.assertPyxformXform( - md=md, - run_odk_validate=self.run_odk_validate, - xml__xpath_match=[ - self.xpath_body_select_search_appearance("q1"), - self.xpath_body_select_config_choice("q1", "c1", "id"), - self.xpath_body_select_search_appearance( - "q2", "search('my_file', 'matches', 'filtercol', 'x1')" - ), - self.xpath_body_select_config_choice("q2", "c1", "id"), - xpc.model_instance_choices_itext("c1", ("id",)), - xpc.model_itext_choice_text_label_by_pos("en", "c1", ("label_en",)), - xpc.model_itext_choice_text_label_by_pos("fr", "c1", ("label_fr",)), - ], - ) - - def test_usage_with_other_selects(self): - """Should include translation for search() items, when used with other selects""" - md = """ - | survey | | | | | | - | | type | name | label::en | label::fr | appearance | - | | select_one c1 | q1 | Question 1 | Chose 1 | search('my_file') | - | | select_one c2 | q2 | Question 2 | Chose 2 | | - | choices | | | | | - | | list_name | name | label::en | label::fr | - | | c1 | id | label_en | label_fr | - | | c2 | na | la-e | la-f | - | | c2 | nb | lb-e | lb-f | - """ - self.assertPyxformXform( - md=md, - run_odk_validate=self.run_odk_validate, - xml__xpath_match=[ - self.xpath_body_select_search_appearance("q1"), - self.xpath_body_select_config_choice("q1", "c1", "id"), - xpc.model_instance_choices_itext("c1", ("id",)), - xpc.model_itext_choice_text_label_by_pos("en", "c1", ("label_en",)), - xpc.model_itext_choice_text_label_by_pos("fr", "c1", ("label_fr",)), - xpc.model_instance_choices_itext("c2", ("na", "nb")), - xpc.model_itext_choice_text_label_by_pos("en", "c2", ("la-e", "lb-e")), - xpc.model_itext_choice_text_label_by_pos("fr", "c2", ("la-f", "lb-f")), - ], - ) - - def test_usage_with_other_selects__invalid_list_reuse_by_non_search_question(self): - """By design, q2 won't pull data but the test is to document output behaviour.""" - md = """ - | survey | | | | | | - | | type | name | label::en | label::fr | appearance | - | | select_one c1 | q1 | Question 1 | Chose 1 | search('my_file') | - | | select_one c1 | q2 | Question 2 | Chose 2 | | - | choices | | | | | - | | list_name | name | label::en | label::fr | - | | c1 | id | label_en | label_fr | - """ - self.assertPyxformXform( - md=md, - run_odk_validate=self.run_odk_validate, - xml__xpath_match=[ - self.xpath_body_select_search_appearance("q1"), - self.xpath_body_select_config_choice("q1", "c1", "id"), - xpq.body_select1_itemset("q2"), - xpc.model_instance_choices_itext("c1", ("id",)), - xpc.model_itext_choice_text_label_by_pos("en", "c1", ("label_en",)), - xpc.model_itext_choice_text_label_by_pos("fr", "c1", ("label_fr",)), - ], - ) - - def test_single_question_usage(self): - """Should include translation for search() items, edge case of single question""" - md = """ - | survey | | | | | | - | | type | name | label::en | label::fr | appearance | - | | select_one c1 | q1 | Question 1 | Chose 1 | search('my_file') | - | choices | | | | | - | | list_name | name | label::en | label::fr | - | | c1 | id | label_en | label_fr | - """ - self.assertPyxformXform( - md=md, - run_odk_validate=self.run_odk_validate, - xml__xpath_match=[ - self.xpath_body_select_search_appearance("q1"), - self.xpath_body_select_config_choice("q1", "c1", "id"), - xpc.model_instance_choices_itext("c1", ("id",)), - xpc.model_itext_choice_text_label_by_pos("en", "c1", ("label_en",)), - xpc.model_itext_choice_text_label_by_pos("fr", "c1", ("label_fr",)), - ], - ) - - def test_additional_static_choices(self): - """Should include translation for search() items, when adding static choices""" - md = """ - | survey | | | | | | - | | type | name | label::en | label::fr | appearance | - | | select_one c1 | q1 | Question 1 | Chose 1 | search('my_file') | - | choices | | | | | - | | list_name | name | label::en | label::fr | - | | c1 | id | label_en | label_fr | - | | c1 | 0 | l0-e | l0-f | - | | c1 | 1 | l1-e | l1-f | - """ - self.assertPyxformXform( - md=md, - run_odk_validate=self.run_odk_validate, - xml__xpath_match=[ - self.xpath_body_select_search_appearance("q1"), - self.xpath_body_select_config_choice("q1", "c1", "id"), - xpc.model_instance_choices_itext("c1", ("id", "0", "1")), - xpc.model_itext_choice_text_label_by_pos( - "en", "c1", ("label_en", "l0-e", "l1-e") - ), - xpc.model_itext_choice_text_label_by_pos( - "fr", "c1", ("label_fr", "l0-f", "l1-f") - ), - ], - ) - - def test_name_clashes(self): - """Should include translation for search() items, avoids any name clashes.""" - md = """ - | survey | | | | | | - | | type | name | label::en | label::fr | appearance | - | | select_one c1-0 | c1-0 | Question 1 | Chose 1 | search('my_file') | - | choices | | | | | - | | list_name | name | label::en | label::fr | - | | c1-0 | id | label_en | label_fr | - """ - self.assertPyxformXform( - md=md, - run_odk_validate=self.run_odk_validate, - xml__xpath_match=[ - self.xpath_body_select_search_appearance("c1-0"), - self.xpath_body_select_config_choice("c1-0", "c1-0", "id"), - xpc.model_instance_choices_itext("c1-0", ("id",)), - xpc.model_itext_choice_text_label_by_pos("en", "c1-0", ("label_en",)), - xpc.model_itext_choice_text_label_by_pos("fr", "c1-0", ("label_fr",)), - ], - ) - - def test_search_and_select_xlsx(self): - """Test to replace the old XLSX-based test fixture, 'search_and_select.xlsx'""" - md = """ - | survey | | | | | - | | type | name | label | appearance | - | | select_one fruits | fruit | Choose a fruit | search('fruits') | - | | note | note_fruit | The fruit ${fruit} pulled from csv | | - | choices | | | | - | | list_name | name | label | - | | fruits | name_key | name | - """ - self.assertPyxformXform( - md=md, - run_odk_validate=self.run_odk_validate, - xml__xpath_match=[ - "/h:html/h:head/x:model[not(descendant::x:itext)]", - xpc.model_instance_choices_label("fruits", (("name_key", "name"),)), - """ - /h:html/h:body/x:select1[ - @ref='/test_name/fruit' - and @appearance="search('fruits')" - ]/x:item[ - ./x:value/text()='name_key' - and ./x:label/text()='name' - ] - """, - xpq.model_instance_bind("fruit", "string"), - xpq.model_instance_bind("note_fruit", "string"), - "/h:html/h:body/x:input[@ref='/test_name/note_fruit']", - ], - ) - - class TestTranslationsOrOther(PyxformTestCase): """Translations behaviour with or_other.""" diff --git a/tests/test_xlsform_spec.py b/tests/test_xlsform_spec.py index 4f76f2880..c3442fae2 100644 --- a/tests/test_xlsform_spec.py +++ b/tests/test_xlsform_spec.py @@ -56,8 +56,6 @@ def test_warnings__count(self): """ warnings = [] self.assertPyxformXform( - debug=True, - name="spec_test", md=md, warnings=warnings, ) diff --git a/tests/test_xml_structure.py b/tests/test_xml_structure.py deleted file mode 100644 index 6be5e6442..000000000 --- a/tests/test_xml_structure.py +++ /dev/null @@ -1,24 +0,0 @@ -""" -Test XForm structure. -""" -from tests.pyxform_test_case import PyxformTestCase - - -class XmlStructureTest(PyxformTestCase): - def test_xml_structure(self): - # re: https://github.com/XLSForm/pyxform/issues/14 - self.assertPyxformXform( - name="testxmlstructure", - md=""" - | survey | | | | - | | type | name | label | - | | note | q | Q | - """, - xml__contains=[ - 'xmlns="http://www.w3.org/2002/xforms"', - 'xmlns:h="http://www.w3.org/1999/xhtml"', - 'xmlns:jr="http://openrosa.org/javarosa"', - 'xmlns:orx="http://openrosa.org/xforms"', - 'xmlns:xsd="http://www.w3.org/2001/XMLSchema"', - ], - ) diff --git a/tests/xpath_helpers/questions.py b/tests/xpath_helpers/questions.py index 95d31ba26..911f09448 100644 --- a/tests/xpath_helpers/questions.py +++ b/tests/xpath_helpers/questions.py @@ -3,6 +3,13 @@ class XPathHelper: XPath expressions for questions assertions. """ + @staticmethod + def model_instance_exists(i_id: str): + """Model instance with the given instance id exists.""" + return rf""" + /h:html/h:head/x:model[./x:instance[@id='{i_id}']] + """ + @staticmethod def model_instance_item(q_name: str): """Model instance contains the question item.""" @@ -20,6 +27,16 @@ def model_instance_bind(q_name: str, _type: str): ] """ + @staticmethod + def model_instance_bind_attr(qname: str, key: str, value: str) -> str: + """Model instance contains the question item and given key/value.""" + return f""" + /h:html/h:head/x:model/x:bind[ + @nodeset='/test_name/{qname}' + and @{key}="{value}" + ] + """ + @staticmethod def model_itext_label(q_name: str, lang: str, q_label: str): """Model itext contains the question label."""