diff --git a/pyxform/aliases.py b/pyxform/aliases.py index ff440b2a..140f43e3 100644 --- a/pyxform/aliases.py +++ b/pyxform/aliases.py @@ -90,7 +90,7 @@ "requiredmsg": ("bind", "jr:requiredMsg"), "required_message": ("bind", "jr:requiredMsg"), "body": "control", - constants.ENTITIES_SAVETO: ("bind", "entities:saveto"), + constants.ENTITIES_SAVETO: ("bind", constants.ENTITIES_SAVETO_NS), } entities_header = {constants.LIST_NAME_U: "dataset"} diff --git a/pyxform/builder.py b/pyxform/builder.py index a9064bfc..e802195d 100644 --- a/pyxform/builder.py +++ b/pyxform/builder.py @@ -4,6 +4,7 @@ import os from collections import defaultdict +from collections.abc import Mapping from typing import Any from pyxform import constants as const @@ -65,7 +66,7 @@ def set_sections(self, sections): the name of the section and the value is a dict that can be used to create a whole survey. """ - if not isinstance(sections, dict): + if not isinstance(sections, Mapping): raise PyXFormError("""Invalid value for `sections`.""") self._sections = sections @@ -79,7 +80,7 @@ def create_survey_element_from_dict( :param d: data to use for constructing SurveyElements. """ - if "add_none_option" in d: + if d.get("add_none_option", None) is not None: self._add_none_option = d["add_none_option"] if d[const.TYPE] in SECTION_CLASSES: @@ -266,7 +267,7 @@ def _name_and_label_substitutions(question_template, column_headers): # if the label in column_headers has multiple languages setup a # dictionary by language to do substitutions. info_by_lang = None - if isinstance(column_headers[const.LABEL], dict): + if isinstance(column_headers[const.LABEL], Mapping): info_by_lang = { lang: { const.NAME: column_headers[const.NAME], @@ -279,10 +280,10 @@ def _name_and_label_substitutions(question_template, column_headers): for key in result: if isinstance(result[key], str): result[key] %= column_headers - elif isinstance(result[key], dict): + elif isinstance(result[key], Mapping): result[key] = result[key].copy() for key2 in result[key]: - if info_by_lang and isinstance(column_headers[const.LABEL], dict): + if info_by_lang and isinstance(column_headers[const.LABEL], Mapping): result[key][key2] %= info_by_lang.get(key2, column_headers) else: result[key][key2] %= column_headers diff --git a/pyxform/constants.py b/pyxform/constants.py index bcdb3f14..01e687d0 100644 --- a/pyxform/constants.py +++ b/pyxform/constants.py @@ -14,6 +14,7 @@ TITLE = "title" NAME = "name" ENTITIES_SAVETO = "save_to" +ENTITIES_SAVETO_NS = "entities:saveto" ID_STRING = "id_string" SMS_KEYWORD = "sms_keyword" SMS_FIELD = "sms_field" diff --git a/pyxform/entities/entities_parsing.py b/pyxform/entities/entities_parsing.py index f2d4c076..0940299d 100644 --- a/pyxform/entities/entities_parsing.py +++ b/pyxform/entities/entities_parsing.py @@ -1,18 +1,207 @@ -from collections.abc import Sequence -from typing import Any +from collections import defaultdict +from collections.abc import Iterable +from dataclasses import dataclass, field +from typing import Any, NamedTuple from pyxform import constants as const from pyxform.elements import action from pyxform.errors import ErrorCode, PyXFormError from pyxform.parsing.expression import is_xml_tag +from pyxform.question_type_dictionary import get_meta_group from pyxform.validators.pyxform.pyxform_reference import parse_pyxform_references EC = const.EntityColumns -def get_entity_declaration( - entities_sheet: Sequence[dict], -) -> dict[str, Any]: +@dataclass(frozen=True, slots=True) +class ContainerNode: + """ + Details of a XForm container: the survey root, a group, or a repeat. + """ + + name: str + type: str + + +@dataclass(frozen=True, slots=True) +class ContainerPath: + """ + Details of a collection of containers, ordered by their depth, which forms a path. + + Example: /survey/group1/repeat1/group2 + """ + + nodes: tuple[ContainerNode, ...] + + @classmethod + def default(cls) -> "ContainerPath": + """ + Create the default ContainerPath, which is the '/survey' root path. + """ + return cls((ContainerNode(name=const.SURVEY, type=const.SURVEY),)) + + @classmethod + def from_stack(cls, stack: list[dict[str, Any]]) -> "ContainerPath": + """ + Create a ContainerPath from the workbook_to_json container stack. + """ + if len(stack) > 1: + return cls( + ( + *stack[-2]["container_path"].nodes, + ContainerNode( + name=stack[-1]["control_name"], + type=stack[-1]["control_type"], + ), + ) + ) + else: + return cls.default() + + +@dataclass(frozen=True, slots=True) +class ReferenceSource: + path: ContainerPath + row: int + property_name: str | None = None + question_name: str | None = None + + def __post_init__(self): + if self.property_name is None and self.question_name is None: + raise PyXFormError( + ErrorCode.INTERNAL_002.value.format(path=self.path_as_str()) + ) + + def get_scope_boundary(self) -> ContainerPath: + for i in range(len(self.path.nodes) - 1, -1, -1): + if self.path.nodes[i].type in {const.REPEAT, const.SURVEY}: + return ContainerPath(self.path.nodes[: i + 1]) + return ContainerPath.default() + + def path_as_str(self) -> str: + return f"/{'/'.join(p.name for p in self.path.nodes)}" + + +@dataclass(slots=True) +class EntityReferences: + dataset_name: str + row_number: int = field(compare=False, hash=False) + references: list[ReferenceSource] = field( + compare=False, hash=False, default_factory=list + ) + + def get_allocation_request(self) -> "AllocationRequest": + """ + Find/validate the preferred path for each entity declaration. + """ + deepest_scope_ref = None + deepest_scope_boundary = None + deepest_container_ref = self.references[0] + deepest_saveto = None + boundaries = [] + + # Find the request constraints for this entity. + for ref in self.references: + ref_path_length = len(ref.path.nodes) + if ref_path_length > len(deepest_container_ref.path.nodes): + deepest_container_ref = ref + + if ref.property_name is not None and ( + deepest_saveto is None or ref_path_length > len(deepest_saveto.path.nodes) + ): + deepest_saveto = ref + + boundary = ref.get_scope_boundary() + boundary_length = len(boundary.nodes) + if deepest_scope_boundary is None or boundary_length > len( + deepest_scope_boundary.nodes + ): + deepest_scope_boundary = boundary + deepest_scope_ref = ref + + boundaries.append((ref, boundary, boundary_length)) + + # Prioritise saveto since they must be in the nearest container with an entity. + if deepest_saveto: + requested_ref = deepest_saveto + else: + requested_ref = deepest_container_ref + + # Validate each reference against the request constraints. + for ref_source, scope_boundary, scope_boundary_length in boundaries: + if ( + deepest_scope_boundary.nodes[:scope_boundary_length] + != scope_boundary.nodes + ): + if ref_source.property_name is not None: # save_to + raise PyXFormError( + ErrorCode.ENTITY_011.value.format( + row=ref_source.row, + dataset=self.dataset_name, + other_scope=deepest_scope_ref.path_as_str(), + scope=ref_source.path_as_str(), + ) + ) + else: # variable + raise PyXFormError( + ErrorCode.ENTITY_012.value.format( + row=self.row_number, + dataset=self.dataset_name, + other_scope=deepest_scope_ref.path_as_str(), + scope=ref_source.path_as_str(), + question=ref_source.question_name, + ) + ) + + if ( + ref_source.property_name is not None # save_to + and deepest_scope_boundary != scope_boundary + ): + raise PyXFormError( + ErrorCode.ENTITY_011.value.format( + row=ref_source.row, + dataset=self.dataset_name, + other_scope=requested_ref.path_as_str(), + scope=ref_source.path_as_str(), + ) + ) + + return AllocationRequest( + scope_path=deepest_scope_boundary, + dataset_name=self.dataset_name, + requested_path=requested_ref.path, + sorting_key=( + int(bool(deepest_saveto)), + len(requested_ref.path.nodes), + -self.row_number, + ), + entity_row_number=self.row_number, + ) + + +class AllocationRequest(NamedTuple): + """ + Details required to place an entity in a valid container path. + + Attributes: + scope_path: The scope boundary for the entity. + dataset_name: The entity list_name. + requested_path: The preferred path for the entity based on references. + sorting_key: First item - if 1 (true), there are save_tos among the references. + Second item - the length of the requested path. Third item - the entity sheet + row number, which is used to break ties, but with the number as negative so + that the reverse sort for priority places earlier rows first. + entity_row_number: The entity sheet row number of the entity. + """ + + scope_path: ContainerPath + dataset_name: str + requested_path: ContainerPath + sorting_key: tuple[int, int, int] + entity_row_number: int + + +def get_entity_declaration(row: dict, row_number: int) -> dict[str, Any]: """ Transform the entities sheet data into a spec for creating an EntityDeclaration. @@ -30,46 +219,49 @@ def get_entity_declaration( 1 1 1 include conditions for create and update (user's responsibility to ensure they're exclusive) - :param entities_sheet: XLSForm entities sheet data. + :param row: A row from the XLSForm entities sheet data. + :param row_number: The sheet row number as a user would see it, counting from row 1. """ - 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." + extra = {k: None for k in row if k not in EC.value_list()} + if 0 < len(extra): + msg = ErrorCode.HEADER_005.value.format( + columns=", ".join(f"'{k}'" for k in extra) ) + raise PyXFormError(msg) - entity_row = entities_sheet[0] - - validate_entities_columns(row=entity_row) - dataset_name = get_validated_dataset_name(entity_row) - entity_id = entity_row.get(EC.ENTITY_ID, 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) + dataset_name = row.get(EC.DATASET, None) + entity_id = row.get(EC.ENTITY_ID, None) + create_if = row.get(EC.CREATE_IF, None) + update_if = row.get(EC.UPDATE_IF, None) + label = row.get(EC.LABEL, None) + validate_dataset_name(dataset_name=dataset_name, row_number=row_number) if not entity_id and update_if: raise PyXFormError( - "The entities sheet is missing the entity_id column which is required when " - "updating entities." + ErrorCode.ENTITY_007.value.format(row=row_number, dataset=dataset_name) ) 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." + ErrorCode.ENTITY_006.value.format(row=row_number, dataset=dataset_name) ) if not entity_id and not label: raise PyXFormError( - "The entities sheet is missing the label column which is required when " - "creating entities." + ErrorCode.ENTITY_005.value.format(row=row_number, dataset=dataset_name) ) + variable_references = set() + for column in (entity_id, create_if, update_if, label): + if column is not None: + references = parse_pyxform_references(value=column) + if references: + for ref in references: + variable_references.add(ref.name) + entity = { const.NAME: const.ENTITY, const.TYPE: const.ENTITY, - EC.REPEAT.value: repeat, const.CHILDREN: [ { const.NAME: "dataset", @@ -77,6 +269,8 @@ def get_entity_declaration( "value": dataset_name, }, ], + "__row_number": row_number, + "__variable_references": variable_references, } id_attr = { @@ -100,14 +294,10 @@ def get_entity_declaration( } 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) + if not entity_id: + first_load = action.ActionLibrary.setvalue_first_load.value.to_dict() + first_load["value"] = "uuid()" + id_attr["actions"].append(first_load) # Update mode if entity_id: @@ -174,178 +364,369 @@ def get_entity_declaration( return entity -def get_validated_dataset_name(entity): - dataset = entity[EC.DATASET] +def validate_dataset_name(dataset_name: str | None, row_number: int) -> None: + """ + Check the dataset_name passes all naming rules. - if dataset.startswith(const.ENTITIES_RESERVED_PREFIX): + :param dataset_name: The value to check. + :param row_number: The entities sheet row number. + """ + if not dataset_name: + raise PyXFormError(ErrorCode.NAMES_015.value.format(row=row_number)) + elif dataset_name.startswith(const.ENTITIES_RESERVED_PREFIX): raise PyXFormError( ErrorCode.NAMES_010.value.format( - sheet=const.ENTITIES, row=2, column=EC.DATASET.value + sheet=const.ENTITIES, row=row_number, column=EC.DATASET.value ) ) - elif "." in dataset: + elif "." in dataset_name: raise PyXFormError( ErrorCode.NAMES_011.value.format( - sheet=const.ENTITIES, row=2, column=EC.DATASET.value + sheet=const.ENTITIES, row=row_number, column=EC.DATASET.value ) ) - elif not is_xml_tag(dataset): + elif not is_xml_tag(dataset_name): raise PyXFormError( ErrorCode.NAMES_008.value.format( - sheet=const.ENTITIES, row=2, column=EC.DATASET.value + sheet=const.ENTITIES, row=row_number, column=EC.DATASET.value ) ) - 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) - raise - else: - if not match or match[0].last_saved: - raise PyXFormError(ErrorCode.ENTITY_001.value.format(value=value)) - else: - return match[0].name - - -def validate_entity_saveto( - row: dict, +def validate_saveto( + saveto: str | None, row_number: int, - stack: Sequence[dict[str, Any]], - entity_declaration: dict[str, Any] | None = None, -): - save_to = row.get(const.BIND, {}).get("entities:saveto", "") - if not save_to: - return - - if not entity_declaration: - raise PyXFormError( - "To save entity properties using the save_to column, you must add an entities sheet and declare an entity." - ) - - if const.GROUP in row.get(const.TYPE) or const.REPEAT in row.get(const.TYPE): - raise PyXFormError( - f"{const.ROW_FORMAT_STRING % row_number} Groups and repeats can't be saved as entity properties." - ) - - 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( - ErrorCode.ENTITY_005.value.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( - ErrorCode.ENTITY_006.value.format(row=row_number, value=save_to) - ) - - # Error: saveto in repeat but no entity repeat declared - if in_repeat and not entity_repeat: + is_container_begin: bool, + is_container_end: bool, + entity_references: EntityReferences, +) -> None: + """ + Check the saveto passes all naming rules. + + :param saveto: The value to check. + :param row_number: The entities sheet row number. + :param is_container_begin: If True, the row type is a "begin_repeat" or "begin_group" + or some parsed alias of these. + :param is_container_end: If True, the row type is a "end_repeat" or "end_group" + or some parsed alias of these. + :param entity_references: All entity references parsed so far in the form. + """ + if not saveto: raise PyXFormError( - ErrorCode.ENTITY_007.value.format(row=row_number, value=save_to) + ErrorCode.NAMES_008.value.format( + sheet=const.SURVEY, row=row_number, column=const.ENTITIES_SAVETO + ) ) - + elif is_container_begin or is_container_end: + raise PyXFormError(ErrorCode.ENTITY_003.value.format(row=row_number)) # Error: naming rules - if save_to.lower() in {const.NAME, const.LABEL}: + elif saveto.lower() in {const.NAME, const.LABEL}: raise PyXFormError( - ErrorCode.NAMES_011.value.format( + ErrorCode.NAMES_012.value.format( sheet=const.SURVEY, row=row_number, column=const.ENTITIES_SAVETO ) ) - elif save_to.startswith(const.ENTITIES_RESERVED_PREFIX): + elif saveto.startswith(const.ENTITIES_RESERVED_PREFIX): raise PyXFormError( ErrorCode.NAMES_010.value.format( sheet=const.SURVEY, row=row_number, column=const.ENTITIES_SAVETO ) ) - elif not is_xml_tag(save_to): + elif not is_xml_tag(saveto): raise PyXFormError( ErrorCode.NAMES_008.value.format( sheet=const.SURVEY, row=row_number, column=const.ENTITIES_SAVETO ) ) + elif entity_references: + for ref_source in entity_references.references: + if ref_source.property_name == saveto: + raise PyXFormError( + ErrorCode.ENTITY_002.value.format( + row=row_number, saveto=saveto, other_row=ref_source.row + ) + ) -def validate_entities_columns(row: dict): - extra = {k: None for k in row if k not in EC.value_list()} - if 0 < len(extra): - fmt_extra = ", ".join(f"'{k}'" for k in extra) - msg = ( - f"The entities sheet included the following unexpected column(s): {fmt_extra}. " - f"These columns are not supported by this version of pyxform. Please either: " - f"check the spelling of the column names, remove the columns, or update " - f"pyxform." +def get_entity_declarations( + entities_sheet: Iterable[dict], +) -> dict[str, dict[str, Any]]: + """ + Collect all entity declarations from the entities sheet. + """ + entities = {} + for row_number, row in enumerate(entities_sheet, start=2): + entity = get_entity_declaration(row=row, row_number=row_number) + dataset_name = next( + c["value"] for c in entity["children"] if c.get("name", "") == "dataset" ) - raise PyXFormError(msg) + if dataset_name in entities: + raise PyXFormError(ErrorCode.NAMES_014.value.format(row=row_number)) + entities[dataset_name] = entity + + return entities + +def get_entity_variable_references( + entity_declarations: dict[str, dict[str, Any]], +) -> dict[str, list[str]]: + """ + Group variable references in the entities delarations by question_name. -def validate_entity_repeat_target( - entity_declaration: dict[str, Any] | None, - stack: Sequence[dict[str, Any]] | None = None, -) -> bool: + :return: dict[question_name: str, list[dataset_name: str]] """ - Check if the entity repeat target exists, is a repeat, and is a name match. + variable_references = defaultdict(list) + for dataset_name, declaration in entity_declarations.items(): + # Not needed anywhere else so remove from declaration. + references = declaration.pop("__variable_references", None) + if references: + for question_name in references: + variable_references[question_name].append(dataset_name) + + return variable_references - 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: +def get_entity_references_by_question( + container_path: ContainerPath, + row: dict[str, Any], + row_number: int, + question_name: str, + entity_declarations: dict[str, dict[str, Any]], + entity_variable_references: dict[str, list[str]], + entity_references_by_question: dict[str, EntityReferences], + is_container_begin: bool, + is_container_end: bool, +) -> None: + """ + For each question store the saveto or variable references that link it to an entity. """ - # 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(ErrorCode.ENTITY_002.value.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(ErrorCode.ENTITY_003.value.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(ErrorCode.ENTITY_004.value.format(value=entity_repeat)) - elif i["control_name"] == entity_repeat: - located = True - - return entity_repeat == control_name + # Collect references for later reconciliation, because otherwise the first + # referent found will determine the scope but there may be deeper refs. + saveto = row.get(const.BIND, {}).get(const.ENTITIES_SAVETO_NS) + if saveto: + if not entity_declarations: + raise PyXFormError(ErrorCode.ENTITY_001.value.format(row=row_number)) + + delimiter_count = saveto.count("#") + if delimiter_count == 1: + dataset_name, saveto = saveto.split("#", maxsplit=1) + row[const.BIND][const.ENTITIES_SAVETO_NS] = saveto + elif delimiter_count > 1: + raise PyXFormError(ErrorCode.ENTITY_013.value.format(row=row_number)) + else: + if len(entity_declarations) > 1: + raise PyXFormError(ErrorCode.ENTITY_008.value.format(row=row_number)) + + dataset_name = next(iter(entity_declarations.keys())) + + if dataset_name not in entity_declarations: + raise PyXFormError( + ErrorCode.ENTITY_004.value.format(row=row_number, dataset=dataset_name) + ) + + if dataset_name not in entity_references_by_question: + entity_references_by_question[dataset_name] = EntityReferences( + dataset_name=dataset_name, + row_number=entity_declarations[dataset_name]["__row_number"], + ) + + validate_saveto( + saveto=saveto, + row_number=row_number, + is_container_begin=is_container_begin, + is_container_end=is_container_end, + entity_references=entity_references_by_question[dataset_name], + ) + + entity_references_by_question[dataset_name].references.append( + ReferenceSource(path=container_path, row=row_number, property_name=saveto) + ) + + if entity_variable_references and question_name in entity_variable_references: + for dataset_name in entity_variable_references[question_name]: + if dataset_name not in entity_references_by_question: + entity_references_by_question[dataset_name] = EntityReferences( + dataset_name=dataset_name, + row_number=entity_declarations[dataset_name]["__row_number"], + ) + entity_references_by_question[dataset_name].references.append( + ReferenceSource( + path=container_path, row=row_number, question_name=question_name + ) + ) + + +def allocate_entities_to_containers( + entity_declarations: dict[str, dict[str, Any]], + entity_references_by_question: dict[str, EntityReferences], +) -> dict[ContainerPath, str]: + """ + Get the paths into which the entities will be placed. + """ + allocations = {} + scope_paths = defaultdict(list) + + # Group requests by container scope. + for entity_references in entity_references_by_question.values(): + req = entity_references.get_allocation_request() + scope_paths[req.scope_path].append(req) + + # For unreferenced declarations, default to the survey scope. + for dataset_name, declaration in entity_declarations.items(): + survey_path = ContainerPath.default() + if dataset_name not in entity_references_by_question: + scope_paths[survey_path].append( + AllocationRequest( + scope_path=survey_path, + dataset_name=dataset_name, + requested_path=survey_path, + sorting_key=(0, 1, -declaration["__row_number"]), + entity_row_number=declaration["__row_number"], + ) + ) + + # Assign the requests to available allowed container nodes. + for scope_path, requests in scope_paths.items(): + scope_path_depth_limit = len(scope_path.nodes) - 1 + + # Prioritise save_to references but otherwise try to put deepest allocation first. + for req in sorted(requests, key=lambda x: x.sorting_key, reverse=True): + placed = False + + # Attempt to place as low as possible, but try going up to the highest allowed. + for depth in range(req.sorting_key[1], scope_path_depth_limit, -1): + current_path = ContainerPath(req.requested_path.nodes[:depth]) + + if current_path in allocations: + # Request with a save_to wants a group that already has an entity. + if req.sorting_key[0] == 1: + break + else: + allocations[current_path] = req.dataset_name + placed = True + break + + if not placed: + raise PyXFormError( + ErrorCode.ENTITY_009.value.format( + row=req.entity_row_number, + scope=f"/{'/'.join(p.name for p in scope_path.nodes)}", + ) + ) + + return allocations + + +def inject_entities_into_json( + node: dict[str, Any], + allocations: dict[ContainerPath, str], + entity_declarations: dict[str, dict[str, Any]], + current_path: ContainerPath, + search_prefixes: set[ContainerPath], + entities_allocated: set[str] | None = None, + has_repeat_ancestor: bool = False, +) -> dict[str, Any]: + """ + Recursively traverse the json_dict to inject entity declarations. + """ + if entities_allocated is None: + entities_allocated = set() + + dataset_name = allocations.get(current_path, None) + if dataset_name and dataset_name not in entities_allocated: + entity_decl = entity_declarations[dataset_name] + if has_repeat_ancestor: + id_attr = next( + iter(c for c in entity_decl[const.CHILDREN] if c[const.NAME] == "id"), + None, + ) + if id_attr and len(id_attr["actions"]) == 1: + new_repeat = action.ActionLibrary.setvalue_new_repeat.value.to_dict() + new_repeat["value"] = id_attr["actions"][0]["value"] + id_attr["actions"].append(new_repeat) + + if const.CHILDREN not in node: + node[const.CHILDREN] = [] + + node[const.CHILDREN].append(get_meta_group(children=[entity_decl])) + entities_allocated.add(dataset_name) + + for child in node.get(const.CHILDREN, []): + child_name = child.get(const.NAME) + child_type = child.get(const.TYPE) + if child_name and child_type in {const.GROUP, const.REPEAT}: + child_path = ContainerPath( + (*current_path.nodes, ContainerNode(name=child_name, type=child_type)) + ) + if not has_repeat_ancestor and child_type == const.REPEAT: + has_repeat_ancestor = True + if child_path in search_prefixes: + inject_entities_into_json( + node=child, + allocations=allocations, + entity_declarations=entity_declarations, + current_path=child_path, + search_prefixes=search_prefixes, + entities_allocated=entities_allocated, + has_repeat_ancestor=has_repeat_ancestor, + ) + + return node + + +def get_search_prefixes( + allocations: dict[ContainerPath, str], +) -> set[ContainerPath]: + """ + Get all the relevant path prefixes to help reduce the path search space. + + :param allocations: The entity path allocations. + :return: path prefixes like (a, b, c) -> ((a,), (a, b), (a, b, c)) + """ + active = set() + for path in allocations.keys(): + # Add every prefix of the path to the set + for i in range(1, len(path.nodes) + 1): + active.add(ContainerPath(path.nodes[:i])) + return active + + +def apply_entities_declarations( + entity_declarations: dict[str, dict[str, Any]], + entity_references_by_question: dict[str, EntityReferences], + json_dict: dict[str, Any], +) -> None: + """ + Traverse the json_dict tree and add meta/entity blocks where appropriate. + + Processing phases: + 1. for each question collect references in get_entity_references_by_question + 2. calculate entity container assignments in allocate_entities_to_containers + 3. apply those meta/entity declarations in inject_entities_into_json + + :param entity_declarations: Entity definition data to be passed to EntityDeclaration, + structured as `{dataset_name: entity_declaration}`. + :param entity_references_by_question: For each entity, details of where and how they + are referred to, structured as `{dataset_name: EntityReferences}`. + :param json_dict: The output dict structure to be emitted from `workbook_to_json`. + :return: The json_dict is modified in-place + """ + allocations = allocate_entities_to_containers( + entity_declarations=entity_declarations, + entity_references_by_question=entity_references_by_question, + ) + json_dict = inject_entities_into_json( + node=json_dict, + allocations=allocations, + entity_declarations=entity_declarations, + current_path=ContainerPath.default(), + search_prefixes=get_search_prefixes(allocations=allocations), + has_repeat_ancestor=json_dict.get(const.TYPE) == const.REPEAT, + ) + + if len(entity_declarations) > 1 or any( + p.type == const.REPEAT for i in allocations.keys() for p in i.nodes + ): + json_dict[const.ENTITY_VERSION] = const.EntityVersion.v2025_1_0 + else: + json_dict[const.ENTITY_VERSION] = const.EntityVersion.v2024_1_0 diff --git a/pyxform/entities/entity_declaration.py b/pyxform/entities/entity_declaration.py index df53637e..8f01c184 100644 --- a/pyxform/entities/entity_declaration.py +++ b/pyxform/entities/entity_declaration.py @@ -6,7 +6,10 @@ from pyxform.utils import combine_lists EC = const.EntityColumns -ENTITY_EXTRA_FIELDS = (const.CHILDREN,) +ENTITY_EXTRA_FIELDS = ( + const.TYPE, + const.CHILDREN, +) ENTITY_FIELDS = (*SURVEY_ELEMENT_FIELDS, *ENTITY_EXTRA_FIELDS) CHILD_TYPES = {"label": Label, "attribute": Attribute} @@ -24,7 +27,7 @@ class EntityDeclaration(Section): def get_slot_names() -> tuple[str, ...]: return ENTITY_FIELDS - def __init__(self, name: str, type: str, **kwargs): + def __init__(self, name: str, type: str = const.ENTITY, **kwargs): super().__init__(name=name, type=type, **kwargs) self.children: list[Label | Attribute] = [] diff --git a/pyxform/errors.py b/pyxform/errors.py index e03aaeeb..101ba5f7 100644 --- a/pyxform/errors.py +++ b/pyxform/errors.py @@ -58,56 +58,123 @@ class ErrorCode(Enum): """ ENTITY_001 = Detail( - name="Entities - invalid entity repeat reference", + name="Entities - save_to but no entities", 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." + "[row : {row}] On the 'survey' sheet, the 'save_to' value is invalid. " + "To save entity properties using the save_to column, add an entities sheet and " + "declare an entity." ), ) ENTITY_002 = Detail( - name="Entities - invalid entity repeat: target not found", + name="Entities - duplicate save_to property", msg=( - "[row : 2] On the 'entities' sheet, the 'repeat' value '{value}' is invalid. " - "The entity repeat target was not found in the 'survey' sheet." + "[row : {row}] On the 'survey' sheet, the 'save_to' value is invalid. " + "The save_to property '{saveto}' is already assigned by row '{other_row}'. " + "Either remove or change one of these duplicate save_to property names." ), ) ENTITY_003 = Detail( - name="Entities - invalid entity repeat: target is not a repeat", + name="Entities - save_to in group or repeat row", msg=( - "[row : 2] On the 'entities' sheet, the 'repeat' value '{value}' is invalid. " - "The entity repeat target is not a repeat." + "[row : {row}] On the 'survey' sheet, the 'save_to' value is invalid. " + "Groups and repeats can't be saved as entity properties. " + "Either remove or move the save_to value in this row." ), ) ENTITY_004 = Detail( - name="Entities - invalid entity repeat: target is in a repeat", + name="Entities - list_name not found", msg=( - "[row : 2] On the 'entities' sheet, the 'repeat' value '{value}' is invalid. " - "The entity repeat target is inside a repeat." + "[row : {row}] On the 'survey' sheet, the 'save_to' value is invalid. " + "The entity list name '{dataset}' was not found on the entities sheet. " + "Please either: check the spelling of the list name in the save_to value, or " + "on the entities sheet add a row for this entity list, or check the spelling " + "of existing entity list names." ), ) ENTITY_005 = Detail( - name="Entities - invalid entity repeat save_to: question in nested repeat", + name="Entities - missing entity create label", 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." + "[row : {row}] On the 'entities' sheet, the entity declaration is invalid. " + "The entity list name '{dataset}' does not have a label, but a 'label' is " + "required when creating entities. Creating entities is indicated by using " + "a 'create_if' expression, or by not using 'entity_id' expression. " + "Please either: add a 'label' for this entity declaration, or to update " + "entities instead provide an 'entity_id' (and optionally 'update_if') expression." ), ) ENTITY_006 = Detail( - name="Entities - invalid entity repeat save_to: question not in entity repeat", + name="Entities - missing entity upsert update_if", 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." + "[row : {row}] On the 'entities' sheet, the entity declaration is invalid. " + "The entity list name '{dataset}' does not have an 'update_if' expression, " + "but an 'update_if' is required when upserting entities. Upserting entities " + "is indicated by using 'create_if' and 'entity_id' expressions. " + "Please either: add an 'update_if' for this entity declaration, or to only " + "create entities instead remove the 'entity_id' expression." ), ) ENTITY_007 = Detail( - name="Entities - invalid entity repeat save_to: question in repeat but no entity repeat defined", + name="Entities - missing entity update/upsert entity_id", 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." + "[row : {row}] On the 'entities' sheet, the entity declaration is invalid. " + "The entity list name '{dataset}' does not have an 'entity_id' expression, " + "but an 'entity_id' is required when updating entities. Updating entities " + "is indicated by using 'entity_id' and/or 'update_if' expressions. " + "Please either: add an 'entity_id' for this entity declaration, or to only " + "create entities instead move the 'update_if' to 'create_if'." + ), + ) + ENTITY_008 = Detail( + name="Entities - missing save_to prefix with multiple entities", + msg=( + "[row : {row}] On the 'survey' sheet, the 'save_to' value is invalid. " + "When there is more than one entity declaration, 'save_to' names must be " + "prefixed with the entity 'list_name' that the property belongs to. " + "Please either: add the entity 'list_name' prefix separated with a '#' " + "e.g. my_list#my_save_to (where 'my_list' is the entity 'list_name', and " + "'my_save_to' is the 'save_to' property name), or remove all but one entity " + "declarations." + ), + ) + ENTITY_009 = Detail( + name="Entities - unsolvable meta/entity topology", + msg=( + "[row : {row}] On the 'entities' sheet, the entity declaration is invalid. " + "Each container (survey, group, repeat) may have only one entity declaration, " + "but there are no valid containers available in the scope: '{scope}'. " + "Please either: check which entities are referred to from the 'survey' sheet" + "in the 'save_to' column, or check which questions are being referred to " + "from the 'entities' sheet with variable references (such as in the 'label')." + ), + ) + ENTITY_011 = Detail( + name="Entities - reference scope conflict (save_to conflicts with other ref)", + msg=( + "[row : {row}] On the 'survey' sheet, the 'save_to' value is invalid. " + "The entity list name '{dataset}' has a reference in container scope '{other_scope}' " + "which is not compatible with this 'save_to' reference in scope '{scope}'. " + "Please either: check the spelling of the list name in the 'save_to', or " + "copy either value into the desired container scope with a 'calculate' " + "question then use that 'calculate' for the 'save_to'." + ), + ) + ENTITY_012 = Detail( + name="Entities - reference scope conflict (variable conflicts with other ref)", + msg=( + "[row : {row}] On the 'entities' sheet, the entity declaration is invalid. " + "The entity list name '{dataset}' has a reference in container scope '{other_scope}' " + "which is not compatible with the variable reference to '{question}' in scope '{scope}'. " + "Please either: check the spelling of the variable references, or " + "copy either value into the desired container scope with a 'calculate' " + "question then use that 'calculate' for the 'save_to'." + ), + ) + ENTITY_013 = Detail( + name="Entities - save_to multiple delimiters", + msg=( + "[row : {row}] On the 'survey' sheet, the 'save_to' value is invalid. " + "A 'save_to' value must have at most one '#' delimiter character. " + "Please check the spelling of this 'save_to' value." ), ) HEADER_001: Detail = Detail( @@ -144,6 +211,15 @@ class ErrorCode(Enum): "Learn more: https://xlsform.org/en/#setting-up-your-worksheets" ), ) + HEADER_005: Detail = Detail( + name="Headers - invalid entities header", + msg=( + "[row : 1] On the 'entities' sheet, one or more column names are invalid. " + "The following column(s) are not supported by this version of pyxform: {columns}. " + "Please either: check the spelling of the column names, remove the columns, " + "or update pyxform." + ), + ) INTERNAL_001: Detail = Detail( name="Internal error - incorrectly processed question trigger data", msg=( @@ -152,6 +228,14 @@ class ErrorCode(Enum): "type '{type}' with value '{value}'." ), ) + INTERNAL_002: Detail = Detail( + name="Internal error - reference source missing both property and question", + msg=( + "Internal error: " + "Both property_name and question_name were None for ReferenceSource with " + "path '{path}'." + ), + ) LABEL_001: Detail = Detail( name="Labels - invalid missing label in the choices sheet", msg=( @@ -254,6 +338,27 @@ class ErrorCode(Enum): "Names used here must not be 'name' or 'label' (case-insensitive)." ), ) + NAMES_013: Detail = Detail( + name="Names - possible sheet name misspelling", + msg=( + "When looking for a sheet named '{sheet}', the following sheets with " + "similar names were found: {candidates}." + ), + ) + NAMES_014: Detail = Detail( + name="Names - invalid duplicate name in the entities sheet", + msg=( + "[row : {row}] On the 'entities' sheet, the 'list_name' value is invalid. " + "The 'list_name' column must not have any duplicate names." + ), + ) + NAMES_015: Detail = Detail( + name="Names - invalid missing name in the entities sheet", + msg=( + "[row : {row}] On the 'entities' sheet, the 'list_name' value is invalid. " + "Entity lists must have a name." + ), + ) PYREF_001: Detail = Detail( name="PyXForm reference - parsing failed", msg=( diff --git a/pyxform/survey.py b/pyxform/survey.py index 8b3aaf71..3d89911e 100644 --- a/pyxform/survey.py +++ b/pyxform/survey.py @@ -183,7 +183,7 @@ class Survey(Section): def get_slot_names() -> tuple[str, ...]: return SURVEY_FIELDS - def __init__(self, **kwargs): + def __init__(self, name: str, type: str = constants.SURVEY, **kwargs): # Internals self._created: datetime.now = datetime.now() self._translations: recursive_dict = recursive_dict() @@ -234,8 +234,7 @@ def __init__(self, **kwargs): list_name: Itemset(name=list_name, choices=values) for list_name, values in choices.items() } - kwargs[constants.TYPE] = constants.SURVEY - super().__init__(fields=SURVEY_EXTRA_FIELDS, **kwargs) + super().__init__(name=name, type=type, fields=SURVEY_EXTRA_FIELDS, **kwargs) def to_json_dict(self, delete_keys: Iterable[str] | None = None) -> dict: to_delete = (k for k in self.get_slot_names() if k.startswith("_")) @@ -997,12 +996,6 @@ def _to_pretty_xml(self) -> str: """Get the XForm with human readable formatting.""" return f"""\n{self.xml().toprettyxml(indent=" ")}""" - def __repr__(self): - return self.__unicode__() - - def __unicode__(self): - return f"" - def _setup_xpath_dictionary(self): if self._xpath: return diff --git a/pyxform/survey_element.py b/pyxform/survey_element.py index 8076961e..668dba60 100644 --- a/pyxform/survey_element.py +++ b/pyxform/survey_element.py @@ -93,7 +93,10 @@ def __setattr__(self, key, value): super().__setattr__(key, value) def __repr__(self): - return f"""{super().__repr__()}(name="{self.name}")""" + type_info = "" + if hasattr(self, "type"): + type_info = f", type={self.type}" + return f"""{super().__repr__()}(name="{self.name}{type_info}")""" def __init__( self, diff --git a/pyxform/validators/pyxform/sheet_misspellings.py b/pyxform/validators/pyxform/sheet_misspellings.py index 06e3851b..0682c406 100644 --- a/pyxform/validators/pyxform/sheet_misspellings.py +++ b/pyxform/validators/pyxform/sheet_misspellings.py @@ -1,6 +1,7 @@ from collections.abc import Iterable from pyxform import constants +from pyxform.errors import ErrorCode from pyxform.utils import levenshtein_distance @@ -25,10 +26,8 @@ def find_sheet_misspellings(key: str, keys: Iterable) -> "str | None": and not _k.startswith("_") ) if 0 < len(candidates): - msg = ( - "When looking for a sheet named '{k}', the following sheets with " - "similar names were found: {c}." - ).format(k=key, c=str(", ".join(f"'{c}'" for c in candidates))) - return msg + return ErrorCode.NAMES_013.value.format( + sheet=key, candidates=str(", ".join(f"'{c}'" for c in candidates)) + ) else: return None diff --git a/pyxform/validators/pyxform/unique_names.py b/pyxform/validators/pyxform/unique_names.py index 728c7cda..a7a6fa13 100644 --- a/pyxform/validators/pyxform/unique_names.py +++ b/pyxform/validators/pyxform/unique_names.py @@ -13,7 +13,7 @@ def validate_question_group_repeat_name( """ 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`. + May append the name to `seen_names` and `seen_names_lower`. May append to `warnings`. :param name: Question or group name. :param seen_names: Names already processed in the sheet. diff --git a/pyxform/xls2json.py b/pyxform/xls2json.py index 2d3b57c6..6a0b2022 100644 --- a/pyxform/xls2json.py +++ b/pyxform/xls2json.py @@ -16,9 +16,11 @@ ) from pyxform.elements import action as action_module from pyxform.entities.entities_parsing import ( - get_entity_declaration, - validate_entity_repeat_target, - validate_entity_saveto, + ContainerPath, + apply_entities_declarations, + get_entity_declarations, + get_entity_references_by_question, + get_entity_variable_references, ) from pyxform.errors import ErrorCode, PyXFormError from pyxform.parsing.expression import is_xml_tag @@ -402,7 +404,8 @@ def workbook_to_json( json_dict[constants.CHOICES] = choices # ########## Entities sheet ########### - entity_declaration = None + entity_declarations = None + entity_variable_references = None if workbook_dict.entities: entities_sheet = dealias_and_group_headers( sheet_name=constants.ENTITIES, @@ -411,7 +414,10 @@ def workbook_to_json( header_aliases=aliases.entities_header, header_columns={i.value for i in constants.EntityColumns.value_list()}, ) - entity_declaration = get_entity_declaration(entities_sheet=entities_sheet.data) + entity_declarations = get_entity_declarations(entities_sheet=entities_sheet.data) + entity_variable_references = get_entity_variable_references( + entity_declarations=entity_declarations + ) else: similar = find_sheet_misspellings(key=constants.ENTITIES, keys=sheet_names) if similar is not None: @@ -472,6 +478,7 @@ def workbook_to_json( "child_names": set(), "child_names_lower": set(), "row_number": None, + "container_path": ContainerPath.default(), } ] # If a group has a table-list appearance flag @@ -485,6 +492,7 @@ def workbook_to_json( element_names = Counter() trigger_references: list[tuple[str, int]] = [] repeat_names = set() + entity_references_by_question = {} # row by row, validate questions, throwing errors and adding warnings where needed. for row_number, row in enumerate(survey_sheet.data, start=2): @@ -737,23 +745,57 @@ def workbook_to_json( json_dict[settings_type] = str(row.get(constants.NAME)) continue + # Try to parse question as begin control statement + # (i.e. begin loop/repeat/group): + begin_control_parse = RE_BEGIN_CONTROL.search(question_type) # Try to parse question as a end control statement # (i.e. end loop/repeat/group): end_control_parse = RE_END_CONTROL.search(question_type) + + # Make sure the row has a valid name + question_name = None + if not end_control_parse: + if constants.NAME not in row: + if row[constants.TYPE] == "note": + # autogenerate names for notes without them + row[constants.NAME] = "generated_note_name_" + str(row_number) + elif not end_control_parse: + raise PyXFormError( + ROW_FORMAT_STRING % row_number + + " Question or group with no name." + ) + question_name = str(row[constants.NAME]) + if not is_xml_tag(question_name): + raise PyXFormError( + ErrorCode.NAMES_008.value.format( + sheet=constants.SURVEY, row=row_number, column=constants.NAME + ) + ) + element_names.update((question_name,)) + + 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, + ) + + get_entity_references_by_question( + container_path=stack[-1]["container_path"], + row=row, + row_number=row_number, + question_name=question_name, + entity_declarations=entity_declarations, + entity_variable_references=entity_variable_references, + entity_references_by_question=entity_references_by_question, + is_container_begin=begin_control_parse is not None, + is_container_end=end_control_parse is not None, + ) + 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"]] if prev_control_type != control_type or len(stack) == 1: raise PyXFormError( @@ -767,41 +809,6 @@ def workbook_to_json( table_list = None continue - # Make sure the row has a valid name - if constants.NAME not in row: - if row[constants.TYPE] == "note": - # autogenerate names for notes without them - row[constants.NAME] = "generated_note_name_" + str(row_number) - else: - raise PyXFormError( - ROW_FORMAT_STRING % row_number + " Question or group with no name." - ) - question_name = str(row[constants.NAME]) - if not is_xml_tag(question_name): - raise PyXFormError( - ErrorCode.NAMES_008.value.format( - sheet=constants.SURVEY, row=row_number, column=constants.NAME - ) - ) - element_names.update((question_name,)) - - 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): - begin_control_parse = RE_BEGIN_CONTROL.search(question_type) if begin_control_parse: parse_dict = begin_control_parse.groupdict() if parse_dict.get("begin") and "type" in parse_dict: @@ -927,10 +934,7 @@ def workbook_to_json( "row_number": row_number, } ) - validate_entity_repeat_target( - stack=stack, - entity_declaration=entity_declaration, - ) + stack[-1]["container_path"] = ContainerPath.from_stack(stack=stack) continue # Assuming a question is anything not processed above as a loop/repeat/group. @@ -1432,15 +1436,26 @@ def workbook_to_json( } ) - if entity_declaration: - validate_entity_repeat_target(entity_declaration=entity_declaration) - json_dict[constants.ENTITY_VERSION] = constants.EntityVersion.v2024_1_0 - meta_children.append(entity_declaration) + if entity_declarations: + apply_entities_declarations( + entity_declarations=entity_declarations, + entity_references_by_question=entity_references_by_question, + json_dict=json_dict, + ) if len(meta_children) > 0: - meta_element = get_meta_group(children=meta_children) - survey_children_array = stack[0]["parent_children"] - survey_children_array.append(meta_element) + existing_meta = next( + ( + e + for e in json_dict[constants.CHILDREN] + if e[constants.NAME] == "meta" and e[constants.TYPE] == constants.GROUP + ), + None, + ) + if existing_meta: + existing_meta[constants.CHILDREN].extend(meta_children) + else: + json_dict[constants.CHILDREN].append(get_meta_group(children=meta_children)) validate_pyxform_references_in_workbook( workbook_dict=workbook_dict, diff --git a/tests/entities/test_create_repeat.py b/tests/entities/test_create_repeat.py index ef9e4436..226e0c11 100644 --- a/tests/entities/test_create_repeat.py +++ b/tests/entities/test_create_repeat.py @@ -1,5 +1,4 @@ from pyxform import constants as co -from pyxform.errors import ErrorCode from tests.pyxform_test_case import PyxformTestCase from tests.xpath_helpers.entities import xpe @@ -8,18 +7,24 @@ 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.""" + 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 | save_to | - | | begin_repeat | r1 | R1 | | - | | text | q1 | Q1 | q1e | - | | end_repeat | | | | + | 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} | + | | list_name | label | + | | e1 | ${q3} | """ self.assertPyxformXform( md=md, @@ -28,7 +33,7 @@ def test_basic_usage__ok(self): xpe.model_entities_version(co.EntityVersion.v2025_1_0.value), xpe.model_instance_meta( "e1", - "/x:r1", + "/x:r2", repeat=True, template=True, create=True, @@ -36,18 +41,17 @@ def test_basic_usage__ok(self): ), xpe.model_instance_meta( "e1", - "/x:r1", + "/x:r2", 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_id(meta_path="/r2"), + xpe.model_setvalue_meta_id("/r2"), + xpe.model_bind_meta_label(" ../../../q3 ", "/r2"), xpe.model_bind_meta_instanceid(), xpe.body_repeat_setvalue_meta_id( - "/x:group/x:repeat[@nodeset='/test_name/r1']", "/r1" + "/x:group/x:repeat[@nodeset='/test_name/r2']", "/r2" ), ], xml__contains=['xmlns:entities="http://www.opendatakit.org/xforms/entities"'], @@ -56,103 +60,59 @@ def test_basic_usage__ok(self): ], ) - def test_minimal_fields__ok(self): - """Should find that omitting all optional entity fields is OK.""" + 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} | + | | list_name | label | + | | e1 | ${q1} | """ 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" + 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, ), - ], - 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_instance_meta( + "e1", + "/x:r1", + repeat=True, + create=True, + label=True, + ), + 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_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 = """ @@ -175,10 +135,42 @@ def test_other_controls_before_and_after__ok(self): | | end_repeat | | | | entities | - | | list_name | label | repeat | - | | e1 | ${q3} | ${r2} | + | | list_name | label | + | | e1 | ${q3} | """ - self.assertPyxformXform(md=md, warnings_count=0) + 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:r2", + repeat=True, + template=True, + create=True, + label=True, + ), + xpe.model_instance_meta( + "e1", + "/x:r2", + repeat=True, + create=True, + label=True, + ), + xpe.model_bind_meta_id(meta_path="/r2"), + xpe.model_setvalue_meta_id("/r2"), + xpe.model_bind_meta_label(" ../../../q3 ", "/r2"), + xpe.model_bind_meta_instanceid(), + xpe.body_repeat_setvalue_meta_id( + "/x:group/x:repeat[@nodeset='/test_name/r2']", "/r2" + ), + ], + xml__contains=['xmlns:entities="http://www.opendatakit.org/xforms/entities"'], + xml__xpath_count=[ + ("/h:html//x:setvalue", 2), + ], + ) 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.""" @@ -186,18 +178,19 @@ def test_question_without_saveto_in_entity_repeat__ok(self): | survey | | | | | | | type | name | label | save_to | | | begin_repeat | r1 | R1 | | - | | text | q1 | Q1 | q1e | + | | text | q1 | Q1 | p1 | | | text | q2 | Q2 | | | | end_repeat | | | | | entities | - | | list_name | label | repeat | - | | e1 | ${q1} | ${r1} | + | | list_name | label | + | | e1 | ${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", @@ -213,7 +206,14 @@ def test_question_without_saveto_in_entity_repeat__ok(self): create=True, label=True, ), - xpe.model_bind_question_saveto("/r1/q1", "q1e"), + xpe.model_bind_question_saveto("/r1/q1", "p1"), + 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" + ), # repeat model instance question """ /h:html/h:head/x:model/x:instance/x:test_name/x:r1[@jr:template='']/x:q2 @@ -229,6 +229,10 @@ def test_question_without_saveto_in_entity_repeat__ok(self): ] """, ], + xml__contains=['xmlns:entities="http://www.opendatakit.org/xforms/entities"'], + xml__xpath_count=[ + ("/h:html//x:setvalue", 2), + ], ) def test_repeat_without_saveto_in_entity_repeat__ok(self): @@ -237,20 +241,44 @@ def test_repeat_without_saveto_in_entity_repeat__ok(self): | survey | | | | | | | type | name | label | save_to | | | begin_repeat | r1 | R1 | | - | | text | q1 | Q1 | q1e | + | | text | q1 | Q1 | p1 | | | begin_repeat | r2 | R2 | | | | text | q2 | Q2 | | | | end_repeat | | | | | | end_repeat | | | | | entities | - | | list_name | label | repeat | - | | e1 | ${q1} | ${r1} | + | | list_name | label | + | | e1 | ${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, + create=True, + label=True, + ), + xpe.model_instance_meta( + "e1", + "/x:r1", + repeat=True, + create=True, + label=True, + ), + xpe.model_bind_question_saveto("/r1/q1", "p1"), + 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" + ), # repeat template for adjacent repeat doesn't get meta block """ /h:html/h:head/x:model/x:instance/x:test_name/x:r1[ @@ -268,306 +296,444 @@ def test_repeat_without_saveto_in_entity_repeat__ok(self): ] """, ], + xml__contains=['xmlns:entities="http://www.opendatakit.org/xforms/entities"'], + xml__xpath_count=[ + ("/h:html//x:setvalue", 2), + ], ) - 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.""" + def test_somewhat_ambiguous_repeat_nesting_references(self): md = """ | survey | - | | type | name | label | save_to | - | | begin_repeat | r1 | R1 | | - | | begin_group | g1 | G1 | | - | | text | q1 | Q1 | q1e | - | | end_group | | | | - | | end_repeat | | | | + | | type | name | label | + | | begin_repeat | r1 | R1 | + | | begin_group | g1 | G1 | + | | text | q1 | Q1 | + | | begin_repeat | r2 | R2 | + | | begin_group | g2 | G2 | + | | text | q2 | Q2 | + | | begin_group | g3 | G3 | + | | text | q3 | Q3 | + | | end_group | | | + | | end_group | | | + | | end_repeat | | | + | | end_group | | | + | | end_repeat | | | | entities | - | | list_name | label | repeat | - | | e1 | ${q1} | ${r1} | + | | list_name | label | + | | e1 | concat(${q1}, " ", ${q2}, " ", ${q3}) | """ 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", + "/x:r1[@jr:template='']/x:g1/x:r2[@jr:template='']/x:g2/x:g3", + template=None, repeat=True, - template=True, create=True, label=True, ), xpe.model_instance_meta( "e1", - "/x:r1", + "/x:r1[@jr:template='']/x:g1/x:r2[not(@jr:template)]/x:g2/x:g3", + template=None, + repeat=True, + create=True, + label=True, + ), + xpe.model_instance_meta( + "e1", + "/x:r1[not(@jr:template)]/x:g1/x:r2[not(@jr:template)]/x:g2/x:g3", + template=None, repeat=True, create=True, label=True, ), - xpe.model_bind_question_saveto("/r1/g1/q1", "q1e"), - xpe.model_bind_meta_label(" ../../../g1/q1 ", "/r1"), + # no save_to in this test + xpe.model_bind_meta_id(meta_path="/r1/g1/r2/g2/g3"), + xpe.model_bind_meta_label( + """concat( ../../../../../../q1 , " ", ../../../../q2 , " ", ../../../q3 )""", + "/r1/g1/r2/g2/g3", + ), + xpe.model_bind_meta_instanceid(), + xpe.body_repeat_setvalue_meta_id( + "/x:group/x:repeat/x:group/x:group/x:repeat[@nodeset='/test_name/r1/g1/r2']", + "/r1/g1/r2/g2/g3", + ), + ], + xml__contains=['xmlns:entities="http://www.opendatakit.org/xforms/entities"'], + xml__xpath_count=[ + ("/h:html//x:setvalue", 2), ], ) - def test_entity_repeat_in_group__ok(self): - """Should find that putting the entity repeat inside a group is OK.""" + def test_somewhat_ambiguous_repeat_nesting_references_with_saveto(self): md = """ | survey | | | type | name | label | save_to | - | | begin_group | g1 | G1 | | | | begin_repeat | r1 | R1 | | - | | text | q1 | Q1 | q1e | + | | begin_group | g1 | G1 | | + | | text | q1 | Q1 | | + | | begin_repeat | r2 | R2 | | + | | begin_group | g2 | G2 | | + | | text | q2 | Q2 | | + | | begin_group | g3 | G3 | | + | | text | q3 | Q3 | | + | | text | q4 | Q4 | p1 | + | | end_group | | | | + | | end_group | | | | | | end_repeat | | | | | | end_group | | | | + | | end_repeat | | | | | entities | - | | list_name | label | repeat | - | | e1 | ${q1} | ${r1} | + | | list_name | label | + | | e1 | concat(${q1}, " ", ${q2}, " ", ${q3}) | """ 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:g1/x:r1", + "/x:r1[@jr:template='']/x:g1/x:r2[@jr:template='']/x:g2/x:g3", + template=None, repeat=True, - template=True, create=True, label=True, ), xpe.model_instance_meta( "e1", - "/x:g1/x:r1", + "/x:r1[@jr:template='']/x:g1/x:r2[not(@jr:template)]/x:g2/x:g3", + template=None, repeat=True, create=True, label=True, ), - xpe.model_bind_question_saveto("/g1/r1/q1", "q1e"), - xpe.model_bind_meta_label(" ../../../q1 ", "/g1/r1"), + xpe.model_instance_meta( + "e1", + "/x:r1[not(@jr:template)]/x:g1/x:r2[not(@jr:template)]/x:g2/x:g3", + template=None, + repeat=True, + create=True, + label=True, + ), + xpe.model_bind_question_saveto("/r1/g1/r2/g2/g3/q4", "p1"), + xpe.model_bind_meta_id(meta_path="/r1/g1/r2/g2/g3"), + xpe.model_bind_meta_label( + """concat( ../../../../../../q1 , " ", ../../../../q2 , " ", ../../../q3 )""", + "/r1/g1/r2/g2/g3", + ), + xpe.model_bind_meta_instanceid(), + xpe.body_repeat_setvalue_meta_id( + "/x:group/x:repeat/x:group/x:group/x:repeat[@nodeset='/test_name/r1/g1/r2']", + "/r1/g1/r2/g2/g3", + ), + ], + xml__contains=['xmlns:entities="http://www.opendatakit.org/xforms/entities"'], + xml__xpath_count=[ + ("/h:html//x:setvalue", 2), ], ) - 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=[ErrorCode.ENTITY_001.value.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=[ErrorCode.ENTITY_002.value.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=[ErrorCode.ENTITY_003.value.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=[ErrorCode.ENTITY_003.value.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=[ErrorCode.ENTITY_004.value.format(value="r2")], - ) - - def test_saveto_question_not_in_entity_repeat_no_entity_repeat__error( + def test_somewhat_ambiguous_repeat_nesting_references_with_saveto_and_competing_lists( 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=[ErrorCode.ENTITY_006.value.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=[ErrorCode.ENTITY_006.value.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 | + | | text | q1 | Q1 | | + | | begin_repeat | r2 | R2 | | + | | begin_group | g2 | G2 | | + | | text | q2 | Q2 | e1#e1p1 | + | | begin_group | g3 | G3 | | + | | text | q3 | Q3 | | + | | text | q4 | Q4 | e2#e2p1 | + | | end_group | | | | | | 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=[ErrorCode.ENTITY_006.value.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_group | | | | | | end_repeat | | | | | entities | - | | list_name | label | repeat | - | | e1 | ${q1} | ${r2} | + | | list_name | label | + | | e1 | concat(${q1}, " ", ${q2}, " ", ${q3}) | + | | e2 | concat(${q1}, " ", ${q2}, " ", ${q3}) | """ self.assertPyxformXform( md=md, - errored=True, - error__contains=[ErrorCode.ENTITY_006.value.format(row=3, value="q1e")], + warnings_count=0, + xml__xpath_match=[ + xpe.model_entities_version(co.EntityVersion.v2025_1_0.value), + xpe.model_instance_meta( + "e1", + "/x:r1[@jr:template='']/x:g1/x:r2[@jr:template='']/x:g2", + template=None, + repeat=True, + create=True, + label=True, + ), + xpe.model_instance_meta( + "e1", + "/x:r1[@jr:template='']/x:g1/x:r2[not(@jr:template)]/x:g2", + template=None, + repeat=True, + create=True, + label=True, + ), + xpe.model_instance_meta( + "e1", + "/x:r1[not(@jr:template)]/x:g1/x:r2[not(@jr:template)]/x:g2", + template=None, + repeat=True, + create=True, + label=True, + ), + xpe.model_instance_meta( + "e2", + "/x:r1[@jr:template='']/x:g1/x:r2[@jr:template='']/x:g2/x:g3", + template=None, + repeat=True, + create=True, + label=True, + ), + xpe.model_instance_meta( + "e2", + "/x:r1[@jr:template='']/x:g1/x:r2[not(@jr:template)]/x:g2/x:g3", + template=None, + repeat=True, + create=True, + label=True, + ), + xpe.model_instance_meta( + "e2", + "/x:r1[not(@jr:template)]/x:g1/x:r2[not(@jr:template)]/x:g2/x:g3", + template=None, + repeat=True, + create=True, + label=True, + ), + xpe.model_bind_question_saveto("/r1/g1/r2/g2/q2", "e1p1"), + xpe.model_bind_question_saveto("/r1/g1/r2/g2/g3/q4", "e2p1"), + xpe.model_bind_meta_id(meta_path="/r1/g1/r2/g2"), + xpe.model_bind_meta_id(meta_path="/r1/g1/r2/g2/g3"), + xpe.model_setvalue_meta_id("/r1/g1/r2/g2"), + xpe.model_setvalue_meta_id("/r1/g1/r2/g2/g3"), + xpe.model_bind_meta_label( + """concat( ../../../../../q1 , " ", ../../../q2 , " ", ../../../g3/q3 )""", + "/r1/g1/r2/g2", + ), + xpe.model_bind_meta_label( + """concat( ../../../../../../q1 , " ", ../../../../q2 , " ", ../../../q3 )""", + "/r1/g1/r2/g2/g3", + ), + xpe.model_bind_meta_instanceid(), + xpe.body_repeat_setvalue_meta_id( + "/x:group/x:repeat/x:group/x:group/x:repeat[@nodeset='/test_name/r1/g1/r2']", + "/r1/g1/r2/g2", + ), + xpe.body_repeat_setvalue_meta_id( + "/x:group/x:repeat/x:group/x:group/x:repeat[@nodeset='/test_name/r1/g1/r2']", + "/r1/g1/r2/g2/g3", + ), + ], + xml__contains=['xmlns:entities="http://www.opendatakit.org/xforms/entities"'], + xml__xpath_count=[ + ("/h:html//x:setvalue", 4), + ], ) - 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.""" + def test_somewhat_ambiguous_repeat_nesting_references_with_saveto_and_many_competing_lists( + self, + ): md = """ | survey | - | | type | name | label | save_to | + | | type | name | label | save_to | meta gets what | | begin_repeat | r1 | R1 | | - | | begin_repeat | r2 | R2 | | - | | text | q1 | Q1 | q1e | + | | begin_group | g1 | G1 | | + | | text | q1 | Q1 | | + | | begin_repeat | r2 | R2 | | e4 (spare slot in R2 scope) + | | begin_group | g2 | G2 | | e1 (pinned by saveto) + | | text | q2 | Q2 | e1#e1p1 | + | | begin_group | g3 | G3 | | e3 (another spare slot in R2 scope) + | | begin_group | g4 | G4 | | e2 (pinned to container by saveto) + | | text | q3 | Q3 | | + | | text | q4 | Q4 | e2#e2p1 | + | | end_group | | | | + | | end_group | | | | + | | end_group | | | | | | end_repeat | | | | + | | end_group | | | | | | end_repeat | | | | | entities | - | | list_name | label | repeat | - | | e1 | ${q1} | ${r1} | + | | list_name | label | create_if | + | | e1 | concat(${q1}, " ", ${q2}, " ", ${q3}) | ${q1} = '' | + | | e2 | concat(${q1}, " ", ${q2}, " ", ${q3}) | | + | | e3 | concat(${q1}, " ", ${q2}, " ", ${q3}) | | + | | e4 | concat(${q1}, " ", ${q2}, " ", ${q3}) | | """ self.assertPyxformXform( md=md, - errored=True, - error__contains=[ErrorCode.ENTITY_005.value.format(row=4, value="q1e")], + warnings_count=0, + xml__xpath_match=[ + xpe.model_entities_version(co.EntityVersion.v2025_1_0.value), + xpe.model_bind_meta_instanceid(), + # model entity x12 (r1 template + r2 template, r1 template + r2, r1 + r2) + xpe.model_instance_meta( + "e1", + "/x:r1[@jr:template='']/x:g1/x:r2[@jr:template='']/x:g2", + template=None, + repeat=True, + create=True, + label=True, + ), + xpe.model_instance_meta( + "e1", + "/x:r1[@jr:template='']/x:g1/x:r2[not(@jr:template)]/x:g2", + template=None, + repeat=True, + create=True, + label=True, + ), + xpe.model_instance_meta( + "e1", + "/x:r1[not(@jr:template)]/x:g1/x:r2[not(@jr:template)]/x:g2", + template=None, + repeat=True, + create=True, + label=True, + ), + xpe.model_instance_meta( + "e2", + "/x:r1[@jr:template='']/x:g1/x:r2[@jr:template='']/x:g2/x:g3/x:g4", + template=None, + repeat=True, + create=True, + label=True, + ), + xpe.model_instance_meta( + "e2", + "/x:r1[@jr:template='']/x:g1/x:r2[not(@jr:template)]/x:g2/x:g3/x:g4", + template=None, + repeat=True, + create=True, + label=True, + ), + xpe.model_instance_meta( + "e2", + "/x:r1[not(@jr:template)]/x:g1/x:r2[not(@jr:template)]/x:g2/x:g3/x:g4", + template=None, + repeat=True, + create=True, + label=True, + ), + xpe.model_instance_meta( + "e3", + "/x:r1[@jr:template='']/x:g1/x:r2[@jr:template='']/x:g2/x:g3", + template=None, + repeat=True, + create=True, + label=True, + ), + xpe.model_instance_meta( + "e3", + "/x:r1[@jr:template='']/x:g1/x:r2[not(@jr:template)]/x:g2/x:g3", + template=None, + repeat=True, + create=True, + label=True, + ), + xpe.model_instance_meta( + "e3", + "/x:r1[not(@jr:template)]/x:g1/x:r2[not(@jr:template)]/x:g2/x:g3", + template=None, + repeat=True, + create=True, + label=True, + ), + xpe.model_instance_meta( + "e4", + "/x:r1[@jr:template='']/x:g1/x:r2[@jr:template='']", + template=None, + repeat=True, + create=True, + label=True, + ), + xpe.model_instance_meta( + "e4", + "/x:r1[@jr:template='']/x:g1/x:r2[not(@jr:template)]", + template=None, + repeat=True, + create=True, + label=True, + ), + xpe.model_instance_meta( + "e4", + "/x:r1[not(@jr:template)]/x:g1/x:r2[not(@jr:template)]", + template=None, + repeat=True, + create=True, + label=True, + ), + # saveto x2 + xpe.model_bind_question_saveto("/r1/g1/r2/g2/q2", "e1p1"), + xpe.model_bind_question_saveto("/r1/g1/r2/g2/g3/g4/q4", "e2p1"), + # model bind meta/entity/@id x4 + xpe.model_bind_meta_id(meta_path="/r1/g1/r2"), + xpe.model_bind_meta_id(meta_path="/r1/g1/r2/g2"), + xpe.model_bind_meta_id(meta_path="/r1/g1/r2/g2/g3"), + xpe.model_bind_meta_id(meta_path="/r1/g1/r2/g2/g3/g4"), + # model setvalue meta/entity/@id x4 + xpe.model_setvalue_meta_id("/r1/g1/r2"), + xpe.model_setvalue_meta_id("/r1/g1/r2/g2"), + xpe.model_setvalue_meta_id("/r1/g1/r2/g2/g3"), + xpe.model_setvalue_meta_id("/r1/g1/r2/g2/g3/g4"), + # model bind meta/entity/label x4 + xpe.model_bind_meta_label( + """concat( ../../../../q1 , " ", ../../../g2/q2 , " ", ../../../g2/g3/g4/q3 )""", + "/r1/g1/r2", + ), + xpe.model_bind_meta_label( + """concat( ../../../../../q1 , " ", ../../../q2 , " ", ../../../g3/g4/q3 )""", + "/r1/g1/r2/g2", + ), + xpe.model_bind_meta_label( + """concat( ../../../../../../q1 , " ", ../../../../q2 , " ", ../../../g4/q3 )""", + "/r1/g1/r2/g2/g3", + ), + xpe.model_bind_meta_label( + """concat( ../../../../../../../q1 , " ", ../../../../../q2 , " ", ../../../q3 )""", + "/r1/g1/r2/g2/g3/g4", + ), + # body repeat setvalue x4 + xpe.body_repeat_setvalue_meta_id( + "/x:group/x:repeat/x:group/x:group/x:repeat[@nodeset='/test_name/r1/g1/r2']", + "/r1/g1/r2", + ), + xpe.body_repeat_setvalue_meta_id( + "/x:group/x:repeat/x:group/x:group/x:repeat[@nodeset='/test_name/r1/g1/r2']", + "/r1/g1/r2/g2", + ), + xpe.body_repeat_setvalue_meta_id( + "/x:group/x:repeat/x:group/x:group/x:repeat[@nodeset='/test_name/r1/g1/r2']", + "/r1/g1/r2/g2/g3", + ), + xpe.body_repeat_setvalue_meta_id( + "/x:group/x:repeat/x:group/x:group/x:repeat[@nodeset='/test_name/r1/g1/r2']", + "/r1/g1/r2/g2/g3/g4", + ), + ], + xml__contains=['xmlns:entities="http://www.opendatakit.org/xforms/entities"'], + xml__xpath_count=[ + ("/h:html//x:setvalue", 8), + ("/h:html/h:head/x:model/x:instance/x:test_name//x:entity", 12), + ], ) diff --git a/tests/entities/test_create_survey.py b/tests/entities/test_create_survey.py deleted file mode 100644 index 50dbf395..00000000 --- a/tests/entities/test_create_survey.py +++ /dev/null @@ -1,469 +0,0 @@ -from pyxform import constants as co -from pyxform.errors import ErrorCode - -from tests.pyxform_test_case import PyxformTestCase -from tests.xpath_helpers.entities import xpe - - -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=""" - | survey | | | | - | | type | name | label | - | | text | a | A | - | entities | | | | - | | dataset | label | | - | | trees | a | | - """, - xml__xpath_match=[ - xpe.model_entities_version(co.EntityVersion.v2024_1_0.value), - # defaults to always creating - 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//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", - md=""" - | survey | | | | - | | type | name | label | - | | text | a | A | - | entities | | | | - | | dataset | | | - | | trees | | | - | | shovels | | | - """, - errored=True, - error__contains=[ - "Currently, you can only declare a single entity per form. Please make sure your entities sheet only declares one entity." - ], - ) - - def test_dataset_with_reserved_prefix__errors(self): - self.assertPyxformXform( - name="data", - md=""" - | survey | | | | - | | type | name | label | - | | text | a | A | - | entities | | | | - | | dataset | label | | - | | __sweet | a | | - """, - errored=True, - error__contains=[ - ErrorCode.NAMES_010.value.format( - sheet=co.ENTITIES, row=2, column=co.EntityColumns.DATASET.value - ) - ], - ) - - def test_dataset_with_invalid_xml_name__errors(self): - self.assertPyxformXform( - name="data", - md=""" - | survey | | | | - | | type | name | label | - | | text | a | A | - | entities | | | | - | | dataset | label | | - | | $sweet | a | | - """, - errored=True, - error__contains=[ - ErrorCode.NAMES_008.value.format( - sheet=co.ENTITIES, row=2, column=co.EntityColumns.DATASET.value - ) - ], - ) - - def test_dataset_with_period_in_name__errors(self): - self.assertPyxformXform( - name="data", - md=""" - | survey | | | | - | | type | name | label | - | | text | a | A | - | entities | | | | - | | dataset | label | | - | | s.w.eet | a | | - """, - errored=True, - error__contains=[ - ErrorCode.NAMES_011.value.format( - sheet=co.ENTITIES, row=2, column=co.EntityColumns.DATASET.value - ) - ], - ) - - def test_worksheet_name_close_to_entities__produces_warning(self): - self.assertPyxformXform( - name="data", - md=""" - | survey | | | | - | | type | name | label | - | | text | a | A | - | entitoes | | | | - | | dataset | label | | - | | trees | a | | - """, - warnings__contains=[ - "When looking for a sheet named 'entities', the following sheets with similar names were found: 'entitoes'." - ], - ) - - def test_create_if_in_entities_sheet__puts_expression_on_bind(self): - self.assertPyxformXform( - md=""" - | survey | | | | - | | type | name | label | - | | text | a | A | - | entities | | | | - | | dataset | create_if | label | - | | trees | string-length(a) > 3 | a | - """, - xml__xpath_match=[ - xpe.model_bind_meta_create("string-length(a) > 3"), - xpe.model_instance_meta("trees", create=True, label=True), - ], - ) - - def test_label_and_create_if_in_entities_sheet__expand_node_selectors_to_xpath(self): - self.assertPyxformXform( - md=""" - | survey | | | | - | | type | name | label | - | | text | a | A | - | entities | | | | - | | dataset | label | create_if | - | | trees | ${a} | string-length(${a}) > 3 | - """, - xml__xpath_match=[ - 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), - ], - ) - - def test_entity_label__required(self): - self.assertPyxformXform( - name="data", - md=""" - | survey | | | | - | | type | name | label | - | | text | a | A | - | entities | | | | - | | dataset | | | - | | trees | | | - """, - errored=True, - error__contains=[ - "The entities sheet is missing the label column which is required when creating entities." - ], - ) - - def test_entities_namespace__omitted_if_no_entities_sheet(self): - self.assertPyxformXform( - name="data", - md=""" - | survey | | | | - | | type | name | label | - | | text | a | A | - """, - xml__excludes=['xmlns:entities="http://www.opendatakit.org/xforms/entities"'], - ) - - def test_entities_version__omitted_if_no_entities_sheet(self): - self.assertPyxformXform( - md=""" - | survey | | | | - | | type | name | label | - | | text | a | A | - """, - xml__xpath_match=[xpe.model_no_entities_version()], - ) - - def test_saveto_column__added_to_xml(self): - self.assertPyxformXform( - md=""" - | survey | | | | | - | | type | name | label | save_to | - | | text | a | A | foo | - | entities | | | | | - | | dataset | label | | | - | | trees | a | | | - """, - xml__xpath_match=[ - xpe.model_bind_question_saveto("/a", "foo"), - ], - ) - - def test_saveto_without_entities_sheet__errors(self): - self.assertPyxformXform( - name="data", - md=""" - | survey | | | | | - | | type | name | label | save_to | - | | text | a | A | foo | - """, - errored=True, - error__contains=[ - "To save entity properties using the save_to column, you must add an entities sheet and declare an entity." - ], - ) - - def test_name_in_saveto_column__errors(self): - self.assertPyxformXform( - name="data", - md=""" - | survey | | | | | - | | type | name | label | save_to | - | | text | a | A | name | - | entities | | | | | - | | dataset | label | | | - | | trees | a | | | - """, - errored=True, - error__contains=[ - ErrorCode.NAMES_011.value.format( - sheet=co.SURVEY, row=2, column=co.ENTITIES_SAVETO - ) - ], - ) - - def test_naMe_in_saveto_column__errors(self): - self.assertPyxformXform( - name="data", - md=""" - | survey | | | | | - | | type | name | label | save_to | - | | text | a | A | naMe | - | entities | | | | | - | | dataset | label | | | - | | trees | a | | | - """, - errored=True, - error__contains=[ - ErrorCode.NAMES_011.value.format( - sheet=co.SURVEY, row=2, column=co.ENTITIES_SAVETO - ) - ], - ) - - def test_label_in_saveto_column__errors(self): - self.assertPyxformXform( - name="data", - md=""" - | survey | | | | | - | | type | name | label | save_to | - | | text | a | A | label | - | entities | | | | | - | | dataset | label | | | - | | trees | a | | | - """, - errored=True, - error__contains=[ - ErrorCode.NAMES_011.value.format( - sheet=co.SURVEY, row=2, column=co.ENTITIES_SAVETO - ) - ], - ) - - def test_lAbEl_in_saveto_column__errors(self): - self.assertPyxformXform( - name="data", - md=""" - | survey | | | | | - | | type | name | label | save_to | - | | text | a | A | lAbEl | - | entities | | | | | - | | dataset | label | | | - | | trees | a | | | - """, - errored=True, - error__contains=[ - ErrorCode.NAMES_011.value.format( - sheet=co.SURVEY, row=2, column=co.ENTITIES_SAVETO - ) - ], - ) - - def test_system_prefix_in_saveto_column__errors(self): - self.assertPyxformXform( - name="data", - md=""" - | survey | | | | | - | | type | name | label | save_to | - | | text | a | A | __a | - | entities | | | | | - | | dataset | label | | | - | | trees | a | | | - """, - errored=True, - error__contains=[ - ErrorCode.NAMES_010.value.format( - sheet=co.SURVEY, row=2, column=co.ENTITIES_SAVETO - ) - ], - ) - - def test_invalid_xml_identifier_in_saveto_column__errors(self): - self.assertPyxformXform( - name="data", - md=""" - | survey | | | | | - | | type | name | label | save_to | - | | text | a | A | $a | - | entities | | | | | - | | dataset | label | | | - | | trees | a | | | - """, - errored=True, - error__contains=[ - ErrorCode.NAMES_008.value.format( - sheet=co.SURVEY, row=2, column=co.ENTITIES_SAVETO - ) - ], - ) - - def test_saveto_on_group__errors(self): - self.assertPyxformXform( - name="data", - md=""" - | survey | | | | | - | | type | name | label | save_to | - | | begin_group | a | A | a | - | | end_group | | | | - | entities | | | | | - | | dataset | label | | | - | | trees | a | | | - """, - errored=True, - error__contains=[ - "[row : 2] Groups and repeats can't be saved as entity properties." - ], - ) - - 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( - md=md, - errored=True, - error__contains=[ErrorCode.ENTITY_007.value.format(row=3, value="q1e")], - ) - - def test_saveto_in_group__works(self): - self.assertPyxformXform( - name="data", - md=""" - | survey | | | | | - | | type | name | label | save_to | - | | begin_group | a | A | | - | | text | size | Size | size | - | | end_group | | | | - | entities | | | | | - | | dataset | label | | | - | | trees | ${size}| | | - """, - warnings_count=0, - ) - - def test_list_name_alias_to_dataset(self): - self.assertPyxformXform( - md=""" - | survey | | | | - | | type | name | label | - | | text | a | A | - | entities | | | | - | | list_name | label | | - | | trees | a | | - """, - xml__xpath_match=[xpe.model_instance_meta("trees", create=True, label=True)], - ) - - def test_entities_columns__all_expected(self): - self.assertPyxformXform( - md=""" - | survey | | | | - | | type | name | label | - | | text | id | Treid | - | | text | a | A | - | | csv-external | trees | | - | entities | | | | - | | dataset | label | update_if | create_if | entity_id | - | | trees | a | id != '' | id = '' | ${a} | - """, - warnings_count=0, - ) - - def test_entities_columns__one_unexpected(self): - self.assertPyxformXform( - md=""" - | survey | | | | - | | type | name | label | - | | text | a | A | - | entities | | | | - | | dataset | label | what | - | | trees | a | ! | - """, - errored=True, - error__contains=[ - "The entities sheet included the following unexpected column(s): 'what'" - ], - ) - - def test_entities_columns__multiple_unexpected(self): - self.assertPyxformXform( - md=""" - | survey | | | | - | | type | name | label | - | | text | a | A | - | entities | | | | - | | dataset | label | what | why | - | | trees | a | ! | ? | - """, - errored=True, - error__contains=[ - "The entities sheet included the following unexpected column(s):", - "'what'", - "'why'", - ], - ) diff --git a/tests/entities/test_entities.py b/tests/entities/test_entities.py new file mode 100644 index 00000000..e506d131 --- /dev/null +++ b/tests/entities/test_entities.py @@ -0,0 +1,3049 @@ +""" +## Entity feature traceability test suite + +Each entities test should reference one (or more) requirements from these lists. + +*Entities spec requirements* + +- ES001: Namespacing +- ES002: Versioning +- ES003: Creating +- ES004: Updating +- ES005: Properties +- ES006: Multiples + +*XLSForm requirements* + +- Validation + - EV001: Sheet name misspelling warning + - EV002: Unexpected entity column error + - EV003: Unresolved variable reference error + - EV004: No entity declarations error + - EV005: Duplicate entity declarations error + - EV006: Duplicate save_to error + - EV007: Container row has save_to error + - EV008: Unresolved entity save_to prefix error + - EV009: Missing entity create label error + - EV010: Missing entity upsert update_if error + - EV011: Missing entity update/upsert entity_id error + - EV012: Missing entity save_to prefix error + - EV014: Unsolvable meta/entity topology error + - EV015: save_to scope breach error + - EV016: Reference scope conflict error + - EV017: Duplicate save_to delimiter error + - EV018: Entity name invalid identifier error + - EV019: Entity name invalid period character error + - EV020: Entity name invalid underscore prefix error + - EV021: save_to name invalid identifier error + - EV022: save_to name invalid reserved names error + - EV023: save_to name invalid underscore prefix error + - EV024: Entity name missing error +- Behaviour + - EB001: Dataset column alias + - EB002: implicit entity_id=0, create_if=0, update_if=0 (create) + - EB003: implicit entity_id=0, create_if=0, update_if=1 (error, EV011) + - EB004: implicit entity_id=0, create_if=1, update_if=0 (create / if) + - EB005: implicit entity_id=0, create_if=1, update_if=1 (error, EV011) + - EB006: implicit entity_id=1, create_if=0, update_if=0 (update) + - EB007: implicit entity_id=1, create_if=0, update_if=1 (update / if) + - EB008: implicit entity_id=1, create_if=1, update_if=0 (error, EV010) + - EB009: implicit entity_id=1, create_if=1, update_if=1 (upsert / if) + - EB010: Meta/entity allocations are stable/deterministic + - EB012: Do not emit entity id setvalue when entity_id present (pyxform/#819) + - EB013: Emit setvalue for default id + - EB014: Always emit the instanceID in a survey-level meta block only + - EB015: Variable reference tokens in the entities sheet are resolved + - EB016: Variable references for repeats are resolved relative to the entity + - EB017: Do not emit entities namespace if entities not used + - EB018: Do not emit entities version if entities not used + - EB019: Do not emit default instance to load the entity from csv + - EB020: Allocate to survey when no references exist for an entity + - EB021: Allocation to survey meta is compatible with other meta settings + - EB022: Allocation searches path ancestors only (not children or siblings) + + +## Topological constraint solver regression suite + +*Path scenarios* + +- /s/q1 survey container +- /s/g1/q1 group container +- /s/r1/q1 repeat container +- /s/g1/r1/q1 repeat in group +- /s/r1/g1/q1 group in repeat +- /s/g1/g2/q1 group in group +- /s/r1/r2/q1 repeat in repeat +- /s/r1/g1/r2/q1 repeat in repeat group +- /s/r1/g1/g2/q1 group in repeat group + +*Notation* + +- tokens: + - s: survey + - g: group + - r: repeat + - q: question + - d: dataset + - p: save_to + - v: reference +- examples: + - /s/q1.d1.v1: survey-level question 1 has a reference from dataset 1 + - /s/q2.d2.p1: survey-level question 2 has a save_to property 1 for dataset 2 + - /s/r1/q1.d1.v1: in repeat 1, question 1 has a reference from dataset 1 + + +*Test matrix* + +- reference only + - one dataset + - each container type, single ref from label, all paths, q1 only + - each container type, multiple refs from label to same level, + all paths with q1 and q2 together, cartesian product of paths + - each container type, multiple refs from label to different levels, + all paths with q1 and q2 distinct, cartesian product of paths + - two datasets + - each container type, multiple refs from label to same level, + all paths with q1 and q2 together in cartesian product of paths but only + where there are 2 or more groups in a container scope + - each container type, multiple refs from label to different levels, + all paths with q1 and q2 separate in cartesian product of paths but only + where there are 2 or more groups in a container scope, or where in different scopes +- save_to only + - as above for "references only" but using save_to to create the linkage +- reference + save_to + - as above for "references only" but just the "two datasets" cases + +*Test matrix schema* + +- requestor_1: scenario path - all cases require at least one allocation request +- requestor_2: scenario path - for multiple dataset cases, another allocation request +- expected_error: if an error case then which error code is expected, otherwise "No" +- dataset_1: xpath - where the meta/entity block should be for requestor_1 +- dataset_2: xpath - where the meta/entity block should be for requestor_2 + + +*Stress test* + +- overall goal: large form with 5k survey rows, 10% containers, saturated with entities. + - with nesting depth 10, node arity 10 (2 of which branch) + - B = (c^(d-1)-1)/(c-1) and then T = (B x a) + 1 + - B = branching nodes, c = continuation rate, d = tree depth, a = arity (nodes per branch) + - For c = 2, d = 10, a = 20: T = (((2^(10-1) - 1)/(2 - 1)) * 10) + 1 = 5111 nodes + - of which B = 511 (10%) are containers (survey + repeats + groups) + - container types: 1 survey, 255 repeats, 255 groups + - 511 entities: 170 by 5x refs, 170 by 5x savetos, 171 by 5x refs + 5x savetos + - and leaf nodes = 5111 - 511 = 4600 leaf nodes (questions) +- test scaling: linear in arity, exponential in continuation rate or tree depth +- expectations: no errors or warnings, and reasonable processing time and memory usage + + +## Additional tests + +- existing test cases of miscellaneous designs for regressions +""" + +from pyxform import constants as co +from pyxform.entities.entities_parsing import ContainerPath, ReferenceSource +from pyxform.errors import ErrorCode, PyXFormError + +from tests.pyxform_test_case import PyxformTestCase +from tests.xpath_helpers.entities import xpe +from tests.xpath_helpers.questions import xpq +from tests.xpath_helpers.settings import xps + + +class TestEntitiesParsing(PyxformTestCase): + def test_sheet_name_misspelling__warning(self): + """Should warn when a name similar to 'entities' is found.""" + # EV001 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + + | entitoes | + | | list_name | label | + | | e1 | E1 | + """ + self.assertPyxformXform( + md=md, + warnings_count=1, + warnings__contains=[ + ErrorCode.NAMES_013.value.format( + sheet=co.ENTITIES, candidates="'entitoes'" + ) + ], + ) + + def test_unexpected_column__single__error(self): + """Should raise an error when an unsupported column name is found on the entities sheet.""" + # EV002 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + + | entities | + | | list_name | label | what | + | | e1 | E1 | ! | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ErrorCode.HEADER_005.value.format(columns="'what'")], + ) + + def test_unexpected_column__multiple__error(self): + """Should raise an error when unsupported columns are found on the entities sheet.""" + # EV002 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + + | entities | + | | list_name | label | what | why | + | | e1 | E1 | ! | ? | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ErrorCode.HEADER_005.value.format(columns="'what', 'why'")], + ) + + def test_unresolved_variable_reference__error(self): + """Should raise an error when an entities variable reference name is not found.""" + # EV003 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + + | entities | + | | list_name | label | + | | e1 | ${q2} | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ + ErrorCode.PYREF_003.value.format( + sheet="entities", row=2, column="label", q="q2" + ) + ], + ) + + def test_no_entity_declarations__error(self): + """Should raise an error when an a save_to value appears but there are no entities.""" + # EV004 + md = """ + | survey | + | | type | name | label | save_to | + | | text | q1 | Q1 | e1p1 | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ErrorCode.ENTITY_001.value.format(row=2)], + ) + + def test_duplicate_entity_declaration__error(self): + """Should raise an error when more than one entity uses the same name.""" + # EV005 + md = """ + | survey | + | | type | name | label | save_to | + | | text | q1 | Q1 | e1#e1p1 | + | | text | q2 | Q2 | e2#e2p1 | + + | entities | + | | list_name | label | + | | e1 | ${q1} | + | | e1 | ${q2} | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ErrorCode.NAMES_014.value.format(row=3)], + ) + + def test_duplicate_entity_property__single_entity__error(self): + """Should raise an error when multiple rows attempt to save_to the same entity property.""" + # EV006 + md = """ + | survey | + | | type | name | label | save_to | + | | text | q1 | Q1 | e1p1 | + | | text | q2 | Q2 | e1p1 | + + | entities | + | | list_name | label | + | | e1 | ${q1} | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ + ErrorCode.ENTITY_002.value.format(row=3, saveto="e1p1", other_row=2) + ], + ) + + def test_duplicate_entity_property__multiple_entity__error(self): + """Should raise an error when multiple rows attempt to save_to the same entity property.""" + # EV006 + md = """ + | survey | + | | type | name | label | save_to | + | | text | q1 | Q1 | e1#e1p1 | + | | text | q2 | Q2 | e1#e1p2 | + | | text | q3 | Q3 | e2#e1p1 | + | | text | q4 | Q4 | e2#e1p1 | + + | entities | + | | list_name | label | + | | e1 | ${q1} | + | | e2 | ${q3} | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ + # This verifies check scoping since otherwise the error would be row 4 + 1 + ErrorCode.ENTITY_002.value.format(row=5, saveto="e1p1", other_row=4) + ], + ) + + def test_container_row_has_save_to__begin_group__error(self): + """Should raise an error when a begin_group has a save_to value.""" + # EV007 + md = """ + | survey | + | | type | name | label | save_to | + | | begin_group | g1 | G1 | e1p1 | + | | text | q1 | Q1 | | + | | end_group | | | | + + | entities | + | | list_name | label | + | | e1 | ${q1} | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ErrorCode.ENTITY_003.value.format(row=2)], + ) + + def test_container_row_has_save_to__end_group__error(self): + """Should raise an error when a end_group has a save_to value.""" + # EV007 + md = """ + | survey | + | | type | name | label | save_to | + | | begin_group | g1 | G1 | | + | | text | q1 | Q1 | | + | | end_group | | | e1p1 | + + | entities | + | | list_name | label | + | | e1 | ${q1} | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ErrorCode.ENTITY_003.value.format(row=4)], + ) + + def test_container_row_has_save_to__begin_repeat__error(self): + """Should raise an error when a begin_repeat has a save_to value.""" + # EV007 + md = """ + | survey | + | | type | name | label | save_to | + | | begin_repeat | g1 | G1 | e1p1 | + | | text | q1 | Q1 | | + | | end_repeat | | | | + + | entities | + | | list_name | label | + | | e1 | ${q1} | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ErrorCode.ENTITY_003.value.format(row=2)], + ) + + def test_container_row_has_save_to__end_repeat__error(self): + """Should raise an error when a end_repeat has a save_to value.""" + # EV007 + md = """ + | survey | + | | type | name | label | save_to | + | | begin_repeat | g1 | G1 | | + | | text | q1 | Q1 | | + | | end_repeat | | | e1p1 | + + | entities | + | | list_name | label | + | | e1 | ${q1} | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ErrorCode.ENTITY_003.value.format(row=4)], + ) + + def test_container_as_entity_property__group__no_false_positive__ok(self): + """Should not raise an error when a valid type contains a container type.""" + # EV007 + md = """ + | survey | + | | type | name | label | save_to | + | | select_one {case} | q1 | Q1 | e1p1 | + + | choices | + | | list_name | name | label | + | | {case} | n1 | N1 | + + | entities | + | | list_name | label | + | | e1 | E1 | + """ + cases = ("group", "my_group1", "repeat", "my_repeat1") + for case in cases: + with self.subTest(msg=case): + self.assertPyxformXform(md=md.format(case=case), warnings_count=0) + + def test_missing_entity_declaration__error(self): + """Should raise an error if there are entities defined but not the one referenced.""" + # EV008 + md = """ + | survey | + | | type | name | label | save_to | + | | text | q1 | Q1 | e2#e1p1 | + + | entities | + | | list_name | label | + | | e1 | ${q1} | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ErrorCode.ENTITY_004.value.format(row=2, dataset="e2")], + ) + + def test_missing_entity_create_label__create_if_present__error(self): + """Should raise an error if an entity is in create mode but there is no label.""" + # EV009 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + + | entities | + | | list_name | + | | e1 | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ErrorCode.ENTITY_005.value.format(row=2, dataset="e1")], + ) + + def test_missing_entity_create_label__entity_id_not_present__error(self): + """Should raise an error if an entity is in create mode but there is no label.""" + # EV009 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + + | entities | + | | list_name | create_if | + | | e1 | ${q1} != '' | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ErrorCode.ENTITY_005.value.format(row=2, dataset="e1")], + ) + + def test_missing_entity_upsert_update_if__error(self): + """Should raise an error if an entity is in upsert mode but there is no update_if.""" + # EB008 EV010 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + + | entities | + | | list_name | create_if | entity_id | + | | e1 | ${q1} != '' | ${q1} | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ErrorCode.ENTITY_006.value.format(row=2, dataset="e1")], + ) + + def test_missing_entity_upsert_update_if__with_label__error(self): + """Should raise an error if an entity is in upsert mode but there is no update_if.""" + # EB008 EV010 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + + | entities | + | | list_name | label | create_if | entity_id | + | | e1 | ${q1} | ${q1} != '' | ${q1} | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ErrorCode.ENTITY_006.value.format(row=2, dataset="e1")], + ) + + def test_missing_entity_entity_id__update__error(self): + """Should raise an error if an entity is in update mode but there is no entity_id.""" + # EB003 EV011 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + + | entities | + | | list_name | update_if | + | | e1 | ${q1} != '' | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ErrorCode.ENTITY_007.value.format(row=2, dataset="e1")], + ) + + def test_missing_entity_entity_id__upsert__error(self): + """Should raise an error if an entity is in upsert mode but there is no entity_id.""" + # EB005 EV011 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + + | entities | + | | list_name | create_if | update_if | + | | e1 | ${q1} != '' | ${q1} != '' | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ErrorCode.ENTITY_007.value.format(row=2, dataset="e1")], + ) + + def test_missing_save_to_prefix__bad_row_first__error(self): + """Should raise an error if 2 or more entities but a save_to is not prefixed.""" + # EV012 + md = """ + | survey | + | | type | name | label | save_to | + | | text | q1 | Q1 | e1p1 | + | | text | q2 | Q2 | e2#e2p1 | + + | entities | + | | list_name | label | + | | e1 | ${q1} | + | | e2 | ${q2} | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ErrorCode.ENTITY_008.value.format(row=2)], + ) + + def test_missing_save_to_prefix__bad_row_second__error(self): + """Should raise an error if 2 or more entities but a save_to is not prefixed.""" + # EV012 + md = """ + | survey | + | | type | name | label | save_to | + | | text | q1 | Q1 | e1#e1p1 | + | | text | q2 | Q2 | e2p1 | + + | entities | + | | list_name | label | + | | e1 | ${q1} | + | | e2 | ${q2} | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ErrorCode.ENTITY_008.value.format(row=3)], + ) + + def test_unsolvable_meta_topology__depth_0__saveto_only__error(self): + """Should raise an error if there is no valid placement for the meta/entity block.""" + # EV014 + md = """ + | survey | + | | type | name | label | save_to | + | | text | q1 | Q1 | e1#e1p1 | + | | text | q2 | Q2 | e2#e2p1 | + + | entities | + | | list_name | label | + | | e1 | E1 | + | | e2 | E2 | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ + ErrorCode.ENTITY_009.value.format(row=3, scope="/survey"), + ], + ) + + def test_unsolvable_meta_topology__depth_0__var_only__error(self): + """Should raise an error if there is no valid placement for the meta/entity block.""" + # EV014 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + + | entities | + | | list_name | label | + | | e1 | ${q1} | + | | e2 | ${q1} | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ + ErrorCode.ENTITY_009.value.format(row=3, scope="/survey"), + ], + ) + + def test_unsolvable_meta_topology__depth_0__saveto_and_var__error(self): + """Should raise an error if there is no valid placement for the meta/entity block.""" + # EV014 + md = """ + | survey | + | | type | name | label | save_to | + | | text | q1 | Q1 | e1#e1p1 | + + | entities | + | | list_name | label | + | | e1 | E1 | + | | e2 | ${q1} | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ + ErrorCode.ENTITY_009.value.format(row=3, scope="/survey"), + ], + ) + + def test_save_to_scope_breach__depth_1_group__save_to_only__ok(self): + """Should not raise an error if an entity save_to is in more than one group.""" + # ES006 EV014 EV015 + md = """ + | survey | + | | type | name | label | save_to | + | | text | q1 | Q1 | e1#e1p1 | + | | begin_group | g1 | G1 | | + | | text | q2 | Q2 | e1#e1p2 | + | | end_group | g1 | | | + + | entities | + | | list_name | label | + | | e1 | E1 | + """ + self.assertPyxformXform(md=md, warnings_count=0) + + def test_save_to_scope_breach__depth_1_group__save_and_var__ok(self): + """Should not raise an error if an entity savsave_to/vare_to is in more than one group.""" + # ES006 EV014 EV015 + md = """ + | survey | + | | type | name | label | save_to | + | | text | q1 | Q1 | e1#e1p1 | + | | begin_group | g1 | G1 | | + | | text | q2 | Q2 | e1#e1p2 | + | | end_group | g1 | | | + | | text | q3 | Q3 | | + + | entities | + | | list_name | label | + | | e1 | concat(${q1}, " ", ${q2}, " ", ${q3}) | + """ + self.assertPyxformXform(md=md, warnings_count=0) + + def test_save_to_scope_breach__depth_1_group__var_only__ok(self): + """Should not raise an error if an entity var is in more than one group.""" + # ES006 EV014 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + | | begin_group | g1 | G1 | + | | text | q2 | Q2 | + | | end_group | g1 | | + | | text | q3 | Q3 | + + | entities | + | | list_name | label | + | | e1 | concat(${q1}, " ", ${q2}, " ", ${q3}) | + """ + self.assertPyxformXform(md=md, warnings_count=0) + + def test_unsolvable_meta_topology__depth_1_group__saveto_only__error(self): + """Should raise an error if there is no valid placement for the meta/entity block.""" + # EV014 + md = """ + | survey | + | | type | name | label | save_to | + | | begin_group | g1 | G1 | | + | | text | q1 | Q1 | e1#e1p1 | + | | text | q2 | Q2 | e2#e2p1 | + | | end_group | g1 | | | + + | entities | + | | list_name | label | + | | e1 | E1 | + | | e2 | E2 | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ + ErrorCode.ENTITY_009.value.format(row=3, scope="/survey"), + ], + ) + + def test_unsolvable_meta_topology__depth_1_group__var_only__error(self): + """Should raise an error if there is no valid placement for the meta/entity block.""" + # EV014 + md = """ + | survey | + | | type | name | label | + | | begin_group | g1 | G1 | + | | text | q1 | Q1 | + | | end_group | g1 | | + + | entities | + | | list_name | label | + | | e1 | ${q1} | + | | e2 | ${q1} | + | | e3 | ${q1} | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ + ErrorCode.ENTITY_009.value.format(row=4, scope="/survey"), + ], + ) + + def test_unsolvable_meta_topology__depth_1_group__saveto_and_var__error(self): + """Should raise an error if there is no valid placement for the meta/entity block.""" + # EV014 + md = """ + | survey | + | | type | name | label | save_to | + | | begin_group | g1 | G1 | | + | | text | q1 | Q1 | e1#e1p1 | + | | end_group | g1 | | | + + | entities | + | | list_name | label | + | | e1 | E1 | + | | e2 | ${q1} | + | | e3 | ${q1} | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ + ErrorCode.ENTITY_009.value.format(row=4, scope="/survey"), + ], + ) + + def test_unsolvable_meta_topology__depth_1_repeat__saveto_only__error(self): + """Should raise an error if there is no valid placement for the meta/entity block.""" + # EV014 + md = """ + | survey | + | | type | name | label | save_to | + | | begin_repeat | r1 | R1 | | + | | text | q1 | Q1 | e1#e1p1 | + | | text | q2 | Q2 | e2#e2p1 | + | | end_repeat | r1 | | | + + | entities | + | | list_name | label | + | | e1 | E1 | + | | e2 | E2 | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ + ErrorCode.ENTITY_009.value.format(row=3, scope="/survey/r1"), + ], + ) + + def test_unsolvable_meta_topology__depth_1_repeat__var_only__error(self): + """Should raise an error if there is no valid placement for the meta/entity block.""" + # EV014 + md = """ + | survey | + | | type | name | label | + | | begin_repeat | r1 | R1 | + | | text | q1 | Q1 | + | | end_repeat | r1 | | + + | entities | + | | list_name | label | + | | e1 | ${q1} | + | | e2 | ${q1} | + | | e3 | ${q1} | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ + ErrorCode.ENTITY_009.value.format(row=3, scope="/survey/r1"), + ], + ) + + def test_unsolvable_meta_topology__depth_1_repeat__saveto_and_var__error(self): + """Should raise an error if there is no valid placement for the meta/entity block.""" + # EV014 + md = """ + | survey | + | | type | name | label | save_to | + | | begin_repeat | r1 | R1 | | + | | text | q1 | Q1 | e1#e1p1 | + | | end_repeat | r1 | | | + + | entities | + | | list_name | label | + | | e1 | E1 | + | | e2 | ${q1} | + | | e3 | ${q1} | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ + ErrorCode.ENTITY_009.value.format(row=3, scope="/survey/r1"), + ], + ) + + def test_unsolvable_meta_topology__depth_1_repeat__error(self): + """Should raise an error if there is no valid placement for the meta/entity block.""" + # EV014 + md = """ + | survey | + | | type | name | label | + | | begin_repeat | r1 | R1 | + | | text | q1 | Q1 | + | | end_repeat | r1 | | + + | entities | + | | list_name | label | + | | e1 | ${q1} | + | | e2 | ${q1} | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ + ErrorCode.ENTITY_009.value.format(row=3, scope="/survey/r1"), + ], + ) + + def test_unsolvable_meta_topology__depth_2_group__error(self): + """Should raise an error if there is no valid placement for the meta/entity block.""" + # EV014 + md = """ + | survey | + | | type | name | label | + | | begin_repeat | r1 | R1 | + | | begin_group | g1 | G1 | + | | text | q1 | Q1 | + | | end_group | g1 | | + | | end_repeat | r1 | | + + | entities | + | | list_name | label | + | | e1 | ${q1} | + | | e2 | ${q1} | + | | e3 | ${q1} | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ + ErrorCode.ENTITY_009.value.format(row=4, scope="/survey/r1"), + ], + ) + + def test_save_to_scope_breach__depth_1_repeat__error(self): + """Should raise an error if an entity save_to is in more than one scope.""" + # ES006 EV015 + md = """ + | survey | + | | type | name | label | save_to | + | | text | q1 | Q1 | e1#e1p1 | + | | begin_repeat | r1 | R1 | | + | | text | q2 | Q2 | e1#e1p2 | + | | end_repeat | r1 | | | + + | entities | + | | list_name | label | + | | e1 | E1 | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ + ErrorCode.ENTITY_011.value.format( + row=2, dataset="e1", other_scope="/survey/r1", scope="/survey" + ), + ], + ) + + def test_save_to_scope_breach__depth_1_repeat__ancestor_var__ok(self): + """Should not raise an error if an ancestor variable is outside the repeat scope.""" + # ES006 EV015 + md = """ + | survey | + | | type | name | label | save_to | + | | text | q1 | Q1 | | + | | begin_repeat | r1 | R1 | | + | | text | q2 | Q2 | e1#e1p1 | + | | end_repeat | r1 | | | + + | entities | + | | list_name | label | + | | e1 | ${q1} | + """ + self.assertPyxformXform(md=md, warnings_count=0) + + def test_save_to_scope_breach__depth_1_repeat__save_to_only__ok(self): + """Should not raise an error if an entity save_to is in more than one group.""" + # ES006 EV014 EV015 + md = """ + | survey | + | | type | name | label | save_to | + | | begin_repeat | r1 | R1 | | + | | text | q1 | Q1 | e1#e1p1 | + | | begin_group | g1 | G1 | | + | | text | q2 | Q2 | e1#e1p2 | + | | end_group | g1 | | | + | | end_repeat | r1 | | | + + | entities | + | | list_name | label | + | | e1 | E1 | + """ + self.assertPyxformXform(md=md, warnings_count=0) + + def test_save_to_scope_breach__depth_1_repeat__save_and_var__ok(self): + """Should not raise an error if an entity save_to/var is in more than one group.""" + # ES006 EV014 EV015 + md = """ + | survey | + | | type | name | label | save_to | + | | begin_repeat | r1 | R1 | | + | | text | q1 | Q1 | e1#e1p1 | + | | begin_group | g1 | G1 | | + | | text | q2 | Q2 | e1#e1p2 | + | | end_group | g1 | | | + | | text | q3 | Q3 | | + | | end_repeat | r1 | | | + + | entities | + | | list_name | label | + | | e1 | concat(${q1}, " ", ${q2}, " ", ${q3}) | + """ + self.assertPyxformXform(md=md, warnings_count=0) + + def test_save_to_scope_breach__depth_1_repeat__var_only__ok(self): + """Should not raise an error if an entity var is in more than one group.""" + # ES006 EV014 + md = """ + | survey | + | | type | name | label | + | | begin_repeat | r1 | R1 | + | | text | q1 | Q1 | + | | begin_group | g1 | G1 | + | | text | q2 | Q2 | + | | end_group | g1 | | + | | text | q3 | Q3 | + | | end_repeat | r1 | | + + | entities | + | | list_name | label | + | | e1 | concat(${q1}, " ", ${q2}, " ", ${q3}) | + """ + self.assertPyxformXform(md=md, warnings_count=0) + + def test_save_to_scope_breach__depth_1_repeat__multiple_lists__ok(self): + """Should not raise an error if different entity save_tos are in different scopes.""" + # ES006 EV015 + md = """ + | survey | + | | type | name | label | save_to | + | | text | q1 | Q1 | e1#e1p1 | + | | begin_repeat | r1 | R1 | | + | | text | q2 | Q2 | e2#e1p1 | + | | end_repeat | r1 | | | + + | entities | + | | list_name | label | + | | e1 | E1 | + | | e2 | E2 | + """ + self.assertPyxformXform(md=md, warnings_count=0) + + def test_save_to_scope_breach__depth_1_group__multiple_lists__ok(self): + """Should not raise an error if different entity save_tos are in different scopes.""" + # ES006 EV015 + md = """ + | survey | + | | type | name | label | save_to | + | | text | q1 | Q1 | e1#e1p1 | + | | begin_group | g1 | G1 | | + | | text | q2 | Q2 | e2#e1p1 | + | | end_group | g1 | | | + + | entities | + | | list_name | label | + | | e1 | E1 | + | | e2 | E2 | + """ + self.assertPyxformXform(md=md, warnings_count=0) + + def test_save_to_scope_breach__depth_1_repeat__same_scope__ok(self): + """Should not raise an error if an entity has multiple save_tos in one scope.""" + # ES006 EV015 + md = """ + | survey | + | | type | name | label | save_to | + | | begin_repeat | r1 | R1 | | + | | text | q1 | Q1 | e1#e1p1 | + | | text | q2 | Q2 | e1#e1p2 | + | | end_repeat | r1 | | | + + | entities | + | | list_name | label | + | | e1 | E1 | + """ + self.assertPyxformXform(md=md, warnings_count=0) + + def test_save_to_scope_breach__depth_1_group__same_scope__ok(self): + """Should not raise an error if an entity has multiple save_tos in one scope.""" + # ES006 EV015 + md = """ + | survey | + | | type | name | label | save_to | + | | begin_group | g1 | G1 | | + | | text | q1 | Q1 | e1#e1p1 | + | | text | q2 | Q2 | e1#e1p2 | + | | end_group | g1 | | | + + | entities | + | | list_name | label | + | | e1 | E1 | + """ + self.assertPyxformXform(md=md, warnings_count=0) + + def test_save_to_scope_breach__depth_2_repeat__error(self): + """Should raise an error if an entity save_to is in more than one nested scope.""" + # ES006 EV015 + md = """ + | survey | + | | type | name | label | save_to | + | | text | q1 | Q1 | | + | | begin_repeat | r1 | R1 | | + | | text | q2 | Q2 | e1#e1p1 | + | | begin_repeat | r2 | R2 | | + | | text | q3 | Q3 | e1#e1p2 | + | | end_repeat | r2 | | | + | | end_repeat | r1 | | | + + | entities | + | | list_name | label | + | | e1 | E1 | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ + ErrorCode.ENTITY_011.value.format( + row=4, + dataset="e1", + other_scope="/survey/r1/r2", + scope="/survey/r1", + ), + ], + ) + + def test_save_to_scope_breach__depth_2_group__save_to_only__ok(self): + """Should not raise an error if an entity save_to is in more than one group.""" + # ES006 EV014 EV015 + md = """ + | survey | + | | type | name | label | save_to | + | | begin_group | g1 | G1 | | + | | begin_repeat | r1 | R1 | | + | | text | q1 | Q1 | e1#e1p1 | + | | begin_group | g2 | G2 | | + | | text | q2 | Q2 | e1#e1p2 | + | | end_group | g2 | | | + | | end_repeat | r1 | | | + | | end_group | g1 | | | + + | entities | + | | list_name | label | + | | e1 | E1 | + """ + self.assertPyxformXform(md=md, warnings_count=0) + + def test_save_to_scope_breach__depth_2_group__save_and_var__ok(self): + """Should not raise an error if an entity save_to/var is in more than one group.""" + # ES006 EV014 EV015 + md = """ + | survey | + | | type | name | label | save_to | + | | begin_group | g1 | G1 | | + | | begin_repeat | r1 | R1 | | + | | text | q1 | Q1 | e1#e1p1 | + | | begin_group | g2 | G2 | | + | | text | q2 | Q2 | e1#e1p2 | + | | end_group | g2 | | | + | | text | q3 | Q3 | | + | | end_repeat | r1 | | | + | | end_group | g1 | | | + + | entities | + | | list_name | label | + | | e1 | concat(${q1}, " ", ${q2}, " ", ${q3}) | + """ + self.assertPyxformXform(md=md, warnings_count=0) + + def test_save_to_scope_breach__depth_2_group__var_only__ok(self): + """Should not raise an error if an entity var is in more than one group.""" + # ES006 EV014 + md = """ + | survey | + | | type | name | label | + | | begin_group | g1 | G1 | + | | begin_repeat | r1 | R1 | + | | text | q1 | Q1 | + | | begin_group | g2 | G2 | + | | text | q2 | Q2 | + | | end_group | g2 | | + | | text | q3 | Q3 | + | | end_repeat | r1 | | + | | end_group | g1 | | + + | entities | + | | list_name | label | + | | e1 | concat(${q1}, " ", ${q2}, " ", ${q3}) | + """ + self.assertPyxformXform(md=md, warnings_count=0) + + def test_ref_scope_conflict__depth_1_sibling_repeat__saveto_only__error(self): + """Should raise an error if an entity save_to is in more than one scope lineage.""" + # EV016 + md = """ + | survey | + | | type | name | label | save_to | + | | text | q1 | Q1 | | + | | begin_repeat | r1 | R1 | | + | | text | q2 | Q2 | e1#e1p1 | + | | end_repeat | r1 | | | + | | begin_repeat | r2 | R2 | | + | | text | q3 | Q3 | e1#e1p2 | + | | end_repeat | r2 | | | + + | entities | + | | list_name | label | + | | e1 | E1 | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ + ErrorCode.ENTITY_011.value.format( + row=7, dataset="e1", other_scope="/survey/r1", scope="/survey/r2" + ), + ], + ) + + def test_ref_scope_conflict__depth_1_sibling_repeat__var_then_saveto__error(self): + """Should raise an error if an entity has references in more than one scope lineage.""" + # EV016 + md = """ + | survey | + | | type | name | label | save_to | + | | text | q1 | Q1 | | + | | begin_repeat | r1 | R1 | | + | | text | q2 | Q2 | | + | | end_repeat | r1 | | | + | | begin_repeat | r2 | R2 | | + | | text | q3 | Q3 | e1#e1p1 | + | | end_repeat | r2 | | | + + | entities | + | | list_name | label | + | | e1 | ${q2} | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ + ErrorCode.ENTITY_011.value.format( + row=7, dataset="e1", other_scope="/survey/r1", scope="/survey/r2" + ), + ], + ) + + def test_ref_scope_conflict__depth_1_sibling_repeat__saveto_then_var__error(self): + """Should raise an error if an entity has references in more than one scope lineage.""" + # EV016 + md = """ + | survey | + | | type | name | label | save_to | + | | text | q1 | Q1 | | + | | begin_repeat | r1 | R1 | | + | | text | q2 | Q2 | e1#e1p1 | + | | end_repeat | r1 | | | + | | begin_repeat | r2 | R2 | | + | | text | q3 | Q3 | | + | | end_repeat | r2 | | | + + | entities | + | | list_name | label | + | | e1 | ${q3} | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ + ErrorCode.ENTITY_012.value.format( + row=2, + dataset="e1", + other_scope="/survey/r1", + scope="/survey/r2", + question="q3", + ), + ], + ) + + def test_ref_scope_conflict__depth_1_sibling_repeat__var_only__error(self): + """Should raise an error if an entity has references in more than one scope lineage.""" + # EV016 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + | | begin_repeat | r1 | R1 | + | | text | q2 | Q2 | + | | end_repeat | r1 | | + | | begin_repeat | r2 | R2 | + | | text | q3 | Q3 | + | | end_repeat | r2 | | + + | entities | + | | list_name | label | + | | e1 | concat(${q2}, " ", ${q3}) | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ + ErrorCode.ENTITY_012.value.format( + row=2, + dataset="e1", + other_scope="/survey/r1", + scope="/survey/r2", + question="q3", + ), + ], + ) + + def test_ref_scope_conflict__depth_2_asymmetric_lineage__saveto_only__error(self): + """Should raise an error if an entity has references in more than one scope lineage.""" + # EV016 + md = """ + | survey | + | | type | name | label | save_to | + | | text | q1 | Q1 | | + | | begin_repeat | r1 | R1 | | + | | text | q2 | Q2 | e1#e1p1 | + | | end_repeat | r1 | | | + | | begin_repeat | r2 | R2 | | + | | begin_repeat | r3 | R3 | | + | | text | q3 | Q3 | e1#e1p2 | + | | end_repeat | r3 | | | + | | end_repeat | r2 | | | + + | entities | + | | list_name | label | + | | e1 | ${q3} | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ + ErrorCode.ENTITY_011.value.format( + row=4, dataset="e1", other_scope="/survey/r2/r3", scope="/survey/r1" + ), + ], + ) + + def test_ref_scope_conflict__depth_2_asymmetric_lineage__var_then_saveto__error(self): + """Should raise an error if an entity has references in more than one scope lineage.""" + # EV016 + md = """ + | survey | + | | type | name | label | save_to | + | | text | q1 | Q1 | | + | | begin_repeat | r1 | R1 | | + | | text | q2 | Q2 | | + | | end_repeat | r1 | | | + | | begin_repeat | r2 | R2 | | + | | begin_repeat | r3 | R3 | | + | | text | q3 | Q3 | e1#e1p1 | + | | end_repeat | r3 | | | + | | end_repeat | r2 | | | + + | entities | + | | list_name | label | + | | e1 | ${q2} | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ + ErrorCode.ENTITY_012.value.format( + row=2, + dataset="e1", + other_scope="/survey/r2/r3", + scope="/survey/r1", + question="q2", + ), + ], + ) + + def test_ref_scope_conflict__depth_2_asymmetric_lineage__saveto_then_var__error(self): + """Should raise an error if an entity has references in more than one scope lineage.""" + # EV016 + md = """ + | survey | + | | type | name | label | save_to | + | | text | q1 | Q1 | | + | | begin_repeat | r1 | R1 | | + | | text | q2 | Q2 | e1#e1p1 | + | | end_repeat | r1 | | | + | | begin_repeat | r2 | R2 | | + | | begin_repeat | r3 | R3 | | + | | text | q3 | Q3 | | + | | end_repeat | r3 | | | + | | end_repeat | r2 | | | + + | entities | + | | list_name | label | + | | e1 | ${q3} | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ + ErrorCode.ENTITY_011.value.format( + row=4, + dataset="e1", + other_scope="/survey/r2/r3", + scope="/survey/r1", + question="q3", + ), + ], + ) + + def test_ref_scope_conflict__depth_2_asymmetric_lineage__var_only__error(self): + """Should raise an error if an entity has references in more than one scope lineage.""" + # EV016 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + | | begin_repeat | r1 | R1 | + | | text | q2 | Q2 | + | | end_repeat | r1 | | + | | begin_repeat | r2 | R2 | + | | begin_repeat | r3 | R3 | + | | text | q3 | Q3 | + | | end_repeat | r3 | | + | | end_repeat | r2 | | + + | entities | + | | list_name | label | + | | e1 | concat(${q2}, " ", ${q3}) | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ + ErrorCode.ENTITY_012.value.format( + row=2, + dataset="e1", + other_scope="/survey/r2/r3", + scope="/survey/r1", + question="q2", + ), + ], + ) + + def test_duplicate_save_to_delimiter__error(self): + """Should raise an error if a save_to has mutliple delimiters.""" + # EV017 + md = """ + | survey | + | | type | name | label | save_to | + | | text | q1 | Q1 | {case} | + + | entities | + | | list_name | label | + | | e1 | E1 | + """ + cases = ("e1##e1p1", "e1#e1#p1", "##e1p1") + for case in cases: + with self.subTest(msg=case): + self.assertPyxformXform( + md=md.format(case=case), + errored=True, + error__contains=[ErrorCode.ENTITY_013.value.format(row=2)], + ) + + def test_dataset_name__xml_identifier__error(self): + """Should raise an error if the dataset name is not a XML identifier.""" + # ES003 EV018 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + + | entities | + | | list_name | label | + | | $e1 | E1 | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ + ErrorCode.NAMES_008.value.format( + sheet=co.ENTITIES, row=2, column=co.EntityColumns.DATASET.value + ) + ], + ) + + def test_dataset_name__period__error(self): + """Should raise an error if the dataset name contains a period.""" + # ES003 EV019 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + + | entities | + | | list_name | label | + | | e.1 | E1 | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ + ErrorCode.NAMES_011.value.format( + sheet=co.ENTITIES, row=2, column=co.EntityColumns.DATASET.value + ) + ], + ) + + def test_dataset_name__reserved_prefix__error(self): + """Should raise an error if the dataset name has the reserved prefix.""" + # ES003 EV020 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + + | entities | + | | list_name | label | + | | __e1 | E1 | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ + ErrorCode.NAMES_010.value.format( + sheet=co.ENTITIES, row=2, column=co.EntityColumns.DATASET.value + ) + ], + ) + + def test_dataset_name__missing__error(self): + """Should raise an error if the dataset name is missing.""" + # ES003 EV024 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + + | entities | + | | list_name | label | + | | | E1 | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ErrorCode.NAMES_015.value.format(row=2)], + ) + + def test_dataset_name__missing_multiple__error(self): + """Should raise an error if the dataset name is missing.""" + # ES003 EV024 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + + | entities | + | | list_name | label | + | | e1 | E1 | + | | | E2 | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ErrorCode.NAMES_015.value.format(row=3)], + ) + + def test_save_to_name__xml_identifier__error(self): + """Should raise an error if the save_to name is not a XML identifier.""" + # ES005 EV021 + md = """ + | survey | + | | type | name | label | save_to | + | | text | q1 | Q1 | {case} | + + | entities | + | | list_name | label | + | | e1 | E1 | + """ + cases = ("$e1p1", "e1#", "e1#$e1p1") + for case in cases: + with self.subTest(msg=case): + self.assertPyxformXform( + md=md.format(case=case), + errored=True, + error__contains=[ + ErrorCode.NAMES_008.value.format( + sheet=co.SURVEY, row=2, column=co.ENTITIES_SAVETO + ) + ], + ) + + def test_save_to_name__reserved_name__error(self): + """Should raise an error if the save_to name is a reserved name.""" + # ES005 EV022 + md = """ + | survey | + | | type | name | label | save_to | + | | text | q1 | Q1 | {case} | + + | entities | + | | list_name | label | + | | e1 | E1 | + """ + cases = ("name", "naMe", "label", "lAbEl") + for case in cases: + with self.subTest(msg=case): + self.assertPyxformXform( + md=md.format(case=case), + errored=True, + error__contains=[ + ErrorCode.NAMES_012.value.format( + sheet=co.SURVEY, row=2, column=co.ENTITIES_SAVETO + ) + ], + ) + + def test_save_to_name__reserved_prefix__error(self): + """Should raise an error if the save_to name has the reserved prefix.""" + # ES005 EV023 + md = """ + | survey | + | | type | name | label | save_to | + | | text | q1 | Q1 | __e1p1 | + + | entities | + | | list_name | label | + | | e1 | E1 | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ + ErrorCode.NAMES_010.value.format( + sheet=co.SURVEY, row=2, column=co.ENTITIES_SAVETO + ) + ], + ) + + def test_list_name_or_dataset_alias__error(self): + """Should be possible to provide the entity name as 'list_name' or 'dataset'.""" + # EB001 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + + | entities | + | | {case} | label | + | | e1 | ${{q1}} | + """ + cases = ("list_name", "dataset") + for case in cases: + with self.subTest(msg=case): + self.assertPyxformXform(md=md.format(case=case), warnings_count=0) + + def test_no_allocations__single_entity__ok(self): + """Should not raise an error if a single entity with no references exists.""" + # EV014 EB020 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + + | entities | + | | list_name | label | + | | e1 | E1 | + """ + self.assertPyxformXform(md=md, warnings_count=0) + + def test_no_allocations__multiple_entity__error(self): + """Should raise an error if a multiple entities with no references exist.""" + # EV014 EB010 EB020 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + + | entities | + | | list_name | label | + | | e1 | E1 | + | | e2 | E2 | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ErrorCode.ENTITY_009.value.format(row=3, scope="/survey")], + ) + + def test_no_allocations__multiple_entity__survey_target__error(self): + """Should raise an error if a multiple entities with no references exist.""" + # EV014 EB010 EB020 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + + | entities | + | | list_name | label | + | | e1 | E1 | + | | e2 | ${q1} | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ErrorCode.ENTITY_009.value.format(row=3, scope="/survey")], + ) + + def test_no_allocations__multiple_entity__survey_target_dispersed__error(self): + """Should raise an error if a multiple entities with no references exist.""" + # EV014 EB010 EB020 EB022 + md = """ + | survey | + | | type | name | label | + | | begin_group | g1 | G1 | + | | text | q1 | Q1 | + | | end_group | g1 | | + + | entities | + | | list_name | label | + | | e1 | E1 | + | | e2 | ${q1} | + | | e3 | E3 | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ErrorCode.ENTITY_009.value.format(row=4, scope="/survey")], + ) + + def test_no_allocations__multiple_entity__no_sibling_search__error(self): + """Should raise an error if a multiple entities with no references exist.""" + # EV014 EB020 EB022 + md = """ + | survey | + | | type | name | label | + | | begin_group | g1 | G1 | + | | text | q1 | Q1 | + | | end_group | g1 | | + + | entities | + | | list_name | label | + | | e1 | E1 | + | | e2 | E2 | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ErrorCode.ENTITY_009.value.format(row=3, scope="/survey")], + ) + + +class TestEntitiesOutput(PyxformTestCase): + def test_namespace__entities_not_used__not_exists(self): + """Should not find the entities namespace definition when entities not used.""" + # ES001 EB017 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + """ + self.assertPyxformXform( + md=md, + xml__excludes=['xmlns:entities="http://www.opendatakit.org/xforms/entities"'], + ) + + def test_namespace__entities_used__exists(self): + """Should find namespace definition in XForm when entities used.""" + # ES001 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + + | entities | + | | list_name | label | + | | e1 | E1 | + """ + self.assertPyxformXform( + md=md, + xml__contains=['xmlns:entities="http://www.opendatakit.org/xforms/entities"'], + ) + + def test_namespace__used_outside_main_instance(self): + """Should find namespace prefix is used outside of the main instance.""" + # ES001 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + + | entities | + | | list_name | label | + | | e1 | E1 | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_entities_version(co.EntityVersion.v2024_1_0.value), + ], + ) + + def test_namespace__not_used_in_main_instance(self): + """Should find namespace prefix not used for additions to the main instance.""" + # ES001 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + + | entities | + | | list_name | label | + | | e1 | E1 | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + """/h:html/h:head/x:model/x:instance/x:test_name/x:meta/x:entity""" + ], + ) + + def test_version__not_entities__not_exists(self): + """Should not find the entities version when entities not used.""" + # ES002 EB018 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[xpe.model_no_entities_version()], + ) + + def test_version__2024_1_0(self): + """Should find that forms using compatible features are 2024.1.0.""" + # ES002 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + + | entities | + | | list_name | label | + | | e1 | E1 | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_entities_version(co.EntityVersion.v2024_1_0.value), + ], + ) + + def test_version__2025_1_0(self): + """Should find that forms using compatible features are 2025.1.0.""" + # ES002 + md = """ + | survey | + | | type | name | label | save_to | + | | begin_repeat | r1 | R1 | | + | | text | q1 | Q1 | e1p1 | + | | end_repeat | | | | + + | entities | + | | list_name | label | + | | e1 | E1 | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_entities_version(co.EntityVersion.v2025_1_0.value), + ], + ) + + def test_create__container_survey__child_of_meta(self): + """Should find that the entity element is a direct child of meta.""" + # ES003 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + + | entities | + | | list_name | label | + | | e1 | E1 | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[xps.instance_meta_survey_element(name="entity")], + ) + + def test_create__container_survey__child_of_meta__other_settings(self): + """Should find that the meta for an unreferenced entity works with settings.""" + # ES003 EB021 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + + | entities | + | | list_name | label | + | | e1 | E1 | + + | settings | + | | instance_name | + | | my_form | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xps.instance_meta_survey_element(name="entity"), + xps.instance_meta_survey_element(name="instanceName"), + ], + ) + + def test_create__container_survey__id_attribute__exists(self): + """Should find that the entity element has an 'id' attribute.""" + # ES003 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + + | entities | + | | list_name | label | + | | e1 | E1 | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + """/h:html/h:head/x:model/x:instance/x:test_name/x:meta/x:entity[@id]""" + ], + ) + + def test_create__container_survey__id_attribute__has_uuid(self): + """Should find that the entity element has an 'id' with uuid.""" + # ES003 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + + | entities | + | | list_name | label | + | | e1 | E1 | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[xpe.model_setvalue_meta_id()], + ) + + def test_create__container_survey__dataset_attribute__exists(self): + """Should find that the entity element has an 'dataset' attribute.""" + # ES003 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + + | entities | + | | list_name | label | + | | e1 | E1 | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + """/h:html/h:head/x:model/x:instance/x:test_name/x:meta/x:entity[@dataset]""" + ], + ) + + def test_create__container_survey__dataset_attribute__has_dataset(self): + """Should find that the entity element has an 'dataset' attribute with name.""" + # ES003 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + + | entities | + | | list_name | label | + | | e1 | E1 | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + """/h:html/h:head/x:model/x:instance/x:test_name/x:meta/x:entity[@dataset='e1']""" + ], + ) + + def test_implicit_create_mode__survey(self): + """Should find that when no entity_id is provided, the entity is in create mode.""" + # ES003 EB002 EB013 EB014 EB019 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + + | entities | + | | list_name | label | + | | e1 | E1 | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_no_instance_csv("e1"), + xpe.model_bind_meta_instanceid(), + xpe.model_instance_meta("e1", create=True, label=True), + xpe.model_bind_meta_label("E1"), + xpe.model_bind_meta_id(), + xpe.model_setvalue_meta_id(), + ], + xml__xpath_count=[ + ("/h:html//x:setvalue", 1), + ], + ) + + def test_implicit_create_mode__repeat(self): + """Should find that when no entity_id is provided, the entity is in create mode.""" + # ES003 EB002 EB013 EB014 EB015 EB016 EB019 + md = """ + | survey | + | | type | name | label | + | | begin_repeat | r1 | R1 | + | | text | q1 | Q1 | + | | end_repeat | r1 | | + + | entities | + | | list_name | label | + | | e1 | ${q1} | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_no_instance_csv("e1"), + xpe.model_bind_meta_instanceid(), + xpe.model_instance_meta( + "e1", "/x:r1", repeat=True, create=True, label=True + ), + xpe.model_bind_meta_label(" ../../../q1 ", "/r1"), + xpe.model_bind_meta_id(meta_path="/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_implicit_create_mode__create_if__survey(self): + """Should find that when no entity_id is provided, the entity is in create mode.""" + # ES003 EB004 EB013 EB014 EB015 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + + | entities | + | | list_name | label | create_if | + | | e1 | E1 | ${q1} = '' | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_bind_meta_instanceid(), + xpe.model_instance_meta("e1", create=True, label=True), + xpe.model_bind_meta_label("E1"), + xpe.model_bind_meta_id(), + xpe.model_setvalue_meta_id(), + xpe.model_bind_meta_create(" /test_name/q1 = ''"), + ], + xml__xpath_count=[ + ("/h:html//x:setvalue", 1), + ], + ) + + def test_implicit_create_mode__create_if__repeat(self): + """Should find that when no entity_id is provided, the entity is in create mode.""" + # ES003 EB004 EB013 EB014 EB015 EB016 + md = """ + | survey | + | | type | name | label | + | | begin_repeat | r1 | R1 | + | | text | q1 | Q1 | + | | end_repeat | r1 | | + + | entities | + | | list_name | label | create_if | + | | e1 | ${q1} | ${q1} = '' | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_bind_meta_instanceid(), + xpe.model_instance_meta( + "e1", "/x:r1", repeat=True, create=True, label=True + ), + xpe.model_bind_meta_label(" ../../../q1 ", "/r1"), + xpe.model_bind_meta_id(meta_path="/r1"), + xpe.model_setvalue_meta_id("/r1"), + xpe.body_repeat_setvalue_meta_id( + "/x:group/x:repeat[@nodeset='/test_name/r1']", "/r1" + ), + xpe.model_bind_meta_create(" ../../../q1 = ''", "/r1"), + ], + xml__xpath_count=[ + ("/h:html//x:setvalue", 2), + ], + ) + + def test_implicit_update_mode__instance_required__error(self): + """Should find that when an update mode, an instance for the entity is required.""" + # ES004 EB006 EB012 EB014 EB015 EB019 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + + | entities | + | | list_name | entity_id | + | | e1 | ${q1} | + """ + self.assertPyxformXform( + md=md, + run_odk_validate=True, + odk_validate_error__contains=[ + "Error evaluating field", + "The problem was located in Calculate expression for ${entity}", + "XPath evaluation: Instance referenced by instance(e1)/root", + "does not exist", + ], + ) + + def test_implicit_update_mode__entity_id__survey(self): + """Should find that when an entity_id is provided, the entity is in update mode.""" + # ES004 EB006 EB012 EB014 EB015 EB019 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + | | csv-external | e1 | | + + | entities | + | | list_name | entity_id | + | | e1 | ${q1} | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_bind_meta_instanceid(), + xpe.model_instance_meta("e1", update=True), + xpe.model_bind_meta_id(" /test_name/q1 "), + xpe.model_bind_meta_baseversion("e1", "/test_name/q1"), + xpe.model_bind_meta_trunkversion("e1", "/test_name/q1"), + xpe.model_bind_meta_branchid("e1", "/test_name/q1"), + ], + xml__xpath_count=[ + ("/h:html//x:setvalue", 0), + ], + ) + + def test_implicit_update_mode__entity_id__repeat(self): + """Should find that when an entity_id is provided, the entity is in update mode.""" + # ES004 EB006 EB012 EB014 EB015 EB016 EB019 + md = """ + | survey | + | | type | name | label | + | | begin_repeat | r1 | R1 | + | | text | q1 | Q1 | + | | end_repeat | r1 | | + | | csv-external | e1 | | + + | entities | + | | list_name | entity_id | + | | e1 | ${q1} | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_bind_meta_instanceid(), + xpe.model_instance_meta("e1", "/x:r1", repeat=True, update=True), + xpe.model_instance_meta( + "e1", "/x:r1", repeat=True, template=True, update=True + ), + 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"), + ], + xml__xpath_count=[ + ("/h:html//x:setvalue", 0), + ], + ) + + def test_implicit_update_mode__entity_id__with_csv_instance__survey(self): + """Should find that when an entity_id is provided, the entity is in update mode.""" + # ES004 EB006 EB012 EB014 EB015 EB019 + md = """ + | survey | + | | type | name | label | + | | csv-external | e1 | | + | | text | q1 | Q1 | + + | entities | + | | list_name | entity_id | + | | e1 | ${q1} | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_instance_csv("e1"), + xpe.model_bind_meta_instanceid(), + xpe.model_instance_meta("e1", update=True), + xpe.model_bind_meta_id(" /test_name/q1 "), + xpe.model_bind_meta_baseversion("e1", "/test_name/q1"), + xpe.model_bind_meta_trunkversion("e1", "/test_name/q1"), + xpe.model_bind_meta_branchid("e1", "/test_name/q1"), + ], + xml__xpath_count=[ + ("/h:html//x:setvalue", 0), + ], + ) + + def test_implicit_update_mode__entity_id__with_csv_instance__repeat(self): + """Should find that when an entity_id is provided, the entity is in update mode.""" + # ES004 EB006 EB012 EB014 EB015 EB016 EB019 + md = """ + | survey | + | | type | name | label | + | | csv-external | e1 | | + | | begin_repeat | r1 | R1 | + | | text | q1 | Q1 | + | | end_repeat | r1 | | + + | entities | + | | list_name | entity_id | + | | e1 | ${q1} | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_instance_csv("e1"), + xpe.model_bind_meta_instanceid(), + xpe.model_instance_meta("e1", "/x:r1", repeat=True, update=True), + xpe.model_instance_meta( + "e1", "/x:r1", repeat=True, template=True, update=True + ), + 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"), + ], + xml__xpath_count=[ + ("/h:html//x:setvalue", 0), + ], + ) + + def test_implicit_update_mode__entity_id__with_label__survey(self): + """Should find that when an entity_id is provided, the entity is in update mode.""" + # ES004 EB006 EB012 EB014 EB015 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + | | csv-external | e1 | | + + | entities | + | | list_name | entity_id | label | + | | e1 | ${q1} | E1 | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_bind_meta_instanceid(), + xpe.model_instance_meta("e1", update=True, label=True), + xpe.model_bind_meta_id(" /test_name/q1 "), + xpe.model_bind_meta_baseversion("e1", "/test_name/q1"), + xpe.model_bind_meta_trunkversion("e1", "/test_name/q1"), + xpe.model_bind_meta_branchid("e1", "/test_name/q1"), + xpe.model_bind_meta_label("E1"), + ], + xml__xpath_count=[ + ("/h:html//x:setvalue", 0), + ], + ) + + def test_implicit_update_mode__entity_id__with_label__repeat(self): + """Should find that when an entity_id is provided, the entity is in update mode.""" + # ES004 EB006 EB012 EB014 EB015 EB016 + md = """ + | survey | + | | type | name | label | + | | begin_repeat | r1 | R1 | + | | text | q1 | Q1 | + | | end_repeat | r1 | | + | | csv-external | e1 | | + + | entities | + | | list_name | entity_id | label | + | | e1 | ${q1} | E1 | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_bind_meta_instanceid(), + xpe.model_instance_meta( + "e1", "/x:r1", repeat=True, update=True, label=True + ), + xpe.model_instance_meta( + "e1", "/x:r1", repeat=True, template=True, update=True, label=True + ), + 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("E1", "/r1"), + ], + xml__xpath_count=[ + ("/h:html//x:setvalue", 0), + ], + ) + + def test_implicit_update_mode__entity_id__with_other_setvalue__survey(self): + """Should find that when an entity_id is provided, the entity is in update mode.""" + # ES004 EB006 EB012 EB014 EB015 + md = """ + | survey | + | | type | name | label | default | + | | text | q1 | Q1 | uuid() | + | | csv-external | e1 | | | + + | entities | + | | list_name | entity_id | + | | e1 | ${q1} | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_bind_meta_instanceid(), + xpe.model_instance_meta("e1", update=True), + xpe.model_bind_meta_id(" /test_name/q1 "), + xpe.model_bind_meta_baseversion("e1", "/test_name/q1"), + xpe.model_bind_meta_trunkversion("e1", "/test_name/q1"), + xpe.model_bind_meta_branchid("e1", "/test_name/q1"), + xpq.setvalue( + "h:head/x:model", "/test_name/q1", "odk-instance-first-load", "uuid()" + ), + ], + xml__xpath_count=[ + ("/h:html//x:setvalue", 1), + ], + ) + + def test_implicit_update_mode__entity_id__with_other_setvalue__repeat(self): + """Should find that when an entity_id is provided, the entity is in update mode.""" + # ES004 EB006 EB012 EB014 EB015 EB016 + md = """ + | survey | + | | type | name | label | default | + | | begin_repeat | r1 | R1 | | + | | text | q1 | Q1 | uuid() | + | | end_repeat | r1 | | | + | | csv-external | e1 | | | + + | entities | + | | list_name | entity_id | + | | e1 | ${q1} | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_bind_meta_instanceid(), + xpe.model_instance_meta("e1", "/x:r1", repeat=True, update=True), + xpe.model_instance_meta( + "e1", "/x:r1", repeat=True, template=True, update=True + ), + 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"), + xpq.setvalue( + "h:head/x:model", + "/test_name/r1/q1", + "odk-instance-first-load", + "uuid()", + ), + xpq.setvalue( + "h:body/x:group/x:repeat[@nodeset='/test_name/r1']", + "/test_name/r1/q1", + "odk-new-repeat", + "uuid()", + ), + ], + xml__xpath_count=[ + ("/h:html//x:setvalue", 2), + ], + ) + + def test_implicit_update_mode__update_if__survey(self): + """Should find that when an update_if is provided, the condition is emitted.""" + # ES004 EB007 EB012 EB014 EB015 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + | | csv-external | e1 | | + + | entities | + | | list_name | entity_id | update_if | + | | e1 | ${q1} | ${q1} != '' | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_bind_meta_instanceid(), + xpe.model_instance_meta("e1", update=True), + xpe.model_bind_meta_id(" /test_name/q1 "), + xpe.model_bind_meta_baseversion("e1", "/test_name/q1"), + xpe.model_bind_meta_trunkversion("e1", "/test_name/q1"), + xpe.model_bind_meta_branchid("e1", "/test_name/q1"), + xpe.model_bind_meta_update(" /test_name/q1 != ''"), + ], + xml__xpath_count=[ + ("/h:html//x:setvalue", 0), + ], + ) + + def test_implicit_update_mode__update_if__repeat(self): + """Should find that when an update_if is provided, the condition is emitted.""" + # ES004 EB007 EB012 EB014 EB015 EB016 + md = """ + | survey | + | | type | name | label | + | | begin_repeat | r1 | R1 | + | | text | q1 | Q1 | + | | end_repeat | r1 | | + | | csv-external | e1 | | + + | entities | + | | list_name | entity_id | update_if | + | | e1 | ${q1} | ${q1} != '' | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_bind_meta_instanceid(), + xpe.model_instance_meta("e1", "/x:r1", repeat=True, update=True), + xpe.model_instance_meta( + "e1", "/x:r1", repeat=True, template=True, update=True + ), + 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_update(" ../../../q1 != ''", "/r1"), + ], + xml__xpath_count=[ + ("/h:html//x:setvalue", 0), + ], + ) + + def test_implicit_upsert_mode__survey(self): + """Should find that entity_id, create_if, and update_if are provided, the entity is in upsert mode.""" + # ES004 EB009 EB012 EB014 EB015 EB019 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + | | csv-external | e1 | | + + | entities | + | | list_name | entity_id | update_if | create_if | + | | e1 | ${q1} | ${q1} != '' | ${q1} != '' | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_bind_meta_instanceid(), + xpe.model_instance_meta("e1", update=True, create=True), + xpe.model_bind_meta_id(" /test_name/q1 "), + xpe.model_bind_meta_baseversion("e1", "/test_name/q1"), + xpe.model_bind_meta_trunkversion("e1", "/test_name/q1"), + xpe.model_bind_meta_branchid("e1", "/test_name/q1"), + xpe.model_bind_meta_update(" /test_name/q1 != ''"), + xpe.model_bind_meta_create(" /test_name/q1 != ''"), + ], + xml__xpath_count=[ + ("/h:html//x:setvalue", 0), + ], + ) + + def test_implicit_upsert_mode__repeat(self): + """Should find that entity_id, create_if, and update_if are provided, the entity is in upsert mode.""" + # ES004 EB009 EB012 EB014 EB015 EB016 EB019 + md = """ + | survey | + | | type | name | label | + | | begin_repeat | r1 | R1 | + | | text | q1 | Q1 | + | | end_repeat | r1 | | + | | csv-external | e1 | | + + | entities | + | | list_name | entity_id | update_if | create_if | + | | e1 | ${q1} | ${q1} != '' | ${q1} != '' | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_bind_meta_instanceid(), + xpe.model_instance_meta( + "e1", "/x:r1", repeat=True, update=True, create=True + ), + xpe.model_instance_meta( + "e1", "/x:r1", repeat=True, template=True, update=True, create=True + ), + 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_update(" ../../../q1 != ''", "/r1"), + xpe.model_bind_meta_create(" ../../../q1 != ''", "/r1"), + ], + xml__xpath_count=[ + ("/h:html//x:setvalue", 0), + ], + ) + + def test_implicit_upsert_mode__with_label__survey(self): + """Should find that entity_id, create_if, and update_if are provided, the entity is in upsert mode.""" + # ES004 EB009 EB012 EB014 EB015 EB019 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | + | | csv-external | e1 | | + + | entities | + | | list_name | entity_id | update_if | create_if | label | + | | e1 | ${q1} | ${q1} != '' | ${q1} != '' | E1 | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_bind_meta_instanceid(), + xpe.model_instance_meta("e1", update=True, create=True, label=True), + xpe.model_bind_meta_id(" /test_name/q1 "), + xpe.model_bind_meta_baseversion("e1", "/test_name/q1"), + xpe.model_bind_meta_trunkversion("e1", "/test_name/q1"), + xpe.model_bind_meta_branchid("e1", "/test_name/q1"), + xpe.model_bind_meta_update(" /test_name/q1 != ''"), + xpe.model_bind_meta_create(" /test_name/q1 != ''"), + xpe.model_bind_meta_label("E1"), + ], + xml__xpath_count=[ + ("/h:html//x:setvalue", 0), + ], + ) + + def test_implicit_upsert_mode__with_label__repeat(self): + """Should find that entity_id, create_if, and update_if are provided, the entity is in upsert mode.""" + # ES004 EB009 EB012 EB014 EB015 EB016 EB019 + md = """ + | survey | + | | type | name | label | + | | begin_repeat | r1 | R1 | + | | text | q1 | Q1 | + | | end_repeat | r1 | | + | | csv-external | e1 | | + + | entities | + | | list_name | entity_id | update_if | create_if | label | + | | e1 | ${q1} | ${q1} != '' | ${q1} != '' | E1 | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_bind_meta_instanceid(), + xpe.model_instance_meta( + "e1", "/x:r1", repeat=True, update=True, create=True, label=True + ), + xpe.model_instance_meta( + "e1", + "/x:r1", + repeat=True, + template=True, + update=True, + create=True, + label=True, + ), + 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_update(" ../../../q1 != ''", "/r1"), + xpe.model_bind_meta_create(" ../../../q1 != ''", "/r1"), + xpe.model_bind_meta_label("E1", "/r1"), + ], + xml__xpath_count=[ + ("/h:html//x:setvalue", 0), + ], + ) + + def test_save_to__create__survey(self): + """Should find the saveto binding is output for save_to in a survey container.""" + # ES003 ES005 + md = """ + | survey | + | | type | name | label | save_to | + | | text | q1 | Q1 | e1p1 | + + | entities | + | | list_name | label | + | | e1 | E1 | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_bind_question_saveto("/q1", "e1p1"), + ], + ) + + def test_save_to__create__group(self): + """Should find the saveto binding is output for save_to in a group container.""" + # ES003 ES005 + md = """ + | survey | + | | type | name | label | save_to | + | | begin_group | g1 | G1 | | + | | text | q1 | Q1 | e1p1 | + | | end_group | g1 | | | + + | entities | + | | list_name | label | + | | e1 | E1 | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_bind_question_saveto("/g1/q1", "e1p1"), + ], + ) + + def test_save_to__create__repeat(self): + """Should find the saveto binding is output for save_to in a repeat container.""" + # ES003 ES005 + md = """ + | survey | + | | type | name | label | save_to | + | | begin_repeat | r1 | R1 | | + | | text | q1 | Q1 | e1p1 | + | | end_repeat | r1 | | | + + | entities | + | | list_name | label | + | | e1 | E1 | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_bind_question_saveto("/r1/q1", "e1p1"), + ], + ) + + def test_save_to__create__repeat_group(self): + """Should find the saveto binding is output for save_to in nested group container.""" + # ES003 ES005 + md = """ + | survey | + | | type | name | label | save_to | + | | begin_repeat | r1 | R1 | | + | | begin_group | g1 | G1 | | + | | text | q1 | Q1 | e1p1 | + | | end_group | g1 | | | + | | end_repeat | r1 | | | + + | entities | + | | list_name | label | + | | e1 | E1 | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_bind_question_saveto("/r1/g1/q1", "e1p1"), + ], + ) + + def test_save_to__create__group_repeat(self): + """Should find the saveto binding is output for save_to in nested repeat container.""" + # ES003 ES005 + md = """ + | survey | + | | type | name | label | save_to | + | | begin_group | g1 | G1 | | + | | begin_repeat | r1 | R1 | | + | | text | q1 | Q1 | e1p1 | + | | end_repeat | r1 | | | + | | end_group | g1 | | | + + | entities | + | | list_name | label | + | | e1 | E1 | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_bind_question_saveto("/g1/r1/q1", "e1p1"), + ], + ) + + def test_save_to__update__survey(self): + """Should find the saveto binding is output for save_to in a survey container.""" + # ES003 ES005 + md = """ + | survey | + | | type | name | label | save_to | + | | text | q1 | Q1 | e1p1 | + | | csv-external | e1 | | | + + | entities | + | | list_name | label | entity_id | + | | e1 | E1 | uuid() | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_bind_question_saveto("/q1", "e1p1"), + ], + ) + + def test_save_to__update__group(self): + """Should find the saveto binding is output for save_to in a group container.""" + # ES003 ES005 + md = """ + | survey | + | | type | name | label | save_to | + | | begin_group | g1 | G1 | | + | | text | q1 | Q1 | e1p1 | + | | end_group | g1 | | | + | | csv-external | e1 | | | + + | entities | + | | list_name | label | entity_id | + | | e1 | E1 | uuid() | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_bind_question_saveto("/g1/q1", "e1p1"), + ], + ) + + def test_save_to__update__repeat(self): + """Should find the saveto binding is output for save_to in a repeat container.""" + # ES003 ES005 + md = """ + | survey | + | | type | name | label | save_to | + | | begin_repeat | r1 | R1 | | + | | text | q1 | Q1 | e1p1 | + | | end_repeat | r1 | | | + | | csv-external | e1 | | | + + | entities | + | | list_name | label | entity_id | + | | e1 | E1 | uuid() | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_bind_question_saveto("/r1/q1", "e1p1"), + ], + ) + + def test_save_to__update__repeat_group(self): + """Should find the saveto binding is output for save_to in nested group container.""" + # ES003 ES005 + md = """ + | survey | + | | type | name | label | save_to | + | | begin_repeat | r1 | R1 | | + | | begin_group | g1 | G1 | | + | | text | q1 | Q1 | e1p1 | + | | end_group | g1 | | | + | | end_repeat | r1 | | | + | | csv-external | e1 | | | + + | entities | + | | list_name | label | entity_id | + | | e1 | E1 | uuid() | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_bind_question_saveto("/r1/g1/q1", "e1p1"), + ], + ) + + def test_save_to__update__group_repeat(self): + """Should find the saveto binding is output for save_to in nested repeat container.""" + # ES004 ES005 + md = """ + | survey | + | | type | name | label | save_to | + | | begin_group | g1 | G1 | | + | | begin_repeat | r1 | R1 | | + | | text | q1 | Q1 | e1p1 | + | | end_repeat | r1 | | | + | | end_group | g1 | | | + | | csv-external | e1 | | | + + | entities | + | | list_name | label | entity_id | + | | e1 | E1 | uuid() | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_bind_question_saveto("/g1/r1/q1", "e1p1"), + ], + ) + + def test_save_to__multiple_properties__survey(self): + """Should find the saveto binding is output for save_to in a survey container.""" + # ES003 ES005 + md = """ + | survey | + | | type | name | label | save_to | + | | text | q1 | Q1 | e1p1 | + | | text | q2 | Q2 | e1p2 | + + | entities | + | | list_name | label | + | | e1 | E1 | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_bind_question_saveto("/q1", "e1p1"), + xpe.model_bind_question_saveto("/q2", "e1p2"), + ], + ) + + def test_save_to__multiple_properties__group(self): + """Should find the saveto binding is output for save_to in a group container.""" + # ES003 ES005 + md = """ + | survey | + | | type | name | label | save_to | + | | begin_group | g1 | G1 | | + | | text | q1 | Q1 | e1p1 | + | | text | q2 | Q2 | e1p2 | + | | end_group | g1 | | | + + | entities | + | | list_name | label | + | | e1 | E1 | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_bind_question_saveto("/g1/q1", "e1p1"), + xpe.model_bind_question_saveto("/g1/q2", "e1p2"), + ], + ) + + def test_save_to__multiple_properties__repeat(self): + """Should find the saveto binding is output for save_to in a repeat container.""" + # ES003 ES005 + md = """ + | survey | + | | type | name | label | save_to | + | | begin_repeat | r1 | R1 | | + | | text | q1 | Q1 | e1p1 | + | | text | q2 | Q2 | e1p2 | + | | end_repeat | r1 | | | + + | entities | + | | list_name | label | + | | e1 | E1 | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_bind_question_saveto("/r1/q1", "e1p1"), + xpe.model_bind_question_saveto("/r1/q2", "e1p2"), + ], + ) + + def test_save_to__multiple_properties__repeat_group(self): + """Should find the saveto binding is output for save_to in nested group container.""" + # ES003 ES005 + md = """ + | survey | + | | type | name | label | save_to | + | | begin_repeat | r1 | R1 | | + | | begin_group | g1 | G1 | | + | | text | q1 | Q1 | e1p1 | + | | text | q2 | Q2 | e1p2 | + | | end_group | g1 | | | + | | end_repeat | r1 | | | + + | entities | + | | list_name | label | + | | e1 | E1 | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_bind_question_saveto("/r1/g1/q1", "e1p1"), + xpe.model_bind_question_saveto("/r1/g1/q2", "e1p2"), + ], + ) + + def test_save_to__multiple_properties__group_repeat(self): + """Should find the saveto binding is output for save_to in nested repeat container.""" + # ES004 ES005 + md = """ + | survey | + | | type | name | label | save_to | + | | begin_group | g1 | G1 | | + | | begin_repeat | r1 | R1 | | + | | text | q1 | Q1 | e1p1 | + | | text | q2 | Q2 | e1p2 | + | | end_repeat | r1 | | | + | | end_group | g1 | | | + + | entities | + | | list_name | label | + | | e1 | E1 | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_bind_question_saveto("/g1/r1/q1", "e1p1"), + xpe.model_bind_question_saveto("/g1/r1/q2", "e1p2"), + ], + ) + + def test_save_to__multiple_entities__survey(self): + """Should find the saveto binding is output for save_to in a survey container.""" + # ES003 ES005 ES006 + md = """ + | survey | + | | type | name | label | save_to | + | | text | q1 | Q1 | e1#e1p1 | + | | begin_group | g1 | G1 | | + | | text | q2 | Q2 | e2#e2p1 | + | | end_group | g1 | | | + + | entities | + | | list_name | label | + | | e1 | E1 | + | | e2 | E2 | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_bind_question_saveto("/q1", "e1p1"), + xpe.model_bind_question_saveto("/g1/q2", "e2p1"), + ], + ) + + def test_save_to__multiple_entities__group(self): + """Should find the saveto binding is output for save_to in a group container.""" + # ES003 ES005 ES006 + md = """ + | survey | + | | type | name | label | save_to | + | | begin_group | g1 | G1 | | + | | text | q1 | Q1 | e1#e1p1 | + | | begin_group | g2 | G2 | | + | | text | q2 | Q2 | e2#e2p1 | + | | end_group | g2 | | | + | | end_group | g1 | | | + + | entities | + | | list_name | label | + | | e1 | E1 | + | | e2 | E2 | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_bind_question_saveto("/g1/q1", "e1p1"), + xpe.model_bind_question_saveto("/g1/g2/q2", "e2p1"), + ], + ) + + def test_save_to__multiple_entities__repeat(self): + """Should find the saveto binding is output for save_to in a repeat container.""" + # ES003 ES005 ES006 + md = """ + | survey | + | | type | name | label | save_to | + | | begin_repeat | r1 | R1 | | + | | text | q1 | Q1 | e1#e1p1 | + | | begin_group | g1 | G1 | | + | | text | q2 | Q2 | e2#e2p1 | + | | end_group | g1 | | | + | | end_repeat | r1 | | | + + | entities | + | | list_name | label | + | | e1 | E1 | + | | e2 | E2 | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_bind_question_saveto("/r1/q1", "e1p1"), + xpe.model_bind_question_saveto("/r1/g1/q2", "e2p1"), + ], + ) + + def test_save_to__multiple_entities__repeat_group(self): + """Should find the saveto binding is output for save_to in nested group container.""" + # ES003 ES005 ES006 + md = """ + | survey | + | | type | name | label | save_to | + | | begin_repeat | r1 | R1 | | + | | begin_group | g1 | G1 | | + | | text | q1 | Q1 | e1#e1p1 | + | | begin_group | g2 | G1 | | + | | text | q2 | Q2 | e2#e2p1 | + | | end_group | g2 | | | + | | end_group | g1 | | | + | | end_repeat | r1 | | | + + | entities | + | | list_name | label | + | | e1 | E1 | + | | e2 | E2 | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_bind_question_saveto("/r1/g1/q1", "e1p1"), + xpe.model_bind_question_saveto("/r1/g1/g2/q2", "e2p1"), + ], + ) + + def test_save_to__multiple_entities__group_repeat(self): + """Should find the saveto binding is output for save_to in nested repeat container.""" + # ES004 ES005 ES006 + md = """ + | survey | + | | type | name | label | save_to | + | | begin_group | g1 | G1 | | + | | begin_repeat | r1 | R1 | | + | | text | q1 | Q1 | e1#e1p1 | + | | begin_group | g2 | G1 | | + | | text | q2 | Q2 | e2#e2p1 | + | | end_group | g2 | | | + | | end_repeat | r1 | | | + | | end_group | g1 | | | + + | entities | + | | list_name | label | + | | e1 | E1 | + | | e2 | E2 | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_bind_question_saveto("/g1/r1/q1", "e1p1"), + xpe.model_bind_question_saveto("/g1/r1/g2/q2", "e2p1"), + ], + ) + + +class TestReferenceSource(PyxformTestCase): + def test_missing_property_and_question_name__error(self): + """Should raise an error if both property_name and question_name are None.""" + with self.assertRaises(PyXFormError) as err: + ReferenceSource(path=ContainerPath.default(), row=1) + self.assertEqual( + err.exception.args[0], ErrorCode.INTERNAL_002.value.format(path="/survey") + ) diff --git a/tests/entities/test_update_repeat.py b/tests/entities/test_update_repeat.py deleted file mode 100644 index d81b37f3..00000000 --- a/tests/entities/test_update_repeat.py +++ /dev/null @@ -1,160 +0,0 @@ -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/entities/test_update_survey.py b/tests/entities/test_update_survey.py deleted file mode 100644 index bcfe62b1..00000000 --- a/tests/entities/test_update_survey.py +++ /dev/null @@ -1,198 +0,0 @@ -from pyxform import constants as co -from pyxform.errors import ErrorCode - -from tests.pyxform_test_case import PyxformTestCase -from tests.xpath_helpers.entities import xpe - - -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=""" - | survey | | | | - | | type | name | label | - | | text | id | Tree id | - | | text | a | A | - | | csv-external | trees | | - | entities | | | | - | | dataset | entity_id | | - | | trees | ${id} | | - """, - xml__xpath_match=[ - xpe.model_entities_version(co.EntityVersion.v2024_1_0.value), - # defaults to always updating if an entity_id is specified - 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//x:setvalue", 0), - ], - xml__contains=['xmlns:entities="http://www.opendatakit.org/xforms/entities"'], - ) - - def test_entity_id_with_creation_condition_only__errors(self): - self.assertPyxformXform( - name="data", - md=""" - | survey | | | | - | | type | name | label | - | | text | id | Tree id | - | | text | a | A | - | entities | | | | - | | dataset | entity_id | create_if | - | | trees | ${id} | true() | - """, - errored=True, - error__contains=[ - "The entities sheet can't specify an entity creation condition and an entity_id without also including an update condition." - ], - ) - - def test_update_condition_without_entity_id__errors(self): - self.assertPyxformXform( - name="data", - md=""" - | survey | | | | - | | type | name | label | - | | text | id | Tree id | - | | text | a | A | - | entities | | | | - | | dataset | update_if | | - | | trees | true() | | - """, - errored=True, - error__contains=[ - "The entities sheet is missing the entity_id column which is required when updating entities." - ], - ) - - def test_update_and_create_conditions_without_entity_id__errors(self): - self.assertPyxformXform( - name="data", - md=""" - | survey | | | | - | | type | name | label | - | | text | id | Tree id | - | | integer | a | A | - | entities | | | | - | | dataset | update_if | create_if | - | | trees | ${id} != ''| ${id} = '' | - """, - errored=True, - error__contains=[ - "The entities sheet is missing the entity_id column which is required when updating entities." - ], - ) - - def test_create_if_with_entity_id_in_entities_sheet__puts_expression_on_bind(self): - self.assertPyxformXform( - md=""" - | survey | | | | - | | type | name | label | - | | text | id | Tree id | - | | text | a | A | - | | csv-external | trees | | - | entities | | | | - | | dataset | update_if | entity_id | - | | trees | string-length(a) > 3 | ${id} | - """, - xml__xpath_match=[ - 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), - ], - ) - - def test_update_and_create_conditions_with_entity_id__puts_both_in_bind_calculations( - self, - ): - self.assertPyxformXform( - md=""" - | survey | | | | | - | | type | name | label | | - | | text | id | Tree id | | - | | integer | a | A | | - | | csv-external | trees | | | - | entities | | | | | - | | dataset | update_if | create_if | entity_id | - | | trees | id != '' | id = '' | ${id} | - """, - xml__xpath_match=[ - 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), - ], - ) - - def test_entity_id_and_label__updates_label(self): - self.assertPyxformXform( - md=""" - | survey | | | | - | | type | name | label | - | | text | id | Tree id | - | | text | a | A | - | | csv-external | trees | | - | entities | | | | - | | dataset | entity_id | label | - | | trees | ${id} | a | - """, - xml__xpath_match=[ - 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( - md=""" - | survey | | | | | - | | type | name | label | save_to | - | | text | id | Tree id | | - | | text | a | A | foo | - | | csv-external | trees | | | - | entities | | | | | - | | dataset | entity_id | | | - | | trees | ${id} | | | - """, - xml__xpath_match=[ - xpe.model_bind_question_saveto("/a", "foo"), - ], - ) - - def test_reference_name_not_found__error(self): - """Should raise an error if a referenced name is not in the survey sheet.""" - md = """ - | survey | - | | type | name | label | - | | text | q1 | Q1 | - - | entities | - | | list_name | entity_id | - | | e1 | ${q1x} | - """ - self.assertPyxformXform( - md=md, - errored=True, - error__contains=[ - ErrorCode.PYREF_003.value.format( - sheet="entities", column="entity_id", row=2, q="q1x" - ) - ], - ) diff --git a/tests/xpath_helpers/entities.py b/tests/xpath_helpers/entities.py index 6fd8793a..33e0a580 100644 --- a/tests/xpath_helpers/entities.py +++ b/tests/xpath_helpers/entities.py @@ -25,12 +25,12 @@ def model_instance_meta( list_name: str, meta_path: str = "", repeat: bool = False, - template: bool = False, + template: bool | None = False, create: bool = False, update: bool = False, label: bool = False, ) -> str: - assertion = {True: "{0}", False: "not({0})"} + assertion = {True: "{0}", False: "not({0})", None: "true()"} repeat_asserts = ("not(./x:instanceID)",) template_asserts = ("@jr:template",) create_asserts = ("@create='1'",) @@ -55,6 +55,22 @@ def model_instance_meta( ] """ + @staticmethod + def model_no_instance_csv(list_name: str) -> str: + return f""" + /h:html/h:head/x:model[ + not(./x:instance[@id='{list_name}' and @src='jr://file-csv/{list_name}.csv']) + ] + """ + + @staticmethod + def model_instance_csv(list_name: str) -> str: + return f""" + /h:html/h:head/x:model/x:instance[ + @id='{list_name}' and @src='jr://file-csv/{list_name}.csv' + ] + """ + @staticmethod def model_setvalue_meta_id(meta_path: str = "") -> str: return f""" @@ -162,8 +178,8 @@ def model_bind_meta_branchid( 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}" + @nodeset='/test_name{meta_path}/meta/entity/label' + and @calculate='{value}' and @type='string' and @readonly='true()' ] diff --git a/tests/xpath_helpers/settings.py b/tests/xpath_helpers/settings.py index ed177886..498fe413 100644 --- a/tests/xpath_helpers/settings.py +++ b/tests/xpath_helpers/settings.py @@ -48,5 +48,12 @@ def language_no_itext(lang: str) -> str: /h:html/h:head/x:model/x:itext[not(descendant::x:translation[@lang='{lang}'])] """ + @staticmethod + def instance_meta_survey_element(name: str) -> str: + """The element 'name' exists in the survey-level meta element.""" + return f""" + /h:html/h:head/x:model/x:instance/x:test_name/x:meta/x:{name} + """ + xps = XPathHelper()