diff --git a/pyxform/aliases.py b/pyxform/aliases.py index 7497c7203..017a5e14f 100644 --- a/pyxform/aliases.py +++ b/pyxform/aliases.py @@ -123,7 +123,6 @@ "audio": survey_header["audio"], "video": survey_header["video"], } -# Note that most of the type aliasing happens in all.xls _type_alias_map = { "imei": "deviceid", "image": "photo", diff --git a/pyxform/constants.py b/pyxform/constants.py index cb568a627..12fa9e69d 100644 --- a/pyxform/constants.py +++ b/pyxform/constants.py @@ -86,6 +86,7 @@ OSM_TYPE = "binary" NAMESPACES = "namespaces" +META = "meta" # The following are the possible sheet names: SUPPORTED_SHEET_NAMES = { @@ -113,10 +114,15 @@ # The ODK XForms version that generated forms comply to CURRENT_XFORMS_VERSION = "1.0.0" + # The ODK entities spec version that generated forms comply to -ENTITIES_OFFLINE_VERSION = "2024.1.0" +class EntityVersion(StrEnum): + v2024_1_0 = "2024.1.0" + v2025_1_0 = "2025.1.0" + + ENTITY = "entity" -ENTITY_FEATURES = "entity_features" +ENTITY_VERSION = "entity_version" ENTITIES_RESERVED_PREFIX = "__" @@ -125,6 +131,7 @@ class EntityColumns(StrEnum): ENTITY_ID = "entity_id" CREATE_IF = "create_if" UPDATE_IF = "update_if" + REPEAT = "repeat" LABEL = "label" @@ -169,3 +176,4 @@ class EntityColumns(StrEnum): } SUPPORTED_MEDIA_TYPES = {"image", "big-image", "audio", "video"} OR_OTHER_CHOICE = {NAME: "other", LABEL: "Other"} +RESERVED_NAMES_SURVEY_SHEET = {META} diff --git a/pyxform/elements/__init__.py b/pyxform/elements/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pyxform/elements/action.py b/pyxform/elements/action.py new file mode 100644 index 000000000..97ccf3a3b --- /dev/null +++ b/pyxform/elements/action.py @@ -0,0 +1,158 @@ +from enum import Enum + +from pyxform.elements.element import Element +from pyxform.util.enum import StrEnum +from pyxform.utils import node + + +class Event(StrEnum): + """ + Supported W3C XForms 1.1 Events and ODK extensions. + """ + + # For actions in model under /html/head/model + ODK_INSTANCE_FIRST_LOAD = "odk-instance-first-load" + ODK_INSTANCE_LOAD = "odk-instance-load" + # For actions in repeat control under /html/body + ODK_NEW_REPEAT = "odk-new-repeat" + # For actions in question control under /html/body + XFORMS_VALUE_CHANGED = "xforms-value-changed" + + +class Action(Element): + """ + Base class for supported Action elements. + + https://getodk.github.io/xforms-spec/#actions + """ + + __slots__ = ("event", "kwargs", "ref", "value") + + def __init__( + self, + ref: str, + event: Event, + value: str | None = None, + **kwargs, + ): + super().__init__() + self.ref: str = ref + self.event: Event = event + self.value: str | None = value + + def node(self): + return node(self.name, ref=self.ref, event=self.event.value) + + +class Setvalue(Action): + """ + Explicitly sets the value of the specified instance data node. + + https://getodk.github.io/xforms-spec/#action:setvalue + """ + + name = "setvalue" + + def __init__(self, ref: str, event: Event, value: str | None = None, **kwargs): + super().__init__(ref=ref, event=event, value=value, **kwargs) + + def node(self): + result = super().node() + if self.value: + result.setAttribute("value", self.value) + return result + + +class ODKSetGeopoint(Action): + """ + Sets the current location's geopoint value in the instance data node specified in the + ref attribute. + + https://getodk.github.io/xforms-spec/#action:setgeopoint + """ + + name = "odk:setgeopoint" + + def __init__( + self, + ref: str, + event: Event, + value: str | None = None, + **kwargs, + ): + super().__init__(ref=ref, event=event, value=value, **kwargs) + + +class ODKRecordAudio(Action): + """ + Records audio starting at the triggering event, saves the audio to a file, and writes + the filename to the node specified in the ref attribute. + + https://getodk.github.io/xforms-spec/#action:recordaudio + """ + + __slots__ = ("quality",) + name = "odk:recordaudio" + + def __init__( + self, + ref: str, + event: Event, + value: str | None = None, + **kwargs, + ): + super().__init__(ref=ref, event=event, value=value, **kwargs) + self.quality: str | None = kwargs.get("odk:quality") + + def node(self): + result = super().node() + if self.quality: + result.setAttribute("odk:quality", self.quality) + return result + + +ACTION_CLASSES = { + "setvalue": Setvalue, + "odk:setgeopoint": ODKSetGeopoint, + "odk:recordaudio": ODKRecordAudio, +} + + +class LibraryMember: + def __init__(self, name: str, event: str): + self.name: str = name + self.event: str = event + + def to_dict(self): + return {"name": self.name, "event": self.event} + + +class ActionLibrary(Enum): + """ + A collection of action/event configs used by pyxform. + """ + + setvalue_first_load = LibraryMember( + name=Setvalue.name, + event=Event.ODK_INSTANCE_FIRST_LOAD.value, + ) + setvalue_new_repeat = LibraryMember( + name=Setvalue.name, + event=Event.ODK_NEW_REPEAT.value, + ) + setvalue_value_changed = LibraryMember( + name=Setvalue.name, + event=Event.XFORMS_VALUE_CHANGED.value, + ) + odk_setgeopoint_first_load = LibraryMember( + name=ODKSetGeopoint.name, + event=Event.ODK_INSTANCE_FIRST_LOAD.value, + ) + odk_setgeopoint_value_changed = LibraryMember( + name=ODKSetGeopoint.name, + event=Event.XFORMS_VALUE_CHANGED.value, + ) + odk_recordaudio_instance_load = LibraryMember( + name=ODKRecordAudio.name, + event=Event.ODK_INSTANCE_LOAD.value, + ) diff --git a/pyxform/elements/element.py b/pyxform/elements/element.py new file mode 100644 index 000000000..9240d27f9 --- /dev/null +++ b/pyxform/elements/element.py @@ -0,0 +1,21 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pyxform.utils import DetachableElement + + +class Element: + """ + Base class for Element interface and default behaviour. + + Unlike SurveyElement which may emit multiple elements for a particular survey + component, this class is for defining individual elements for output. + """ + + name: str + + def node(self) -> "DetachableElement": + """ + Create the element. + """ + raise NotImplementedError() diff --git a/pyxform/entities/entities_parsing.py b/pyxform/entities/entities_parsing.py index f67263e17..3167911a4 100644 --- a/pyxform/entities/entities_parsing.py +++ b/pyxform/entities/entities_parsing.py @@ -2,18 +2,93 @@ from typing import Any from pyxform import constants as const -from pyxform.errors import PyXFormError +from pyxform.elements import action +from pyxform.errors import Detail, PyXFormError from pyxform.parsing.expression import is_xml_tag +from pyxform.validators.pyxform.pyxform_reference import parse_pyxform_references EC = const.EntityColumns +ENTITY001 = Detail( + name="Invalid entity repeat reference", + msg=( + "[row : 2] On the 'entities' sheet, the 'repeat' value '{value}' is invalid. " + "The 'repeat' column, if specified, must contain only a single reference variable " + "(like '${{q1}}'), and the reference variable must contain a valid name." + ), +) +ENTITY002 = Detail( + name="Invalid entity repeat: target not found", + msg=( + "[row : 2] On the 'entities' sheet, the 'repeat' value '{value}' is invalid. " + "The entity repeat target was not found in the 'survey' sheet." + ), +) +ENTITY003 = Detail( + name="Invalid entity repeat: target is not a repeat", + msg=( + "[row : 2] On the 'entities' sheet, the 'repeat' value '{value}' is invalid. " + "The entity repeat target is not a repeat." + ), +) +ENTITY004 = Detail( + name="Invalid entity repeat: target is in a repeat", + msg=( + "[row : 2] On the 'entities' sheet, the 'repeat' value '{value}' is invalid. " + "The entity repeat target is inside a repeat." + ), +) +ENTITY005 = Detail( + name="Invalid entity repeat save_to: question in nested repeat", + msg=( + "[row : {row}] On the 'survey' sheet, the 'save_to' value '{value}' is invalid. " + "The entity property populated with 'save_to' must not be inside of a nested " + "repeat within the entity repeat." + ), +) +ENTITY006 = Detail( + name="Invalid entity repeat save_to: question not in entity repeat", + msg=( + "[row : {row}] On the 'survey' sheet, the 'save_to' value '{value}' is invalid. " + "The entity property populated with 'save_to' must be inside of the entity " + "repeat." + ), +) +ENTITY007 = Detail( + name="Invalid entity repeat save_to: question in repeat but no entity repeat defined", + msg=( + "[row : {row}] On the 'survey' sheet, the 'save_to' value '{value}' is invalid. " + "The entity property populated with 'save_to' must be inside a repeat that is " + "declared in the 'repeat' column of the 'entities' sheet." + ), +) def get_entity_declaration( entities_sheet: Sequence[dict], ) -> dict[str, Any]: + """ + Transform the entities sheet data into a spec for creating an EntityDeclaration. + + The combination of entity_id, create_if and update_if columns determines what entity + behaviour is intended: + + entity_id create_if update_if result + 0 0 0 always create + 0 0 1 error, need id to update + 0 1 0 create based on condition + 0 1 1 error, need id to update + 1 0 0 always update + 1 0 1 update based on condition + 1 1 0 error, id only acceptable when updating + 1 1 1 include conditions for create and update + (user's responsibility to ensure they're exclusive) + + :param entities_sheet: XLSForm entities sheet data. + """ if len(entities_sheet) > 1: raise PyXFormError( - "Currently, you can only declare a single entity per form. Please make sure your entities sheet only declares one entity." + "Currently, you can only declare a single entity per form. " + "Please make sure your entities sheet only declares one entity." ) entity_row = entities_sheet[0] @@ -21,37 +96,136 @@ def get_entity_declaration( validate_entities_columns(row=entity_row) dataset_name = get_validated_dataset_name(entity_row) entity_id = entity_row.get(EC.ENTITY_ID, None) - create_condition = entity_row.get(EC.CREATE_IF, None) - update_condition = entity_row.get(EC.UPDATE_IF, None) - entity_label = entity_row.get(EC.LABEL, None) + create_if = entity_row.get(EC.CREATE_IF, None) + update_if = entity_row.get(EC.UPDATE_IF, None) + label = entity_row.get(EC.LABEL, None) + repeat = get_validated_repeat_name(entity_row) - if update_condition and not entity_id: + if not entity_id and update_if: raise PyXFormError( - "The entities sheet is missing the entity_id column which is required when updating entities." + "The entities sheet is missing the entity_id column which is required when " + "updating entities." ) - if entity_id and create_condition and not update_condition: + if entity_id and create_if and not update_if: raise PyXFormError( - "The entities sheet can't specify an entity creation condition and an entity_id without also including an update condition." + "The entities sheet can't specify an entity creation condition and an " + "entity_id without also including an update condition." ) - if not entity_id and not entity_label: + if not entity_id and not label: raise PyXFormError( - "The entities sheet is missing the label column which is required when creating entities." + "The entities sheet is missing the label column which is required when " + "creating entities." ) - return { + entity = { const.NAME: const.ENTITY, const.TYPE: const.ENTITY, - const.PARAMETERS: { - EC.DATASET.value: dataset_name, - EC.ENTITY_ID.value: entity_id, - EC.CREATE_IF.value: create_condition, - EC.UPDATE_IF.value: update_condition, - EC.LABEL.value: entity_label, + EC.REPEAT.value: repeat, + const.CHILDREN: [ + { + const.NAME: "dataset", + const.TYPE: "attribute", + "value": dataset_name, + }, + ], + } + + id_attr = { + const.NAME: "id", + const.TYPE: "attribute", + const.BIND: { + "readonly": "true()", + "type": "string", }, + "actions": [], } + # Create mode + if not entity_id or create_if: + create_attr = {const.NAME: "create", const.TYPE: "attribute", "value": "1"} + if create_if: + create_attr[const.BIND] = { + "calculate": create_if, + "readonly": "true()", + "type": "string", + } + entity[const.CHILDREN].append(create_attr) + + first_load = action.ActionLibrary.setvalue_first_load.value.to_dict() + first_load["value"] = "uuid()" + id_attr["actions"].append(first_load) + + if repeat: + new_repeat = action.ActionLibrary.setvalue_new_repeat.value.to_dict() + new_repeat["value"] = "uuid()" + id_attr["actions"].append(new_repeat) + + # Update mode + if entity_id: + update_attr = {const.NAME: "update", const.TYPE: "attribute", "value": "1"} + if update_if: + update_attr[const.BIND] = { + "calculate": update_if, + "readonly": "true()", + "type": "string", + } + + entity[const.CHILDREN].append(update_attr) + + id_attr[const.BIND]["calculate"] = entity_id + + entity_id_expression = f"instance('{dataset_name}')/root/item[name={entity_id}]" + entity[const.CHILDREN].extend( + [ + { + const.NAME: "baseVersion", + const.TYPE: "attribute", + const.BIND: { + "calculate": f"{entity_id_expression}/__version", + "readonly": "true()", + "type": "string", + }, + }, + { + const.NAME: "trunkVersion", + const.TYPE: "attribute", + const.BIND: { + "calculate": f"{entity_id_expression}/__trunkVersion", + "readonly": "true()", + "type": "string", + }, + }, + { + const.NAME: "branchId", + const.TYPE: "attribute", + const.BIND: { + "calculate": f"{entity_id_expression}/__branchId", + "readonly": "true()", + "type": "string", + }, + }, + ] + ) + + entity[const.CHILDREN].append(id_attr) + + if label: + entity[const.CHILDREN].append( + { + const.TYPE: "label", + const.NAME: "label", + const.BIND: { + "calculate": label, + "readonly": "true()", + "type": "string", + }, + }, + ) + + return entity + def get_validated_dataset_name(entity): dataset = entity[EC.DATASET] @@ -77,10 +251,26 @@ def get_validated_dataset_name(entity): return dataset +def get_validated_repeat_name(entity) -> str | None: + if EC.REPEAT.value not in entity: + return None + + value = entity[EC.REPEAT] + try: + match = parse_pyxform_references(value=value, match_limit=1, match_full=True) + except PyXFormError as e: + e.context.update(sheet="entities", column="repeat", row=2) + else: + if not match or not is_xml_tag(match[0]): + raise PyXFormError(ENTITY001.format(value=value)) + else: + return match[0] + + def validate_entity_saveto( row: dict, row_number: int, - in_repeat: bool, + stack: Sequence[dict[str, Any]], entity_declaration: dict[str, Any] | None = None, ): save_to = row.get(const.BIND, {}).get("entities:saveto", "") @@ -97,10 +287,27 @@ def validate_entity_saveto( f"{const.ROW_FORMAT_STRING % row_number} Groups and repeats can't be saved as entity properties." ) - if in_repeat: - raise PyXFormError( - f"{const.ROW_FORMAT_STRING % row_number} Currently, you can't create entities from repeats. You may only specify save_to values for form fields outside of repeats." - ) + entity_repeat = entity_declaration.get(EC.REPEAT, None) + in_repeat = False + located = False + for i in reversed(stack): + if not i["control_name"] or not i["control_type"]: + break + elif i["control_type"] == const.REPEAT: + # Error: saveto in nested repeat inside entity repeat. + if in_repeat: + raise PyXFormError(ENTITY005.format(row=row_number, value=save_to)) + elif i["control_name"] == entity_repeat: + located = True + in_repeat = True + + # Error: saveto not in entity repeat + if entity_repeat and not located: + raise PyXFormError(ENTITY006.format(row=row_number, value=save_to)) + + # Error: saveto in repeat but no entity repeat declared + if in_repeat and not entity_repeat: + raise PyXFormError(ENTITY007.format(row=row_number, value=save_to)) error_start = f"{const.ROW_FORMAT_STRING % row_number} Invalid save_to name:" @@ -134,3 +341,56 @@ def validate_entities_columns(row: dict): f"pyxform." ) raise PyXFormError(msg) + + +def validate_entity_repeat_target( + entity_declaration: dict[str, Any] | None, + stack: Sequence[dict[str, Any]] | None = None, +) -> bool: + """ + Check if the entity repeat target exists, is a repeat, and is a name match. + + Raises an error if the control type or name is None (such as for the Survey), or if + the control type is not a repeat. + + :param entity_declaration: + :param stack: The control stack from workbook_to_json. + :return: + """ + # Ignore: entity already processed. + if not entity_declaration: + return False + + entity_repeat = entity_declaration.get(EC.REPEAT, None) + + # Ignore: no repeat declared for the entity. + if not entity_repeat: + return False + + # Error: repeat not found while processing survey sheet. + if not stack: + raise PyXFormError(ENTITY002.format(value=entity_repeat)) + + control_name = stack[-1]["control_name"] + control_type = stack[-1]["control_type"] + + # Ignore: current control is not the target. + if control_name and control_name != entity_repeat: + return False + + # Error: target is not a repeat. + if control_type and control_type != const.REPEAT: + raise PyXFormError(ENTITY003.format(value=entity_repeat)) + + # Error: repeat is in nested repeat. + located = False + for i in reversed(stack): + if not i["control_name"] or not i["control_type"]: + break + elif i["control_type"] == const.REPEAT: + if located: + raise PyXFormError(ENTITY004.format(value=entity_repeat)) + elif i["control_name"] == entity_repeat: + located = True + + return entity_repeat == control_name diff --git a/pyxform/entities/entity_declaration.py b/pyxform/entities/entity_declaration.py index ea63bebac..df53637eb 100644 --- a/pyxform/entities/entity_declaration.py +++ b/pyxform/entities/entity_declaration.py @@ -1,37 +1,21 @@ -from typing import TYPE_CHECKING - from pyxform import constants as const -from pyxform.survey_element import SURVEY_ELEMENT_FIELDS, SurveyElement -from pyxform.utils import node - -if TYPE_CHECKING: - from pyxform.survey import Survey - +from pyxform.section import Section +from pyxform.survey_element import SURVEY_ELEMENT_FIELDS +from pyxform.survey_elements.attribute import Attribute +from pyxform.survey_elements.label import Label +from pyxform.utils import combine_lists EC = const.EntityColumns -ENTITY_EXTRA_FIELDS = ( - const.TYPE, - const.PARAMETERS, -) +ENTITY_EXTRA_FIELDS = (const.CHILDREN,) ENTITY_FIELDS = (*SURVEY_ELEMENT_FIELDS, *ENTITY_EXTRA_FIELDS) +CHILD_TYPES = {"label": Label, "attribute": Attribute} -class EntityDeclaration(SurveyElement): +class EntityDeclaration(Section): """ - An entity declaration includes an entity instance node with optional label child, some attributes, and corresponding bindings. - - The ODK XForms Entities specification can be found at https://getodk.github.io/xforms-spec/entities - - XLSForm uses a combination of the entity_id, create_if and update_if columns to determine what entity action is intended: - id create update result - 1 0 0 always update - 1 0 1 update based on condition - 1 1 0 error, id only acceptable when updating - 1 1 1 include conditions for create and update, user's responsibility to make sure they're exclusive - 0 0 0 always create - 0 0 1 error, need id to update - 0 1 0 create based on condition - 0 1 1 error, need id to update + An entity declaration produces an entity instance node with optional label child, + some variable attributes, and corresponding bindings. The ODK XForms Entities + specification can be found at https://getodk.github.io/xforms-spec/entities """ __slots__ = ENTITY_EXTRA_FIELDS @@ -40,107 +24,13 @@ class EntityDeclaration(SurveyElement): def get_slot_names() -> tuple[str, ...]: return ENTITY_FIELDS - def __init__(self, name: str, type: str, parameters: dict, **kwargs): - self.parameters: dict = parameters - self.type: str = type - super().__init__(name=name, **kwargs) - - def xml_instance(self, **kwargs): - parameters = self.parameters - - attributes = { - EC.DATASET.value: parameters.get(EC.DATASET, ""), - "id": "", - } - - entity_id_expression = parameters.get(EC.ENTITY_ID, None) - create_condition = parameters.get(EC.CREATE_IF, None) - update_condition = parameters.get(EC.UPDATE_IF, None) - - if entity_id_expression: - attributes["update"] = "1" - attributes["baseVersion"] = "" - attributes["trunkVersion"] = "" - attributes["branchId"] = "" - - if create_condition or (not update_condition and not entity_id_expression): - attributes["create"] = "1" - - if parameters.get(EC.LABEL, None): - return node(const.ENTITY, node(const.LABEL), **attributes) - else: - return node(const.ENTITY, **attributes) - - def xml_bindings(self, survey: "Survey"): - """ - See the class comment for an explanation of the logic for generating bindings. - """ - parameters = self.parameters - entity_id_expression = parameters.get(EC.ENTITY_ID, None) - create_condition = parameters.get(EC.CREATE_IF, None) - update_condition = parameters.get(EC.UPDATE_IF, None) - label_expression = parameters.get(EC.LABEL, None) - - bind_nodes = [] - - if create_condition: - bind_nodes.append(self._get_bind_node(survey, create_condition, "/@create")) - - bind_nodes.append(self._get_id_bind_node(survey, entity_id_expression)) - - if create_condition or not entity_id_expression: - bind_nodes.append(self._get_id_setvalue_node()) - - if update_condition: - bind_nodes.append(self._get_bind_node(survey, update_condition, "/@update")) - - if entity_id_expression: - dataset_name = parameters.get(EC.DATASET, "") - entity = f"instance('{dataset_name}')/root/item[name={entity_id_expression}]" - bind_nodes.append( - self._get_bind_node(survey, f"{entity}/__version", "/@baseVersion") - ) - bind_nodes.append( - self._get_bind_node(survey, f"{entity}/__trunkVersion", "/@trunkVersion") - ) - bind_nodes.append( - self._get_bind_node(survey, f"{entity}/__branchId", "/@branchId") - ) - - if label_expression: - bind_nodes.append(self._get_bind_node(survey, label_expression, "/label")) - - return bind_nodes - - def _get_id_bind_node(self, survey, entity_id_expression): - id_bind = {const.TYPE: "string", "readonly": "true()"} - - if entity_id_expression: - id_bind["calculate"] = survey.insert_xpaths( - text=entity_id_expression, context=self - ) - - return node(const.BIND, nodeset=self.get_xpath() + "/@id", **id_bind) - - def _get_id_setvalue_node(self): - id_setvalue_attrs = { - "event": "odk-instance-first-load", - const.TYPE: "string", - "readonly": "true()", - "value": "uuid()", - } - - return node("setvalue", ref=self.get_xpath() + "/@id", **id_setvalue_attrs) - - def _get_bind_node(self, survey, expression, destination): - expr = survey.insert_xpaths(text=expression, context=self) - bind_attrs = { - "calculate": expr, - const.TYPE: "string", - "readonly": "true()", - } - - return node(const.BIND, nodeset=self.get_xpath() + destination, **bind_attrs) + def __init__(self, name: str, type: str, **kwargs): + super().__init__(name=name, type=type, **kwargs) + self.children: list[Label | Attribute] = [] - def xml_control(self, survey: "Survey"): - raise NotImplementedError() + children = combine_lists( + a=kwargs.pop("children", None), b=kwargs.pop(const.CHILDREN, None) + ) + if children: + self.children = [CHILD_TYPES[c["type"]](**c) for c in children] + self._link_children() diff --git a/pyxform/errors.py b/pyxform/errors.py index 5b53deb53..f744f12a1 100644 --- a/pyxform/errors.py +++ b/pyxform/errors.py @@ -15,7 +15,11 @@ def __init__(self, default_value: str = "unknown"): def get_value(self, key, args, kwargs): if isinstance(key, str): - return kwargs.get(key, self.default_value) + value = kwargs.get(key, None) + if value is None: + return self.default_value + else: + return value else: return super().get_value(key, args, kwargs) @@ -23,7 +27,7 @@ def get_value(self, key, args, kwargs): _ERROR_FORMATTER = _ErrorFormatter() -class _Detail: +class Detail: """ErrorCode details.""" __slots__ = ("msg", "name") @@ -37,28 +41,28 @@ def format(self, **kwargs): class ErrorCode(Enum): - PYREF_001: _Detail = _Detail( + PYREF_001: Detail = Detail( name="PyXForm Reference Parsing Failed", msg=( "[row : {row}] On the '{sheet}' sheet, the '{column}' value is invalid. " "Reference variables must start with '${{', then a question name, and end with '}}'." ), ) - PYREF_002: _Detail = _Detail( + PYREF_002: Detail = Detail( name="PyXForm Reference Parsing Limit Reached", msg=( "[row : {row}] On the '{sheet}' sheet, the '{column}' value is invalid. " "Reference variable lists must have a comma between each variable." ), ) - PYREF_003: _Detail = _Detail( + PYREF_003: Detail = Detail( name="PyXForm Reference Question Not Found", msg=( "[row : {row}] On the '{sheet}' sheet, the '{column}' value is invalid. " "Reference variables must refer to a question name. Could not find '{q}'." ), ) - INTERNAL_001: _Detail = _Detail( + INTERNAL_001: Detail = Detail( name="Internal error: Incorrectly Processed Question Trigger Data", msg=( "Internal error: " diff --git a/pyxform/question.py b/pyxform/question.py index bc4791d6b..40603d374 100644 --- a/pyxform/question.py +++ b/pyxform/question.py @@ -15,6 +15,7 @@ EXTERNAL_CHOICES_ITEMSET_REF_VALUE_GEOJSON, EXTERNAL_INSTANCE_EXTENSIONS, ) +from pyxform.elements import action from pyxform.errors import PyXFormError from pyxform.parsing.expression import maybe_strip from pyxform.question_type_dictionary import QUESTION_TYPE_DICT @@ -32,9 +33,10 @@ QUESTION_EXTRA_FIELDS = ( + "_dynamic_default", "_qtd_defaults", "_qtd_kwargs", - "action", + "actions", "default", "guidance_hint", "instance", @@ -78,11 +80,12 @@ def get_slot_names() -> tuple[str, ...]: def __init__(self, fields: tuple[str, ...] | None = None, **kwargs): # Internals + self._dynamic_default: bool | None = None self._qtd_defaults: dict | None = None self._qtd_kwargs: dict | None = None # Structure - self.action: dict[str, str] | None = None + self.actions: list[dict[str, str]] | None = None self.bind: dict | None = None self.control: dict | None = None self.instance: dict | None = None @@ -148,9 +151,7 @@ def xml_control(self, survey: "Survey"): ((self.bind is not None and "calculate" in self.bind) or self.trigger) and not (self.label or self.hint) ): - nested_setvalues = survey.get_trigger_values_for_question_name( - self.name, "setvalue" - ) + nested_setvalues = survey.setvalues_by_triggering_ref.get(self.name) if nested_setvalues: for setvalue in nested_setvalues: msg = ( @@ -164,45 +165,60 @@ def xml_control(self, survey: "Survey"): xml_node = self.build_xml(survey=survey) if xml_node: - # Get nested setvalue and setgeopoint items - setvalue_items = survey.get_trigger_values_for_question_name( - self.name, "setvalue" - ) - setgeopoint_items = survey.get_trigger_values_for_question_name( - self.name, "setgeopoint" - ) + setvalue = survey.setvalues_by_triggering_ref.get(self.name) + setvalue_action = action.ActionLibrary.setvalue_value_changed.value + if setvalue: + for question_name, value in setvalue: + xml_node.appendChild( + action.ACTION_CLASSES[setvalue_action.name]( + ref=survey.get_element_by_name(question_name).get_xpath(), + event=action.Event(setvalue_action.event), + value=survey.insert_xpaths(text=value, context=self), + ).node() + ) - # Only call nest_set_nodes if the respective nested items list is not empty - if setvalue_items: - self.nest_set_nodes(survey, xml_node, "setvalue", setvalue_items) - if setgeopoint_items: - self.nest_set_nodes( - survey, xml_node, "odk:setgeopoint", setgeopoint_items - ) + setgeopoint = survey.setgeopoint_by_triggering_ref.get(self.name) + setgeopoint_action = action.ActionLibrary.odk_setgeopoint_value_changed.value + if setgeopoint: + for question_name, value in setgeopoint: + xml_node.appendChild( + action.ACTION_CLASSES[setgeopoint_action.name]( + ref=survey.get_element_by_name(question_name).get_xpath(), + event=action.Event(setgeopoint_action.event), + value=survey.insert_xpaths(text=value, context=self), + ).node() + ) return xml_node - def xml_action(self) -> DetachableElement | None: + def xml_actions(self, survey: "Survey", in_repeat: bool = False): """ - Return the action for this survey element. + Return the action(s) for this survey element. """ - if self.action: - result = node(self.action["name"], ref=self.get_xpath()) - for k, v in self.action.items(): - if k != "name": - result.setAttribute(k, v) - return result - - def nest_set_nodes(self, survey, xml_node, tag, nested_items): - for item in nested_items: - node_attrs = { - "ref": survey.get_element_by_name(item[0]).get_xpath(), - "event": "xforms-value-changed", - } - if item[1]: - node_attrs["value"] = survey.insert_xpaths(text=item[1], context=self) - set_node = node(tag, **node_attrs) - xml_node.appendChild(set_node) + action_fields = {"name", "event", "value"} + if self.actions: + for _action in self.actions: + event = action.Event(_action["event"]) + if (not in_repeat and event == action.Event.ODK_NEW_REPEAT.value) or ( + in_repeat and event != action.Event.ODK_NEW_REPEAT + ): + continue + + if self._dynamic_default is True or ( + self.default and default_is_dynamic(self.default, self.type) + ): + value = survey.insert_xpaths(text=self.default, context=self) + elif self.default: + value = self.default + else: + value = _action.get("value") + + yield action.ACTION_CLASSES[_action["name"]]( + ref=self.get_xpath(), + event=event, + value=value, + **{k: v for k, v in _action.items() if k not in action_fields}, + ).node() def _build_xml(self, survey: "Survey") -> DetachableElement | None: """ @@ -238,23 +254,6 @@ def to_json_dict(self, delete_keys: Iterable[str] | None = None) -> dict: result[k] = v return result - def get_setvalue_node_for_dynamic_default( - self, survey: "Survey", in_repeat=False - ) -> DetachableElement | None: - if not self.default or not default_is_dynamic(self.default, self.type): - return None - - triggering_events = "odk-instance-first-load" - if in_repeat: - triggering_events = f"{triggering_events} odk-new-repeat" - - return node( - "setvalue", - ref=self.get_xpath(), - value=survey.insert_xpaths(text=self.default, context=self), - event=triggering_events, - ) - class InputQuestion(Question): """ @@ -516,7 +515,7 @@ def get_slot_names() -> tuple[str, ...]: return OSM_QUESTION_FIELDS def __init__(self, **kwargs): - self.children: tuple[Option, ...] | None = None + self.children: tuple[Tag, ...] | None = None choices = combine_lists( a=kwargs.pop("tags", None), b=kwargs.pop(constants.CHILDREN, None) diff --git a/pyxform/question_type_dictionary.py b/pyxform/question_type_dictionary.py index 0d5e0857d..b934322ca 100644 --- a/pyxform/question_type_dictionary.py +++ b/pyxform/question_type_dictionary.py @@ -2,21 +2,11 @@ XForm survey question type mapping dictionary module. """ +from collections.abc import Sequence from types import MappingProxyType +from typing import Any -from pyxform.xls2json import QuestionTypesReader, print_pyobj_to_json - - -def generate_new_dict(): - """ - This is just here incase there is ever any need to generate the question - type dictionary from all.xls again. - It shouldn't be called as part of any application. - """ - path_to_question_types = "/pyxform/question_types/all.xls" - json_dict = QuestionTypesReader(path_to_question_types).to_json_dict() - print_pyobj_to_json(json_dict, "new_question_type_dict.json") - +from pyxform import constants as const _QUESTION_TYPE_DICT = { "q picture": { @@ -377,12 +367,10 @@ def generate_new_dict(): "start-geopoint": { "control": {"tag": "action"}, "bind": {"type": "geopoint"}, - "action": {"name": "odk:setgeopoint", "event": "odk-instance-first-load"}, }, "background-audio": { "control": {"tag": "action"}, "bind": {"type": "binary"}, - "action": {"name": "odk:recordaudio", "event": "odk-instance-load"}, }, "background-geopoint": { "control": {"tag": "trigger"}, @@ -392,3 +380,14 @@ def generate_new_dict(): # Read-only view of the types. QUESTION_TYPE_DICT = MappingProxyType(_QUESTION_TYPE_DICT) + + +def get_meta_group(children: Sequence[dict[str, Any]]) -> dict[str, Any]: + if children is None: + children = [] + return { + const.NAME: "meta", + const.TYPE: const.GROUP, + const.CONTROL: {"bodyless": True}, + const.CHILDREN: children, + } diff --git a/pyxform/question_types/all.xls b/pyxform/question_types/all.xls deleted file mode 100644 index af0b3f5c5..000000000 Binary files a/pyxform/question_types/all.xls and /dev/null differ diff --git a/pyxform/question_types/base.xls b/pyxform/question_types/base.xls deleted file mode 100644 index 9241298e1..000000000 Binary files a/pyxform/question_types/base.xls and /dev/null differ diff --git a/pyxform/question_types/beorse.xls b/pyxform/question_types/beorse.xls deleted file mode 100644 index bb8eb0a74..000000000 Binary files a/pyxform/question_types/beorse.xls and /dev/null differ diff --git a/pyxform/question_types/nigeria.xls b/pyxform/question_types/nigeria.xls deleted file mode 100644 index f43139a5d..000000000 Binary files a/pyxform/question_types/nigeria.xls and /dev/null differ diff --git a/pyxform/section.py b/pyxform/section.py index 6ea4c42fc..d37a267c4 100644 --- a/pyxform/section.py +++ b/pyxform/section.py @@ -5,12 +5,13 @@ from collections.abc import Callable, Generator, Iterable from itertools import chain from typing import TYPE_CHECKING +from xml.dom.minidom import Attr from pyxform import constants -from pyxform.errors import PyXFormError from pyxform.external_instance import ExternalInstance from pyxform.survey_element import SURVEY_ELEMENT_FIELDS, SurveyElement -from pyxform.utils import DetachableElement, node +from pyxform.utils import node +from pyxform.validators.pyxform import unique_names if TYPE_CHECKING: from pyxform.question import Question @@ -94,18 +95,18 @@ def iter_descendants( iter_into_section_items=iter_into_section_items, ) - # there's a stronger test of this when creating the xpath - # dictionary for a survey. def _validate_uniqueness_of_element_names(self): - element_slugs = set() + child_names = set() + child_names_lower = set() + warnings = [] for element in self.children: - elem_lower = element.name.lower() - if elem_lower in element_slugs: - raise PyXFormError( - f"There are more than one survey elements named '{elem_lower}' " - f"(case-insensitive) in the section named '{self.name}'." - ) - element_slugs.add(elem_lower) + unique_names.validate_question_group_repeat_name( + name=element.name, + seen_names=child_names, + seen_names_lower=child_names_lower, + warnings=warnings, + check_reserved=False, + ) def xml_instance(self, survey: "Survey", **kwargs): """ @@ -133,9 +134,13 @@ def xml_instance(self, survey: "Survey", **kwargs): if isinstance(child, RepeatingSection) and not append_template: append_template = not append_template repeating_template = child.generate_repeating_template(survey=survey) - result.appendChild( - child.xml_instance(survey=survey, append_template=append_template) + child_instance = child.xml_instance( + survey=survey, append_template=append_template ) + if isinstance(child_instance, Attr): + result.setAttributeNode(child_instance) + else: + result.appendChild(child_instance) if append_template and repeating_template: append_template = not append_template result.insertBefore(repeating_template, result._get_lastChild()) @@ -230,8 +235,25 @@ def xml_control(self, survey: "Survey"): for n in Section.xml_control(self, survey=survey): repeat_node.appendChild(n) - for setvalue_node in self._dynamic_defaults_helper(current=self, survey=survey): - repeat_node.appendChild(setvalue_node) + # Get setvalue nodes for all descendants of this repeat that have dynamic defaults + # and aren't nested in other repeats. Let nested repeats handle their own defaults + from pyxform.question import Question + from pyxform.survey_elements.attribute import Attribute + + def condition(i, parent=self): + return isinstance(i, Attribute | Question) and ( + i.parent is self + or parent + == next( + i.iter_ancestors(condition=lambda j: isinstance(j, RepeatingSection)), + (None, None), + )[0] + ) + + for e in self.iter_descendants(condition=condition): + for dynamic_default in e.xml_actions(survey=survey, in_repeat=True): + if dynamic_default: + repeat_node.appendChild(dynamic_default) label = self.xml_label(survey=survey) if label: @@ -241,21 +263,6 @@ def xml_control(self, survey: "Survey"): else: return node("group", repeat_node, ref=self.get_xpath()) - # Get setvalue nodes for all descendants of this repeat that have dynamic defaults and aren't nested in other repeats. - def _dynamic_defaults_helper( - self, current: "Section", survey: "Survey" - ) -> Generator[DetachableElement, None, None]: - if not isinstance(current, Section): - return - for e in current.children: - if e.type != "repeat": # let nested repeats handle their own defaults - dynamic_default = e.get_setvalue_node_for_dynamic_default( - in_repeat=True, survey=survey - ) - if dynamic_default: - yield dynamic_default - yield from self._dynamic_defaults_helper(current=e, survey=survey) - # I'm anal about matching function signatures when overriding a function, # but there's no reason for kwargs to be an argument def template_instance(self, survey: "Survey", **kwargs): diff --git a/pyxform/survey.py b/pyxform/survey.py index dd18c51f4..e07c60609 100644 --- a/pyxform/survey.py +++ b/pyxform/survey.py @@ -20,8 +20,9 @@ from pyxform.parsing.expression import RE_PYXFORM_REF from pyxform.parsing.instance_expression import replace_with_output from pyxform.question import Itemset, MultipleChoiceQuestion, Option, Question, Tag -from pyxform.section import SECTION_EXTRA_FIELDS, Section +from pyxform.section import SECTION_EXTRA_FIELDS, RepeatingSection, Section from pyxform.survey_element import _GET_SENTINEL, SURVEY_ELEMENT_FIELDS, SurveyElement +from pyxform.survey_elements.attribute import Attribute from pyxform.utils import ( LAST_SAVED_INSTANCE_NAME, DetachableElement, @@ -29,6 +30,7 @@ node, ) from pyxform.validators import enketo_validate, odk_validate +from pyxform.validators.pyxform import unique_names from pyxform.validators.pyxform.iana_subtags.validation import get_languages_with_bad_tags from pyxform.validators.pyxform.pyxform_reference import ( has_pyxform_reference_with_last_saved, @@ -165,7 +167,7 @@ def recursive_dict(): constants.CLIENT_EDITABLE, constants.COMPACT_DELIMITER, constants.COMPACT_PREFIX, - constants.ENTITY_FEATURES, + constants.ENTITY_VERSION, ) SURVEY_FIELDS = (*SURVEY_ELEMENT_FIELDS, *SECTION_EXTRA_FIELDS, *SURVEY_EXTRA_FIELDS) @@ -191,7 +193,7 @@ def __init__(self, **kwargs): # attribute is for custom instance attrs from settings e.g. attribute::abc:xyz self.attribute: dict | None = None self.choices: dict[str, Itemset] | None = None - self.entity_features: list[str] | None = None + self.entity_version: constants.EntityVersion | None = None self.setgeopoint_by_triggering_ref: dict[str, list[str]] = {} self.setvalues_by_triggering_ref: dict[str, list[str]] = {} @@ -249,25 +251,20 @@ def validate(self): def _validate_uniqueness_of_section_names(self): root_node_name = self.name - section_names = set() - for element in self.iter_descendants(condition=lambda i: isinstance(i, Section)): - if element.name in section_names: - if element.name == root_node_name: - # The root node name is rarely explictly set; explain - # the problem in a more helpful way (#510) - msg = ( - f"The name '{element.name}' is the same as the form name. " - "Use a different section name (or change the form name in " - "the 'name' column of the settings sheet)." - ) - raise PyXFormError(msg) - msg = f"There are two sections with the name {element.name}." - raise PyXFormError(msg) - section_names.add(element.name) + repeat_names = set() + for element in self.iter_descendants( + condition=lambda i: isinstance(i, RepeatingSection) + ): + unique_names.validate_repeat_name( + name=element.name, + control_type=constants.REPEAT, + instance_element_name=root_node_name, + seen_names=repeat_names, + ) def get_nsmap(self): """Add additional namespaces""" - if self.entity_features: + if self.entity_version: entities_ns = " entities=http://www.opendatakit.org/xforms/entities" if self.namespaces is None: self.namespaces = entities_ns @@ -311,12 +308,6 @@ def xml(self): **nsmap, ) - def get_trigger_values_for_question_name(self, question_name: str, trigger_type: str): - if trigger_type == "setvalue": - return self.setvalues_by_triggering_ref.get(question_name) - elif trigger_type == "setgeopoint": - return self.setgeopoint_by_triggering_ref.get(question_name) - def _generate_static_instances( self, list_name: str, itemset: Itemset ) -> InstanceInfo: @@ -603,31 +594,17 @@ def get_element_instances(): yield i.instance seen[i.name] = i - def xml_descendent_bindings(self) -> Generator[DetachableElement | None, None, None]: + def xml_model_bindings(self) -> Generator[DetachableElement | None, None, None]: """ - Yield bindings for this node and all its descendants. + Yield bindings (bind or action elements) for this node and all its descendants. """ for e in self.iter_descendants( condition=lambda i: not isinstance(i, Option | Tag) ): yield from e.xml_bindings(survey=self) - # dynamic defaults for repeats go in the body. All other dynamic defaults (setvalue actions) go in the model - if not next( - e.iter_ancestors(condition=lambda i: i.type == constants.REPEAT), False - ): - dynamic_default = e.get_setvalue_node_for_dynamic_default(survey=self) - if dynamic_default: - yield dynamic_default - - def xml_actions(self) -> Generator[DetachableElement, None, None]: - """ - Yield xml_actions for this node and all its descendants. - """ - for e in self.iter_descendants(condition=lambda i: isinstance(i, Question)): - xml_action = e.xml_action() - if xml_action is not None: - yield xml_action + if isinstance(e, Attribute | Question): + yield from e.xml_actions(survey=self, in_repeat=False) def xml_model(self): """ @@ -639,8 +616,8 @@ def xml_model(self): model_kwargs = {"odk:xforms-version": constants.CURRENT_XFORMS_VERSION} - if self.entity_features: - model_kwargs["entities:entities-version"] = constants.ENTITIES_OFFLINE_VERSION + if self.entity_version: + model_kwargs["entities:entities-version"] = self.entity_version.value model_children = [] if self._translations: @@ -676,8 +653,7 @@ def xml_model(self): def model_children_generator(): yield from model_children yield from self._generate_instances() - yield from self.xml_descendent_bindings() - yield from self.xml_actions() + yield from self.xml_model_bindings() return node("model", model_children_generator(), **model_kwargs) diff --git a/pyxform/survey_element.py b/pyxform/survey_element.py index fc45b19bc..d694b17a7 100644 --- a/pyxform/survey_element.py +++ b/pyxform/survey_element.py @@ -13,11 +13,13 @@ from pyxform import constants as const from pyxform.errors import PyXFormError from pyxform.parsing.expression import is_xml_tag -from pyxform.utils import INVALID_XFORM_TAG_REGEXP, DetachableElement, node -from pyxform.validators.pyxform.pyxform_reference import ( - has_pyxform_reference, +from pyxform.utils import ( + INVALID_XFORM_TAG_REGEXP, + DetachableElement, + node, + print_pyobj_to_json, ) -from pyxform.xls2json import print_pyobj_to_json +from pyxform.validators.pyxform.pyxform_reference import has_pyxform_reference if TYPE_CHECKING: from pyxform.survey import Survey @@ -135,6 +137,10 @@ def __init__( self.label = " " super().__init__() + @property + def name_for_xpath(self) -> str: + return self.name + def _link_children(self): if self.children is not None: for child in self.children: @@ -286,7 +292,7 @@ def stop_before(e): ) ) if condition(self): - lineage = chain(parent_lineage, (self.name,)) + lineage = chain(parent_lineage, (self.name_for_xpath,)) else: lineage = parent_lineage new_value = f"/{'/'.join(n for n in lineage)}" @@ -588,6 +594,11 @@ def xml_control(self, survey: "Survey"): """ raise NotImplementedError("Control not implemented") + def xml_actions( + self, survey: "Survey", in_repeat: bool = False + ) -> Generator[DetachableElement | None, None, None]: + yield + def hashable(v): """Determine whether `v` can be hashed.""" diff --git a/pyxform/survey_elements/__init__.py b/pyxform/survey_elements/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pyxform/survey_elements/attribute.py b/pyxform/survey_elements/attribute.py new file mode 100644 index 000000000..ad2aaf1af --- /dev/null +++ b/pyxform/survey_elements/attribute.py @@ -0,0 +1,68 @@ +from collections.abc import Generator +from itertools import chain +from typing import TYPE_CHECKING +from xml.dom.minidom import Attr + +from pyxform import constants as const +from pyxform.elements import action +from pyxform.survey_element import SURVEY_ELEMENT_FIELDS, SurveyElement +from pyxform.utils import DetachableElement + +if TYPE_CHECKING: + from pyxform.survey import Survey + + +ATTRIBUTE_EXTRA_FIELDS = ( + "actions", + "value", + const.BIND, + const.TYPE, +) +ATTRIBUTE_FIELDS = (*SURVEY_ELEMENT_FIELDS, *ATTRIBUTE_EXTRA_FIELDS) + + +class Attribute(SurveyElement): + @staticmethod + def get_slot_names() -> tuple[str, ...]: + return ATTRIBUTE_FIELDS + + def __init__(self, fields: tuple[str, ...] | None = None, **kwargs): + # Structure + self.actions: list[dict[str, str]] | None = None + self.value: str = "" + self.bind: dict | None = None + self.type: str | None = None + + if fields is None: + fields = ATTRIBUTE_FIELDS + else: + fields = chain(ATTRIBUTE_FIELDS, fields) + super().__init__(fields=fields, **kwargs) + + @property + def name_for_xpath(self) -> str: + return f"@{self.name}" + + def xml_instance(self, survey: "Survey", **kwargs): + attr = Attr(self.name) + attr.value = self.value + return attr + + def xml_actions( + self, survey: "Survey", in_repeat: bool = False + ) -> Generator[DetachableElement | None, None, None]: + action_fields = {"name", "event", "value"} + if self.actions: + for _action in self.actions: + event = action.Event(_action["event"]) + if (not in_repeat and event == action.Event.ODK_NEW_REPEAT.value) or ( + in_repeat and event != action.Event.ODK_NEW_REPEAT + ): + continue + + yield action.ACTION_CLASSES[_action["name"]]( + ref=self.get_xpath(), + event=event, + value=_action.get("value"), + **{k: v for k, v in _action.items() if k not in action_fields}, + ).node() diff --git a/pyxform/survey_elements/label.py b/pyxform/survey_elements/label.py new file mode 100644 index 000000000..7003e4c41 --- /dev/null +++ b/pyxform/survey_elements/label.py @@ -0,0 +1,36 @@ +from itertools import chain +from typing import TYPE_CHECKING + +from pyxform import constants as const +from pyxform.survey_element import SURVEY_ELEMENT_FIELDS, SurveyElement +from pyxform.utils import node + +if TYPE_CHECKING: + from pyxform.survey import Survey + + +LABEL_EXTRA_FIELDS = ( + const.BIND, + const.TYPE, +) +LABEL_FIELDS = (*SURVEY_ELEMENT_FIELDS, *LABEL_EXTRA_FIELDS) + + +class Label(SurveyElement): + @staticmethod + def get_slot_names() -> tuple[str, ...]: + return LABEL_FIELDS + + def __init__(self, fields: tuple[str, ...] | None = None, **kwargs): + # Structure + self.bind: dict | None = None + self.type: str | None = None + + if fields is None: + fields = LABEL_FIELDS + else: + fields = chain(LABEL_EXTRA_FIELDS, fields) + super().__init__(fields=fields, **kwargs) + + def xml_instance(self, survey: "Survey", **kwargs): + return node(self.name) diff --git a/pyxform/utils.py b/pyxform/utils.py index 7f1c3f830..b211b08d1 100644 --- a/pyxform/utils.py +++ b/pyxform/utils.py @@ -6,6 +6,7 @@ import csv import json import re +import sys from collections.abc import Generator, Iterable from functools import lru_cache from io import StringIO @@ -163,6 +164,18 @@ def get_pyobj_from_json(str_or_path): return doc +def print_pyobj_to_json(pyobj, path=None): + """ + dump a python nested array/dict structure to the specified file + or stdout if no file is specified + """ + if path: + with open(path, mode="w", encoding="utf-8") as fp: + json.dump(pyobj, fp=fp, ensure_ascii=False, indent=4) + else: + sys.stdout.write(json.dumps(pyobj, ensure_ascii=False, indent=4)) + + def flatten(li): for subli in li: yield from subli diff --git a/pyxform/validators/pyxform/pyxform_reference.py b/pyxform/validators/pyxform/pyxform_reference.py index b7512eaf2..855c297a3 100644 --- a/pyxform/validators/pyxform/pyxform_reference.py +++ b/pyxform/validators/pyxform/pyxform_reference.py @@ -131,6 +131,7 @@ def has_pyxform_reference_with_last_saved(value: str) -> bool: def parse_pyxform_references( value: str, match_limit: int | None = None, + match_full: bool = False, ) -> tuple[str, ...]: """ Parse all pyxform references in a string. @@ -138,8 +139,10 @@ def parse_pyxform_references( :param value: The string to inspect. :param match_limit: If provided, parse only this many references in the string, and if more references than the limit are found, then raise an error. + :param match_full: If True, require that the string contains a reference and nothing + else (no other characters or references). """ - return tuple(_parse(value=value, match_limit=match_limit)) + return tuple(_parse(value=value, match_limit=match_limit, match_full=match_full)) def validate_pyxform_reference_syntax( diff --git a/pyxform/validators/pyxform/unique_names.py b/pyxform/validators/pyxform/unique_names.py new file mode 100644 index 000000000..566ec50ca --- /dev/null +++ b/pyxform/validators/pyxform/unique_names.py @@ -0,0 +1,113 @@ +from pyxform import constants as const +from pyxform.errors import Detail, PyXFormError + +NAMES001 = Detail( + name="Invalid duplicate name in same context", + msg=( + "[row : {row}] On the 'survey' sheet, the 'name' value '{value}' is invalid. " + "Questions, groups, and repeats must be unique within their nearest parent group " + "or repeat, or the survey if not inside a group or repeat." + ), +) +NAMES002 = Detail( + name="Invalid duplicate name in context (case-insensitive)", + msg=( + "[row : {row}] On the 'survey' sheet, the 'name' value '{value}' is problematic. " + "The name is a case-insensitive match to another name. Questions, groups, and " + "repeats should be unique within the nearest parent group or repeat, or the survey " + "if not inside a group or repeat. Some data processing tools are not " + "case-sensitive, so the current names may make analysis difficult." + ), +) +NAMES003 = Detail( + name="Invalid repeat name same as survey", + msg=( + "[row : {row}] On the 'survey' sheet, the 'name' value '{value}' is invalid. " + "Repeat names must not be the same as the survey root (which defaults to 'data')." + ), +) +NAMES004 = Detail( + name="Invalid duplicate repeat name in the survey", + msg=( + "[row : {row}] On the 'survey' sheet, the 'name' value '{value}' is invalid. " + "Repeat names must unique anywhere in the survey, at all levels of group or " + "repeat nesting." + ), +) +NAMES005 = Detail( + name="Invalid duplicate meta name in the survey", + msg=( + "[row : {row}] On the 'survey' sheet, the 'name' value 'meta' is invalid. " + "The name 'meta' is reserved for form metadata." + ), +) + + +def validate_question_group_repeat_name( + name: str | None, + seen_names: set[str], + seen_names_lower: set[str], + warnings: list[str], + row_number: int | None = None, + check_reserved: bool = True, +): + """ + Warn about duplicate or problematic names on the survey sheet. + + May append the name to `seen_names` and `neen_names_lower`. May append to `warnings`. + + :param name: Question or group name. + :param seen_names: Names already processed in the sheet. + :param seen_names_lower: Same as seen_names but lower case. + :param warnings: Warnings list. + :param row_number: Current sheet row number. + :param check_reserved: If True, check the name against any reserved names. When + checking names in the context of SurveyElement processing, it's difficult to + differentiate user-specified names from names added by pyxform. + """ + if not name: + return + + if check_reserved and not seen_names >= const.RESERVED_NAMES_SURVEY_SHEET: + seen_names.update(const.RESERVED_NAMES_SURVEY_SHEET) + + if name in seen_names: + if name == const.META: + raise PyXFormError(NAMES005.format(row=row_number)) + else: + raise PyXFormError(NAMES001.format(row=row_number, value=name)) + seen_names.add(name) + + question_name_lower = name.lower() + if question_name_lower in seen_names_lower: + # No case-insensitive warning for 'meta' since it's not an exported data table. + warnings.append(NAMES002.format(row=row_number, value=name)) + seen_names_lower.add(question_name_lower) + + +def validate_repeat_name( + name: str | None, + control_type: str, + instance_element_name: str, + seen_names: set[str], + row_number: int | None = None, +): + """ + Warn about duplicate or problematic names. + + May appends the name to `seen_names` and `neen_names_lower`. May append to `warnings`. + These checks are additional to the above in validate_survey_sheet_name so does not + re-check reserved names etc. + + :param row_number: Current sheet row number. + :param name: Question or group name. + :param control_type: E.g. group, repeat, or loop. + :param instance_element_name: Name of the main survey instance element. + :param seen_names: Names already processed in the sheet. + """ + if control_type == const.REPEAT: + if name == instance_element_name: + raise PyXFormError(NAMES003.format(row=row_number, value=name)) + elif name in seen_names: + raise PyXFormError(NAMES004.format(row=row_number, value=name)) + seen_names.add(name) diff --git a/pyxform/xls2json.py b/pyxform/xls2json.py index 07146fc2c..6500c48a7 100644 --- a/pyxform/xls2json.py +++ b/pyxform/xls2json.py @@ -2,7 +2,6 @@ A Python script to convert excel files into JSON. """ -import json import os import re import sys @@ -16,18 +15,25 @@ ROW_FORMAT_STRING, XML_IDENTIFIER_ERROR_MESSAGE, ) +from pyxform.elements import action as action_module from pyxform.entities.entities_parsing import ( get_entity_declaration, + validate_entity_repeat_target, validate_entity_saveto, ) -from pyxform.errors import PyXFormError +from pyxform.errors import Detail, PyXFormError from pyxform.parsing.expression import ( is_xml_tag, maybe_strip, ) from pyxform.parsing.sheet_headers import dealias_and_group_headers -from pyxform.utils import coalesce, default_is_dynamic -from pyxform.validators.pyxform import parameters_generic, select_from_file +from pyxform.question_type_dictionary import get_meta_group +from pyxform.utils import ( + coalesce, + default_is_dynamic, + print_pyobj_to_json, +) +from pyxform.validators.pyxform import parameters_generic, select_from_file, unique_names from pyxform.validators.pyxform import question_types as qt from pyxform.validators.pyxform.android_package_name import validate_android_package_name from pyxform.validators.pyxform.choices import validate_and_clean_choices @@ -63,18 +69,20 @@ RE_OSM = re.compile( r"(?P(" + "|".join(aliases.osm) + r")) (?P\S+)" ) - - -def print_pyobj_to_json(pyobj, path=None): - """ - dump a python nested array/dict structure to the specified file - or stdout if no file is specified - """ - if path: - with open(path, mode="w", encoding="utf-8") as fp: - json.dump(pyobj, fp=fp, ensure_ascii=False, indent=4) - else: - sys.stdout.write(json.dumps(pyobj, ensure_ascii=False, indent=4)) +SURVEY_001 = Detail( + name="Survey Sheet Unmatched Group/Repeat/Loop End", + msg=( + "[row : {row}] Unmatched 'end_{type}'. " + "No matching 'begin_{type}' was found for the name '{name}'." + ), +) +SURVEY_002 = Detail( + name="Survey Sheet Unmatched Group/Repeat/Loop Begin", + msg=( + "[row : {row}] Unmatched 'begin_{type}'. " + "No matching 'end_{type}' was found for the name '{name}'." + ), +) def dealias_types(dict_array): @@ -457,18 +465,12 @@ def workbook_to_json( entities_sheet = clean_text_values( sheet_name=constants.ENTITIES, data=workbook_dict.entities ) - from pyxform.entities.entity_declaration import EntityDeclaration - entities_sheet = dealias_and_group_headers( sheet_name=constants.ENTITIES, sheet_data=entities_sheet, sheet_header=workbook_dict.entities_header, header_aliases=aliases.entities_header, - # Entities treat some actual columns as if they are parameters. - header_columns={ - *EntityDeclaration.get_slot_names(), - *(i.value for i in constants.EntityColumns.value_list()), - }, + header_columns={i.value for i in constants.EntityColumns.value_list()}, ) entity_declaration = get_entity_declaration(entities_sheet=entities_sheet.data) else: @@ -534,6 +536,9 @@ def workbook_to_json( "control_type": None, "control_name": None, "parent_children": json_dict.get(constants.CHILDREN), + "child_names": set(), + "child_names_lower": set(), + "row_number": None, } ] # If a group has a table-list appearance flag @@ -545,15 +550,15 @@ def workbook_to_json( # To check that questions with triggers refer to other questions that exist. question_names = set() trigger_references: list[tuple[str, int]] = [] + repeat_names = set() # row by row, validate questions, throwing errors and adding warnings where needed. for row_number, row in enumerate(survey_sheet.data, start=2): - if stack[-1] is not None: - prev_control_type = stack[-1]["control_type"] - parent_children_array = stack[-1]["parent_children"] - else: - prev_control_type = None - parent_children_array = [] + prev_control_type = stack[-1]["control_type"] + parent_children_array = stack[-1]["parent_children"] + child_names = stack[-1]["child_names"] + child_names_lower = stack[-1]["child_names_lower"] + # Disabled should probably be first # so the attributes below can be disabled. if "disabled" in row: @@ -572,7 +577,6 @@ def workbook_to_json( # Get question type question_type = row.get(constants.TYPE) - question_name = row.get(constants.NAME) if not question_type: # if name and label are also missing, @@ -595,6 +599,21 @@ def workbook_to_json( trigger_references=trigger_references, ) + if "default" in row: + question_default = row.get("default") + if question_default and default_is_dynamic(question_default, question_type): + row["_dynamic_default"] = True + row["actions"] = row.get("actions", []) + row["actions"].append( + action_module.ActionLibrary.setvalue_first_load.value.to_dict() + ) + if next( + (i for i in reversed(stack) if i["control_type"] == "repeat"), False + ): + row["actions"].append( + action_module.ActionLibrary.setvalue_new_repeat.value.to_dict() + ) + parameters = parameters_generic.parse(raw_parameters=row.get("parameters", "")) # Pull out questions that will go in meta block @@ -765,10 +784,7 @@ def workbook_to_json( if question_type == "calculate": calculation = row.get("bind", {}).get("calculate") - question_default = row.get("default") - if not calculation and not ( - question_default and default_is_dynamic(question_default, question_type) - ): + if not calculation and not row.get("_dynamic_default", False): raise PyXFormError( ROW_FORMAT_STRING % row_number + " Missing calculation." ) @@ -793,17 +809,25 @@ def workbook_to_json( if end_control_parse: parse_dict = end_control_parse.groupdict() if parse_dict.get("end") and "type" in parse_dict: + if validate_entity_repeat_target( + entity_declaration=entity_declaration, + stack=stack, + ): + parent_children_array.append( + get_meta_group(children=[entity_declaration]) + ) + json_dict[constants.ENTITY_VERSION] = ( + constants.EntityVersion.v2025_1_0 + ) + entity_declaration = None control_type = aliases.control[parse_dict["type"]] - control_name = question_name if prev_control_type != control_type or len(stack) == 1: raise PyXFormError( - ROW_FORMAT_STRING % row_number - + " Unmatched end statement. Previous control type: " - + str(prev_control_type) - + ", Control type: " - + str(control_type) - + ", Control name: " - + str(control_name) + SURVEY_001.format( + row=row_number, + type=control_type, + name=row.get(constants.NAME), + ) ) stack.pop() table_list = None @@ -811,12 +835,9 @@ def workbook_to_json( # Make sure the row has a valid name if constants.NAME not in row: - if row["type"] == "note": + if row[constants.TYPE] == "note": # autogenerate names for notes without them - row["name"] = "generated_note_name_" + str(row_number) - # elif 'group' in row['type'].lower(): - # # autogenerate names for groups without them - # row['name'] = "generated_group_name_" + str(row_number) + row[constants.NAME] = "generated_note_name_" + str(row_number) else: raise PyXFormError( ROW_FORMAT_STRING % row_number + " Question or group with no name." @@ -830,8 +851,19 @@ def workbook_to_json( f"{ROW_FORMAT_STRING % row_number} Invalid question name '{question_name}'. Names {XML_IDENTIFIER_ERROR_MESSAGE}" ) - in_repeat = any(ancestor["control_type"] == "repeat" for ancestor in stack) - validate_entity_saveto(row, row_number, in_repeat, entity_declaration) + unique_names.validate_question_group_repeat_name( + row_number=row_number, + name=question_name, + seen_names=child_names, + seen_names_lower=child_names_lower, + warnings=warnings, + ) + validate_entity_saveto( + row=row, + row_number=row_number, + stack=stack, + entity_declaration=entity_declaration, + ) # Try to parse question as begin control statement # (i.e. begin loop/repeat/group): @@ -845,7 +877,14 @@ def workbook_to_json( # (so following questions are nested under it) # until an end command is encountered. control_type = aliases.control[parse_dict["type"]] - control_name = question_name + + unique_names.validate_repeat_name( + row_number=row_number, + name=question_name, + control_type=control_type, + instance_element_name=json_dict[constants.NAME], + seen_names=repeat_names, + ) # Check if the control item has a label, if applicable. # This label check used to apply to all items, but no longer is @@ -857,10 +896,7 @@ def workbook_to_json( and row.get(constants.MEDIA) is None and question_type not in aliases.label_optional_types and not row.get("bind", {}).get("calculate") - and not ( - row.get("default") - and default_is_dynamic(row.get("default"), question_type) - ) + and not row.get("_dynamic_default", False) and not ( control_type is constants.GROUP and row.get("control", {}).get("appearance") @@ -879,7 +915,7 @@ def workbook_to_json( new_json_dict[constants.TYPE] = control_type child_list = [] new_json_dict[constants.CHILDREN] = child_list - if control_type is constants.LOOP: + if control_type == constants.LOOP: if not parse_dict.get(constants.LIST_NAME_U): # TODO: Perhaps warn and make repeat into a group? raise PyXFormError( @@ -957,10 +993,17 @@ def workbook_to_json( stack.append( { "control_type": control_type, - "control_name": control_name, + "control_name": question_name, "parent_children": child_list, + "child_names": set(), + "child_names_lower": set(), + "row_number": row_number, } ) + validate_entity_repeat_target( + stack=stack, + entity_declaration=entity_declaration, + ) continue # Assuming a question is anything not processed above as a loop/repeat/group. @@ -1310,6 +1353,9 @@ def workbook_to_json( if question_type == "background-audio": new_dict = row.copy() parameters_generic.validate(parameters=parameters, allowed=("quality",)) + action = ( + action_module.ActionLibrary.odk_recordaudio_instance_load.value.to_dict() + ) if "quality" in parameters: if parameters["quality"] not in { @@ -1319,8 +1365,10 @@ def workbook_to_json( }: raise PyXFormError("Invalid value for quality.") - new_dict["action"] = new_dict.get("action", {}) - new_dict["action"].update({"odk:quality": parameters["quality"]}) + action["odk:quality"] = parameters["quality"] + + new_dict["actions"] = new_dict.get("actions", []) + new_dict["actions"].append(action) parent_children_array.append(new_dict) continue @@ -1378,6 +1426,15 @@ def workbook_to_json( continue # TODO: Consider adding some question_type validation here. + if question_type == "start-geopoint": + new_dict = row.copy() + new_dict["actions"] = new_dict.get("actions", []) + new_dict["actions"].append( + action_module.ActionLibrary.odk_setgeopoint_first_load.value.to_dict() + ) + parent_children_array.append(new_dict) + continue + if question_type == "background-geopoint": qt.validate_background_geopoint_trigger( trigger=row.get("trigger"), row_num=row_number @@ -1394,10 +1451,13 @@ def workbook_to_json( e.context.update(sheet="survey", column="trigger") raise - if len(stack) != 1: + if len(stack) > 1: raise PyXFormError( - "Unmatched begin statement: " - + str(stack[-1]["control_type"] + " (" + stack[-1]["control_name"] + ")") + SURVEY_002.format( + row=stack[-1]["row_number"], + type=stack[-1]["control_type"], + name=stack[-1]["control_name"], + ) ) if settings.get("flat", False): @@ -1431,16 +1491,12 @@ def workbook_to_json( ) if entity_declaration: - json_dict[constants.ENTITY_FEATURES] = ["create", "update", "offline"] + validate_entity_repeat_target(entity_declaration=entity_declaration) + json_dict[constants.ENTITY_VERSION] = constants.EntityVersion.v2024_1_0 meta_children.append(entity_declaration) if len(meta_children) > 0: - meta_element = { - "name": "meta", - "type": "group", - "control": {"bodyless": True}, - "children": meta_children, - } + meta_element = get_meta_group(children=meta_children) survey_children_array = stack[0]["parent_children"] survey_children_array.append(meta_element) @@ -1470,26 +1526,6 @@ def parse_file_to_json( ) -def organize_by_values(dict_list, key): - """ - dict_list -- a list of dicts - key -- a key shared by all the dicts in dict_list - Returns a dict of dicts keyed by the value of the specified key - in each dictionary. - If two dictionaries fall under the same key an error is thrown. - If a dictionary is doesn't have the specified key it is omitted - """ - result = {} - for dicty in dict_list: - if key in dicty: - dicty_copy = dicty.copy() - val = dicty_copy.pop(key) - if val in result: - raise PyXFormError("Duplicate key: " + val) - result[val] = dicty_copy - return result - - class SpreadsheetReader: def __init__(self, path_or_file): path = path_or_file @@ -1542,30 +1578,6 @@ def print_warning_log(self, warn_out_file): warn_out.write("\n".join(self._warnings)) -class QuestionTypesReader(SpreadsheetReader): - """ - Class for reading spreadsheet file that specifies the available - question types. - @see question_type_dictionary - """ - - def __init__(self, path): - super().__init__(path) - self._setup_question_types_dictionary() - - def _setup_question_types_dictionary(self): - types_sheet = "question types" - self._dict = self._dict[types_sheet] - self._dict = dealias_and_group_headers( - sheet_name=types_sheet, - sheet_data=self._dict, - header_aliases={}, - header_columns=set(), - default_language=constants.DEFAULT_LANGUAGE_VALUE, - ).data - self._dict = organize_by_values(self._dict, "name") - - if __name__ == "__main__": # Open the excel file specified by the argument of this python call, # convert that file to json, then print it diff --git a/tests/bug_example_xls/not_closed_group_test.xls b/tests/bug_example_xls/not_closed_group_test.xls deleted file mode 100644 index 6e8da1a11..000000000 Binary files a/tests/bug_example_xls/not_closed_group_test.xls and /dev/null differ diff --git a/tests/entities/__init__.py b/tests/entities/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/entities/test_create_repeat.py b/tests/entities/test_create_repeat.py new file mode 100644 index 000000000..f05d00de9 --- /dev/null +++ b/tests/entities/test_create_repeat.py @@ -0,0 +1,556 @@ +from pyxform import constants as co +from pyxform.entities import entities_parsing as ep +from pyxform.errors import ErrorCode + +from tests.pyxform_test_case import PyxformTestCase +from tests.xpath_helpers.entities import xpe + + +class TestEntitiesCreateRepeat(PyxformTestCase): + """Test entity create specs for entities declared in a repeat""" + + def test_basic_usage__ok(self): + """Should find that the entity repeat has a meta block and the bindings target it.""" + md = """ + | survey | | | | | + | | type | name | label | save_to | + | | begin_repeat | r1 | R1 | | + | | text | q1 | Q1 | q1e | + | | end_repeat | | | | + + | entities | + | | list_name | label | repeat | + | | e1 | ${q1} | ${r1} | + """ + self.assertPyxformXform( + md=md, + warnings_count=0, + xml__xpath_match=[ + xpe.model_entities_version(co.EntityVersion.v2025_1_0.value), + xpe.model_instance_meta( + "e1", + "/x:r1", + repeat=True, + template=True, + create=True, + label=True, + ), + xpe.model_instance_meta( + "e1", + "/x:r1", + repeat=True, + create=True, + label=True, + ), + xpe.model_bind_question_saveto("/r1/q1", "q1e"), + xpe.model_bind_meta_id(meta_path="/r1"), + xpe.model_setvalue_meta_id("/r1"), + xpe.model_bind_meta_label(" ../../../q1 ", "/r1"), + xpe.model_bind_meta_instanceid(), + xpe.body_repeat_setvalue_meta_id( + "/x:group/x:repeat[@nodeset='/test_name/r1']", "/r1" + ), + ], + xml__contains=['xmlns:entities="http://www.opendatakit.org/xforms/entities"'], + xml__xpath_count=[ + ("/h:html//x:setvalue", 2), + ], + ) + + def test_minimal_fields__ok(self): + """Should find that omitting all optional entity fields is OK.""" + md = """ + | survey | + | | type | name | label | + | | begin_repeat | r1 | R1 | + | | text | q1 | Q1 | + | | end_repeat | | | + + | entities | + | | list_name | label | repeat | + | | e1 | ${q1} | ${r1} | + """ + self.assertPyxformXform( + md=md, + warnings_count=0, + xml__xpath_match=[ + xpe.model_setvalue_meta_id("/r1"), + xpe.body_repeat_setvalue_meta_id( + "/x:group/x:repeat[@nodeset='/test_name/r1']", "/r1" + ), + ], + xml__xpath_count=[ + ("/h:html//x:setvalue", 2), + ], + ) + + def test_create_if__ok(self): + """Should find that the create_if expression targets the entity repeat.""" + md = """ + | survey | + | | type | name | label | save_to | + | | begin_repeat | r1 | R1 | | + | | text | q1 | Q1 | q1e | + | | end_repeat | | | | + + | entities | + | | list_name | label | repeat | create_if | + | | e1 | ${q1} | ${r1} | ${q1} = '' | + """ + self.assertPyxformXform( + md=md, + warnings_count=0, + xml__xpath_match=[ + xpe.model_bind_meta_create(" ../../../q1 = ''", "/r1"), + xpe.model_setvalue_meta_id("/r1"), + xpe.body_repeat_setvalue_meta_id( + "/x:group/x:repeat[@nodeset='/test_name/r1']", "/r1" + ), + ], + xml__xpath_count=[ + ("/h:html//x:setvalue", 2), + ], + ) + + def test_other_controls_before__ok(self): + """Should find that having other control types before the entity repeat is OK.""" + md = """ + | survey | + | | type | name | label | + | | begin_repeat | r1 | R1 | + | | text | q1 | Q1 | + | | end_repeat | | | + | | begin_group | g1 | G1 | + | | text | q2 | Q2 | + | | end_group | | | + | | begin_repeat | r2 | R2 | + | | text | q3 | Q3 | + | | end_repeat | | | + + | entities | + | | list_name | label | repeat | + | | e1 | ${q3} | ${r2} | + """ + self.assertPyxformXform(md=md, warnings_count=0) + + def test_other_controls_after__ok(self): + """Should find that having other control types after the entity repeat is OK.""" + md = """ + | survey | + | | type | name | label | + | | begin_repeat | r1 | R1 | + | | text | q1 | Q1 | + | | end_repeat | | | + | | begin_group | g1 | G1 | + | | text | q2 | Q2 | + | | end_group | | | + | | begin_repeat | r2 | R2 | + | | text | q3 | Q3 | + | | end_repeat | | | + + | entities | + | | list_name | label | repeat | + | | e1 | ${q1} | ${r1} | + """ + self.assertPyxformXform(md=md, warnings_count=0) + + def test_other_controls_before_and_after__ok(self): + """Should find that having other control types before or after the entity repeat is OK.""" + md = """ + | survey | + | | type | name | label | + | | begin_repeat | r1 | R1 | + | | text | q1 | Q1 | + | | end_repeat | | | + | | begin_group | g1 | G1 | + | | text | q2 | Q2 | + | | end_group | | | + | | begin_repeat | r2 | R2 | + | | text | q3 | Q3 | + | | end_repeat | | | + | | begin_group | g2 | G2 | + | | text | q4 | Q4 | + | | end_group | | | + | | begin_repeat | r3 | R3 | + | | text | q5 | Q5 | + | | end_repeat | | | + + | entities | + | | list_name | label | repeat | + | | e1 | ${q3} | ${r2} | + """ + self.assertPyxformXform(md=md, warnings_count=0) + + def test_question_without_saveto_in_entity_repeat__ok(self): + """Should find that including a question with no save_to in the entity repeat is OK.""" + md = """ + | survey | | | | | + | | type | name | label | save_to | + | | begin_repeat | r1 | R1 | | + | | text | q1 | Q1 | q1e | + | | text | q2 | Q2 | | + | | end_repeat | | | | + + | entities | + | | list_name | label | repeat | + | | e1 | ${q1} | ${r1} | + """ + self.assertPyxformXform( + md=md, + warnings_count=0, + xml__xpath_match=[ + xpe.model_instance_meta( + "e1", + "/x:r1", + repeat=True, + template=True, + create=True, + label=True, + ), + xpe.model_instance_meta( + "e1", + "/x:r1", + repeat=True, + create=True, + label=True, + ), + xpe.model_bind_question_saveto("/r1/q1", "q1e"), + # repeat model instance question + """ + /h:html/h:head/x:model/x:instance/x:test_name/x:r1[@jr:template='']/x:q2 + """, + """ + /h:html/h:head/x:model/x:instance/x:test_name/x:r1[not(@jr:template='')]/x:q2 + """, + # repeat bind question no saveto + """ + /h:html/h:head/x:model/x:bind[ + @nodeset='/test_name/r1/q2' + and not(@entities:saveto) + ] + """, + ], + ) + + def test_repeat_without_saveto_in_entity_repeat__ok(self): + """Should find that including a repeat with no save_to in the entity repeat is OK.""" + md = """ + | survey | | | | | + | | type | name | label | save_to | + | | begin_repeat | r1 | R1 | | + | | text | q1 | Q1 | q1e | + | | begin_repeat | r2 | R2 | | + | | text | q2 | Q2 | | + | | end_repeat | | | | + | | end_repeat | | | | + + | entities | + | | list_name | label | repeat | + | | e1 | ${q1} | ${r1} | + """ + self.assertPyxformXform( + md=md, + warnings_count=0, + xml__xpath_match=[ + # repeat template for adjacent repeat doesn't get meta block + """ + /h:html/h:head/x:model/x:instance/x:test_name/x:r1[ + @jr:template='' + and ./x:q1 + and ./x:r2[not(./x:meta)] + ] + """, + # repeat default for adjacent repeat doesn't get meta block + """ + /h:html/h:head/x:model/x:instance/x:test_name/x:r1[ + not(@jr:template) + and ./x:q1 + and ./x:r2[not(./x:meta)] + ] + """, + ], + ) + + def test_saveto_question_in_nested_group__ok(self): + """Should find that putting a save_to question in a group inside the entity repeat is OK.""" + md = """ + | survey | + | | type | name | label | save_to | + | | begin_repeat | r1 | R1 | | + | | begin_group | g1 | G1 | | + | | text | q1 | Q1 | q1e | + | | end_group | | | | + | | end_repeat | | | | + + | entities | + | | list_name | label | repeat | + | | e1 | ${q1} | ${r1} | + """ + self.assertPyxformXform( + md=md, + warnings_count=0, + xml__xpath_match=[ + xpe.model_instance_meta( + "e1", + "/x:r1", + repeat=True, + template=True, + create=True, + label=True, + ), + xpe.model_instance_meta( + "e1", + "/x:r1", + repeat=True, + create=True, + label=True, + ), + xpe.model_bind_question_saveto("/r1/g1/q1", "q1e"), + xpe.model_bind_meta_label(" ../../../g1/q1 ", "/r1"), + ], + ) + + def test_entity_repeat_in_group__ok(self): + """Should find that putting the entity repeat inside a group is OK.""" + md = """ + | survey | + | | type | name | label | save_to | + | | begin_group | g1 | G1 | | + | | begin_repeat | r1 | R1 | | + | | text | q1 | Q1 | q1e | + | | end_repeat | | | | + | | end_group | | | | + + | entities | + | | list_name | label | repeat | + | | e1 | ${q1} | ${r1} | + """ + self.assertPyxformXform( + md=md, + warnings_count=0, + xml__xpath_match=[ + xpe.model_instance_meta( + "e1", + "/x:g1/x:r1", + repeat=True, + template=True, + create=True, + label=True, + ), + xpe.model_instance_meta( + "e1", + "/x:g1/x:r1", + repeat=True, + create=True, + label=True, + ), + xpe.model_bind_question_saveto("/g1/r1/q1", "q1e"), + xpe.model_bind_meta_label(" ../../../q1 ", "/g1/r1"), + ], + ) + + def test_entity_repeat_is_not_a_single_reference__error(self): + """Should raise an error if the entity repeat is not a reference.""" + md = """ + | survey | + | | type | name | label | save_to | + | | begin_repeat | r1 | R1 | | + | | text | q1 | Q1 | q1e | + | | end_repeat | | | | + + | entities | + | | list_name | label | repeat | + | | e1 | ${{q1}} | {case} | + """ + # Looks like a single reference but fails to parse. + cases_pyref = ("${.a}", "${a }", "${ }") + for case in cases_pyref: + with self.subTest(msg=case): + self.assertPyxformXform( + md=md.format(case=case), + errored=True, + error__contains=[ + ErrorCode.PYREF_001.value.format( + sheet="entities", column="repeat", row=2, value=case + ) + ], + ) + # Doesn't parse, or isn't a single reference. + cases = (".", "r1", "${r1}a", "${r1}${r2}", "${last-saved#r1}", "${}") + for case in cases: + with self.subTest(msg=case): + self.assertPyxformXform( + md=md.format(case=case), + errored=True, + error__contains=[ep.ENTITY001.format(value=case)], + ) + + def test_entity_repeat_not_found__error(self): + """Should raise an error if the entity repeat was not found in the survey sheet.""" + md = """ + | survey | + | | type | name | label | + | | begin_repeat | r1 | R1 | + | | text | q1 | Q1 | + | | end_repeat | | | + + | entities | + | | list_name | label | repeat | + | | e1 | ${q1} | ${r2} | + """ + self.assertPyxformXform( + md=md, errored=True, error__contains=[ep.ENTITY002.format(value="r2")] + ) + + def test_entity_repeat_is_a_group__error(self): + """Should raise an error if the entity repeat is not a repeat.""" + md = """ + | survey | + | | type | name | label | save_to | + | | begin_group | g1 | G1 | | + | | text | q1 | Q1 | q1e | + | | end_group | | | | + + | entities | + | | list_name | label | repeat | + | | e1 | ${q1} | ${g1} | + """ + self.assertPyxformXform( + md=md, errored=True, error__contains=[ep.ENTITY003.format(value="g1")] + ) + + def test_entity_repeat_is_a_loop__error(self): + """Should raise an error if the entity repeat is not a repeat.""" + md = """ + | survey | + | | type | name | label | save_to | + | | begin_loop over c1 | l1 | L1 | | + | | text | q1 | Q1 | q1e | + | | end_loop | | | | + + | choices | + | | list_name | name | label | + | | c1 | o1 | l1 | + + | entities | + | | list_name | label | repeat | + | | e1 | ${q1} | ${l1} | + """ + self.assertPyxformXform( + md=md, errored=True, error__contains=[ep.ENTITY003.format(value="l1")] + ) + + def test_entity_repeat_in_repeat__error(self): + """Should raise an error if the entity repeat is inside a repeat.""" + md = """ + | survey | + | | type | name | label | + | | begin_repeat | r1 | R1 | + | | begin_repeat | r2 | R2 | + | | text | q1 | Q1 | + | | end_repeat | | | + | | end_repeat | | | + + | entities | + | | list_name | label | repeat | + | | e1 | ${q1} | ${r2} | + """ + self.assertPyxformXform( + md=md, errored=True, error__contains=[ep.ENTITY004.format(value="r2")] + ) + + def test_saveto_question_not_in_entity_repeat_no_entity_repeat__error( + self, + ): + """Should raise an error if a save_to question is not in the entity repeat.""" + md = """ + | survey | + | | type | name | label | save_to | + | | begin_repeat | r1 | R1 | | + | | text | q1 | Q1 | q1e | + | | end_repeat | | | | + + | entities | + | | list_name | label | repeat | + | | e1 | ${q1} | ${r2} | + """ + self.assertPyxformXform( + md=md, errored=True, error__contains=[ep.ENTITY006.format(row=3, value="q1e")] + ) + + def test_saveto_question_not_in_entity_repeat_in_survey__error(self): + """Should raise an error if a save_to question is not in the entity repeat.""" + md = """ + | survey | + | | type | name | label | save_to | + | | text | q1 | Q1 | q1e | + | | begin_repeat | r1 | R1 | | + | | text | q2 | Q2 | | + | | end_repeat | | | | + + | entities | + | | list_name | label | repeat | + | | e1 | ${q1} | ${r1} | + """ + self.assertPyxformXform( + md=md, errored=True, error__contains=[ep.ENTITY006.format(row=2, value="q1e")] + ) + + def test_saveto_question_not_in_entity_repeat_in_group__error(self): + """Should raise an error if a save_to question is not in the entity repeat.""" + md = """ + | survey | + | | type | name | label | save_to | + | | begin_group | g1 | G1 | | + | | text | q1 | Q1 | q1e | + | | end_group | | | | + | | begin_repeat | r1 | R1 | | + | | text | q2 | Q2 | | + | | end_repeat | | | | + + | entities | + | | list_name | label | repeat | + | | e1 | ${q1} | ${r1} | + """ + self.assertPyxformXform( + md=md, errored=True, error__contains=[ep.ENTITY006.format(row=3, value="q1e")] + ) + + def test_saveto_question_not_in_entity_repeat_in_repeat__error(self): + """Should raise an error if a save_to question is not in the entity repeat.""" + md = """ + | survey | + | | type | name | label | save_to | + | | begin_repeat | r1 | R1 | | + | | text | q1 | Q1 | q1e | + | | end_repeat | | | | + | | begin_repeat | r2 | R2 | | + | | text | q2 | Q2 | | + | | end_repeat | | | | + + | entities | + | | list_name | label | repeat | + | | e1 | ${q1} | ${r2} | + """ + self.assertPyxformXform( + md=md, errored=True, error__contains=[ep.ENTITY006.format(row=3, value="q1e")] + ) + + def test_saveto_question_in_nested_repeat__error(self): + """Should raise an error if a save_to question is in a repeat inside the entity repeat.""" + md = """ + | survey | + | | type | name | label | save_to | + | | begin_repeat | r1 | R1 | | + | | begin_repeat | r2 | R2 | | + | | text | q1 | Q1 | q1e | + | | end_repeat | | | | + | | end_repeat | | | | + + | entities | + | | list_name | label | repeat | + | | e1 | ${q1} | ${r1} | + """ + self.assertPyxformXform( + md=md, errored=True, error__contains=[ep.ENTITY005.format(row=4, value="q1e")] + ) diff --git a/tests/test_entities_create.py b/tests/entities/test_create_survey.py similarity index 86% rename from tests/test_entities_create.py rename to tests/entities/test_create_survey.py index 0cab15e7c..58e11ec4f 100644 --- a/tests/test_entities_create.py +++ b/tests/entities/test_create_survey.py @@ -1,10 +1,13 @@ from pyxform import constants as co +from pyxform.entities import entities_parsing as ep from tests.pyxform_test_case import PyxformTestCase from tests.xpath_helpers.entities import xpe -class EntitiesCreationTest(PyxformTestCase): +class TestEntitiesCreateSurvey(PyxformTestCase): + """Test entity create specs for entities declared at the survey level""" + def test_basic_entity_creation_building_blocks(self): self.assertPyxformXform( md=""" @@ -16,25 +19,32 @@ def test_basic_entity_creation_building_blocks(self): | | trees | a | | """, xml__xpath_match=[ - xpe.model_instance_dataset("trees"), + xpe.model_entities_version(co.EntityVersion.v2024_1_0.value), # defaults to always creating - '/h:html/h:head/x:model/x:instance/x:test_name/x:meta/x:entity[@create = "1"]', - '/h:html/h:head/x:model/x:instance/x:test_name/x:meta/x:entity[@id = ""]', - '/h:html/h:head/x:model/x:bind[@nodeset = "/test_name/meta/entity/@id" and @type = "string" and @readonly = "true()"]', - '/h:html/h:head/x:model/x:setvalue[@event = "odk-instance-first-load" and @type = "string" and @ref = "/test_name/meta/entity/@id" and @value = "uuid()"]', - "/h:html/h:head/x:model/x:instance/x:test_name/x:meta/x:entity/x:label", - xpe.model_bind_label("a"), - f"""/h:html/h:head/x:model[@entities:entities-version = '{co.ENTITIES_OFFLINE_VERSION}']""", + xpe.model_instance_meta("trees", create=True, label=True), + xpe.model_bind_meta_id(), + xpe.model_setvalue_meta_id(), + xpe.model_bind_meta_label("a"), ], xml__xpath_count=[ - ( - "/h:html/h:head/x:model/x:instance/x:test_name/x:meta/x:entity/@update", - 0, - ), + ("/h:html//x:setvalue", 1), ], xml__contains=['xmlns:entities="http://www.opendatakit.org/xforms/entities"'], ) + def test_create_repeat__minimal_fields__ok(self): + """Should find that omitting all optional entity fields is OK.""" + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + + | entities | + | | list_name | label | + | | e1 | ${q1} | + """ + self.assertPyxformXform(md=md, warnings_count=0) + def test_multiple_dataset_rows_in_entities_sheet__errors(self): self.assertPyxformXform( name="data", @@ -122,7 +132,6 @@ def test_worksheet_name_close_to_entities__produces_warning(self): def test_create_if_in_entities_sheet__puts_expression_on_bind(self): self.assertPyxformXform( - name="data", md=""" | survey | | | | | | type | name | label | @@ -132,8 +141,8 @@ def test_create_if_in_entities_sheet__puts_expression_on_bind(self): | | trees | string-length(a) > 3 | a | """, xml__xpath_match=[ - '/h:html/h:head/x:model/x:bind[@nodeset = "/data/meta/entity/@create" and @calculate = "string-length(a) > 3"]', - '/h:html/h:head/x:model/x:instance/x:data/x:meta/x:entity[@create = "1"]', + xpe.model_bind_meta_create("string-length(a) > 3"), + xpe.model_instance_meta("trees", create=True, label=True), ], ) @@ -148,8 +157,12 @@ def test_label_and_create_if_in_entities_sheet__expand_node_selectors_to_xpath(s | | trees | ${a} | string-length(${a}) > 3 | """, xml__xpath_match=[ - '/h:html/h:head/x:model/x:bind[@nodeset = "/test_name/meta/entity/@create" and @calculate = "string-length( /test_name/a ) > 3"]', - xpe.model_bind_label(" /test_name/a "), + xpe.model_bind_meta_create("string-length( /test_name/a ) > 3"), + xpe.model_bind_meta_label(" /test_name/a "), + xpe.model_setvalue_meta_id(), + ], + xml__xpath_count=[ + ("/h:html//x:setvalue", 1), ], ) @@ -183,18 +196,16 @@ def test_entities_namespace__omitted_if_no_entities_sheet(self): def test_entities_version__omitted_if_no_entities_sheet(self): self.assertPyxformXform( - name="data", md=""" | survey | | | | | | type | name | label | | | text | a | A | """, - xml__excludes=['entities:entities-version = "2022.1.0"'], + xml__xpath_match=[xpe.model_no_entities_version()], ) def test_saveto_column__added_to_xml(self): self.assertPyxformXform( - name="data", md=""" | survey | | | | | | | type | name | label | save_to | @@ -204,7 +215,7 @@ def test_saveto_column__added_to_xml(self): | | trees | a | | | """, xml__xpath_match=[ - '/h:html/h:head/x:model/x:bind[@nodeset = "/data/a" and @entities:saveto = "foo"]' + xpe.model_bind_question_saveto("/a", "foo"), ], ) @@ -343,22 +354,22 @@ def test_saveto_on_group__errors(self): ) def test_saveto_in_repeat__errors(self): + """Should find that an error is raised if a save_to is in an undeclared entity repeat.""" + md = """ + | survey | + | | type | name | label | save_to | + | | begin_repeat | r1 | R1 | | + | | text | q1 | Q1 | q1e | + | | end_repeat | r1 | | | + + | entities | + | | dataset | label | + | | trees | ${q1} | + """ self.assertPyxformXform( - name="data", - md=""" - | survey | | | | | - | | type | name | label | save_to | - | | begin_repeat| a | A | | - | | text | size | Size | size | - | | end_repeat | | | | - | entities | | | | | - | | dataset | label | | | - | | trees | ${size}| | | - """, + md=md, errored=True, - error__contains=[ - "[row : 3] Currently, you can't create entities from repeats. You may only specify save_to values for form fields outside of repeats." - ], + error__contains=[ep.ENTITY007.format(row=3, value="q1e")], ) def test_saveto_in_group__works(self): @@ -374,6 +385,7 @@ def test_saveto_in_group__works(self): | | dataset | label | | | | | trees | ${size}| | | """, + warnings_count=0, ) def test_list_name_alias_to_dataset(self): @@ -386,7 +398,7 @@ def test_list_name_alias_to_dataset(self): | | list_name | label | | | | trees | a | | """, - xml__xpath_match=[xpe.model_instance_dataset("trees")], + xml__xpath_match=[xpe.model_instance_meta("trees", create=True, label=True)], ) def test_entities_columns__all_expected(self): diff --git a/tests/entities/test_update_repeat.py b/tests/entities/test_update_repeat.py new file mode 100644 index 000000000..d81b37f38 --- /dev/null +++ b/tests/entities/test_update_repeat.py @@ -0,0 +1,160 @@ +from pyxform import constants as co + +from tests.pyxform_test_case import PyxformTestCase +from tests.xpath_helpers.entities import xpe + + +class TestEntitiesUpdateRepeat(PyxformTestCase): + """ + Test entity update specs for entities declared in a repeat. + + These tests feature 'csv-external | [entity list name]' in order to include an + instance for the entity data, and thereby satisfy a ODK Validate check that instance() + expressions refer to an instance that exists in the form. Per: + https://docs.getodk.org/entities-quick-reference/#using-entity-data + """ + + def test_basic_usage__ok(self): + """Should find that the entity repeat has a meta block and the bindings target it.""" + md = """ + | survey | + | | type | name | label | save_to | + | | begin_repeat | r1 | R1 | | + | | text | q1 | Q1 | q1e | + | | end_repeat | | | | + | | csv-external | e1 | | | + + | entities | + | | list_name | label | repeat | entity_id | + | | e1 | ${q1} | ${r1} | ${q1} | + """ + self.assertPyxformXform( + md=md, + warnings_count=0, + xml__xpath_match=[ + xpe.model_entities_version(co.EntityVersion.v2025_1_0.value), + xpe.model_instance_meta( + "e1", + "/x:r1", + repeat=True, + template=True, + update=True, + label=True, + ), + xpe.model_instance_meta( + "e1", + "/x:r1", + repeat=True, + update=True, + label=True, + ), + xpe.model_bind_question_saveto("/r1/q1", "q1e"), + xpe.model_bind_meta_id(" ../../../q1 ", "/r1"), + xpe.model_bind_meta_baseversion("e1", "current()/../../../q1", "/r1"), + xpe.model_bind_meta_trunkversion("e1", "current()/../../../q1", "/r1"), + xpe.model_bind_meta_branchid("e1", "current()/../../../q1", "/r1"), + xpe.model_bind_meta_label(" ../../../q1 ", "/r1"), + xpe.model_bind_meta_instanceid(), + xpe.model_no_setvalue_meta_id("/r1"), + ], + xml__contains=['xmlns:entities="http://www.opendatakit.org/xforms/entities"'], + xml__xpath_count=[ + ("/h:html//x:setvalue", 0), + ], + ) + + def test_minimal_fields__ok(self): + """Should find that omitting all optional entity fields is OK.""" + md = """ + | survey | + | | type | name | label | + | | begin_repeat | r1 | R1 | + | | text | q1 | Q1 | + | | end_repeat | | | + | | csv-external | e1 | | + + | entities | + | | list_name | repeat | entity_id | + | | e1 | ${r1} | ${q1} | + """ + self.assertPyxformXform( + md=md, + warnings_count=0, + xml__xpath_count=[ + ("/h:html//x:setvalue", 0), + ], + ) + + def test_update_if__ok(self): + """Should find that the update_if expression targets the entity repeat.""" + md = """ + | survey | + | | type | name | label | save_to | + | | begin_repeat | r1 | R1 | | + | | text | q1 | Q1 | q1e | + | | end_repeat | | | | + | | csv-external | e1 | | | + + | entities | + | | list_name | label | repeat | entity_id | update_if | + | | e1 | ${q1} | ${r1} | ${q1} | ${q1} = '' | + """ + self.assertPyxformXform( + md=md, + warnings_count=0, + xml__xpath_count=[ + ("/h:html//x:setvalue", 0), + ], + ) + + def test_all_fields__ok(self): + """Should find that using all entity fields at once is OK.""" + md = """ + | survey | + | | type | name | label | save_to | + | | begin_repeat | r1 | R1 | | + | | text | q1 | Q1 | q1e | + | | end_repeat | | | | + | | csv-external | e1 | | | + + | entities | + | | list_name | label | repeat | entity_id | create_if | update_if | + | | e1 | ${q1} | ${r1} | ${q1} | ${q1} = '' | ${q1} = '' | + """ + self.assertPyxformXform( + md=md, + warnings_count=0, + xml__xpath_match=[ + xpe.model_instance_meta( + "e1", + "/x:r1", + repeat=True, + template=True, + create=True, + update=True, + label=True, + ), + xpe.model_instance_meta( + "e1", + "/x:r1", + repeat=True, + create=True, + update=True, + label=True, + ), + xpe.model_bind_question_saveto("/r1/q1", "q1e"), + xpe.model_bind_meta_id(" ../../../q1 ", "/r1"), + xpe.model_setvalue_meta_id("/r1"), + xpe.model_bind_meta_baseversion("e1", "current()/../../../q1", "/r1"), + xpe.model_bind_meta_trunkversion("e1", "current()/../../../q1", "/r1"), + xpe.model_bind_meta_branchid("e1", "current()/../../../q1", "/r1"), + xpe.model_bind_meta_label(" ../../../q1 ", "/r1"), + xpe.model_bind_meta_instanceid(), + xpe.body_repeat_setvalue_meta_id( + "/x:group/x:repeat[@nodeset='/test_name/r1']", "/r1" + ), + ], + xml__xpath_count=[ + ("/h:html//x:setvalue", 2), + ], + ) diff --git a/tests/test_entities_update.py b/tests/entities/test_update_survey.py similarity index 60% rename from tests/test_entities_update.py rename to tests/entities/test_update_survey.py index 9e5c7fb5c..9a6b6b298 100644 --- a/tests/test_entities_update.py +++ b/tests/entities/test_update_survey.py @@ -4,7 +4,9 @@ from tests.xpath_helpers.entities import xpe -class EntitiesUpdateTest(PyxformTestCase): +class TestEntitiesUpdateSurvey(PyxformTestCase): + """Test entity update specs for entities declared at the survey level""" + def test_basic_entity_update_building_blocks(self): self.assertPyxformXform( md=""" @@ -18,47 +20,17 @@ def test_basic_entity_update_building_blocks(self): | | trees | ${id} | | """, xml__xpath_match=[ - xpe.model_instance_dataset("trees"), + xpe.model_entities_version(co.EntityVersion.v2024_1_0.value), # defaults to always updating if an entity_id is specified - '/h:html/h:head/x:model/x:instance/x:test_name/x:meta/x:entity[@update = "1"]', - '/h:html/h:head/x:model/x:instance/x:test_name/x:meta/x:entity[@id = ""]', - '/h:html/h:head/x:model/x:bind[@nodeset = "/test_name/meta/entity/@id" and @type = "string" and @readonly = "true()" and @calculate = " /test_name/id "]', - '/h:html/h:head/x:model/x:instance/x:test_name/x:meta/x:entity[@baseVersion = ""]', - '/h:html/h:head/x:model/x:bind[@nodeset = "/test_name/meta/entity/@baseVersion" and @type = "string" and @readonly = "true()" and @calculate = "instance(\'trees\')/root/item[name= /test_name/id ]/__version"]', - f"""/h:html/h:head/x:model[@entities:entities-version = '{co.ENTITIES_OFFLINE_VERSION}']""", - """ - /h:html/h:head/x:model/x:instance/x:test_name/x:meta/x:entity[ - @trunkVersion = '' - and @branchId = '' - ] - """, - """ - /h:html/h:head/x:model/x:bind[ - @nodeset = '/test_name/meta/entity/@trunkVersion' - and @calculate = "instance('trees')/root/item[name= /test_name/id ]/__trunkVersion" - and @type = 'string' - and @readonly = 'true()' - ] - """, - """ - /h:html/h:head/x:model/x:bind[ - @nodeset = '/test_name/meta/entity/@branchId' - and @calculate = "instance('trees')/root/item[name= /test_name/id ]/__branchId" - and @type = 'string' - and @readonly = 'true()' - ] - """, + xpe.model_instance_meta("trees", create=False, update=True), + xpe.model_bind_meta_id(" /test_name/id "), + xpe.model_bind_meta_baseversion("trees", "/test_name/id"), + xpe.model_bind_meta_trunkversion("trees", "/test_name/id"), + xpe.model_bind_meta_branchid("trees", "/test_name/id"), + xpe.model_no_setvalue_meta_id(), ], xml__xpath_count=[ - ( - "/h:html/h:head/x:model/x:instance/x:test_name/x:meta/x:entity/x:label", - 0, - ), - ( - "/h:html/h:head/x:model/x:instance/x:test_name/x:meta/x:entity/@create", - 0, - ), - ("/h:html/h:head/x:model/x:setvalue", 0), + ("/h:html//x:setvalue", 0), ], xml__contains=['xmlns:entities="http://www.opendatakit.org/xforms/entities"'], ) @@ -119,7 +91,6 @@ def test_update_and_create_conditions_without_entity_id__errors(self): def test_create_if_with_entity_id_in_entities_sheet__puts_expression_on_bind(self): self.assertPyxformXform( - name="data", md=""" | survey | | | | | | type | name | label | @@ -131,20 +102,21 @@ def test_create_if_with_entity_id_in_entities_sheet__puts_expression_on_bind(sel | | trees | string-length(a) > 3 | ${id} | """, xml__xpath_match=[ - '/h:html/h:head/x:model/x:bind[@nodeset = "/data/meta/entity/@update" and @calculate = "string-length(a) > 3"]', - '/h:html/h:head/x:model/x:instance/x:data/x:meta/x:entity[@update = "1"]', - '/h:html/h:head/x:model/x:bind[@nodeset = "/data/meta/entity/@id" and @type = "string" and @readonly = "true()" and @calculate = " /data/id "]', - '/h:html/h:head/x:model/x:instance/x:data/x:meta/x:entity[@baseVersion = ""]', - '/h:html/h:head/x:model/x:bind[@nodeset = "/data/meta/entity/@baseVersion" and @type = "string" and @readonly = "true()" and @calculate = "instance(\'trees\')/root/item[name= /data/id ]/__version"]', + xpe.model_instance_meta("trees", update=True), + xpe.model_bind_meta_update("string-length(a) > 3"), + xpe.model_bind_meta_id(" /test_name/id "), + xpe.model_bind_meta_baseversion("trees", "/test_name/id"), + xpe.model_no_setvalue_meta_id(), + ], + xml__xpath_count=[ + ("/h:html//x:setvalue", 0), ], - xml__xpath_count=[("/h:html/h:head/x:model/x:setvalue", 0)], ) def test_update_and_create_conditions_with_entity_id__puts_both_in_bind_calculations( self, ): self.assertPyxformXform( - name="data", md=""" | survey | | | | | | | type | name | label | | @@ -156,14 +128,15 @@ def test_update_and_create_conditions_with_entity_id__puts_both_in_bind_calculat | | trees | id != '' | id = '' | ${id} | """, xml__xpath_match=[ - '/h:html/h:head/x:model/x:bind[@nodeset = "/data/meta/entity/@update" and @calculate = "id != \'\'"]', - '/h:html/h:head/x:model/x:instance/x:data/x:meta/x:entity[@update = "1"]', - '/h:html/h:head/x:model/x:bind[@nodeset = "/data/meta/entity/@create" and @calculate = "id = \'\'"]', - '/h:html/h:head/x:model/x:instance/x:data/x:meta/x:entity[@create = "1"]', - '/h:html/h:head/x:model/x:setvalue[@event = "odk-instance-first-load" and @type = "string" and @ref = "/data/meta/entity/@id" and @value = "uuid()"]', - '/h:html/h:head/x:model/x:bind[@nodeset = "/data/meta/entity/@id" and @type = "string" and @readonly = "true()" and @calculate = " /data/id "]', - '/h:html/h:head/x:model/x:instance/x:data/x:meta/x:entity[@baseVersion = ""]', - '/h:html/h:head/x:model/x:bind[@nodeset = "/data/meta/entity/@baseVersion" and @type = "string" and @readonly = "true()" and @calculate = "instance(\'trees\')/root/item[name= /data/id ]/__version"]', + xpe.model_instance_meta("trees", create=True, update=True), + xpe.model_bind_meta_update("id != ''"), + xpe.model_bind_meta_create("id = ''"), + xpe.model_setvalue_meta_id(), + xpe.model_bind_meta_id(" /test_name/id "), + xpe.model_bind_meta_baseversion("trees", "/test_name/id"), + ], + xml__xpath_count=[ + ("/h:html//x:setvalue", 1), ], ) @@ -180,14 +153,13 @@ def test_entity_id_and_label__updates_label(self): | | trees | ${id} | a | """, xml__xpath_match=[ - "/h:html/h:head/x:model/x:instance/x:test_name/x:meta/x:entity/x:label", - xpe.model_bind_label("a"), + xpe.model_instance_meta("trees", update=True, label=True), + xpe.model_bind_meta_label("a"), ], ) def test_save_to_with_entity_id__puts_save_tos_on_bind(self): self.assertPyxformXform( - name="data", md=""" | survey | | | | | | | type | name | label | save_to | @@ -199,6 +171,6 @@ def test_save_to_with_entity_id__puts_save_tos_on_bind(self): | | trees | ${id} | | | """, xml__xpath_match=[ - '/h:html/h:head/x:model/x:bind[@nodeset = "/data/a" and @entities:saveto = "foo"]' + xpe.model_bind_question_saveto("/a", "foo"), ], ) diff --git a/tests/example_xls/another_loop.xls b/tests/example_xls/another_loop.xls deleted file mode 100644 index 27922cfb3..000000000 Binary files a/tests/example_xls/another_loop.xls and /dev/null differ diff --git a/tests/example_xls/group_names_must_be_unique.xls b/tests/example_xls/group_names_must_be_unique.xls deleted file mode 100644 index 74f4919d0..000000000 Binary files a/tests/example_xls/group_names_must_be_unique.xls and /dev/null differ diff --git a/tests/test_builder.py b/tests/test_builder.py index f8ea870fa..c568f50c2 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -11,7 +11,7 @@ from pyxform import InputQuestion, Survey from pyxform.builder import SurveyElementBuilder, create_survey_from_xls from pyxform.errors import ErrorCode, PyXFormError -from pyxform.xls2json import print_pyobj_to_json +from pyxform.utils import print_pyobj_to_json from tests import utils @@ -21,25 +21,10 @@ class BuilderTests(TestCase): maxDiff = None - # Moving to spec tests - # def test_new_widgets(self): - # survey = utils.build_survey('widgets.xls') - # path = utils.path_to_text_fixture('widgets.xml') - # survey.to_xml - # with open(path) as f: - # expected = ETree.fromstring(survey.to_xml()) - # result = ETree.fromstring(f.read()) - # self.assertTrue(xml_compare(expected, result)) - def test_unknown_question_type(self): with self.assertRaises(PyXFormError): utils.build_survey("unknown_question_type.xls") - def test_uniqueness_of_section_names(self): - # Looking at the xls file, I think this test might be broken. - survey = utils.build_survey("group_names_must_be_unique.xls") - self.assertRaises(Exception, survey.to_xml) - def setUp(self): self.this_directory = os.path.dirname(__file__) survey_out = Survey(name="age", sms_keyword="age", type="survey") diff --git a/tests/test_choices_sheet.py b/tests/test_choices_sheet.py index 6ace48325..38f428e28 100644 --- a/tests/test_choices_sheet.py +++ b/tests/test_choices_sheet.py @@ -1,3 +1,5 @@ +from pyxform.validators.pyxform import choices as vc + from tests.pyxform_test_case import PyxformTestCase from tests.xpath_helpers.choices import xpc from tests.xpath_helpers.questions import xpq @@ -154,3 +156,136 @@ def test_unreferenced_lists_included_in_output(self): xpc.model_instance_choices_label("choices2", (("1", "Y"), ("2", "N"))), ], ) + + def test_duplicate_choices_without_setting(self): + self.assertPyxformXform( + md=""" + | survey | | | | + | | type | name | label | + | | select_one list | S1 | s1 | + | choices | | | | + | | list name | name | label | + | | list | a | option a | + | | list | b | option b | + | | list | b | option c | + """, + errored=True, + error__contains=[vc.INVALID_DUPLICATE.format(row=4)], + ) + + def test_multiple_duplicate_choices_without_setting(self): + self.assertPyxformXform( + md=""" + | survey | | | | + | | type | name | label | + | | select_one list | S1 | s1 | + | choices | | | | + | | list name | name | label | + | | list | a | option a | + | | list | a | option b | + | | list | b | option c | + | | list | b | option d | + """, + errored=True, + error__contains=[ + vc.INVALID_DUPLICATE.format(row=3), + vc.INVALID_DUPLICATE.format(row=5), + ], + ) + + def test_duplicate_choices_with_setting_not_set_to_yes(self): + self.assertPyxformXform( + md=""" + | survey | | | | + | | type | name | label | + | | select_one list | S1 | s1 | + | choices | | | | + | | list name | name | label | + | | list | a | option a | + | | list | b | option b | + | | list | b | option c | + | settings | | | | + | | id_string | allow_choice_duplicates | + | | Duplicates | Bob | + """, + errored=True, + error__contains=[vc.INVALID_DUPLICATE.format(row=4)], + ) + + def test_duplicate_choices_with_allow_choice_duplicates_setting(self): + md = """ + | survey | | | | + | | type | name | label | + | | select_one list | S1 | s1 | + | choices | | | | + | | list name | name | label | + | | list | a | A | + | | list | b | B | + | | list | b | C | + | settings | | | + | | id_string | allow_choice_duplicates | + | | Duplicates | Yes | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpc.model_instance_choices_label( + "list", (("a", "A"), ("b", "B"), ("b", "C")) + ), + xpq.body_select1_itemset("S1"), + ], + ) + + def test_duplicate_choices_with_allow_choice_duplicates_setting_and_translations( + self, + ): + md = """ + | survey | | | | + | | type | name | label::en | label::ko | + | | select_one list | S1 | s1 | 질문 1 | + | choices | | | | + | | list name | name | label::en | label::ko | + | | list | a | Pass | 패스 | + | | list | b | Fail | 실패 | + | | list | c | Skipped | 건너뛴 | + | | list | c | Not Applicable | 해당 없음 | + | settings | | | + | | id_string | allow_choice_duplicates | + | | Duplicates | Yes | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpc.model_itext_choice_text_label_by_pos( + "en", + "list", + ("Pass", "Fail", "Skipped", "Not Applicable"), + ), + xpc.model_itext_choice_text_label_by_pos( + "ko", + "list", + ("패스", "실패", "건너뛴", "해당 없음"), + ), + ], + ) + + def test_choice_list_without_duplicates_is_successful(self): + md = """ + | survey | | | | + | | type | name | label | + | | select_one list | S1 | s1 | + | choices | | | | + | | list name | name | label | + | | list | a | A | + | | list | b | B | + | settings | | | + | | id_string | allow_choice_duplicates | + | | Duplicates | Yes | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpc.model_instance_choices_label("list", (("a", "A"), ("b", "B"))), + xpq.body_select1_itemset("S1"), + ], + ) diff --git a/tests/test_dynamic_default.py b/tests/test_dynamic_default.py index d2c31b228..13ed2b982 100644 --- a/tests/test_dynamic_default.py +++ b/tests/test_dynamic_default.py @@ -224,19 +224,20 @@ def test_dynamic_default_in_repeat(self): ./x:bind[@nodeset='/test_name/r1/q0' and @type='int'] ]/x:instance/x:test_name[@id="data"]/x:r1[not(@jr:template)]/x:q0[not(text())] """, - # q0 dynamic default value not in model setvalue. - """ - /h:html/h:head/x:model[not(./x:setvalue[@ref='data/r1/q0'])] - """, - # q0 dynamic default value in body group setvalue, with 2 events. - """ - /h:html/h:body/x:group[@ref='/test_name/r1']/x:repeat[@nodeset='/test_name/r1'] - /x:setvalue[ - @event='odk-instance-first-load odk-new-repeat' - and @ref='/test_name/r1/q0' - and @value='random()' - ] - """, + # q0 dynamic default value in model setvalue, with first-load event. + xpq.setvalue( + path="""h:head/x:model""", + ref="/test_name/r1/q0", + event="odk-instance-first-load", + value="random()", + ), + # q0 dynamic default value in body group setvalue, with new-repeat event. + xpq.setvalue( + path="""h:body/x:group[@ref='/test_name/r1']/x:repeat[@nodeset='/test_name/r1']""", + ref="/test_name/r1/q0", + event="odk-new-repeat", + value="random()", + ), # q1 static default value in repeat template. """ /h:html/h:head/x:model[ @@ -250,6 +251,8 @@ def test_dynamic_default_in_repeat(self): ]/x:instance/x:test_name[@id="data"]/x:r1[not(@jr:template)]/x:q1[text()='not_func$'] """, ], + # No other setvalue expected besides those above. + xml__xpath_count=[("/h:html//x:setvalue", 2)], ) def test_dynamic_default_in_group(self): @@ -272,13 +275,12 @@ def test_dynamic_default_in_group(self): # q1 dynamic default not in instance. """/h:html/h:head/x:model/x:instance/x:test_name[@id="data"]/x:g1/x:q1[not(text())]""", # q1 dynamic default value in model setvalue, with 1 event. - """ - /h:html/h:head/x:model/x:setvalue[ - @event="odk-instance-first-load" - and @ref='/test_name/g1/q1' - and @value=' /test_name/q0 ' - ] - """, + xpq.setvalue( + path="""h:head/x:model""", + ref="/test_name/g1/q1", + event="odk-instance-first-load", + value=" /test_name/q0 ", + ), # q1 dynamic default value not in body group setvalue. """ /h:html/h:body/x:group[ @@ -287,10 +289,12 @@ def test_dynamic_default_in_group(self): ] """, ], + # No other setvalue expected besides those above. + xml__xpath_count=[("/h:html//x:setvalue", 1)], ) def test_sibling_dynamic_default_in_group(self): - """Should use model setvalue for dynamic default form inside a group.""" + """Should find model setvalue for dynamic default form inside a group.""" md = """ | survey | | | | | | | type | name | label | default | @@ -309,13 +313,12 @@ def test_sibling_dynamic_default_in_group(self): # q1 dynamic default not in instance. """/h:html/h:head/x:model/x:instance/x:test_name[@id="data"]/x:g1/x:q1[not(text())]""", # q1 dynamic default value in model setvalue, with 1 event. - """ - /h:html/h:head/x:model/x:setvalue[ - @event="odk-instance-first-load" - and @ref='/test_name/g1/q1' - and @value=' /test_name/g1/q0 ' - ] - """, + xpq.setvalue( + path="""h:head/x:model""", + ref="/test_name/g1/q1", + event="odk-instance-first-load", + value=" /test_name/g1/q0 ", + ), # q1 dynamic default value not in body group setvalue. """ /h:html/h:body/x:group[ @@ -324,10 +327,12 @@ def test_sibling_dynamic_default_in_group(self): ] """, ], + # No other setvalue expected besides those above. + xml__xpath_count=[("/h:html//x:setvalue", 1)], ) def test_sibling_dynamic_default_in_repeat(self): - """Should use body setvalue for dynamic default form inside a repeat.""" + """Should find body setvalue for dynamic default form inside a repeat.""" md = """ | survey | | | | | | | type | name | label | default | @@ -359,24 +364,27 @@ def test_sibling_dynamic_default_in_repeat(self): ./x:bind[@nodeset='/test_name/r1/q0' and @type='int'] ]/x:instance/x:test_name[@id="data"]/x:r1[not(@jr:template)]/x:q0 """, - # q1 dynamic default value not in model setvalue. - """ - /h:html/h:head/x:model[not(./x:setvalue[@ref='data/r1/q1'])] - """, - # q1 dynamic default value in body group setvalue, with 2 events. - """ - /h:html/h:body/x:group[@ref='/test_name/r1']/x:repeat[@nodeset='/test_name/r1'] - /x:setvalue[ - @event='odk-instance-first-load odk-new-repeat' - and @ref='/test_name/r1/q1' - and @value=' ../q0 ' - ] - """, + # q1 dynamic default value in model setvalue, with first-load event. + xpq.setvalue( + path="""h:head/x:model""", + ref="/test_name/r1/q1", + event="odk-instance-first-load", + value=" ../q0 ", + ), + # q1 dynamic default value in body group setvalue, with new-repeat event. + xpq.setvalue( + path="""h:body/x:group[@ref='/test_name/r1']/x:repeat[@nodeset='/test_name/r1']""", + ref="/test_name/r1/q1", + event="odk-new-repeat", + value=" ../q0 ", + ), ], + # No other setvalue expected besides those above. + xml__xpath_count=[("/h:html//x:setvalue", 2)], ) def test_dynamic_default_in_group_nested_in_repeat(self): - """Should use body setvalue for dynamic default form inside a group and repeat.""" + """Should find body setvalue for dynamic default form inside a group and repeat.""" md = """ | survey | | | | | | | type | name | label | default | @@ -410,24 +418,27 @@ def test_dynamic_default_in_group_nested_in_repeat(self): ./x:bind[@nodeset='/test_name/r1/g1/q0' and @type='int'] ]/x:instance/x:test_name[@id="data"]/x:r1[not(@jr:template)]/x:g1/x:q0 """, - # q1 dynamic default value not in model setvalue. - """ - /h:html/h:head/x:model[not(./x:setvalue[@ref='data/r1/g1/q1'])] - """, - # q1 dynamic default value in body group setvalue, with 2 events. - """ - /h:html/h:body/x:group[@ref='/test_name/r1']/x:repeat[@nodeset='/test_name/r1'] - /x:setvalue[ - @event='odk-instance-first-load odk-new-repeat' - and @ref='/test_name/r1/g1/q1' - and @value=' ../q0 ' - ] - """, + # q1 dynamic default value in model setvalue, with first-load event. + xpq.setvalue( + path="""h:head/x:model""", + ref="/test_name/r1/g1/q1", + event="odk-instance-first-load", + value=" ../q0 ", + ), + # q1 dynamic default value in body group setvalue, with new-repeat event. + xpq.setvalue( + path="""h:body/x:group[@ref='/test_name/r1']/x:repeat[@nodeset='/test_name/r1']""", + ref="/test_name/r1/g1/q1", + event="odk-new-repeat", + value=" ../q0 ", + ), ], + # No other setvalue expected besides those above. + xml__xpath_count=[("/h:html//x:setvalue", 2)], ) def test_dynamic_default_in_repeat_nested_in_repeat(self): - """Should use body setvalue for dynamic default form inside 2 levels of repeat.""" + """Should find body setvalue for dynamic default form inside 2 levels of repeat.""" md = """ | survey | | | | | | | type | name | label | default | @@ -462,19 +473,20 @@ def test_dynamic_default_in_repeat_nested_in_repeat(self): ./x:bind[@nodeset='/test_name/r1/q0' and @type='date'] ]/x:instance/x:test_name[@id="data"]/x:r1[not(@jr:template)]/x:q0 """, - # q0 dynamic default value not in model setvalue. - """ - /h:html/h:head/x:model[not(./x:setvalue[@ref='data/r1/q0'])] - """, - # q0 dynamic default value in body group setvalue, with 2 events. - """ - /h:html/h:body/x:group[@ref='/test_name/r1']/x:repeat[@nodeset='/test_name/r1'] - /x:setvalue[ - @event='odk-instance-first-load odk-new-repeat' - and @ref='/test_name/r1/q0' - and @value='now()' - ] - """, + # q1 dynamic default value in model setvalue, with first-load event. + xpq.setvalue( + path="""h:head/x:model""", + ref="/test_name/r1/q0", + event="odk-instance-first-load", + value="now()", + ), + # q1 dynamic default value in body group setvalue, with new-repeat event. + xpq.setvalue( + path="""h:body/x:group[@ref='/test_name/r1']/x:repeat[@nodeset='/test_name/r1']""", + ref="/test_name/r1/q0", + event="odk-new-repeat", + value="now()", + ), # q1 element in repeat template. """ /h:html/h:head/x:model[ @@ -499,21 +511,59 @@ def test_dynamic_default_in_repeat_nested_in_repeat(self): ./x:bind[@nodeset='/test_name/r1/q1' and @type='int'] ]/x:instance/x:test_name[@id="data"]/x:r1[not(@jr:template)]/x:r2[not(@jr:template)]/x:q2 """, - # q2 dynamic default value not in model setvalue. - """ - /h:html/h:head/x:model[not(./x:setvalue[@ref='data/r1/r2/q2'])] - """, - # q2 dynamic default value in body group setvalue, with 2 events. - """ - /h:html/h:body/x:group[@ref='/test_name/r1']/x:repeat[@nodeset='/test_name/r1'] - /x:group[@ref='/test_name/r1/r2']/x:repeat[@nodeset='/test_name/r1/r2'] - /x:setvalue[ - @event='odk-instance-first-load odk-new-repeat' - and @ref='/test_name/r1/r2/q2' - and @value=' ../../q1 ' - ] - """, + # q2 dynamic default value in model setvalue, with first-load event. + xpq.setvalue( + path="""h:head/x:model""", + ref="/test_name/r1/r2/q2", + event="odk-instance-first-load", + value=" ../../q1 ", + ), + # q2 dynamic default value in body group setvalue, with new-repeat event. + xpq.setvalue( + path="""h:body/x:group/x:repeat/x:group/x:repeat[@nodeset='/test_name/r1/r2']""", + ref="/test_name/r1/r2/q2", + event="odk-new-repeat", + value=" ../../q1 ", + ), + ], + # No other setvalue expected besides those above. + xml__xpath_count=[("/h:html//x:setvalue", 4)], + ) + + def test_dynamic_default_in_repeat_in_repeat_in_group(self): + """Should find body setvalue for dynamic default form inside 2 repeats and 1 group.""" + md = """ + | survey | + | | type | name | label | default | + | | begin_repeat | r1 | | | + | | begin_repeat | r2 | | | + | | begin_group | g3 | | | + | | integer | q1 | Q1 | | + | | integer | q2 | Q2 | 1 + 1 | + | | end_group | g3 | | | + | | end_repeat | r2 | | | + | | end_repeat | r1 | | | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + # q1 dynamic default value in model setvalue, with first-load event. + xpq.setvalue( + path="""h:head/x:model""", + ref="/test_name/r1/r2/g3/q2", + event="odk-instance-first-load", + value="1 + 1", + ), + # q1 dynamic default value in body group setvalue, with new-repeat event. + xpq.setvalue( + path="""h:body/x:group/x:repeat/x:group/x:repeat[@nodeset='/test_name/r1/r2']""", + ref="/test_name/r1/r2/g3/q2", + event="odk-new-repeat", + value="1 + 1", + ), ], + # No other setvalue expected besides those above. + xml__xpath_count=[("/h:html//x:setvalue", 2)], ) def test_dynamic_default_does_not_warn(self): @@ -579,6 +629,85 @@ def test_dynamic_default_select_choice_name_with_hyphen(self): ], ) + def test_dynamic_default_with_trigger(self): + """Should find that setvalues are created for the dynamic default and trigger.""" + md = """ + | survey | + | | type | name | label | default | trigger | + | | begin_repeat | r1 | | | | + | | integer | q1 | Q1 | 1 + 1 | | + | | text | q2 | Q2 | | ${q1} | + | | end_repeat | r1 | | | | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + # q1 dynamic default value in model setvalue, with first-load event. + xpq.setvalue( + path="""h:head/x:model""", + ref="/test_name/r1/q1", + event="odk-instance-first-load", + value="1 + 1", + ), + # q1 dynamic default value in body group setvalue, with new-repeat event. + xpq.setvalue( + path="""h:body/x:group/x:repeat[@nodeset='/test_name/r1']""", + ref="/test_name/r1/q1", + event="odk-new-repeat", + value="1 + 1", + ), + # q1 trigger in body input control setvalue, with new-repeat event. + xpq.setvalue( + path="""h:body/x:group/x:repeat/x:input[@ref='/test_name/r1/q1']""", + ref="/test_name/r1/q2", + event="xforms-value-changed", + ), + ], + # No other setvalue expected besides those above. + xml__xpath_count=[("/h:html//x:setvalue", 3)], + ) + + def test_dynamic_default_with_start_geopoint(self): + """Should find that actions are created for the dynamic default and start-geopoint.""" + md = """ + | survey | + | | type | name | label | default | + | | begin_repeat | r1 | | | + | | start-geopoint | q1 | Q1 | | + | | geopoint | q2 | Q2 | ${q1} | + | | end_repeat | r1 | | | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + # q2 dynamic default value in model setvalue, with first-load event. + xpq.setvalue( + path="""h:head/x:model""", + ref="/test_name/r1/q2", + event="odk-instance-first-load", + value=" ../q1 ", + ), + # q2 dynamic default value in body group setvalue, with new-repeat event. + xpq.setvalue( + path="""h:body/x:group/x:repeat[@nodeset='/test_name/r1']""", + ref="/test_name/r1/q2", + event="odk-new-repeat", + value=" ../q1 ", + ), + # q1 setgeopoint in model, with first-load event. + xpq.setgeopoint( + path="""h:head/x:model""", + ref="/test_name/r1/q1", + event="odk-instance-first-load", + ), + ], + # No other setvalue/setgeopoint expected besides those above. + xml__xpath_count=[ + ("/h:html//x:setvalue", 2), + ("/h:html//odk:setgeopoint", 1), + ], + ) + class TestDynamicDefaultSimpleInput(PyxformTestCase): """ diff --git a/tests/test_external_instances.py b/tests/test_external_instances.py index 5cf829a03..f20c2b21f 100644 --- a/tests/test_external_instances.py +++ b/tests/test_external_instances.py @@ -6,6 +6,8 @@ from textwrap import dedent +from pyxform.validators.pyxform import unique_names + from tests.pyxform_test_case import PyxformTestCase, PyxformTestError from tests.xpath_helpers.choices import xpc @@ -39,20 +41,16 @@ def test_can__output_single_external_csv_item(self): def test_cannot__use_same_external_xml_id_in_same_section(self): """Duplicate external instances in the same section raises an error.""" - with self.assertRaises(PyxformTestError) as ctx: - self.assertPyxformXform( - md=""" - | survey | | | | - | | type | name | label | - | | xml-external | mydata | | - | | xml-external | mydata | | - """, - model__contains=[], - ) - # This is caught first by existing validation rule. - self.assertIn( - "There are more than one survey elements named 'mydata'", - repr(ctx.exception), + md = """ + | survey | | | | + | | type | name | label | + | | xml-external | mydata | | + | | xml-external | mydata | | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[unique_names.NAMES001.format(row=3, value="mydata")], ) def test_can__use_unique_external_xml_in_same_section(self): diff --git a/tests/test_fields.py b/tests/test_fields.py index a3d679aac..b4cb6b5a8 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -2,211 +2,461 @@ Test duplicate survey question field name. """ -from pyxform.validators.pyxform import choices as vc +from pyxform.validators.pyxform import unique_names from tests.pyxform_test_case import PyxformTestCase -from tests.xpath_helpers.choices import xpc -from tests.xpath_helpers.questions import xpq -class FieldsTests(PyxformTestCase): +class TestQuestionParsing(PyxformTestCase): """ Test XLSForm Fields """ - def test_duplicate_fields(self): + def test_names__question_basic_case__ok(self): + """Should find that a single unique question name is ok.""" + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + """ + self.assertPyxformXform( + md=md, + warnings_count=0, + ) + + def test_names__question_different_names_same_context__ok(self): + """Should find that questions with unique names in the same context is ok.""" + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + | | text | q2 | Q2 | + """ + self.assertPyxformXform( + md=md, + warnings_count=0, + ) + + def test_names__question_same_as_question_in_different_group_context__ok(self): + """Should find that a question name can be the same as another question in a different context.""" + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + | | begin group | g1 | G1 | + | | text | q1 | Q1 | + | | end group | | | """ - Ensure that duplicate field names are not allowed + self.assertPyxformXform( + md=md, + warnings_count=0, + ) + + def test_names__question_same_as_question_in_different_repeat_context__ok(self): + """Should find that a question name can be the same as another question in a different context.""" + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + | | begin repeat | r1 | R1 | + | | text | q1 | Q1 | + | | end repeat | | | """ self.assertPyxformXform( - name="duplicatefields", - md=""" - | Survey | | | | - | | Type | Name | Label | - | | integer | age | the age | - | | integer | age | the age | - """, + md=md, + warnings_count=0, + ) + + def test_names__question_same_as_group_in_different_group_context__ok(self): + """Should find that a question name can be the same a group in a different context.""" + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + | | begin group | g1 | G1 | + | | begin group | q1 | G1 | + | | text | q2 | Q1 | + | | end group | | | + | | end group | | | + """ + self.assertPyxformXform( + md=md, + warnings_count=0, + ) + + def test_names__question_same_as_group_in_different_repeat_context__ok(self): + """Should find that a question name can be the same a group in a different context.""" + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + | | begin repeat | r1 | R1 | + | | begin group | q1 | G1 | + | | text | q2 | Q1 | + | | end group | | | + | | end repeat | | | + """ + self.assertPyxformXform( + md=md, + warnings_count=0, + ) + + def test_names__question_same_as_repeat_in_different_group_context__ok(self): + """Should find that a question name can be the same a repeat in a different context.""" + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + | | begin group | g1 | G1 | + | | begin repeat | q1 | G1 | + | | text | q2 | Q1 | + | | end repeat | | | + | | end group | | | + """ + self.assertPyxformXform( + md=md, + warnings_count=0, + ) + + def test_names__question_same_as_repeat_in_different_repeat_context__ok(self): + """Should find that a question name can be the same a repeat in a different context.""" + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + | | begin repeat | r1 | R1 | + | | begin repeat | q1 | G1 | + | | text | q2 | Q1 | + | | end repeat | | | + | | end repeat | | | + """ + self.assertPyxformXform( + md=md, + warnings_count=0, + ) + + def test_names__question_same_as_survey_root__ok(self): + """Should find that a question name can be the same as the survey root.""" + md = """ + | survey | + | | type | name | label | + | | text | data | Q1 | + """ + self.assertPyxformXform( + md=md, + name="data", + warnings_count=0, + ) + + def test_names__question_same_as_survey_root_case_insensitive__ok(self): + """Should find that a question name can be the same (CI) as the survey root.""" + md = """ + | survey | + | | type | name | label | + | | text | DATA | Q1 | + """ + self.assertPyxformXform( + md=md, + name="data", + warnings_count=0, + ) + + def test_names__question_same_as_question_in_same_context_in_survey__error(self): + """Should find that a duplicate question name raises an error.""" + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + | | text | q1 | Q2 | + """ + self.assertPyxformXform( + md=md, errored=True, - error__contains=["There are more than one survey elements named 'age'"], + error__contains=[unique_names.NAMES001.format(row=3, value="q1")], ) - def test_duplicate_fields_diff_cases(self): + def test_names__question_same_as_group_in_same_context_in_survey__error(self): + """Should find that a duplicate question name raises an error.""" + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + | | begin group | q1 | G2 | + | | text | q2 | Q2 | + | | end group | | | """ - Ensure that duplicate field names with different cases are not allowed + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[unique_names.NAMES001.format(row=3, value="q1")], + ) + + def test_names__question_same_as_repeat_in_same_context_in_survey__error(self): + """Should find that a duplicate question name raises an error.""" + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + | | begin repeat | q1 | G2 | + | | text | q2 | Q2 | + | | end repeat | | | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[unique_names.NAMES001.format(row=3, value="q1")], + ) + + def test_names__question_same_as_question_in_same_context_in_group__error(self): + """Should find that a duplicate question name raises an error.""" + md = """ + | survey | + | | type | name | label | + | | begin group | g1 | G1 | + | | text | q1 | Q1 | + | | text | q1 | Q2 | + | | end group | | | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[unique_names.NAMES001.format(row=4, value="q1")], + ) + + def test_names__question_same_as_group_in_same_context_in_group__error(self): + """Should find that a duplicate question name raises an error.""" + md = """ + | survey | + | | type | name | label | + | | begin group | g1 | G1 | + | | text | q1 | Q1 | + | | begin group | q1 | G2 | + | | text | q2 | Q2 | + | | end group | | | + | | end group | | | """ self.assertPyxformXform( - name="duplicatefieldsdiffcases", - md=""" - | Survey | | | | - | | Type | Name | Label | - | | integer | age | the age | - | | integer | Age | the age | - """, + md=md, errored=True, - error__contains=["There are more than one survey elements named 'age'"], + error__contains=[unique_names.NAMES001.format(row=4, value="q1")], ) - def test_duplicate_choices_without_setting(self): + def test_names__question_same_as_repeat_in_same_context_in_group__error(self): + """Should find that a duplicate question name raises an error.""" + md = """ + | survey | + | | type | name | label | + | | begin group | g1 | G1 | + | | text | q1 | Q1 | + | | begin repeat | q1 | G2 | + | | text | q2 | Q2 | + | | end repeat | | | + | | end group | | | + """ self.assertPyxformXform( - md=""" - | survey | | | | - | | type | name | label | - | | select_one list | S1 | s1 | - | choices | | | | - | | list name | name | label | - | | list | a | option a | - | | list | b | option b | - | | list | b | option c | - """, + md=md, errored=True, - error__contains=[vc.INVALID_DUPLICATE.format(row=4)], - ) - - def test_multiple_duplicate_choices_without_setting(self): - self.assertPyxformXform( - md=""" - | survey | | | | - | | type | name | label | - | | select_one list | S1 | s1 | - | choices | | | | - | | list name | name | label | - | | list | a | option a | - | | list | a | option b | - | | list | b | option c | - | | list | b | option d | - """, + error__contains=[unique_names.NAMES001.format(row=4, value="q1")], + ) + + def test_names__question_same_as_question_in_same_context_in_repeat__error(self): + """Should find that a duplicate question name raises an error.""" + md = """ + | survey | + | | type | name | label | + | | begin repeat | r1 | R1 | + | | text | q1 | Q1 | + | | text | q1 | Q2 | + | | end repeat | | | + """ + self.assertPyxformXform( + md=md, errored=True, - error__contains=[ - vc.INVALID_DUPLICATE.format(row=3), - vc.INVALID_DUPLICATE.format(row=5), - ], - ) - - def test_duplicate_choices_with_setting_not_set_to_yes(self): - self.assertPyxformXform( - md=""" - | survey | | | | - | | type | name | label | - | | select_one list | S1 | s1 | - | choices | | | | - | | list name | name | label | - | | list | a | option a | - | | list | b | option b | - | | list | b | option c | - | settings | | | | - | | id_string | allow_choice_duplicates | - | | Duplicates | Bob | - """, + error__contains=[unique_names.NAMES001.format(row=4, value="q1")], + ) + + def test_names__question_same_as_group_in_same_context_in_repeat__error(self): + """Should find that a duplicate question name raises an error.""" + md = """ + | survey | + | | type | name | label | + | | begin repeat | r1 | R1 | + | | text | q1 | Q1 | + | | begin group | q1 | G2 | + | | text | q2 | Q2 | + | | end group | | | + | | end repeat | | | + """ + self.assertPyxformXform( + md=md, errored=True, - error__contains=[vc.INVALID_DUPLICATE.format(row=4)], + error__contains=[unique_names.NAMES001.format(row=4, value="q1")], ) - def test_duplicate_choices_with_allow_choice_duplicates_setting(self): + def test_names__question_same_as_repeat_in_same_context_in_repeat__error(self): + """Should find that a duplicate question name raises an error.""" md = """ - | survey | | | | - | | type | name | label | - | | select_one list | S1 | s1 | - | choices | | | | - | | list name | name | label | - | | list | a | A | - | | list | b | B | - | | list | b | C | - | settings | | | - | | id_string | allow_choice_duplicates | - | | Duplicates | Yes | - """ + | survey | + | | type | name | label | + | | begin repeat | r1 | R1 | + | | text | q1 | Q1 | + | | begin repeat | q1 | G2 | + | | text | q2 | Q2 | + | | end repeat | | | + | | end repeat | | | + """ self.assertPyxformXform( md=md, - xml__xpath_match=[ - xpc.model_instance_choices_label( - "list", (("a", "A"), ("b", "B"), ("b", "C")) - ), - xpq.body_select1_itemset("S1"), - ], + errored=True, + error__contains=[unique_names.NAMES001.format(row=4, value="q1")], ) - def test_duplicate_choices_with_allow_choice_duplicates_setting_and_translations( + def test_names__question_same_as_question_in_same_context_in_survey__case_insensitive_warning( self, ): + """Should find that a duplicate question name (CI) raises a warning.""" md = """ - | survey | | | | - | | type | name | label::en | label::ko | - | | select_one list | S1 | s1 | 질문 1 | - | choices | | | | - | | list name | name | label::en | label::ko | - | | list | a | Pass | 패스 | - | | list | b | Fail | 실패 | - | | list | c | Skipped | 건너뛴 | - | | list | c | Not Applicable | 해당 없음 | - | settings | | | - | | id_string | allow_choice_duplicates | - | | Duplicates | Yes | - """ - self.assertPyxformXform( - md=md, - xml__xpath_match=[ - xpc.model_itext_choice_text_label_by_pos( - "en", - "list", - ("Pass", "Fail", "Skipped", "Not Applicable"), - ), - xpc.model_itext_choice_text_label_by_pos( - "ko", - "list", - ("패스", "실패", "건너뛴", "해당 없음"), - ), - ], - ) - - def test_choice_list_without_duplicates_is_successful(self): - md = """ - | survey | | | | - | | type | name | label | - | | select_one list | S1 | s1 | - | choices | | | | - | | list name | name | label | - | | list | a | A | - | | list | b | B | - | settings | | | - | | id_string | allow_choice_duplicates | - | | Duplicates | Yes | - """ - self.assertPyxformXform( - md=md, - xml__xpath_match=[ - xpc.model_instance_choices_label("list", (("a", "A"), ("b", "B"))), - xpq.body_select1_itemset("S1"), - ], - ) - - def test_duplicate_form_name_in_section_name(self): - """ - Ensure that the section name cannot be the same as form name - """ - self.assertPyxformXform( - name="foo", - md=""" - | Survey | | | | - | | Type | Name | Label | - | | begin group | foo | A group | - | | text | a | Enter text | - | | end group | | | - """, - errored=True, - error__contains=["The name 'foo' is the same as the form name"], + | survey | + | | type | name | label | + | | text | q1 | Q1 | + | | text | Q1 | Q2 | + """ + self.assertPyxformXform( + md=md, warnings__contains=[unique_names.NAMES002.format(row=3, value="Q1")] ) - def test_field_name_may_match_form_name(self): + def test_names__question_same_as_group_in_same_context_in_survey__case_insensitive_warning( + self, + ): + """Should find that a duplicate question name (CI) raises a warning.""" + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + | | begin group | Q1 | G2 | + | | text | q2 | Q2 | + | | end group | | | """ - Unlike section names, it's okay for a field name to match the form - name, which becomes the XML root node name + self.assertPyxformXform( + md=md, warnings__contains=[unique_names.NAMES002.format(row=3, value="Q1")] + ) + + def test_names__question_same_as_repeat_in_same_context_in_survey__case_insensitive_warning( + self, + ): + """Should find that a duplicate question name (CI) raises a warning.""" + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + | | begin repeat | Q1 | G2 | + | | text | q2 | Q2 | + | | end repeat | | | + """ + self.assertPyxformXform( + md=md, warnings__contains=[unique_names.NAMES002.format(row=3, value="Q1")] + ) + + def test_names__question_same_as_question_in_same_context_in_group__case_insensitive_warning( + self, + ): + """Should find that a duplicate question name (CI) raises a warning.""" + md = """ + | survey | + | | type | name | label | + | | begin group | g1 | G1 | + | | text | q1 | Q1 | + | | text | Q1 | Q2 | + | | end group | | | + """ + self.assertPyxformXform( + md=md, warnings__contains=[unique_names.NAMES002.format(row=4, value="Q1")] + ) + + def test_names__question_same_as_group_in_same_context_in_group__case_insensitive_warning( + self, + ): + """Should find that a duplicate question name (CI) raises a warning.""" + md = """ + | survey | + | | type | name | label | + | | begin group | g1 | G1 | + | | text | q1 | Q1 | + | | begin group | Q1 | G2 | + | | text | q2 | Q2 | + | | end group | | | + | | end group | | | + """ + self.assertPyxformXform( + md=md, warnings__contains=[unique_names.NAMES002.format(row=4, value="Q1")] + ) + + def test_names__question_same_as_repeat_in_same_context_in_group__case_insensitive_warning( + self, + ): + """Should find that a duplicate question name (CI) raises a warning.""" + md = """ + | survey | + | | type | name | label | + | | begin group | g1 | G1 | + | | text | q1 | Q1 | + | | begin repeat | Q1 | G2 | + | | text | q2 | Q2 | + | | end repeat | | | + | | end group | | | + """ + self.assertPyxformXform( + md=md, warnings__contains=[unique_names.NAMES002.format(row=4, value="Q1")] + ) + + def test_names__question_same_as_question_in_same_context_in_repeat__case_insensitive_warning( + self, + ): + """Should find that a duplicate question name (CI) raises a warning.""" + md = """ + | survey | + | | type | name | label | + | | begin repeat | r1 | R1 | + | | text | q1 | Q1 | + | | text | Q1 | Q2 | + | | end repeat | | | + """ + self.assertPyxformXform( + md=md, warnings__contains=[unique_names.NAMES002.format(row=4, value="Q1")] + ) + + def test_names__question_same_as_group_in_same_context_in_repeat__case_insensitive_warning( + self, + ): + """Should find that a duplicate question name (CI) raises a warning.""" + md = """ + | survey | + | | type | name | label | + | | begin repeat | r1 | R1 | + | | text | q1 | Q1 | + | | begin group | Q1 | G2 | + | | text | q2 | Q2 | + | | end group | | | + | | end repeat | | | + """ + self.assertPyxformXform( + md=md, warnings__contains=[unique_names.NAMES002.format(row=4, value="Q1")] + ) + + def test_names__question_same_as_repeat_in_same_context_in_repeat__case_insensitive_warning( + self, + ): + """Should find that a duplicate question name (CI) raises a warning.""" + md = """ + | survey | + | | type | name | label | + | | begin repeat | r1 | R1 | + | | text | q1 | Q1 | + | | begin repeat | Q1 | G2 | + | | text | q2 | Q2 | + | | end repeat | | | + | | end repeat | | | """ self.assertPyxformXform( - name="activity", - md=""" - | survey | | | | - | | type | name | label | - | | date | date | Observation date | - | | text | activity | Describe activity | - """, + md=md, warnings__contains=[unique_names.NAMES002.format(row=4, value="Q1")] ) diff --git a/tests/test_for_loop.py b/tests/test_for_loop.py deleted file mode 100644 index 0720ea212..000000000 --- a/tests/test_for_loop.py +++ /dev/null @@ -1,38 +0,0 @@ -""" -Test loop question type. -""" - -from tests.pyxform_test_case import PyxformTestCase - - -class TestLoop(PyxformTestCase): - """ - Test loop question type. - """ - - def test_loop(self): - """ - Test loop question type. - """ - self.assertPyxformXform( - name="test_loop", - md=""" - | survey | | | | | - | | type | name | bind:relevant | label | - | | begin repeat | for-block | | Oh HAI | - | | string | input | (${done}='no') | HI HI | - | | string | done | | DONE? | - | | end repeat | | | | - """, - instance__contains=['', ""], - model__contains=[ - """""" - ], - xml__contains=[ - '', - "", - "", - ], - ) diff --git a/tests/test_group.py b/tests/test_group.py deleted file mode 100644 index 7daa235f0..000000000 --- a/tests/test_group.py +++ /dev/null @@ -1,78 +0,0 @@ -""" -Testing simple cases for Xls2Json -""" - -from unittest import TestCase - -from pyxform.builder import create_survey_element_from_dict -from pyxform.xls2json import SurveyReader - -from tests import utils - - -class GroupTests(TestCase): - def test_json(self): - x = SurveyReader(utils.path_to_text_fixture("group.xls"), default_name="group") - x_results = x.to_json_dict() - expected_dict = { - "name": "group", - "title": "group", - "id_string": "group", - "sms_keyword": "group", - "default_language": "default", - "type": "survey", - "children": [ - { - "name": "family_name", - "type": "text", - "label": {"English (en)": "What's your family name?"}, - }, - { - "name": "father", - "type": "group", - "label": {"English (en)": "Father"}, - "children": [ - { - "name": "phone_number", - "type": "phone number", - "label": { - "English (en)": "What's your father's phone number?" - }, - }, - { - "name": "age", - "type": "integer", - "label": {"English (en)": "How old is your father?"}, - }, - ], - }, - { - "children": [ - { - "bind": {"jr:preload": "uid", "readonly": "true()"}, - "name": "instanceID", - "type": "calculate", - } - ], - "control": {"bodyless": True}, - "name": "meta", - "type": "group", - }, - ], - } - self.maxDiff = None - self.assertEqual(x_results, expected_dict) - - def test_equality_of_to_dict(self): - x = SurveyReader(utils.path_to_text_fixture("group.xls"), default_name="group") - x_results = x.to_json_dict() - - survey = create_survey_element_from_dict(x_results) - survey_dict = survey.to_json_dict() - # using the builder sets the title attribute to equal name - # this won't happen through reading the excel file as done by - # SurveyReader. - # Now it happens. - # del survey_dict[u'title'] - self.maxDiff = None - self.assertEqual(x_results, survey_dict) diff --git a/tests/test_groups.py b/tests/test_groups.py index db44fb2b3..23e6b49b7 100644 --- a/tests/test_groups.py +++ b/tests/test_groups.py @@ -1,13 +1,20 @@ """ -Test XForm groups. +Test groups. """ +from unittest import TestCase + +from pyxform.builder import create_survey_element_from_dict +from pyxform.validators.pyxform import unique_names +from pyxform.xls2json import SURVEY_001, SURVEY_002 +from pyxform.xls2xform import convert + from tests.pyxform_test_case import PyxformTestCase -class GroupsTests(PyxformTestCase): +class TestGroupOutput(PyxformTestCase): """ - Test XForm groups. + Test output for groups. """ def test_group_type(self): @@ -71,3 +78,587 @@ def test_group_relevant_included_in_bind(self): """ ], ) + + +class TestGroupParsing(PyxformTestCase): + def test_names__group_basic_case__ok(self): + """Should find that a single unique group name is ok.""" + md = """ + | survey | + | | type | name | label | + | | begin group | g1 | G1 | + | | text | q1 | Q1 | + | | end group | | | + """ + self.assertPyxformXform( + md=md, + warnings_count=0, + ) + + def test_names__group_different_names_same_context__ok(self): + """Should find that groups with unique names in the same context is ok.""" + md = """ + | survey | + | | type | name | label | + | | begin group | g1 | G1 | + | | text | q1 | Q1 | + | | end group | | | + | | begin group | g2 | G2 | + | | text | q2 | Q2 | + | | end group | | | + """ + self.assertPyxformXform( + md=md, + warnings_count=0, + ) + + def test_names__group_same_as_group_in_different_group_context__ok(self): + """Should find that a group name can be the same as another group in a different context.""" + md = """ + | survey | + | | type | name | label | + | | begin group | g1 | G1 | + | | text | q1 | Q1 | + | | end group | | | + | | begin group | g2 | G2 | + | | begin group | g1 | G1 | + | | text | q1 | Q1 | + | | end group | | | + | | text | q2 | Q2 | + | | end group | | | + """ + self.assertPyxformXform( + md=md, + warnings_count=0, + ) + + def test_names__group_same_as_group_in_different_repeat_context__ok(self): + """Should find that a group name can be the same as another group in a different context.""" + md = """ + | survey | + | | type | name | label | + | | begin group | g1 | G1 | + | | text | q1 | Q1 | + | | end group | | | + | | begin repeat | r1 | R1 | + | | begin group | g1 | G1 | + | | text | q1 | Q1 | + | | end group | | | + | | text | q2 | Q2 | + | | end repeat | | | + """ + self.assertPyxformXform( + md=md, + warnings_count=0, + ) + + def test_names__group_same_as_repeat_in_different_group_context__ok(self): + """Should find that a repeat name can be the same as a group in a different context.""" + md = """ + | survey | + | | type | name | label | + | | begin group | g1 | G1 | + | | begin repeat | g2 | G2 | + | | text | q1 | Q1 | + | | end repeat | | | + | | end group | | | + | | begin group | g2 | G2 | + | | text | q2 | Q2 | + | | end group | | | + """ + self.assertPyxformXform( + md=md, + warnings_count=0, + ) + + def test_names__group_same_as_repeat_in_different_repeat_context__ok(self): + """Should find that a repeat name can be the same as a group in a different context.""" + md = """ + | survey | + | | type | name | label | + | | begin repeat | r1 | R1 | + | | begin repeat | g2 | G2 | + | | text | q1 | Q1 | + | | end repeat | | | + | | end repeat | | | + | | begin group | g2 | G2 | + | | text | q2 | Q2 | + | | end group | | | + """ + self.assertPyxformXform( + md=md, + warnings_count=0, + ) + + def test_names__group_same_as_survey_root__ok(self): + """Should find that a group name can be the same as the survey root.""" + md = """ + | survey | + | | type | name | label | + | | begin group | data | G1 | + | | text | q1 | Q1 | + | | end group | | | + """ + self.assertPyxformXform( + md=md, + name="data", + warnings_count=0, + ) + + def test_names__group_same_as_survey_root_case_insensitive__ok(self): + """Should find that a group name can be the same (CI) as the survey root.""" + md = """ + | survey | + | | type | name | label | + | | begin group | DATA | G1 | + | | text | q1 | Q1 | + | | end group | | | + """ + self.assertPyxformXform( + md=md, + name="data", + warnings_count=0, + ) + + def test_names__group_same_as_group_in_same_context_in_survey__error(self): + """Should find that a duplicate group name raises an error.""" + md = """ + | survey | + | | type | name | label | + | | begin group | g1 | G1 | + | | text | q1 | Q1 | + | | end group | | | + | | begin group | g1 | G2 | + | | text | q2 | Q2 | + | | end group | | | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[unique_names.NAMES001.format(row=5, value="g1")], + ) + + def test_names__group_same_as_repeat_in_same_context_in_survey__error(self): + """Should find that a duplicate group name raises an error.""" + md = """ + | survey | + | | type | name | label | + | | begin repeat | g1 | G1 | + | | text | q1 | Q1 | + | | end repeat | | | + | | begin group | g1 | G2 | + | | text | q2 | Q2 | + | | end group | | | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[unique_names.NAMES001.format(row=5, value="g1")], + ) + + def test_names__group_same_as_group_in_same_context_in_group__error(self): + """Should find that a duplicate group name raises an error.""" + md = """ + | survey | + | | type | name | label | + | | begin group | g1 | G1 | + | | begin group | g2 | G2 | + | | text | q1 | Q1 | + | | end group | | | + | | begin group | g2 | G2 | + | | text | q2 | Q2 | + | | end group | | | + | | end group | | | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[unique_names.NAMES001.format(row=6, value="g2")], + ) + + def test_names__group_same_as_repeat_in_same_context_in_group__error(self): + """Should find that a duplicate group name raises an error.""" + md = """ + | survey | + | | type | name | label | + | | begin group | g1 | G1 | + | | begin repeat | g2 | G2 | + | | text | q1 | Q1 | + | | end repeat | | | + | | begin group | g2 | G2 | + | | text | q2 | Q2 | + | | end group | | | + | | end group | | | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[unique_names.NAMES001.format(row=6, value="g2")], + ) + + def test_names__group_same_as_group_in_same_context_in_repeat__error(self): + """Should find that a duplicate group name raises an error.""" + md = """ + | survey | + | | type | name | label | + | | begin repeat | r1 | R1 | + | | begin group | g2 | G2 | + | | text | q1 | Q1 | + | | end group | | | + | | begin group | g2 | G2 | + | | text | q2 | Q2 | + | | end group | | | + | | end repeat | | | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[unique_names.NAMES001.format(row=6, value="g2")], + ) + + def test_names__group_same_as_repeat_in_same_context_in_repeat__error(self): + """Should find that a duplicate group name raises an error.""" + md = """ + | survey | + | | type | name | label | + | | begin repeat | r1 | R1 | + | | begin repeat | g2 | G2 | + | | text | q1 | Q1 | + | | end repeat | | | + | | begin group | g2 | G2 | + | | text | q2 | Q2 | + | | end group | | | + | | end repeat | | | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[unique_names.NAMES001.format(row=6, value="g2")], + ) + + def test_names__group_same_as_group_in_same_context_in_survey__case_insensitive_warning( + self, + ): + """Should find that a duplicate group name (CI) raises a warning.""" + md = """ + | survey | + | | type | name | label | + | | begin group | g1 | G1 | + | | text | q1 | Q1 | + | | end group | | | + | | begin group | G1 | G2 | + | | text | q2 | Q2 | + | | end group | | | + """ + self.assertPyxformXform( + md=md, warnings__contains=[unique_names.NAMES002.format(row=5, value="G1")] + ) + + def test_names__group_same_as_repeat_in_same_context_in_survey__case_insensitive_warning( + self, + ): + """Should find that a duplicate group name (CI) raises a warning.""" + md = """ + | survey | + | | type | name | label | + | | begin group | g1 | G1 | + | | text | q1 | Q1 | + | | end group | | | + | | begin group | G1 | G2 | + | | text | q2 | Q2 | + | | end group | | | + """ + self.assertPyxformXform( + md=md, warnings__contains=[unique_names.NAMES002.format(row=5, value="G1")] + ) + + def test_names__group_same_as_group_in_same_context_in_group__case_insensitive_warning( + self, + ): + """Should find that a duplicate group name (CI) raises a warning.""" + md = """ + | survey | + | | type | name | label | + | | begin group | g1 | G1 | + | | begin group | g2 | G2 | + | | text | q1 | Q1 | + | | end group | | | + | | begin group | G2 | G2 | + | | text | q2 | Q2 | + | | end group | | | + | | end group | | | + """ + self.assertPyxformXform( + md=md, warnings__contains=[unique_names.NAMES002.format(row=6, value="G2")] + ) + + def test_names__group_same_as_repeat_in_same_context_in_group__case_insensitive_warning( + self, + ): + """Should find that a duplicate group name (CI) raises a warning.""" + md = """ + | survey | + | | type | name | label | + | | begin group | g1 | G1 | + | | begin repeat | g2 | G2 | + | | text | q1 | Q1 | + | | end repeat | | | + | | begin group | G2 | G2 | + | | text | q2 | Q2 | + | | end group | | | + | | end group | | | + """ + self.assertPyxformXform( + md=md, warnings__contains=[unique_names.NAMES002.format(row=6, value="G2")] + ) + + def test_names__group_same_as_group_in_same_context_in_repeat__case_insensitive_warning( + self, + ): + """Should find that a duplicate group name (CI) raises a warning.""" + md = """ + | survey | + | | type | name | label | + | | begin repeat | r1 | R1 | + | | begin group | g2 | G2 | + | | text | q1 | Q1 | + | | end group | | | + | | begin group | G2 | G2 | + | | text | q2 | Q2 | + | | end group | | | + | | end repeat | | | + """ + self.assertPyxformXform( + md=md, warnings__contains=[unique_names.NAMES002.format(row=6, value="G2")] + ) + + def test_names__group_same_as_repeat_in_same_context_in_repeat__case_insensitive_warning( + self, + ): + """Should find that a duplicate group name (CI) raises a warning.""" + md = """ + | survey | + | | type | name | label | + | | begin repeat | r1 | R1 | + | | begin repeat | g2 | G2 | + | | text | q1 | Q1 | + | | end repeat | | | + | | begin group | G2 | G2 | + | | text | q2 | Q2 | + | | end group | | | + | | end repeat | | | + """ + self.assertPyxformXform( + md=md, warnings__contains=[unique_names.NAMES002.format(row=6, value="G2")] + ) + + def test_group__no_end_error__no_name(self): + """Should raise an error if there is a "begin group" with no "end group" and no name.""" + md = """ + | survey | + | | type | name | label | + | | begin group | | G1 | + | | text | q1 | Q1 | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=["[row : 2] Question or group with no name."], + ) + + def test_group__no_end_error(self): + """Should raise an error if there is a "begin group" with no "end group".""" + md = """ + | survey | + | | type | name | label | + | | begin group | g1 | G1 | + | | text | q1 | Q1 | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[SURVEY_002.format(row=2, type="group", name="g1")], + ) + + def test_group__no_end_error__different_end_type(self): + """Should raise an error if there is a "begin group" with no "end group".""" + md = """ + | survey | + | | type | name | label | + | | begin group | g1 | G1 | + | | text | q1 | Q1 | + | | end repeat | | | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[SURVEY_001.format(row=4, type="repeat")], + ) + + def test_group__no_end_error__with_another_closed_group(self): + """Should raise an error if there is a "begin group" with no "end group".""" + md = """ + | survey | + | | type | name | label | + | | begin group | g1 | G1 | + | | begin group | g2 | G2 | + | | text | q1 | Q1 | + | | end group | | | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[SURVEY_002.format(row=2, type="group", name="g1")], + ) + + def test_group__no_begin_error(self): + """Should raise an error if there is a "end group" with no "begin group".""" + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + | | end group | | | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[SURVEY_001.format(row=3, type="group")], + ) + + def test_group__no_begin_error__with_name(self): + """Should raise an error if there is a "end group" with no "begin group".""" + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + | | end group | g1 | | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[SURVEY_001.format(row=3, type="group", name="g1")], + ) + + def test_group__no_begin_error__with_another_closed_group(self): + """Should raise an error if there is a "end group" with no "begin group".""" + md = """ + | survey | + | | type | name | label | + | | begin group | g1 | G1 | + | | text | q1 | Q1 | + | | end group | | | + | | end group | | | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ + SURVEY_001.format( + row=5, + type="group", + ) + ], + ) + + def test_group__no_begin_error__with_another_closed_repeat(self): + """Should raise an error if there is a "end group" with no "begin group".""" + md = """ + | survey | + | | type | name | label | + | | begin repeat | g1 | G1 | + | | text | q1 | Q1 | + | | end group | | | + | | end repeat | | | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[SURVEY_001.format(row=4, type="group")], + ) + + +class TestGroupInternalRepresentations(TestCase): + maxDiff = None + + def test_survey_to_json_output(self): + """Should find that the survey.to_json_dict output remains consistent.""" + md = """ + | survey | + | | type | name | label::English (en) | + | | text | family_name | What's your family name? | + | | begin group | father | Father | + | | phone number | phone_number | What's your father's phone number? | + | | integer | age | How old is your father? | + | | end group | | | + + | settings | + | | id_string | + | | group | + """ + observed = convert(xlsform=md, form_name="group")._survey.to_json_dict() + expected = { + "name": "group", + "title": "group", + "id_string": "group", + "sms_keyword": "group", + "default_language": "default", + "type": "survey", + "children": [ + { + "name": "family_name", + "type": "text", + "label": {"English (en)": "What's your family name?"}, + }, + { + "name": "father", + "type": "group", + "label": {"English (en)": "Father"}, + "children": [ + { + "name": "phone_number", + "type": "phone number", + "label": { + "English (en)": "What's your father's phone number?" + }, + }, + { + "name": "age", + "type": "integer", + "label": {"English (en)": "How old is your father?"}, + }, + ], + }, + { + "children": [ + { + "bind": {"jr:preload": "uid", "readonly": "true()"}, + "name": "instanceID", + "type": "calculate", + } + ], + "control": {"bodyless": True}, + "name": "meta", + "type": "group", + }, + ], + } + self.assertEqual(expected, observed) + + def test_to_json_round_trip(self): + """Should find that survey.to_json_dict output can be re-used to build the survey.""" + md = """ + | survey | + | | type | name | label::English (en) | + | | text | family_name | What's your family name? | + | | begin group | father | Father | + | | phone number | phone_number | What's your father's phone number? | + | | integer | age | How old is your father? | + | | end group | | | + + | settings | + | | id_string | + | | group | + """ + expected = convert(xlsform=md, form_name="group")._survey.to_json_dict() + observed = create_survey_element_from_dict(expected).to_json_dict() + self.assertEqual(expected, observed) diff --git a/tests/test_loop.py b/tests/test_loop.py index 0e8689f46..9dd0e8baa 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -4,18 +4,245 @@ from unittest import TestCase +from pyxform.validators.pyxform import unique_names from pyxform.xls2xform import convert -from tests import utils +from tests.pyxform_test_case import PyxformTestCase +from tests.xpath_helpers.questions import xpq -class TestLoop(TestCase): +class TestLoop(PyxformTestCase): + """ + A 'loop' is a type of group that, for each choice in the referenced choice list, + generates grouped set of questions using the questions inside the loop definition. + The pattern "%(name)s" or "%(label)s" can be used to insert the choice name or label + into the question columns, e.g. to adjust the label to each choice. + """ + + def test_loop(self): + """Should find that each item in the loop is repeated for each loop choice.""" + md = """ + | survey | + | | type | name | label | + | | begin loop over c1 | l1 | | + | | integer | q1 | Age | + | | select_one c2 | q2 | Size of %(label)s | + | | end loop | | | + + | choices | + | | list_name | name | label | + | | c1 | thing1 | Thing 1 | + | | c1 | thing2 | Thing 2 | + | | c2 | type1 | Big | + | | c2 | type2 | Small | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + # Instance + xpq.model_instance_item("l1/x:thing1/x:q1"), + xpq.model_instance_item("l1/x:thing1/x:q2"), + xpq.model_instance_item("l1/x:thing2/x:q1"), + xpq.model_instance_item("l1/x:thing2/x:q2"), + # Bind + xpq.model_instance_bind("l1/thing1/q1", "int"), + xpq.model_instance_bind("l1/thing1/q2", "string"), + xpq.model_instance_bind("l1/thing1/q1", "int"), + xpq.model_instance_bind("l1/thing1/q1", "int"), + # Control + """ + /h:html/h:body/x:group[@ref = '/test_name/l1']/x:group[ + @ref = '/test_name/l1/thing1' + and ./x:label = 'Thing 1' + and ./x:input[ + @ref = '/test_name/l1/thing1/q1' + and ./x:label = 'Age' + ] + and ./x:select1[ + @ref = '/test_name/l1/thing1/q2' + and ./x:label = 'Size of Thing 1' + and ./x:itemset[ + @nodeset = "instance('c2')/root/item" + and ./x:value[@ref = 'name'] + and ./x:label[@ref = 'label'] + ] + ] + ] + """, + """ + /h:html/h:body/x:group[@ref = '/test_name/l1']/x:group[ + @ref = '/test_name/l1/thing2' + and ./x:label = 'Thing 2' + and ./x:input[ + @ref = '/test_name/l1/thing2/q1' + and ./x:label = 'Age' + ] + and ./x:select1[ + @ref = '/test_name/l1/thing2/q2' + and ./x:label = 'Size of Thing 2' + and ./x:itemset[ + @nodeset = "instance('c2')/root/item" + and ./x:value[@ref = 'name'] + and ./x:label[@ref = 'label'] + ] + ] + ] + """, + ], + ) + + def test_loop__groups_error(self): + """Should find that using a group in a loop results in an error.""" + md = """ + | survey | + | | type | name | label | + | | begin loop over c1 | l1 | | + | | begin group | g1 | | + | | integer | q1 | Age | + | | select_one c2 | q2 | Size of %(label)s | + | | end group | | | + | | end loop | | | + + | choices | + | | list_name | name | label | + | | c1 | thing1 | Thing 1 | + | | c1 | thing2 | Thing 2 | + | | c2 | type1 | Big | + | | c2 | type2 | Small | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + # Instance + xpq.model_instance_item("l1/x:thing1/x:g1/x:q1"), + xpq.model_instance_item("l1/x:thing1/x:g1/x:q2"), + xpq.model_instance_item("l1/x:thing2/x:g1/x:q1"), + xpq.model_instance_item("l1/x:thing2/x:g1/x:q2"), + # Bind + xpq.model_instance_bind("l1/thing1/g1/q1", "int"), + xpq.model_instance_bind("l1/thing1/g1/q2", "string"), + xpq.model_instance_bind("l1/thing1/g1/q1", "int"), + xpq.model_instance_bind("l1/thing1/g1/q1", "int"), + # Control + # TODO: name/label substitution doesn't work with nested group + """ + /h:html/h:body/x:group[@ref = '/test_name/l1']/x:group[ + @ref = '/test_name/l1/thing1' + and ./x:label = 'Thing 1' + ]/x:group[ + @ref = '/test_name/l1/thing1/g1' + and ./x:input[ + @ref = '/test_name/l1/thing1/g1/q1' + and ./x:label = 'Age' + ] + and ./x:select1[ + @ref = '/test_name/l1/thing1/g1/q2' + and ./x:label = 'Size of %(label)s' + and ./x:itemset[ + @nodeset = "instance('c2')/root/item" + and ./x:value[@ref = 'name'] + and ./x:label[@ref = 'label'] + ] + ] + ] + """, + """ + /h:html/h:body/x:group[@ref = '/test_name/l1']/x:group[ + @ref = '/test_name/l1/thing2' + and ./x:label = 'Thing 2' + ]/x:group[ + @ref = '/test_name/l1/thing2/g1' + and ./x:input[ + @ref = '/test_name/l1/thing2/g1/q1' + and ./x:label = 'Age' + ] + and ./x:select1[ + @ref = '/test_name/l1/thing2/g1/q2' + and ./x:label = 'Size of %(label)s' + and ./x:itemset[ + @nodeset = "instance('c2')/root/item" + and ./x:value[@ref = 'name'] + and ./x:label[@ref = 'label'] + ] + ] + ] + """, + ], + ) + + def test_loop__repeats_error(self): + """Should find that using a repeat in a loop results in an error.""" + md = """ + | survey | + | | type | name | label | + | | begin loop over c1 | l1 | | + | | begin repeat | r1 | | + | | integer | q1 | Count %(label)s | + | | select_one c2 | q2 | Type of %(label)s | + | | end repeat | | | + | | end loop | | | + + | choices | + | | list_name | name | label | + | | c1 | thing1 | Thing 1 | + | | c1 | thing2 | Thing 2 | + | | c2 | type1 | Big | + | | c2 | type2 | Small | + """ + self.assertPyxformXform( + md=md, + errored=True, + # Not caught by xls2json since loops are currently generated in builder.py + error__contains=[unique_names.NAMES004.format(row=None, value="r1")], + ) + + def test_loop__references_error(self): + """Should find that using a reference variable in a loop results in an error.""" + md = """ + | survey | + | | type | name | label | + | | begin loop over c1 | l1 | | + | | integer | q1 | Count %(label)s | + | | note | q2 | Counted ${q1} | + | | end loop | | | + + | choices | + | | list_name | name | label | + | | c1 | thing1 | Thing 1 | + | | c1 | thing2 | Thing 2 | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ + "There has been a problem trying to replace ${q1} with the XPath to the " + "survey element named 'q1'. There are multiple survey elements named 'q1'." + ], + ) + + +class TestLoopInternalRepresentation(TestCase): maxDiff = None - def test_table(self): - path = utils.path_to_text_fixture("simple_loop.xls") - observed = convert(xlsform=path)._pyxform + def test_pyxform(self): + """Should find that the internal pyxform data structure remains consistent.""" + md = """ + | survey | + | | type | name | label::English | + | | begin loop over my_columns | my_table | My Table | + | | integer | count | How many are there in this group? | + | | end loop | | | + + | choices | + | | list name | name | label:English | + | | my_columns | col1 | Column 1 | + | | my_columns | col2 | Column 2 | + | settings | + | | id_string | + | | simple_loop | + """ + observed = convert(xlsform=md)._pyxform expected = { "name": "data", "title": "simple_loop", @@ -62,9 +289,26 @@ def test_table(self): } self.assertEqual(expected, observed) - def test_loop(self): - path = utils.path_to_text_fixture("another_loop.xls") - observed = convert(xlsform=path)._survey.to_json_dict() + def test_survey_to_json_output(self): + """Should find that the survey.to_json_dict output remains consistent.""" + md = """ + | survey | + | | type | name | label::English | label::French | constraint | + | | begin loop over types | loop_vehicle_types | | | | + | | integer | total | How many do you have? | Combien avoir? | | + | | integer | working | How many are working? | Combien marcher? | . <= ../total | + | | end loop | | | | | + + | choices | + | | list_name | name | label::English | label::French | + | | types | car | Car | Voiture | + | | types | motor_cycle | Motorcycle | Moto | + + | settings | + | | id_string | + | | another_loop | + """ + observed = convert(xlsform=md)._survey.to_json_dict() observed.pop("_translations", None) observed.pop("_xpath", None) expected = { diff --git a/tests/test_metadata.py b/tests/test_metadata.py index e008dbe77..7ff645a6c 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -2,10 +2,12 @@ Test language warnings. """ +from pyxform.validators.pyxform import unique_names + from tests.pyxform_test_case import PyxformTestCase -class MetadataTest(PyxformTestCase): +class TestMetadata(PyxformTestCase): """ Test metadata and related warnings. """ @@ -64,3 +66,166 @@ def test_subscriber_id_deprecation_warning(self): "Only old versions of Collect on Android versions older than 11 still support it." ], ) + + def test_names__survey_named_meta__ok(self): + """Should find that using the name 'meta' for the survey is OK.""" + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + """ + self.assertPyxformXform(md=md, name="meta", warnings_count=0) + + def test_names__question_named_meta__in_survey__case_insensitive_ok(self): + """Should find that using the name 'meta' in a different case is OK.""" + md = """ + | survey | + | | type | name | label | + | | text | META | Q1 | + """ + self.assertPyxformXform(md=md, warnings_count=0) + + def test_names__group_named_meta__in_survey__case_insensitive_ok(self): + """Should find that using the name 'meta' in a different case is OK.""" + md = """ + | survey | + | | type | name | label | + | | begin group | META | G1 | + | | text | q1 | Q1 | + | | end group | | | + """ + self.assertPyxformXform(md=md, warnings_count=0) + + def test_names__repeat_named_meta__in_survey__case_insensitive_ok(self): + """Should find that using the name 'meta' in a different case is OK.""" + md = """ + | survey | + | | type | name | label | + | | begin repeat | META | G1 | + | | text | q1 | Q1 | + | | end repeat | | | + """ + self.assertPyxformXform(md=md, warnings_count=0) + + def test_names__question_named_meta__in_survey__error(self): + """Should find that using the name 'meta' raises an error.""" + md = """ + | survey | + | | type | name | label | + | | text | meta | Q1 | + """ + self.assertPyxformXform( + md=md, errored=True, error__contains=[unique_names.NAMES005.format(row=2)] + ) + + def test_names__group_named_meta__in_survey__error(self): + """Should find that using the name 'meta' raises an error.""" + md = """ + | survey | + | | type | name | label | + | | begin group | meta | G1 | + | | text | q1 | Q1 | + | | end group | | | + """ + self.assertPyxformXform( + md=md, errored=True, error__contains=[unique_names.NAMES005.format(row=2)] + ) + + def test_names__repeat_named_meta__in_survey__error(self): + """Should find that using the name 'meta' raises an error.""" + md = """ + | survey | + | | type | name | label | + | | begin repeat | meta | G1 | + | | text | q1 | Q1 | + | | end repeat | | | + """ + self.assertPyxformXform( + md=md, errored=True, error__contains=[unique_names.NAMES005.format(row=2)] + ) + + def test_names__question_named_meta__in_group__error(self): + """Should find that using the name 'meta' raises an error.""" + md = """ + | survey | + | | type | name | label | + | | begin group | g1 | G1 | + | | text | meta | Q1 | + | | end group | | | + """ + self.assertPyxformXform( + md=md, errored=True, error__contains=[unique_names.NAMES005.format(row=3)] + ) + + def test_names__group_named_meta__in_group__error(self): + """Should find that using the name 'meta' raises an error.""" + md = """ + | survey | + | | type | name | label | + | | begin group | g1 | G1 | + | | begin group | meta | G1 | + | | text | q1 | Q1 | + | | end group | | | + | | end group | | | + """ + self.assertPyxformXform( + md=md, errored=True, error__contains=[unique_names.NAMES005.format(row=3)] + ) + + def test_names__repeat_named_meta__in_group__error(self): + """Should find that using the name 'meta' raises an error.""" + md = """ + | survey | + | | type | name | label | + | | begin group | g1 | G1 | + | | begin repeat | meta | G1 | + | | text | q1 | Q1 | + | | end repeat | | | + | | end group | | | + """ + self.assertPyxformXform( + md=md, errored=True, error__contains=[unique_names.NAMES005.format(row=3)] + ) + + def test_names__question_named_meta__in_repeat__error(self): + """Should find that using the name 'meta' raises an error.""" + md = """ + | survey | + | | type | name | label | + | | begin repeat | r1 | R1 | + | | text | meta | Q1 | + | | end group | | | + """ + self.assertPyxformXform( + md=md, errored=True, error__contains=[unique_names.NAMES005.format(row=3)] + ) + + def test_names__group_named_meta__in_repeat__error(self): + """Should find that using the name 'meta' raises an error.""" + md = """ + | survey | + | | type | name | label | + | | begin repeat | r1 | R1 | + | | begin group | meta | G1 | + | | text | q1 | Q1 | + | | end group | | | + | | end repeat | | | + """ + self.assertPyxformXform( + md=md, errored=True, error__contains=[unique_names.NAMES005.format(row=3)] + ) + + def test_names__repeat_named_meta__in_repeat__error(self): + """Should find that using the name 'meta' raises an error.""" + md = """ + | survey | + | | type | name | label | + | | begin repeat | r1 | R1 | + | | begin repeat | meta | G1 | + | | text | q1 | Q1 | + | | end repeat | | | + | | end repeat | | | + """ + self.assertPyxformXform( + md=md, errored=True, error__contains=[unique_names.NAMES005.format(row=3)] + ) diff --git a/tests/test_repeat.py b/tests/test_repeat.py index ae9b62b7d..23fbf9e44 100644 --- a/tests/test_repeat.py +++ b/tests/test_repeat.py @@ -7,15 +7,17 @@ from unittest import skip from psutil import Process +from pyxform.validators.pyxform import unique_names from pyxform.xls2json_backends import SupportedFileTypes from pyxform.xls2xform import convert from tests.pyxform_test_case import PyxformTestCase +from tests.xpath_helpers.questions import xpq -class TestRepeat(PyxformTestCase): +class TestRepeatOutput(PyxformTestCase): """ - TestRepeat class. + Test output for repeats. """ def test_repeat_relative_reference(self): @@ -482,37 +484,65 @@ def test_indexed_repeat_regular_calculation_relative_path_exception(self): def test_indexed_repeat_dynamic_default_relative_path_exception(self): """Test relative path exception (absolute path) in indexed-repeat() using dynamic default.""" # dynamic default indexed-repeat 1st, 2nd, 4th, and 6th argument is using absolute path + md = """ + | survey | + | | type | name | label | default | + | | begin_repeat | r1 | Person | | + | | text | q1 | Enter name | | + | | text | q2 | Name in previous repeat instance | indexed-repeat(${q1}, ${r1}, position(..)-1) | + | | end repeat | | | | + """ self.assertPyxformXform( - md=""" - | survey | | | | | - | | type | name | label | default | - | | begin_repeat | person | Person | | - | | text | name | Enter name | | - | | text | prev_name | Name in previous repeat instance | indexed-repeat(${name}, ${person}, position(..)-1) | - | | end repeat | | | | - """, # pylint: disable=line-too-long - xml__contains=[ - """""" # pylint: disable=line-too-long + md=md, + xml__xpath_match=[ + # q2 dynamic default value in model setvalue, with first-load event. + xpq.setvalue( + path="""h:head/x:model""", + ref="/test_name/r1/q2", + event="odk-instance-first-load", + value="indexed-repeat( /test_name/r1/q1 , /test_name/r1 , position(..)-1)", + ), + # q2 dynamic default value in body group setvalue, with new-repeat event. + xpq.setvalue( + path="""h:body/x:group[@ref='/test_name/r1']/x:repeat[@nodeset='/test_name/r1']""", + ref="/test_name/r1/q2", + event="odk-new-repeat", + value="indexed-repeat( /test_name/r1/q1 , /test_name/r1 , position(..)-1)", + ), ], ) def test_indexed_repeat_nested_repeat_relative_path_exception(self): """Test relative path exception (absolute path) in indexed-repeat() using nested repeat.""" # In nested repeat, indexed-repeat 1st, 2nd, 4th, and 6th argument is using absolute path + md = """ + | survey | + | | type | name | label | default | + | | begin_repeat | r1 | Family | | + | | integer | q1 | How many members in this family? | | + | | begin_repeat | r2 | Person | | + | | text | q3 | Enter name | | + | | text | q4 | Non-sensible previous name in first family, 2nd person | indexed-repeat(${q3}, ${r1}, 1, ${r2}, 2) | + | | end repeat | r1 | | | + | | end repeat | r2 | | | + """ self.assertPyxformXform( - md=""" - | survey | | | | | - | | type | name | label | default | - | | begin_repeat | family | Family | | - | | integer | members_number | How many members in this family? | | - | | begin_repeat | person | Person | | - | | text | name | Enter name | | - | | text | prev_name | Non-sensible previous name in first family, 2nd person | indexed-repeat(${name}, ${family}, 1, ${person}, 2) | - | | end repeat | | | | - | | end repeat | | | | - """, # pylint: disable=line-too-long - xml__contains=[ - """""" # pylint: disable=line-too-long + md=md, + xml__xpath_match=[ + # q4 dynamic default value in model setvalue, with first-load event. + xpq.setvalue( + path="""h:head/x:model""", + ref="/test_name/r1/r2/q4", + event="odk-instance-first-load", + value="indexed-repeat( /test_name/r1/r2/q3 , /test_name/r1 , 1, /test_name/r1/r2 , 2)", + ), + # q4 dynamic default value in body group setvalue, with new-repeat event. + xpq.setvalue( + path="""h:body/x:group/x:repeat[@nodeset='/test_name/r1']/x:group/x:repeat[@nodeset='/test_name/r1/r2']""", + ref="/test_name/r1/r2/q4", + event="odk-new-repeat", + value="indexed-repeat( /test_name/r1/r2/q3 , /test_name/r1 , 1, /test_name/r1/r2 , 2)", + ), ], ) @@ -590,21 +620,35 @@ def test_mixed_variables_and_indexed_repeat_in_expression_integer_type_nested_re ): """Test relative path exception (absolute path) in an expression contains variables and indexed-repeat() in an integer type using nested repeat.""" # In nested repeat, indexed-repeat 1st, 2nd, 4th, and 6th argument is using absolute path + md = """ + | survey | + | | type | name | label | default | + | | begin_group | g1 | | | + | | begin_repeat | r1 | | | + | | integer | q1 | Repeating group entry | | + | | text | q2 | Position | | + | | integer | q3 | Systolic pressure | | + | | integer | q4 | Diastolic pressure | if(${q1} = 1, '', indexed-repeat(${q4}, ${r1}, ${q1} - 1)) | + | | end_repeat | r1 | | | + | | end_group | g1 | | | + """ self.assertPyxformXform( - md=""" - | survey | | | | | - | | type | name | label | default | - | | begin_group | page | | | - | | begin_repeat | bp_rg | | | - | | integer | bp_row | Repeating group entry | | - | | text | bp_pos | Position | | - | | integer | bp_sys | Systolic pressure | | - | | integer | bp_dia | Diastolic pressure | if(${bp_row} = 1, '', indexed-repeat(${bp_dia}, ${bp_rg}, ${bp_row} - 1)) | - | | end repeat | | | | - | | end group | | | | - """, # pylint: disable=line-too-long - xml__contains=[ - """""" # pylint: disable=line-too-long + md=md, + xml__xpath_match=[ + # q4 dynamic default value in model setvalue, with first-load event. + xpq.setvalue( + path="""h:head/x:model""", + ref="/test_name/g1/r1/q4", + event="odk-instance-first-load", + value="""if( ../q1 = 1, '', indexed-repeat( /test_name/g1/r1/q4 , /test_name/g1/r1 , ../q1 - 1))""", + ), + # q4 dynamic default value in body group setvalue, with new-repeat event. + xpq.setvalue( + path="""h:body/x:group/x:group/x:repeat[@nodeset='/test_name/g1/r1']""", + ref="/test_name/g1/r1/q4", + event="odk-new-repeat", + value="""if( ../q1 = 1, '', indexed-repeat( /test_name/g1/r1/q4 , /test_name/g1/r1 , ../q1 - 1))""", + ), ], ) @@ -1026,3 +1070,242 @@ def test_calculation_using_node_from_nested_repeat_has_relative_reference(self): """, ], ) + + +class TestRepeatParsing(PyxformTestCase): + def test_names__repeat_basic_case__ok(self): + """Should find that a single unique repeat name is ok.""" + md = """ + | survey | + | | type | name | label | + | | begin repeat | r1 | R1 | + | | text | q1 | Q1 | + | | end repeat | | | + """ + self.assertPyxformXform( + md=md, + warnings_count=0, + ) + + def test_names__repeat_different_names_same_context__ok(self): + """Should find that repeats with unique names in the same context is ok.""" + md = """ + | survey | + | | type | name | label | + | | begin repeat | r1 | R1 | + | | text | q1 | Q1 | + | | end repeat | | | + | | begin repeat | r2 | R2 | + | | text | q2 | Q2 | + | | end repeat | | | + """ + self.assertPyxformXform( + md=md, + warnings_count=0, + ) + + def test_names__repeat_same_as_repeat_in_different_context_case_insensitive__ok(self): + """Should find that a repeat name can be the same (CI) as another repeat in a different context.""" + md = """ + | survey | + | | type | name | label | + | | begin group | g1 | G1 | + | | begin repeat | r1 | R1 | + | | text | q1 | Q1 | + | | end repeat | | | + | | end group | | | + | | begin repeat | R1 | R2 | + | | text | q2 | Q2 | + | | end repeat | | | + """ + self.assertPyxformXform( + md=md, + warnings_count=0, + ) + + def test_names__repeat_same_as_survey_root_case_insensitive__ok(self): + """Should find that a repeat name can be the same (CI) as the survey root.""" + md = """ + | survey | + | | type | name | label | + | | begin repeat | DATA | R1 | + | | text | q1 | Q1 | + | | end repeat | | | + """ + self.assertPyxformXform( + md=md, + name="data", + warnings_count=0, + ) + + def test_names__repeat_same_as_repeat_in_same_context_in_survey__error(self): + """Should find that a duplicate repeat name raises an error.""" + md = """ + | survey | + | | type | name | label | + | | begin repeat | r1 | R1 | + | | text | q1 | Q1 | + | | end repeat | | | + | | begin repeat | r1 | R1 | + | | text | q2 | Q2 | + | | end repeat | | | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[unique_names.NAMES001.format(row=5, value="r1")], + ) + + def test_names__repeat_same_as_repeat_in_same_context_in_group__error(self): + """Should find that a duplicate repeat name raises an error.""" + md = """ + | survey | + | | type | name | label | + | | begin group | g1 | G1 | + | | begin repeat | r1 | R1 | + | | text | q1 | Q1 | + | | end repeat | | | + | | begin repeat | r1 | R1 | + | | text | q2 | Q2 | + | | end repeat | | | + | | end group | | | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[unique_names.NAMES001.format(row=6, value="r1")], + ) + + def test_names__repeat_same_as_repeat_in_same_context_in_repeat__error(self): + """Should find that a duplicate repeat name raises an error.""" + md = """ + | survey | + | | type | name | label | + | | begin repeat | r1 | R1 | + | | begin repeat | r2 | R2 | + | | text | q1 | Q1 | + | | end repeat | | | + | | begin repeat | r2 | R2 | + | | text | q2 | Q2 | + | | end repeat | | | + | | end repeat | | | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[unique_names.NAMES001.format(row=6, value="r2")], + ) + + def test_names__repeat_same_as_repeat_in_same_context_in_survey__case_insensitive_warning( + self, + ): + """Should find that a duplicate repeat name (CI) raises a warning.""" + md = """ + | survey | + | | type | name | label | + | | begin repeat | r1 | R1 | + | | text | q1 | Q1 | + | | end repeat | | | + | | begin repeat | R1 | R1 | + | | text | q2 | Q2 | + | | end repeat | | | + """ + self.assertPyxformXform( + md=md, warnings__contains=[unique_names.NAMES002.format(row=5, value="R1")] + ) + + def test_names__repeat_same_as_repeat_in_same_context_in_group__case_insensitive_warning( + self, + ): + """Should find that a duplicate repeat name (CI) raises a warning.""" + md = """ + | survey | + | | type | name | label | + | | begin group | g1 | G1 | + | | begin repeat | r1 | R1 | + | | text | q1 | Q1 | + | | end repeat | | | + | | begin repeat | R1 | R1 | + | | text | q2 | Q2 | + | | end repeat | | | + | | end group | | | + """ + self.assertPyxformXform( + md=md, warnings__contains=[unique_names.NAMES002.format(row=6, value="R1")] + ) + + def test_names__repeat_same_as_repeat_in_same_context_in_repeat__case_insensitive_warning( + self, + ): + """Should find that a duplicate repeat name (CI) raises a warning.""" + md = """ + | survey | + | | type | name | label | + | | begin repeat | r1 | R1 | + | | begin repeat | r2 | R2 | + | | text | q1 | Q1 | + | | end repeat | | | + | | begin repeat | R2 | R2 | + | | text | q2 | Q2 | + | | end repeat | | | + | | end repeat | | | + """ + self.assertPyxformXform( + md=md, warnings__contains=[unique_names.NAMES002.format(row=6, value="R2")] + ) + + def test_names__repeat_same_as_survey_root__error(self): + """Should find that a repeat name same as the survey root raises an error.""" + md = """ + | survey | + | | type | name | label | + | | begin repeat | data | R1 | + | | text | q1 | Q1 | + | | end repeat | | | + """ + self.assertPyxformXform( + md=md, + name="data", + errored=True, + error__contains=[unique_names.NAMES003.format(row=2, value="data")], + ) + + def test_names__repeat_same_as_repeat_in_different_context_in_group__error(self): + """Should find that a duplicate repeat name raises an error.""" + md = """ + | survey | + | | type | name | label | + | | begin group | g1 | G1 | + | | begin repeat | r1 | R1 | + | | text | q1 | Q1 | + | | end repeat | | | + | | end group | | | + | | begin repeat | r1 | R1 | + | | text | q2 | Q2 | + | | end repeat | | | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[unique_names.NAMES004.format(row=7, value="r1")], + ) + + def test_names__repeat_same_as_repeat_in_different_context_in_repeat__error(self): + """Should find that a duplicate repeat name raises an error.""" + md = """ + | survey | + | | type | name | label | + | | begin repeat | r1 | R1 | + | | begin repeat | r2 | R2 | + | | text | q1 | Q1 | + | | end repeat | | | + | | end repeat | | | + | | begin repeat | r2 | R2 | + | | text | q2 | Q2 | + | | end repeat | | | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[unique_names.NAMES004.format(row=7, value="r2")], + ) diff --git a/tests/test_repeat_count.py b/tests/test_repeat_count.py index c23fe4cad..03afd0501 100644 --- a/tests/test_repeat_count.py +++ b/tests/test_repeat_count.py @@ -1,3 +1,5 @@ +from pyxform.validators.pyxform.unique_names import NAMES001 + from tests.pyxform_test_case import PyxformTestCase from tests.xpath_helpers.questions import xpq @@ -95,10 +97,7 @@ def test_expression__generated_element_same_name__error(self): self.assertPyxformXform( md=md, errored=True, - error__contains=[ - "There are more than one survey elements named 'r1_count' " - "(case-insensitive) in the section named 'test_name'." - ], + error__contains=[NAMES001.format(value="r1_count")], ) def test_expression__generated_element_different_name__ok(self): @@ -154,10 +153,7 @@ def test_manual_xpath__generated_element_same_name__error(self): self.assertPyxformXform( md=md, errored=True, - error__contains=[ - "There are more than one survey elements named 'r1_count' " - "(case-insensitive) in the section named 'test_name'." - ], + error__contains=[NAMES001.format(value="r1_count")], ) def test_manual_xpath__generated_element_different_name__ok(self): @@ -208,10 +204,7 @@ def test_constant_integer__generated_element_same_name__error(self): self.assertPyxformXform( md=md, errored=True, - error__contains=[ - "There are more than one survey elements named 'r1_count' " - "(case-insensitive) in the section named 'test_name'." - ], + error__contains=[NAMES001.format(value="r1_count")], ) def test_constant_integer__generated_element_different_name__ok(self): diff --git a/tests/test_xls2json_backends.py b/tests/test_xls2json_backends.py index 6604cd520..743f84224 100644 --- a/tests/test_xls2json_backends.py +++ b/tests/test_xls2json_backends.py @@ -98,8 +98,8 @@ def test_case_insensitivity(self): ] """, # entities - xpe.model_instance_dataset("e1"), - xpe.model_bind_label("l1"), + xpe.model_instance_meta("e1", create=True, label=True), + xpe.model_bind_meta_label("l1"), # osm xpq.body_upload_tags("q3", (("n1-o", "l1-o"), ("n2-o", "l2-o"))), ] diff --git a/tests/xform_test_case/test_bugs.py b/tests/xform_test_case/test_bugs.py index 7b2f574e0..797515888 100644 --- a/tests/xform_test_case/test_bugs.py +++ b/tests/xform_test_case/test_bugs.py @@ -25,10 +25,6 @@ def test_conversion_raises(self): """Should find that conversion results in an error being raised by pyxform.""" cases = ( ("group_name_test.xls", "[row : 3] Question or group with no name."), - ( - "not_closed_group_test.xls", - "Unmatched begin statement: group (open_group_1)", - ), ("duplicate_columns.xlsx", "Duplicate column header: label"), ("calculate_without_calculation.xls", "[row : 34] Missing calculation."), ) diff --git a/tests/xpath_helpers/entities.py b/tests/xpath_helpers/entities.py index d83868ca0..6fd8793ae 100644 --- a/tests/xpath_helpers/entities.py +++ b/tests/xpath_helpers/entities.py @@ -4,28 +4,189 @@ class XPathHelper: """ @staticmethod - def model_instance_entity() -> str: - """The base path to the expected entities nodeset.""" + def model_entities_version(version: str): + return f""" + /h:html/h:head/x:model[@entities:entities-version='{version}'] + """ + + @staticmethod + def model_no_entities_version(): return """ - /h:html/h:head/x:model/x:instance/x:test_name/x:meta/x:entity + /h:html/h:head/x:model/@*[ + not( + namespace-uri()='http://www.opendatakit.org/xforms/entities' + and local-name()='entities-version' + ) + ] + """ + + @staticmethod + def model_instance_meta( + list_name: str, + meta_path: str = "", + repeat: bool = False, + template: bool = False, + create: bool = False, + update: bool = False, + label: bool = False, + ) -> str: + assertion = {True: "{0}", False: "not({0})"} + repeat_asserts = ("not(./x:instanceID)",) + template_asserts = ("@jr:template",) + create_asserts = ("@create='1'",) + update_asserts = ( + "@update='1'", + "@baseVersion=''", + "@branchId=''", + "@trunkVersion=''", + ) + label_asserts = ("./x:label",) + return f""" + /h:html/h:head/x:model/x:instance/x:test_name{meta_path}[ + {" and ".join(assertion[template].format(i) for i in template_asserts)} + ]/x:meta[ + {" and ".join(assertion[repeat].format(i) for i in repeat_asserts)} + ]/x:entity[ + @dataset='{list_name}' + and @id='' + and {" and ".join(assertion[create].format(i) for i in create_asserts)} + and {" and ".join(assertion[update].format(i) for i in update_asserts)} + and {" and ".join(assertion[label].format(i) for i in label_asserts)} + ] """ @staticmethod - def model_instance_dataset(value) -> str: - """An entity dataset has this value.""" + def model_setvalue_meta_id(meta_path: str = "") -> str: return f""" - /h:html/h:head/x:model/x:instance/x:test_name/x:meta/x:entity[@dataset='{value}'] + /h:html/h:head/x:model/x:setvalue[ + @ref='/test_name{meta_path}/meta/entity/@id' + and @event='odk-instance-first-load' + and @value='uuid()' + ] """ @staticmethod - def model_bind_label(value) -> str: - """An entity binding label has this value, with expected properties.""" + def model_no_setvalue_meta_id(meta_path: str = "") -> str: + return f""" + /h:html/h:head/x:model[ + not(./x:setvalue[@ref='/test_name{meta_path}/meta/entity/@id']) + ] + """ + + @staticmethod + def model_bind_question_saveto(qpath: str, saveto: str) -> str: return f""" /h:html/h:head/x:model/x:bind[ - @nodeset="/test_name/meta/entity/label" + @nodeset='/test_name{qpath}' + and @entities:saveto='{saveto}' + ] + """ + + @staticmethod + def model_bind_meta_id(expression: str = "", meta_path: str = "") -> str: + assertion = {True: "{0}", False: "not({0})"} + expression_asserts = ("@calculate",) + if expression: + expression_asserts = (f"@calculate='{expression}'",) + return f""" + /h:html/h:head/x:model/x:bind[ + @nodeset='/test_name{meta_path}/meta/entity/@id' + and {" and ".join(assertion[bool(expression)].format(i) for i in expression_asserts)} + and @type='string' + and @readonly='true()' + ] + """ + + @staticmethod + def model_bind_meta_create(expression: str, meta_path: str = "") -> str: + return f""" + /h:html/h:head/x:model/x:bind[ + @nodeset='/test_name{meta_path}/meta/entity/@create' + and @calculate="{expression}" + and @type='string' + and @readonly='true()' + ] + """ + + @staticmethod + def model_bind_meta_update(expression: str, meta_path: str = "") -> str: + return f""" + /h:html/h:head/x:model/x:bind[ + @nodeset='/test_name{meta_path}/meta/entity/@update' + and @calculate="{expression}" + and @type='string' + and @readonly='true()' + ] + """ + + @staticmethod + def model_bind_meta_baseversion( + list_name: str, id_path: str, meta_path: str = "" + ) -> str: + return f""" + /h:html/h:head/x:model/x:bind[ + @nodeset='/test_name{meta_path}/meta/entity/@baseVersion' + and @calculate="instance('{list_name}')/root/item[name= {id_path} ]/__version" + and @type='string' + and @readonly='true()' + ] + """ + + @staticmethod + def model_bind_meta_trunkversion( + list_name: str, id_path: str, meta_path: str = "" + ) -> str: + return f""" + /h:html/h:head/x:model/x:bind[ + @nodeset='/test_name{meta_path}/meta/entity/@trunkVersion' + and @calculate="instance('{list_name}')/root/item[name= {id_path} ]/__trunkVersion" + and @type='string' + and @readonly='true()' + ] + """ + + @staticmethod + def model_bind_meta_branchid( + list_name: str, id_path: str, meta_path: str = "" + ) -> str: + return f""" + /h:html/h:head/x:model/x:bind[ + @nodeset='/test_name{meta_path}/meta/entity/@branchId' + and @calculate="instance('{list_name}')/root/item[name= {id_path} ]/__branchId" + and @type='string' + and @readonly='true()' + ] + """ + + @staticmethod + def model_bind_meta_label(value: str, meta_path: str = "") -> str: + return f""" + /h:html/h:head/x:model/x:bind[ + @nodeset="/test_name{meta_path}/meta/entity/label" and @calculate="{value}" - and @type="string" - and @readonly="true()" + and @type='string' + and @readonly='true()' + ] + """ + + @staticmethod + def model_bind_meta_instanceid() -> str: + return """ + /h:html/h:head/x:model/x:bind[ + @nodeset='/test_name/meta/instanceID' + and @readonly='true()' + and @type='string' + and @jr:preload='uid' + ] + """ + + @staticmethod + def body_repeat_setvalue_meta_id(repeat_path: str = "", meta_path: str = "") -> str: + return f""" + /h:html/h:body{repeat_path}/x:setvalue[ + @ref='/test_name{meta_path}/meta/entity/@id' + and @event='odk-new-repeat' + and @value='uuid()' ] """ diff --git a/tests/xpath_helpers/questions.py b/tests/xpath_helpers/questions.py index dc8a048e9..3b9e80e59 100644 --- a/tests/xpath_helpers/questions.py +++ b/tests/xpath_helpers/questions.py @@ -37,6 +37,28 @@ def model_instance_bind_attr(qname: str, key: str, value: str) -> str: ] """ + @staticmethod + def setvalue(path: str, ref: str, event: str, value: str = "") -> str: + if value: + value = f"""and @value="{value}" """ + + return f""" + /h:html/{path}/x:setvalue[ + @ref='{ref}' + and @event='{event}' + {value} + ] + """ + + @staticmethod + def setgeopoint(path: str, ref: str, event: str) -> str: + return f""" + /h:html/{path}/odk:setgeopoint[ + @ref='{ref}' + and @event='{event}' + ] + """ + @staticmethod def model_itext_label(q_name: str, lang: str, q_label: str) -> str: """Model itext contains the question label."""