diff --git a/pyxform/builder.py b/pyxform/builder.py index e802195d..6a8b2c26 100644 --- a/pyxform/builder.py +++ b/pyxform/builder.py @@ -13,6 +13,7 @@ from pyxform.errors import ErrorCode, PyXFormError from pyxform.external_instance import ExternalInstance from pyxform.question import ( + GeoQuestion, InputQuestion, MultipleChoiceQuestion, Option, @@ -39,6 +40,11 @@ "select1": MultipleChoiceQuestion, "trigger": TriggerQuestion, "upload": UploadQuestion, + "__by_type__": { + "geopoint": GeoQuestion, + "geoshape": GeoQuestion, + "geotrace": GeoQuestion, + }, } SECTION_CLASSES = { const.GROUP: GroupedSection, @@ -104,7 +110,7 @@ def create_survey_element_from_dict( d = self._sections[section_name] full_survey = self.create_survey_element_from_dict(d=d, choices=choices) return full_survey.children - elif d[const.TYPE] in {"xml-external", "csv-external"}: + elif d[const.TYPE] in const.EXTERNAL_INSTANCE_TYPES: return ExternalInstance(**d) elif d[const.TYPE] == "entity": return EntityDeclaration(**d) @@ -157,7 +163,9 @@ def _create_question_from_dict( if question_class: if choices: - d_choices = d.get(const.CHOICES, d.get(const.CHILDREN)) + d_choices = choices.get( + d.get(const.ITEMSET), d.get(const.CHOICES, d.get(const.CHILDREN)) + ) if d_choices: return question_class( question_type_dictionary=question_type_dictionary, @@ -166,7 +174,7 @@ def _create_question_from_dict( for k, v in d.items() if k not in {const.CHOICES, const.CHILDREN} }, - choices=choices.get(d[const.ITEMSET], d_choices), + choices=d_choices, ) return question_class(question_type_dictionary=question_type_dictionary, **d) @@ -196,6 +204,10 @@ def _get_question_class(question_type_str, question_type_dictionary): and find what class it maps to going through type_dictionary -> QUESTION_CLASSES """ + by_type = QUESTION_CLASSES["__by_type__"].get(question_type_str, None) + if by_type is not None: + return by_type + control_tag = "" question_type = question_type_dictionary.get(question_type_str) if question_type: diff --git a/pyxform/constants.py b/pyxform/constants.py index 67e11597..722f11ba 100644 --- a/pyxform/constants.py +++ b/pyxform/constants.py @@ -136,6 +136,7 @@ class EntityColumns(StrEnum): AUDIO_QUALITY_EXTERNAL = "external" EXTERNAL_INSTANCE_EXTENSIONS = {".xml", ".csv", ".geojson"} +EXTERNAL_INSTANCE_TYPES = {"csv-external", "xml-external"} DEFAULT_ITEMSET_LABEL_REF = "label" DEFAULT_ITEMSET_VALUE_REF = "name" @@ -194,11 +195,13 @@ class ParametersAudit(StrEnum): class ParametersGeo(StrEnum): ALLOW_MOCK_ACCURACY = "allow-mock-accuracy" INCREMENTAL = "incremental" + REFERENCE_GEOMETRY = "reference-geometry" class ParametersGeoPoint(StrEnum): ALLOW_MOCK_ACCURACY = "allow-mock-accuracy" CAPTURE_ACCURACY = "capture-accuracy" + REFERENCE_GEOMETRY = "reference-geometry" WARNING_ACCURACY = "warning-accuracy" diff --git a/pyxform/errors.py b/pyxform/errors.py index 74cdb777..d02cb4bb 100644 --- a/pyxform/errors.py +++ b/pyxform/errors.py @@ -521,6 +521,36 @@ class ErrorCode(Enum): "The following are invalid parameter key(s): '{rejected}'." ), ) + SURVEY_006: Detail = Detail( + name="Survey sheet - invalid geo parameter 'reference-geometry'", + msg=( + "[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. " + "The 'reference-geometry' parameter must either match a secondary instance " + "name (a choices list_name, an entities list_name, or the base name of an " + "attached file), or be a reference variable containing the name of a repeat." + ), + ) + SURVEY_007: Detail = Detail( + name="Survey sheet - invalid geo parameter 'capture-accuracy'", + msg=( + "[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. " + "The 'capture-accuracy' parameter must have a numeric value." + ), + ) + SURVEY_008: Detail = Detail( + name="Survey sheet - invalid geo parameter 'warning-accuracy'", + msg=( + "[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. " + "The 'warning-accuracy' parameter must have a numeric value." + ), + ) + SURVEY_009: Detail = Detail( + name="Survey sheet - invalid geo parameter 'allow-mock-accuracy'", + msg=( + "[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. " + "The 'allow-mock-accuracy' parameter must be either 'true' or 'false'." + ), + ) class PyXFormError(Exception): diff --git a/pyxform/question.py b/pyxform/question.py index 9e6187e3..b69dc54b 100644 --- a/pyxform/question.py +++ b/pyxform/question.py @@ -22,11 +22,14 @@ from pyxform.survey_element import SURVEY_ELEMENT_FIELDS, SurveyElement from pyxform.utils import ( DetachableElement, + coalesce, combine_lists, default_is_dynamic, node, ) -from pyxform.validators.pyxform.pyxform_reference import has_pyxform_reference +from pyxform.validators.pyxform.pyxform_reference import ( + has_pyxform_reference, +) if TYPE_CHECKING: from pyxform.survey import Survey @@ -53,15 +56,18 @@ ) QUESTION_FIELDS = (*SURVEY_ELEMENT_FIELDS, *QUESTION_EXTRA_FIELDS) -ITEM_QUESTION_EXTRA_FIELDS = ( +ITEMSET_QUESTION_EXTRA_FIELDS = ( + "_itemset_has_ref", constants.CHOICES, constants.ITEMSET, - constants.LIST_NAME_U, ) -SELECT_QUESTION_FIELDS = (*QUESTION_FIELDS, *ITEM_QUESTION_EXTRA_FIELDS) +ITEMSET_QUESTION_FIELDS = (*QUESTION_FIELDS, *ITEMSET_QUESTION_EXTRA_FIELDS) + +SELECT_QUESTION_EXTRA_FIELDS = (constants.LIST_NAME_U,) +SELECT_QUESTION_FIELDS = (*ITEMSET_QUESTION_FIELDS, *SELECT_QUESTION_EXTRA_FIELDS) OSM_QUESTION_EXTRA_FIELDS = (constants.CHILDREN,) -OSM_QUESTION_FIELDS = (*QUESTION_FIELDS, *ITEM_QUESTION_EXTRA_FIELDS) +OSM_QUESTION_FIELDS = (*QUESTION_FIELDS, *ITEMSET_QUESTION_EXTRA_FIELDS) OPTION_EXTRA_FIELDS = ( "_choice_itext_ref", @@ -353,29 +359,205 @@ def get_options(self, choices: Iterable[dict]) -> Generator[Option, None, None]: self.requires_itext = requires_itext -class MultipleChoiceQuestion(Question): - __slots__ = ITEM_QUESTION_EXTRA_FIELDS +class ItemsetNode: + nodeset_template: str = "instance('{}')/root/item" + + def __bool__(self): + return True + + def __init__( + self, + value_ref: str | None = None, + label_ref: str | None = None, + nodeset: str | None = None, + choice_filter: str | None = None, + ): + self.value_ref: str | None = value_ref + self.label_ref: str | None = label_ref + self.nodeset: str | None = nodeset + self.choice_filter: str | None = choice_filter + + def node(self): + return node( + constants.ITEMSET, + node("value", ref=self.value_ref), + node("label", ref=self.label_ref), + nodeset=self.nodeset, + ) + + +class _SupportsItemset(Question): + __slots__ = ITEMSET_QUESTION_EXTRA_FIELDS @staticmethod def get_slot_names() -> tuple[str, ...]: - return SELECT_QUESTION_FIELDS + return ITEMSET_QUESTION_FIELDS def __init__( - self, itemset: str | None = None, list_name: str | None = None, **kwargs + self, + choices: Itemset | None = None, + itemset: str | None = None, + fields: tuple[str, ...] | None = None, + **kwargs, ): - if not itemset and not list_name: + # Internals + self._itemset_has_ref: bool | None = None + + # Structure + self.choices: Itemset | None = choices + self.itemset: str | None = itemset + + if fields is None: + fields = ITEMSET_QUESTION_EXTRA_FIELDS + else: + fields = chain(ITEMSET_QUESTION_EXTRA_FIELDS, fields) + super().__init__(fields=fields, **kwargs) + + @property + def itemset_has_ref(self) -> bool: + if self._itemset_has_ref is None: + self._itemset_has_ref = has_pyxform_reference(value=self.itemset) + return self._itemset_has_ref + + def _build_itemset_instance(self, choices: Itemset | None) -> ItemsetNode: + """Build a default itemset node from referencing an internal instance.""" + label_ref = DEFAULT_ITEMSET_LABEL_REF + if choices and choices.requires_itext: + label_ref = "jr:itext(itextId)" + return ItemsetNode( + value_ref=DEFAULT_ITEMSET_VALUE_REF, + label_ref=label_ref, + nodeset=ItemsetNode.nodeset_template.format(self.itemset), + ) + + def _build_itemset_file( + self, question: "Question", allowed: set[str] + ) -> ItemsetNode | None: + """Try to build an itemset node referencing a file instance.""" + itemset_base, file_ext = os.path.splitext(self.itemset) + if file_ext in allowed: + if file_ext == ".geojson": + value_ref = EXTERNAL_CHOICES_ITEMSET_REF_VALUE_GEOJSON + label_ref = EXTERNAL_CHOICES_ITEMSET_REF_LABEL_GEOJSON + else: + value_ref = DEFAULT_ITEMSET_VALUE_REF + label_ref = DEFAULT_ITEMSET_LABEL_REF + + params = question.parameters + if params is not None: + value_ref = params.get( + constants.ParametersSelectFromFile.VALUE, value_ref + ) + label_ref = params.get( + constants.ParametersSelectFromFile.LABEL, label_ref + ) + + return ItemsetNode( + value_ref=value_ref, + label_ref=label_ref, + nodeset=ItemsetNode.nodeset_template.format(itemset_base), + ) + + def _build_itemset_reference( + self, question: "Question", survey: "Survey" + ) -> ItemsetNode | None: + """Try to build an itemset node using a pyxform reference.""" + if self.itemset_has_ref: + path = ( + survey.insert_xpaths( + text=self.itemset, context=question, reference_parent=True + ) + .strip() + .split("/") + ) + return ItemsetNode( + value_ref=path[-1], + label_ref=path[-1], + nodeset="/".join(path[:-1]), + choice_filter=f"./{path[-1]} != ''", + ) + + def _process_choice_filter( + self, itemset_node: ItemsetNode, question: "Question", survey: "Survey" + ) -> None: + """Attach a choice_filter, if any, to the existing nodeset.""" + # Resolve and process the user-specified choice filter. + if question.choice_filter: + choice_filter = survey.insert_xpaths( + text=question.choice_filter, + context=question, + use_current=True, + reference_parent=self.itemset_has_ref, + ) + if self.itemset_has_ref: + choice_filter = choice_filter.replace( + f"current()/{itemset_node.nodeset}", "." + ).replace(itemset_node.nodeset, ".") + + itemset_node.choice_filter = choice_filter + + # Append the user-specified or generated choice_filter. + if itemset_node.choice_filter: + itemset_node.nodeset += f"[{itemset_node.choice_filter}]" + + @staticmethod + def _process_parameter_randomize( + itemset_node: ItemsetNode, question: "Question", survey: "Survey" + ): + """Wrap the existing nodeset in a randomize() call, optionally with a seed.""" + params = question.parameters + if ( + params + and constants.ParametersSelect.RANDOMIZE in params + and params[constants.ParametersSelect.RANDOMIZE] == "true" + ): + if constants.ParametersSelect.SEED in params: + seed = maybe_strip( + survey.insert_xpaths( + text=params[constants.ParametersSelect.SEED], context=question + ) + ) + itemset_node.nodeset = f"randomize({itemset_node.nodeset}, {seed})" + else: + itemset_node.nodeset = f"randomize({itemset_node.nodeset})" + + def build_itemset( + self, choices: Itemset, survey: "Survey" + ) -> "DetachableElement | None": + itemset_node = coalesce( + self._build_itemset_file(question=self, allowed=EXTERNAL_INSTANCE_EXTENSIONS), + self._build_itemset_reference(question=self, survey=survey), + self._build_itemset_instance(choices=choices), + ) + + self._process_choice_filter( + itemset_node=itemset_node, question=self, survey=survey + ) + self._process_parameter_randomize( + itemset_node=itemset_node, question=self, survey=survey + ) + return itemset_node.node() + + +class MultipleChoiceQuestion(_SupportsItemset): + __slots__ = SELECT_QUESTION_EXTRA_FIELDS + + @staticmethod + def get_slot_names() -> tuple[str, ...]: + return SELECT_QUESTION_FIELDS + + def __init__(self, list_name: str | None = None, **kwargs): + if not kwargs.get(constants.ITEMSET, None) and not list_name: raise PyXFormError( "Arguments 'itemset' and 'list_name' must not both be None or empty." ) # Structure - self.choices: Itemset | None = None - self.itemset: str | None = itemset self.list_name: str | None = list_name - choices = kwargs.pop(constants.CHOICES, None) - if isinstance(choices, Itemset): - self.choices = choices + choices = kwargs.get(constants.CHOICES, None) + if not isinstance(choices, Itemset): + kwargs.pop(constants.CHOICES, None) super().__init__(**kwargs) def build_xml(self, survey: "Survey"): @@ -392,93 +574,9 @@ def build_xml(self, survey: "Survey"): # itemset are only supposed to be strings, # check to prevent the rare dicts that show up if self.itemset and isinstance(self.itemset, str): - itemset, file_extension = os.path.splitext(self.itemset) - - if file_extension == ".geojson": - itemset_value_ref = EXTERNAL_CHOICES_ITEMSET_REF_VALUE_GEOJSON - itemset_label_ref = EXTERNAL_CHOICES_ITEMSET_REF_LABEL_GEOJSON - else: - itemset_value_ref = DEFAULT_ITEMSET_VALUE_REF - itemset_label_ref = DEFAULT_ITEMSET_LABEL_REF - if self.parameters is not None: - itemset_value_ref = self.parameters.get( - constants.ParametersSelectFromFile.VALUE, itemset_value_ref - ) - itemset_label_ref = self.parameters.get( - constants.ParametersSelectFromFile.LABEL, itemset_label_ref - ) - - is_previous_question = has_pyxform_reference(self.itemset) - - if file_extension in EXTERNAL_INSTANCE_EXTENSIONS: - pass - elif choices and choices.requires_itext: - itemset = self.itemset - itemset_label_ref = "jr:itext(itextId)" - else: - itemset = self.itemset - - choice_filter = self.choice_filter - if choice_filter: - choice_filter = survey.insert_xpaths( - text=choice_filter, - context=self, - use_current=True, - reference_parent=is_previous_question, - ) - if is_previous_question: - path = ( - survey.insert_xpaths( - text=self.itemset, context=self, reference_parent=True - ) - .strip() - .split("/") - ) - nodeset = "/".join(path[:-1]) - itemset_value_ref = path[-1] - itemset_label_ref = path[-1] - if choice_filter: - choice_filter = choice_filter.replace( - f"current()/{nodeset}", "." - ).replace(nodeset, ".") - else: - # Choices must have a value. Filter out repeat instances without - # an answer for the linked question - name = path[-1] - choice_filter = f"./{name} != ''" - else: - nodeset = f"instance('{itemset}')/root/item" - - if choice_filter: - nodeset += f"[{choice_filter}]" - - if self.parameters: - params = self.parameters - - if ( - constants.ParametersSelect.RANDOMIZE in params - and params[constants.ParametersSelect.RANDOMIZE] == "true" - ): - nodeset = f"randomize({nodeset}" - - if constants.ParametersSelect.SEED in params: - seed = maybe_strip( - survey.insert_xpaths( - text=params[constants.ParametersSelect.SEED], context=self - ) - ) - nodeset = f"{nodeset}, {seed}" - - nodeset += ")" - - result.appendChild( - node( - "itemset", - node("value", ref=itemset_value_ref), - node("label", ref=itemset_label_ref), - nodeset=nodeset, - ) - ) + itemset_node = self.build_itemset(choices=choices, survey=survey) + if itemset_node: + result.appendChild(itemset_node) elif choices: # Options processing specific to XLSForms using the "search()" function. # The _choice_itext_ref is prepared by Survey._redirect_is_search_itext. @@ -557,44 +655,25 @@ def build_xml(self, survey: "Survey"): return result -class RangeQuestion(Question): - __slots__ = ITEM_QUESTION_EXTRA_FIELDS +class RangeQuestion(_SupportsItemset): + def build_xml(self, survey: "Survey"): + result = self._build_xml(survey=survey) - def __init__( - self, itemset: str | None = None, list_name: str | None = None, **kwargs - ): - self.itemset: str | None = itemset - self.list_name: str | None = list_name + if self.itemset: + itemset_node = self._build_itemset_instance(choices=self.choices) + if itemset_node: + result.appendChild(itemset_node.node()) - super().__init__(**kwargs) + return result - def build_xml(self, survey: "Survey"): - if self.bind["type"] not in {"int", "decimal"}: - raise PyXFormError( - f"""Invalid value for `self.bind["type"]`: {self.bind["type"]}""" - ) +class GeoQuestion(_SupportsItemset): + def build_xml(self, survey: "Survey"): result = self._build_xml(survey=survey) - params = self.parameters - if params: - for k, v in params.items(): - result.setAttribute(k, v) - - if survey.choices and self.itemset: - if survey.choices.get(self.itemset, None).requires_itext: - itemset_label_ref = "jr:itext(itextId)" - else: - itemset_label_ref = DEFAULT_ITEMSET_LABEL_REF - - nodeset = f"instance('{self.itemset}')/root/item" - result.appendChild( - node( - "itemset", - node("value", ref=DEFAULT_ITEMSET_VALUE_REF), - node("label", ref=itemset_label_ref), - nodeset=nodeset, - ) - ) + if self.itemset: + itemset_node = self.build_itemset(choices=self.choices, survey=survey) + if itemset_node: + result.appendChild(itemset_node) return result diff --git a/pyxform/validators/pyxform/choices.py b/pyxform/validators/pyxform/choices.py index 61c29495..4ecf5c04 100644 --- a/pyxform/validators/pyxform/choices.py +++ b/pyxform/validators/pyxform/choices.py @@ -1,4 +1,4 @@ -from pyxform import constants +from pyxform import constants as co from pyxform.errors import ErrorCode, PyXFormError @@ -8,7 +8,7 @@ def validate_headers( def check(): for header in headers: header = header[0] - if header != constants.LIST_NAME_S and (" " in header or header == ""): + if header != co.LIST_NAME_S and (" " in header or header == ""): warnings.append(ErrorCode.HEADER_004.value.format(column=header)) yield header @@ -21,13 +21,13 @@ def validate_choice_list( seen_options = set() duplicate_errors = [] for option in options: - if constants.NAME not in option: + if co.NAME not in option: raise PyXFormError(ErrorCode.NAMES_006.value.format(row=option["__row"])) - elif constants.LABEL not in option: + elif co.LABEL not in option: warnings.append(ErrorCode.LABEL_001.value.format(row=option["__row"])) if not allow_duplicates: - name = option[constants.NAME] + name = option[co.NAME] if name in seen_options: duplicate_errors.append( ErrorCode.NAMES_007.value.format(row=option["__row"]) diff --git a/pyxform/validators/pyxform/parameters.py b/pyxform/validators/pyxform/parameters.py index 227343c5..5bae8dac 100644 --- a/pyxform/validators/pyxform/parameters.py +++ b/pyxform/validators/pyxform/parameters.py @@ -10,7 +10,7 @@ def validate( parameters: PARAMETERS_TYPE, accepted: type[StrEnum], row_number: int, -) -> dict[str, str]: +) -> None: """ Raise an error if 'parameters' includes any keys not named in 'accepted'. """ @@ -23,4 +23,3 @@ def validate( rejected=", ".join(sorted(extras)), ) ) - return parameters diff --git a/pyxform/validators/pyxform/question_types/__init__.py b/pyxform/validators/pyxform/question_types/__init__.py new file mode 100644 index 00000000..8e24eca4 --- /dev/null +++ b/pyxform/validators/pyxform/question_types/__init__.py @@ -0,0 +1,92 @@ +""" +Validations for question types. +""" + +from collections.abc import Collection, Iterable + +from pyxform.errors import ErrorCode, PyXFormError +from pyxform.validators.pyxform.pyxform_reference import ( + is_pyxform_reference_candidate, + parse_pyxform_references, +) + +BACKGROUND_GEOPOINT_CALCULATION = ( + "[row : {r}] For 'background-geopoint' questions, " + "the 'calculation' column must be empty." +) +BACKGROUND_GEOPOINT_TRIGGER = ( + "[row : {r}] For 'background-geopoint' questions, " + "the 'trigger' column must not be empty." +) + + +def validate_background_geopoint_calculation(row: dict, row_num: int) -> bool: + """A background-geopoint must not have a calculation.""" + try: + row["bind"]["calculate"] + except KeyError: + return True + else: + raise PyXFormError(BACKGROUND_GEOPOINT_CALCULATION.format(r=row_num)) + + +def validate_background_geopoint_trigger(trigger: str | None, row_num: int) -> bool: + """A background-geopoint must have a trigger.""" + if not trigger: + raise PyXFormError(BACKGROUND_GEOPOINT_TRIGGER.format(r=row_num)) + return True + + +def validate_references( + referrers: Iterable[Iterable[str | None, int]], questions: Collection[str] +) -> bool: + """Pyxform references must refer to a question that exists.""" + for target, row_num in referrers: + if target not in questions: + raise PyXFormError( + code=ErrorCode.PYREF_003, context={"q": target, "row": row_num} + ) + return True + + +def process_trigger( + trigger: str | None, row_num: int, trigger_references: list[tuple[str, int]] +) -> tuple[str, ...] | None: + """ + Try to parse the content of the "trigger" column into a list of question names. + + A trigger may contain one pyxform reference, or multiple comma-separated references. + If a trigger is found, it will be added to trigger_references. + + :param trigger: The trigger data. + :param row_num: The current row number. + :param trigger_references: Which questions are being referred to by which row. + """ + if not trigger: + return None + elif not is_pyxform_reference_candidate(trigger): + raise PyXFormError( + code=ErrorCode.PYREF_001, + context={ + "sheet": "survey", + "column": "trigger", + "row": row_num, + "q": trigger, + }, + ) + + try: + trigger = tuple( + r.name + for t in trigger.split(",") + for r in parse_pyxform_references(value=t, match_limit=1) + ) + except PyXFormError as e: + e.context.update(sheet="survey", column="trigger", row=row_num) + raise + + if trigger: + trigger_references.extend((t, row_num) for t in trigger) + return trigger + else: + return None diff --git a/pyxform/validators/pyxform/question_types/geo.py b/pyxform/validators/pyxform/question_types/geo.py new file mode 100644 index 00000000..c1cf3bd7 --- /dev/null +++ b/pyxform/validators/pyxform/question_types/geo.py @@ -0,0 +1,146 @@ +from collections.abc import Iterable +from typing import Any + +from pyxform import aliases +from pyxform import constants as co +from pyxform.errors import ErrorCode, PyXFormError +from pyxform.validators.pyxform import parameters as pv +from pyxform.validators.pyxform.parameters import PARAMETERS_TYPE +from pyxform.validators.pyxform.pyxform_reference import ( + is_pyxform_reference_candidate, + parse_pyxform_references, +) + + +def validate_parameter_incremental(value: str) -> None: + """For geoshape and geotrace, the 'incremental' parameter can only resolve to 'true'.""" + incremental = aliases.yes_no.get(value, None) + if incremental is None or not incremental: + raise PyXFormError(code=ErrorCode.SURVEY_003) + + +def validate_parameter_reference_geometry( + geo_references: Iterable[Iterable[str, int]], + secondary_instances: set[str], + repeat_names: set[str], + choices: dict[str, list[dict]], + entity_declarations: dict[str, dict[str, Any]] | None = None, +) -> None: + """ + Check that the reference-geometry name can be resolved to valid nodeset target. + + Does not support secondary instances that would be generated solely by: + - pulldata() calls + - search() calls + - last-saved usages in variables + + :param geo_references: Pairs of (target, source row_num) for reference_geometry usage. + :param secondary_instances: The names of valid secondary instances in the form. + :param repeat_names: Names of repeat groups in the form. + :param choices: The choices data as `{list_name: [choice_items[options], ...]}`. + :param entity_declarations: The entities data `{list_name: declaration]}`. + """ + for target, row_num in geo_references: + if ( + target in secondary_instances + or target in choices + or (entity_declarations and target in entity_declarations) + ): + continue + # Separated this part of the check since it requires slower parsing. + elif is_pyxform_reference_candidate(target): + try: + refs = parse_pyxform_references(target, match_limit=1, match_full=True) + if refs and refs[0].name in repeat_names: + continue + except PyXFormError as err: + raise PyXFormError( + code=ErrorCode.SURVEY_006, context={"row": row_num} + ) from err + + raise PyXFormError(code=ErrorCode.SURVEY_006, context={"row": row_num}) + + +def process_geo_question_type( + row_number: int, + row: dict[str, Any], + parameters: PARAMETERS_TYPE, + geo_references: list[tuple[str, int]], +) -> dict[str, Any]: + """ + Amend the row data with processed parameters or other logic specific to the question type. + + :param row_number: The row position in the 'survey' sheet. + :param row: The 'survey' sheet row data. + :param parameters: The parsed parameters from the row data. + :param geo_references: Pairs of (target, source row_num) for reference_geometry usage. + :return: The updated row. Also appends to geo_references. + """ + row[co.CONTROL] = row.get(co.CONTROL, {}) + question_type = row.get(co.TYPE) + + if question_type == "geopoint": + qt_params = co.ParametersGeoPoint + pv.validate( + parameters=parameters, + accepted=qt_params, + row_number=row_number, + ) + + if qt_params.CAPTURE_ACCURACY in parameters: + try: + float(parameters[qt_params.CAPTURE_ACCURACY]) + row[co.CONTROL]["accuracyThreshold"] = parameters[ + qt_params.CAPTURE_ACCURACY + ] + except ValueError as err: + raise PyXFormError( + code=ErrorCode.SURVEY_007, context={"row": row_number} + ) from err + + if qt_params.WARNING_ACCURACY in parameters: + try: + float(parameters[qt_params.WARNING_ACCURACY]) + row[co.CONTROL]["unacceptableAccuracyThreshold"] = parameters[ + qt_params.WARNING_ACCURACY + ] + except ValueError as err: + raise PyXFormError( + code=ErrorCode.SURVEY_008, context={"row": row_number} + ) from err + + else: + qt_params = co.ParametersGeo + pv.validate( + parameters=parameters, + accepted=qt_params, + row_number=row_number, + ) + if qt_params.INCREMENTAL in parameters: + try: + validate_parameter_incremental(value=parameters[qt_params.INCREMENTAL]) + except PyXFormError as e: + e.context.update( + sheet=co.SURVEY, + column=co.PARAMETERS, + row=row_number, + ) + raise + else: + row[co.CONTROL][qt_params.INCREMENTAL.value] = "true" + + if qt_params.ALLOW_MOCK_ACCURACY in parameters: + if parameters[qt_params.ALLOW_MOCK_ACCURACY] not in {"true", "false"}: + raise PyXFormError(code=ErrorCode.SURVEY_009, context={"row": row_number}) + + row[co.BIND] = row.get(co.BIND, {}) + row[co.BIND][f"odk:{qt_params.ALLOW_MOCK_ACCURACY.value}"] = parameters[ + qt_params.ALLOW_MOCK_ACCURACY + ] + + if qt_params.REFERENCE_GEOMETRY in parameters: + value = parameters[qt_params.REFERENCE_GEOMETRY] + geo_references.append((value, row_number)) + row[co.ITEMSET] = value + + return row diff --git a/pyxform/validators/pyxform/question_types.py b/pyxform/validators/pyxform/question_types/range.py similarity index 64% rename from pyxform/validators/pyxform/question_types.py rename to pyxform/validators/pyxform/question_types/range.py index cf3596af..1c346e7d 100644 --- a/pyxform/validators/pyxform/question_types.py +++ b/pyxform/validators/pyxform/question_types/range.py @@ -1,114 +1,12 @@ -""" -Validations for question types. -""" - -from collections.abc import Collection, Iterable from decimal import Decimal, InvalidOperation from math import isinf from typing import Any -from pyxform import aliases from pyxform import constants as co from pyxform.errors import ErrorCode, PyXFormError from pyxform.question_type_dictionary import QUESTION_TYPE_DICT from pyxform.validators.pyxform import parameters as pv -from pyxform.validators.pyxform.parameters import ( - PARAMETERS_TYPE, -) -from pyxform.validators.pyxform.pyxform_reference import ( - is_pyxform_reference_candidate, - parse_pyxform_references, -) - -BACKGROUND_GEOPOINT_CALCULATION = ( - "[row : {r}] For 'background-geopoint' questions, " - "the 'calculation' column must be empty." -) -BACKGROUND_GEOPOINT_TRIGGER = ( - "[row : {r}] For 'background-geopoint' questions, " - "the 'trigger' column must not be empty." -) - - -def validate_background_geopoint_calculation(row: dict, row_num: int) -> bool: - """A background-geopoint must not have a calculation.""" - try: - row["bind"]["calculate"] - except KeyError: - return True - else: - raise PyXFormError(BACKGROUND_GEOPOINT_CALCULATION.format(r=row_num)) - - -def validate_background_geopoint_trigger(trigger: str | None, row_num: int) -> bool: - """A background-geopoint must have a trigger.""" - if not trigger: - raise PyXFormError(BACKGROUND_GEOPOINT_TRIGGER.format(r=row_num)) - return True - - -def validate_references( - referrers: Iterable[Iterable[str | None, int]], questions: Collection[str] -) -> bool: - """Pyxform references must refer to a question that exists.""" - for target, row_num in referrers: - if target not in questions: - raise PyXFormError( - code=ErrorCode.PYREF_003, context={"q": target, "row": row_num} - ) - return True - - -def process_trigger( - trigger: str | None, row_num: int, trigger_references: list[tuple[str, int]] -) -> tuple[str, ...] | None: - """ - Try to parse the content of the "trigger" column into a list of question names. - - A trigger may contain one pyxform reference, or multiple comma-separated references. - If a trigger is found, it will be added to trigger_references. - - :param trigger: The trigger data. - :param row_num: The current row number. - :param trigger_references: Which questions are being referred to by which row. - """ - if not trigger: - return None - elif not is_pyxform_reference_candidate(trigger): - raise PyXFormError( - code=ErrorCode.PYREF_001, - context={ - "sheet": "survey", - "column": "trigger", - "row": row_num, - "q": trigger, - }, - ) - - try: - trigger = tuple( - r.name - for t in trigger.split(",") - for r in parse_pyxform_references(value=t, match_limit=1) - ) - except PyXFormError as e: - e.context.update(sheet="survey", column="trigger", row=row_num) - raise - - if trigger: - trigger_references.extend((t, row_num) for t in trigger) - return trigger - else: - return None - - -def validate_geo_parameter_incremental(value: str) -> None: - """For geoshape and geotrace, the 'incremental' parameter can only resolve to 'true'.""" - incremental = aliases.yes_no.get(value, None) - if incremental is None or not incremental: - raise PyXFormError( - code=ErrorCode.SURVEY_003, - ) +from pyxform.validators.pyxform.parameters import PARAMETERS_TYPE def process_range_question_type( @@ -118,16 +16,22 @@ def process_range_question_type( appearance: str, choices: dict[str, Any], ) -> dict[str, Any]: + """ + Amend the row data with processed parameters or other logic specific to the question type. + + :param row_number: The row position in the 'survey' sheet. + :param row: The 'survey' sheet row data. + :param parameters: The parsed parameters from the row data. + :param appearance: The 'appearance' value from the row data. + :param choices: The choices data as `{list_name: [choice_items[options], ...]}`. + :return: The updated row. + """ """ Returns a new row that includes the Range parameters start, end and step. Raises PyXFormError when invalid range parameters are used. """ - parameters = pv.validate( - parameters=parameters, - accepted=co.ParametersRange, - row_number=row_number, - ) + pv.validate(parameters=parameters, accepted=co.ParametersRange, row_number=row_number) if ( appearance and appearance not in {"vertical", "no-ticks"} @@ -273,9 +177,15 @@ def process_parameter(name: str) -> Decimal | None: i is not None and not i == i.to_integral_value() for i in (start, end, step, tick_interval, placeholder) ): - row["bind"] = row.get("bind", {}) - row["bind"].update({"type": "decimal"}) + row[co.BIND] = row.get(co.BIND, {}) + row[co.BIND][co.TYPE] = "decimal" + row[co.CONTROL] = row.get(co.CONTROL, {}) + row[co.CONTROL].update(parameters) row[co.PARAMETERS] = parameters + # To trigger addition of itemset child nodes. + if tick_labelset is not None: + row[co.ITEMSET] = tick_labelset + return row diff --git a/pyxform/xls2json.py b/pyxform/xls2json.py index e5c59160..bc091098 100644 --- a/pyxform/xls2json.py +++ b/pyxform/xls2json.py @@ -43,6 +43,8 @@ is_pyxform_reference, validate_pyxform_references_in_workbook, ) +from pyxform.validators.pyxform.question_types import geo as qt_geo +from pyxform.validators.pyxform.question_types import range as qt_range from pyxform.validators.pyxform.sheet_misspellings import find_sheet_misspellings from pyxform.validators.pyxform.translations_checks import SheetTranslations from pyxform.xls2json_backends import DefinitionData, get_xlsform @@ -453,7 +455,9 @@ def workbook_to_json( question_names = set() element_names = Counter() trigger_references: list[tuple[str, int]] = [] - repeat_names = set() + geo_references: list[tuple[str, int]] = [] + secondary_instances: set[str] = set() + repeat_names: set[str] = set() entity_references_by_question = {} # row by row, validate questions, throwing errors and adding warnings where needed. @@ -881,6 +885,8 @@ def workbook_to_json( # Assuming a question is anything not processed above as a loop/repeat/group. question_names.add(question_name) + if row[constants.TYPE] in constants.EXTERNAL_INSTANCE_TYPES: + secondary_instances.add(os.path.splitext(question_name)[0]) # Try to parse question as a select: select_parse = RE_SELECT.search(question_type) @@ -897,7 +903,8 @@ def workbook_to_json( + " select one external is only meant for filtered selects." ) list_name = parse_dict[constants.LIST_NAME_U] - file_extension = os.path.splitext(list_name)[1] + instance_name, file_extension = os.path.splitext(list_name) + if select_type == constants.SELECT_ONE_EXTERNAL: if not external_choices: k = constants.EXTERNAL_CHOICES @@ -916,6 +923,9 @@ def workbook_to_json( + "List name not in external choices sheet: " + list_name ) + else: + secondary_instances.add(instance_name) + select_from_file.validate_list_name_extension( select_command=parse_dict["select_command"], list_name=list_name, @@ -1137,27 +1147,14 @@ def workbook_to_json( continue - # range question_type if question_type == "range": - tick_labelset = parameters.get(constants.ParametersRange.TICK_LABELSET.value) - - new_dict = qt.process_range_question_type( + new_dict = qt_range.process_range_question_type( row_number=row_number, row=row, parameters=parameters, appearance=appearance, choices=choices, ) - - if tick_labelset is not None: - add_choices_info_to_question( - question=new_dict, - list_name=tick_labelset, - choices=choices, - choice_filter=None, - file_extension=None, - ) - parent_children_array.append(new_dict) continue @@ -1290,80 +1287,12 @@ def workbook_to_json( continue if question_type in {"geopoint", "geoshape", "geotrace"}: - new_dict = row.copy() - new_dict["control"] = new_dict.get("control", {}) - - if question_type == "geopoint": - qt_params = constants.ParametersGeoPoint - pv.validate( - parameters=parameters, - accepted=qt_params, - row_number=row_number, - ) - - if qt_params.CAPTURE_ACCURACY in parameters: - try: - float(parameters[qt_params.CAPTURE_ACCURACY]) - new_dict["control"].update( - {"accuracyThreshold": parameters[qt_params.CAPTURE_ACCURACY]} - ) - except ValueError as ca_err: - raise PyXFormError( - f"Parameter {qt_params.CAPTURE_ACCURACY.value} must have a numeric value" - ) from ca_err - - if qt_params.WARNING_ACCURACY in parameters: - try: - float(parameters[qt_params.WARNING_ACCURACY]) - new_dict["control"].update( - { - "unacceptableAccuracyThreshold": parameters[ - qt_params.WARNING_ACCURACY - ] - } - ) - except ValueError as wa_err: - raise PyXFormError( - f"Parameter {qt_params.WARNING_ACCURACY.value} must have a numeric value" - ) from wa_err - - else: - qt_params = constants.ParametersGeo - pv.validate( - parameters=parameters, - accepted=qt_params, - row_number=row_number, - ) - if qt_params.INCREMENTAL in parameters: - try: - qt.validate_geo_parameter_incremental( - value=parameters[qt_params.INCREMENTAL] - ) - except PyXFormError as e: - e.context.update( - sheet=constants.SURVEY, - column=constants.PARAMETERS, - row=row_number, - ) - raise - else: - new_dict["control"][qt_params.INCREMENTAL.value] = "true" - - if qt_params.ALLOW_MOCK_ACCURACY in parameters: - if parameters[qt_params.ALLOW_MOCK_ACCURACY] not in {"true", "false"}: - raise PyXFormError( - f"Invalid value for {qt_params.ALLOW_MOCK_ACCURACY.value}." - ) - - new_dict["bind"] = new_dict.get("bind", {}) - new_dict["bind"].update( - { - f"odk:{qt_params.ALLOW_MOCK_ACCURACY.value}": parameters[ - qt_params.ALLOW_MOCK_ACCURACY - ] - } - ) - + new_dict = qt_geo.process_geo_question_type( + row_number=row_number, + row=row, + parameters=parameters, + geo_references=geo_references, + ) parent_children_array.append(new_dict) continue # TODO: Consider adding some question_type validation here. @@ -1392,6 +1321,13 @@ def workbook_to_json( except PyXFormError as e: e.context.update(sheet="survey", column="trigger") raise + qt_geo.validate_parameter_reference_geometry( + geo_references=geo_references, + secondary_instances=secondary_instances, + repeat_names=repeat_names, + choices=choices, + entity_declarations=entity_declarations, + ) if len(stack) > 1: raise PyXFormError( diff --git a/tests/test_allow_mock_accuracy.py b/tests/test_allow_mock_accuracy.py index 75679da7..e3280326 100644 --- a/tests/test_allow_mock_accuracy.py +++ b/tests/test_allow_mock_accuracy.py @@ -95,7 +95,7 @@ def test_foo_allow_mock_accuracy_value_fails(self): | | geopoint | geopoint | Geopoint | allow-mock-accuracy=foo | """, errored=True, - error__contains=["Invalid value for allow-mock-accuracy."], + error__contains=[ErrorCode.SURVEY_009.value.format(row=2)], ) self.assertPyxformXform( @@ -106,7 +106,7 @@ def test_foo_allow_mock_accuracy_value_fails(self): | | geoshape | geoshape | Geoshape | allow-mock-accuracy=foo | """, errored=True, - error__contains=["Invalid value for allow-mock-accuracy."], + error__contains=[ErrorCode.SURVEY_009.value.format(row=2)], ) self.assertPyxformXform( @@ -117,7 +117,7 @@ def test_foo_allow_mock_accuracy_value_fails(self): | | geotrace | geotrace | Geotrace | allow-mock-accuracy=foo | """, errored=True, - error__contains=["Invalid value for allow-mock-accuracy."], + error__contains=[ErrorCode.SURVEY_009.value.format(row=2)], ) def test_numeric_geopoint_capture_accuracy_is_passed_through(self): @@ -142,7 +142,7 @@ def test_string_geopoint_capture_accuracy_errors(self): | | geopoint | geopoint | Geopoint | capture-accuracy=foo | """, errored=True, - error__contains=["Parameter capture-accuracy must have a numeric value"], + error__contains=[ErrorCode.SURVEY_007.value.format(row=2)], ) def test_geopoint_warning_accuracy_is_passed_through(self): @@ -167,7 +167,7 @@ def test_string_geopoint_warning_accuracy_errors(self): | | geopoint | geopoint | Geopoint | warning-accuracy=foo | """, errored=True, - error__contains=["Parameter warning-accuracy must have a numeric value"], + error__contains=[ErrorCode.SURVEY_008.value.format(row=2)], ) def test_geopoint_parameters_combine(self): @@ -197,7 +197,7 @@ def test_geoshape_with_accuracy_parameters_errors(self): ErrorCode.SURVEY_005.value.format( row=2, accepted=co.ParametersGeo.value_str_sorted(), - rejected="warning-accuracy", + rejected=co.ParametersGeoPoint.WARNING_ACCURACY.value, ), ], ) @@ -215,7 +215,7 @@ def test_geotrace_with_accuracy_parameters_errors(self): ErrorCode.SURVEY_005.value.format( row=2, accepted=co.ParametersGeo.value_str_sorted(), - rejected="warning-accuracy", + rejected=co.ParametersGeoPoint.WARNING_ACCURACY.value, ), ], ) diff --git a/tests/test_geo.py b/tests/test_geo.py index e5d80874..eaab397b 100644 --- a/tests/test_geo.py +++ b/tests/test_geo.py @@ -1,11 +1,38 @@ """ -Test geo widgets. +## Geo control traceability + +Each test should reference one (or more) requirements from these lists. + +- parameter 'reference-geometry': + - Validation + - RG001: must match the name of a secondary instance or repeat. + - RG002: must not match multiple secondary instance names. + - RG003: must be a nodeset target type that is supported. + - Behaviour + - RG004: when the parameter is absent, body control does not have a child itemset. + - RG005: when the parameter is present, the body control has a child itemset element. + - RG006: the itemset's nodeset attribute is an instance() lookup for secondary instance targets. + - RG007: the itemset's nodeset attribute is an path expression for repeat targets. + - RG008: the nodeset target can be an internal choice list instance. + - RG009: the nodeset target can be an entity list instance. + - RG010: the nodeset target can be an repeat. + - RG011: the nodeset target can be an external file instance. + - RG012: the nodeset target can be an select_*_from_file instance. + - RG013: the itemset's label uses itext() when using translated internal choices. + - RG014: the itemset's @nodeset instance() lookup has a choice filter, if any, appended. + - RG015: supported nodeset target types can be used with unsupported nodeset target types. + - RG016: the parameter is not emitted as an attribute of the body control. """ +from unittest import expectedFailure + from pyxform import constants as co from pyxform.errors import ErrorCode from tests.pyxform_test_case import PyxformTestCase +from tests.xpath_helpers.questions import xpq + +GEO_TYPES = ("geoshape", "geotrace", "geopoint") class GeoWidgetsTest(PyxformTestCase): @@ -203,3 +230,509 @@ def test_with_incremental__wrong_type_no_params__ok(self): "/h:html/h:body/x:input[@ref='/test_name/q1' and not(@incremental)]", ], ) + + +class TestParameterReferenceGeometryParsing(PyxformTestCase): + def test_list_name__not_found__error(self): + """Should raise an error when the list_name is not resolvable.""" + # RG001 + md = """ + | survey | + | | type | name | label | parameters | + | | {type} | q1 | Q1 | reference-geometry=c1 | + """ + for t in GEO_TYPES: + with self.subTest(t): + self.assertPyxformXform( + md=md.format(type=t), + errored=True, + error__contains=[ErrorCode.SURVEY_006.value.format(row=2)], + ) + + def test_list_name__ambiguous__error(self): + """Should raise an error when the list_name matches both a choice and entity list_name.""" + # RG002 + md = """ + | survey | + | | type | name | label | parameters | + | | csv-external | c1 | | | + | | {type} | q1 | Q1 | reference-geometry=c1 | + + | choices | + | | list_name | name | label | geometry | + | | c1 | n1 | N1 | 123 ... | + + | entities | + | | list_name | label | + | | c1 | E1 | + """ + for t in GEO_TYPES: + with self.subTest(t): + self.assertPyxformXform( + md=md.format(type=t), + errored=True, + error__contains=[ + "The same instance id will be generated for different external instance source URIs" + ], + ) + + def test_not_supported__pulldata__error(self): + """Should raise an error when the secondary instance triggers are not supported.""" + # RG003 + md = """ + | survey | + | | type | name | label | parameters | calculation | + | | {type} | q1 | Q1 | reference-geometry=c1 | | + | | calculate | q2 | Q2 | | pulldata('c1', 'name', 'name', 'test') | + """ + for t in GEO_TYPES: + with self.subTest(t): + self.assertPyxformXform( + md=md.format(type=t), + errored=True, + error__contains=[ErrorCode.SURVEY_006.value.format(row=2)], + ) + + def test_not_supported__last_saved__error(self): + """Should raise an error when the secondary instance triggers are not supported.""" + # RG003 + md = """ + | survey | + | | type | name | label | parameters | calculation | + | | text | q1 | Q1 | | | + | | calculate | c1 | C1 | | ${{last-saved#q1}} | + | | {type} | q2 | Q2 | reference-geometry=__last-saved | | + """ + for t in GEO_TYPES: + with self.subTest(t): + self.assertPyxformXform( + md=md.format(type=t), + errored=True, + error__contains=[ErrorCode.SURVEY_006.value.format(row=4)], + ) + + def test_not_supported__select_one_external__error(self): + """Should raise an error when the secondary instance triggers are not supported.""" + # RG003 + md = """ + | survey | + | | type | name | label | parameters | choice_filter | + | | {type} | q1 | Q1 | reference-geometry=c1 | | + | | select_one_external c1 | q2 | Q2 | | false() | + + | external_choices | + | | list_name | name | label | + | | c1 | n1 | N1 | + """ + for t in GEO_TYPES: + with self.subTest(t): + self.assertPyxformXform( + md=md.format(type=t), + errored=True, + error__contains=[ErrorCode.SURVEY_006.value.format(row=2)], + ) + + # The search() causes no instance to be emitted but the reference-geometry value is allowed. + # Ideally this is an error case but detecting it accurately may not be straightforward. + @expectedFailure + def test_not_supported__search__error(self): + """Should raise an error when the secondary instance triggers are not supported.""" + # RG003 + md = """ + | survey | + | | type | name | label | parameters | appearance | + | | select_one c1 | q3 | Q3 | | search('my_file') | + | | {type} | q1 | Q1 | reference-geometry=c1 | | + + | choices | + | | list_name | name | label | + | | c1 | n1 | N1 | + """ + for t in GEO_TYPES: + with self.subTest(t): + self.assertPyxformXform( + md=md.format(type=t), + errored=True, + error__contains=[ErrorCode.SURVEY_006.value.format(row=2)], + ) + + +class TestParameterReferenceGeometryOutput(PyxformTestCase): + def test_not_emitted_by_default(self): + """Should find that a child itemset is not emitted.""" + # RG004 + md = """ + | survey | + | | type | name | label | + | | {type} | q1 | Q1 | + """ + for t in GEO_TYPES: + with self.subTest(t): + self.assertPyxformXform( + md=md.format(type=t), + xml__xpath_match=[ + xpq.model_instance_bind("q1", t), + """ + /h:html/h:body/x:input[ + @ref='/test_name/q1' + and count(@*) = 1 + and not(./x:itemset) + ] + """, + ], + ) + + def test_choices_sheet__ok(self): + """Should find that a child itemset is emitted, with default value/label.""" + # RG005 RG006 RG008 RG016 + md = """ + | survey | + | | type | name | label | parameters | + | | {type} | q1 | Q1 | reference-geometry=c1 | + + | choices | + | | list_name | name | label | geometry | + | | c1 | n1 | N1 | 123 ... | + """ + for t in GEO_TYPES: + with self.subTest(t): + self.assertPyxformXform( + md=md.format(type=t), + xml__xpath_match=[ + xpq.model_instance_exists("c1"), + xpq.model_instance_bind("q1", t), + xpq.body_itemset( + q_name="q1", + nodeset="instance('c1')/root/item", + extra_q_assertions="and not(@reference-geometry)", + ), + ], + ) + + def test_choices_sheet__translated__ok(self): + """Should find that a child itemset is emitted, with translations label.""" + # RG005 RG006 RG008 RG013 RG016 + md = """ + | survey | + | | type | name | label | parameters | + | | {type} | q1 | Q1 | reference-geometry=c1 | + + | choices | + | | list_name | name | label::English (en) | geometry | + | | c1 | n1 | N1 | 123 ... | + """ + for t in GEO_TYPES: + with self.subTest(t): + self.assertPyxformXform( + md=md.format(type=t), + xml__xpath_match=[ + xpq.model_instance_exists("c1"), + xpq.model_instance_bind("q1", t), + xpq.body_itemset( + q_name="q1", + nodeset="instance('c1')/root/item", + label_ref="jr:itext(itextId)", + extra_q_assertions="and not(@reference-geometry)", + ), + ], + ) + + def test_choices_sheet__choice_filter__ok(self): + """Should find that a child itemset is emitted, with a choice_filter predicate.""" + # RG005 RG006 RG008 RG014 RG016 + md = """ + | survey | + | | type | name | label | parameters | choice_filter | + | | text | q1 | Q1 | | | + | | {type} | q2 | Q2 | reference-geometry=c1 | name = ${{q1}} | + + | choices | + | | list_name | name | label | geometry | + | | c1 | n1 | N1 | 123 ... | + """ + for t in GEO_TYPES: + with self.subTest(t): + self.assertPyxformXform( + md=md.format(type=t), + xml__xpath_match=[ + xpq.model_instance_exists("c1"), + xpq.model_instance_bind("q2", t), + xpq.body_itemset( + q_name="q2", + nodeset="instance('c1')/root/item[name = /test_name/q1 ]", + extra_q_assertions="and not(@reference-geometry)", + ), + ], + ) + + def test_entity_list__ok(self): + """Should find that a child itemset is emitted, with default value/label.""" + # RG005 RG006 RG009 RG016 + md = """ + | survey | + | | type | name | label | parameters | + | | csv-external | e1 | | | + | | {type} | q1 | Q1 | reference-geometry=e1 | + + | entities | + | | list_name | label | + | | e1 | E1 | + """ + for t in GEO_TYPES: + with self.subTest(t): + self.assertPyxformXform( + md=md.format(type=t), + xml__xpath_match=[ + xpq.model_instance_exists("e1"), + xpq.model_instance_bind("q1", t), + xpq.body_itemset( + q_name="q1", + nodeset="instance('e1')/root/item", + extra_q_assertions="and not(@reference-geometry)", + ), + ], + ) + + def test_entity_list__choice_filter__ok(self): + """Should find that a child itemset is emitted, with a choice_filter predicate.""" + # RG005 RG006 RG009 RG014 RG016 + md = """ + | survey | + | | type | name | label | parameters | choice_filter | + | | csv-external | e1 | | | | + | | {type} | q1 | Q1 | reference-geometry=e1 | region = 1 | + + | entities | + | | list_name | label | + | | e1 | E1 | + """ + for t in GEO_TYPES: + with self.subTest(t): + self.assertPyxformXform( + md=md.format(type=t), + xml__xpath_match=[ + xpq.model_instance_exists("e1"), + xpq.model_instance_bind("q1", t), + xpq.body_itemset( + q_name="q1", + nodeset="instance('e1')/root/item[region = 1]", + extra_q_assertions="and not(@reference-geometry)", + ), + ], + ) + + def test_pyxform_reference__ok(self): + """Should find that a child itemset is emitted, with reference-specific value/label.""" + # RG005 RG007 RG010 RG016 + md = """ + | survey | + | | type | name | label | parameters | + | | begin_repeat | r1 | R1 | | + | | text | q1 | Q1 | | + | | end_repeat | r1 | | | + | | {type} | q2 | Q2 | reference-geometry=${{r1}} | + """ + for t in GEO_TYPES: + with self.subTest(t): + self.assertPyxformXform( + md=md.format(type=t), + xml__xpath_match=[ + xpq.model_instance_item("r1[not(@jr:template)]"), + xpq.model_instance_bind("q2", t), + xpq.body_itemset( + q_name="q2", + nodeset="/test_name[./r1 != '']", + value_ref="r1", + label_ref="r1", + extra_q_assertions="and not(@reference-geometry)", + ), + ], + ) + + def test_pyxform_reference__choice_filter__ok(self): + """Should find that a child itemset is emitted, with a choice_filter predicate.""" + # RG005 RG007 RG010 RG014 RG016 + md = """ + | survey | + | | type | name | label | parameters | choice_filter | + | | begin_repeat | r1 | R1 | | | + | | text | q1 | Q1 | | | + | | end_repeat | r1 | | | | + | | {type} | q2 | Q2 | reference-geometry=${{r1}} | ${{q1}} = 1 | + """ + for t in GEO_TYPES: + with self.subTest(t): + self.assertPyxformXform( + md=md.format(type=t), + xml__xpath_match=[ + xpq.model_instance_item("r1[not(@jr:template)]"), + xpq.model_instance_bind("q2", t), + xpq.body_itemset( + q_name="q2", + nodeset="/test_name[ ./r1/q1 = 1]", + value_ref="r1", + label_ref="r1", + extra_q_assertions="and not(@reference-geometry)", + ), + ], + ) + + def test_external_file__ok(self): + """Should find that a child itemset is emitted, with default value/label.""" + # RG005 RG006 RG011 RG016 + md = """ + | survey | + | | type | name | label | parameters | + | | {ext} | x1 | | | + | | {type} | q2 | Q2 | reference-geometry=x1 | + """ + for t in GEO_TYPES: + for ext in co.EXTERNAL_INSTANCE_TYPES: + with self.subTest((t, ext)): + self.assertPyxformXform( + md=md.format(type=t, ext=ext), + xml__xpath_match=[ + xpq.model_instance_exists("x1"), + xpq.model_instance_bind("q2", t), + xpq.body_itemset( + q_name="q2", + nodeset="instance('x1')/root/item", + extra_q_assertions="and not(@reference-geometry)", + ), + ], + ) + + def test_external_file__choice_filter__ok(self): + """Should find that a child itemset is emitted, with a choice_filter predicate.""" + # RG005 RG006 RG011 RG014 RG016 + md = """ + | survey | + | | type | name | label | parameters | choice_filter | + | | {ext} | x1 | | | | + | | {type} | q2 | Q2 | reference-geometry=x1 | region = 1 | + """ + for t in GEO_TYPES: + for ext in co.EXTERNAL_INSTANCE_TYPES: + with self.subTest((t, ext)): + self.assertPyxformXform( + md=md.format(type=t, ext=ext), + xml__xpath_match=[ + xpq.model_instance_exists("x1"), + xpq.model_instance_bind("q2", t), + xpq.body_itemset( + q_name="q2", + nodeset="instance('x1')/root/item[region = 1]", + extra_q_assertions="and not(@reference-geometry)", + ), + ], + ) + + def test_select_from_file__ok(self): + """Should find that a child itemset is emitted, with default value/label.""" + # RG005 RG006 RG012 RG016 + md = """ + | survey | + | | type | name | label | parameters | + | | select_one_from_file s1{ext} | q1 | Q1 | | + | | {type} | q2 | Q2 | reference-geometry=s1 | + """ + for t in GEO_TYPES: + for ext in co.EXTERNAL_INSTANCE_EXTENSIONS: + with self.subTest((t, ext)): + self.assertPyxformXform( + md=md.format(type=t, ext=ext), + xml__xpath_match=[ + xpq.model_instance_exists("s1"), + xpq.model_instance_bind("q2", t), + xpq.body_itemset( + q_name="q2", + nodeset="instance('s1')/root/item", + extra_q_assertions="and not(@reference-geometry)", + ), + ], + ) + + def test_select_from_file__choice_filter__ok(self): + """Should find that a child itemset is emitted, with a choice_filter predicate.""" + # RG005 RG006 RG012 RG014 RG016 + md = """ + | survey | + | | type | name | label | parameters | choice_filter | + | | select_one_from_file s1{ext} | q1 | Q1 | | | + | | {type} | q2 | Q2 | reference-geometry=s1 | region = 1 | + """ + for t in GEO_TYPES: + for ext in co.EXTERNAL_INSTANCE_EXTENSIONS: + with self.subTest((t, ext)): + self.assertPyxformXform( + md=md.format(type=t, ext=ext), + xml__xpath_match=[ + xpq.model_instance_exists("s1"), + xpq.model_instance_bind("q2", t), + xpq.body_itemset( + q_name="q2", + nodeset="instance('s1')/root/item[region = 1]", + extra_q_assertions="and not(@reference-geometry)", + ), + ], + ) + + def test_select_one_external__ok(self): + """Should find that a child itemset is emitted, with default value/label.""" + # RG005 RG006 RG011 RG015 RG016 + # select_one_external doesn't generate an instance, so the csv-external does that, + # and the file name "itemsets.csv" is the hard-coded file name external_choices. + md = """ + | survey | + | | type | name | label | parameters | choice_filter | relevant | + | | csv-external | itemsets | | | | | + | | select_one_external c1 | q1 | Q1 | | false() | false() | + | | {type} | q2 | Q2 | reference-geometry=itemsets | | | + + | external_choices | + | | list_name | name | label | geometry | + | | c1 | n1 | N1 | 123 ... | + """ + for t in GEO_TYPES: + with self.subTest(t): + self.assertPyxformXform( + md=md.format(type=t), + xml__xpath_match=[ + xpq.model_instance_exists("itemsets"), + xpq.model_instance_bind("q2", t), + xpq.body_itemset( + q_name="q2", + nodeset="instance('itemsets')/root/item", + extra_q_assertions="and not(@reference-geometry)", + ), + ], + ) + + def test_select_one_external__choice_filter__ok(self): + """Should find that a child itemset is emitted, with a choice_filter predicate.""" + # RG005 RG006 RG011 RG014 RG015 RG016 + md = """ + | survey | + | | type | name | label | parameters | choice_filter | relevant | + | | csv-external | itemsets | | | | | + | | select_one_external c1 | q1 | Q1 | | false() | false() | + | | {type} | q2 | Q2 | reference-geometry=itemsets | name = 'n1' | | + + | external_choices | + | | list_name | name | label | geometry | + | | c1 | n1 | N1 | 123 ... | + """ + for t in GEO_TYPES: + with self.subTest(t): + self.assertPyxformXform( + md=md.format(type=t), + xml__xpath_match=[ + xpq.model_instance_exists("itemsets"), + xpq.model_instance_bind("q2", t), + xpq.body_itemset( + q_name="q2", + nodeset="instance('itemsets')/root/item[name = 'n1']", + extra_q_assertions="and not(@reference-geometry)", + ), + ], + ) diff --git a/tests/test_range.py b/tests/test_range.py index e2a747ab..20d17333 100644 --- a/tests/test_range.py +++ b/tests/test_range.py @@ -30,8 +30,9 @@ - Behaviour - RB001: non-appearance parameters must be control attributes in the default xforms namespace. - RB002: appearance parameters must be control attributes in the 'odk' namespace. - - RB003: the default data type must be 'integer'. - - RB004: if the range has values that are decimals the data type must be 'decimal'. + - RB003: the default bind data type must be 'integer'. + - RB004: if the range has values that are decimals the bind data type must be 'decimal'. + - RB005: if the tick_labelset is for a translated list, the itemset label ref must use itext. """ from pyxform import constants as co @@ -105,8 +106,8 @@ def test_parameter_list__ok(self): "odk:tick-interval": "2", "odk:placeholder": "6", }, + "c1", ), - xpq.range_itemset("q1", "c1"), ], ) @@ -135,8 +136,8 @@ def test_parameter_list__mixed_case__ok(self): "odk:tick-interval": "2", "odk:placeholder": "6", }, + "c1", ), - xpq.range_itemset("q1", "c1"), ], ) @@ -504,7 +505,7 @@ def test_tick_labelset_not_found__ok(self): """ self.assertPyxformXform( md=md, - xml__xpath_match=[xpq.body_range("q1"), xpq.range_itemset("q1", "c1")], + xml__xpath_match=[xpq.body_range("q1", cname="c1")], ) def test_tick_labelset_empty__error(self): @@ -562,8 +563,7 @@ def test_tick_labelset_no_ticks_too_many_choices__ok(self): self.assertPyxformXform( md=md, xml__xpath_match=[ - xpq.body_range("q1", {"appearance": "no-ticks"}), - xpq.range_itemset("q1", "c1"), + xpq.body_range("q1", {"appearance": "no-ticks"}, "c1"), ], ) @@ -608,8 +608,7 @@ def test_tick_labelset_no_ticks_too_many_choices__allow_duplicates__ok(self): self.assertPyxformXform( md=md, xml__xpath_match=[ - xpq.body_range("q1", {"appearance": "no-ticks"}), - xpq.range_itemset("q1", "c1"), + xpq.body_range("q1", {"appearance": "no-ticks"}, "c1"), ], ) @@ -724,7 +723,7 @@ def test_tick_labelset_choice_is_not_a_number__ok(self): md=md.format(value=value), xml__xpath_match=[ xpq.model_instance_bind("q1", "int"), - xpq.range_itemset("q1", "c1"), + xpq.body_range("q1", cname="c1"), ], ) @@ -795,8 +794,8 @@ def test_tick_labelset_choice_outside_range__ok(self): "end": "7", "step": "1", }, + "c1", ), - xpq.range_itemset("q1", "c1"), ], ) @@ -825,8 +824,8 @@ def test_tick_labelset_choice_outside_inverted_range__ok(self): "end": "3", "step": "2", }, + "c1", ), - xpq.range_itemset("q1", "c1"), ], ) @@ -880,8 +879,8 @@ def test_tick_labelset_choice_not_a_multiple_of_step__ok(self): "end": "7", "step": "1", }, + "c1", ), - xpq.range_itemset("q1", "c1"), ], ) @@ -910,8 +909,8 @@ def test_tick_labelset_choice_not_aligned_with_tick_interval__both__ok(self): "step": "2", "odk:tick-interval": "4", }, + "c1", ), - xpq.range_itemset("q1", "c1"), ], ) @@ -964,13 +963,13 @@ def test_parameters__numeric__int(self): # RB001 RB002 RB003 md = """ | survey | - | | type | name | label | parameters | + | | type | name | label | parameters | | | range | q1 | Q1 | start=3 end=13 step=2 tick_interval=2 placeholder=7 tick_labelset=c1 | | choices | | | list_name | name | label | - | | c1 | 5 | N1 | - | | c1 | 11 | N2 | + | | c1 | 5 | N1 | + | | c1 | 11 | N2 | """ self.assertPyxformXform( md=md, @@ -986,8 +985,8 @@ def test_parameters__numeric__int(self): "odk:tick-interval": "2", "odk:placeholder": "7", }, + "c1", ), - xpq.range_itemset("q1", "c1"), ], ) @@ -996,7 +995,7 @@ def test_parameters__numeric__decimal(self): # RB001 RB002 RB004 md = """ | survey | - | | type | name | label | parameters | + | | type | name | label | parameters | | | range | q1 | Q1 | start=0.5 end=5.0 step=0.5 tick_interval=1.5 placeholder=2.5 tick_labelset=c1 | | choices | @@ -1018,7 +1017,40 @@ def test_parameters__numeric__decimal(self): "odk:tick-interval": "1.5", "odk:placeholder": "2.5", }, + "c1", + ), + ], + ) + + def test_parameters__numeric__int__translated(self): + """Should find that the itemset label ref uses jr:itext for a translated tick_labelset.""" + # RB001 RB003 RB005 + md = """ + | survey | + | | type | name | label | parameters | + | | range | q1 | Q1 | start=3 end=13 step=2 tick_interval=2 placeholder=7 tick_labelset=c1 | + + | choices | + | | list_name | name | label | label::French(fr) | + | | c1 | 5 | N1 | F1 | + | | c1 | 11 | N2 | F2 | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpq.model_instance_item("q1"), + xpq.model_instance_bind("q1", "int"), + xpq.body_range( + "q1", + { + "start": "3", + "end": "13", + "step": "2", + "odk:tick-interval": "2", + "odk:placeholder": "7", + }, + "c1", + translated=True, ), - xpq.range_itemset("q1", "c1"), ], ) diff --git a/tests/xpath_helpers/questions.py b/tests/xpath_helpers/questions.py index b5625b3d..30de9053 100644 --- a/tests/xpath_helpers/questions.py +++ b/tests/xpath_helpers/questions.py @@ -22,7 +22,7 @@ def model_instance_item(q_name: str) -> str: @staticmethod def model_instance_bind(q_name: str, _type: str) -> str: - """Model instance contains the question item.""" + """Model instance contains a binding for the question item.""" return rf""" /h:html/h:head/x:model/x:bind[ @nodeset='/test_name/{q_name}' @@ -103,6 +103,27 @@ def body_label_itext(q_type: str, q_name: str) -> str: /x:label[@ref="jr:itext('/test_name/{q_name}:label')" and not(text())] """ + @staticmethod + def body_itemset( + q_name: str, + nodeset: str, + q_type: str = "input", + value_ref: str = "name", + label_ref: str = "label", + extra_q_assertions: str = "", + ) -> str: + """Body has a direct child control with an itemset, and no inline items.""" + return rf""" + /h:html/h:body/x:{q_type}[ + @ref="/test_name/{q_name}" + and ./x:itemset[@nodeset="{nodeset}"] + and ./x:itemset/x:value[@ref="{value_ref}"] + and ./x:itemset/x:label[@ref="{label_ref}"] + and not(./x:item) + {extra_q_assertions} + ] + """ + @staticmethod def body_select1_itemset(q_name: str) -> str: """Body has a select1 with an itemset, and no inline items.""" @@ -184,21 +205,29 @@ def body_upload_tags(qname: str, tags: tuple[tuple[str, ...], ...]) -> str: """ @staticmethod - def body_range(qname: str, attrs: dict[str, str] | None = None) -> str: + def body_range( + qname: str, + attrs: dict[str, str] | None = None, + cname: str | None = None, + translated: bool = False, + ) -> str: parameters = QUESTION_TYPE_DICT["range"]["parameters"].copy() if attrs is not None: parameters.update(attrs) - attrs = " and ".join(f"@{k}='{v}'" for k, v in parameters.items()) - + attrs = "\n and ".join(f"@{k}='{v}'" for k, v in parameters.items()) + itemset = "" + itemset_label = "" + if cname is not None: + itemset = f"""\n and ./x:itemset[@nodeset="instance('{cname}')/root/item"]""" + clabel = "label" + if translated: + clabel = "jr:itext(itextId)" + itemset_label = f"""\n and ./x:itemset/x:label[@ref='{clabel}']""" return f""" /h:html/h:body/x:range[ - @ref='/test_name/{qname}' and {attrs} + @ref='/test_name/{qname}' and {attrs} {itemset} {itemset_label} ] """ - @staticmethod - def range_itemset(qname: str, labelset: str) -> str: - return f"/h:html/h:body/x:range[@ref='/test_name/{qname}']/x:itemset[@nodeset=\"instance('{labelset}')/root/item\"]" - xpq = XPathHelper()