diff --git a/pyxform/entities/entities_parsing.py b/pyxform/entities/entities_parsing.py index 6a619676..083d895e 100644 --- a/pyxform/entities/entities_parsing.py +++ b/pyxform/entities/entities_parsing.py @@ -498,7 +498,6 @@ def validate_saveto( ) elif is_container_begin or is_container_end: raise PyXFormError(ErrorCode.ENTITY_003.value.format(row=row_number)) - # Error: naming rules elif saveto.lower() in {const.NAME, const.LABEL}: raise PyXFormError( ErrorCode.NAMES_012.value.format( @@ -677,7 +676,7 @@ def allocate_entities_to_containers( 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. + # Prioritise by row order to help make allocations stable. for req in sorted(requests, key=lambda x: x.entity_row_number): conflict_dataset = None diff --git a/tests/entities/test_entities.py b/tests/entities/test_entities.py index 92617806..93192447 100644 --- a/tests/entities/test_entities.py +++ b/tests/entities/test_entities.py @@ -61,7 +61,7 @@ - EB021: Allocation to survey meta is compatible with other meta settings - EB022: Allocation searches path ancestors only (not children or siblings) - EB023: Allocation selects deepest boundary scope (pyxform/#822) - - EB024: ALlocation is to survey for only one entity not in repeats (pyxform/#825) + - EB024: Allocation is to survey for only one entity not in repeats (pyxform/#825) ## Topological constraint solver regression suite @@ -1212,6 +1212,109 @@ def test_unsolvable_meta_topology__depth_1_repeat__conflict_group__saveto_only__ ], ) + def test_unsolvable_meta_topology__sibling_request_candidates__saveto_and_var__error( + self, + ): + """Should raise an error if the candidate containers are siblings.""" + # EV014 EB010 EB022 EB023 + md = """ + | survey | + | | type | name | label | save_to | + | | begin_repeat | r1 | R1 | | + | | begin_group | g1 | G1 | | e1 + | | text | q1 | Q1 | e2#e2p1 | + | | text | q2 | Q2 | | + | | end_group | | | | + | | begin_group | g2 | G2 | | + | | text | q3 | Q3 | | + | | end_group | | | | + | | end_repeat | | | | + + | entities | + | | list_name | label | + | | e1 | concat(${q1}, ${q2}, ${q3}) | + | | e2 | E2 | + """ + self.assertPyxformXform( + md=md, + debug=True, + errored=True, + error__contains=[ + ErrorCode.ENTITY_009.value.format(row=3, scope="/survey/r1", other_row=2) + ], + ) + + def test_unsolvable_meta_topology__sibling_request_candidates__saveto_and_var__sorted__ok( + self, + ): + """Should be able to resolve above error case by placing the save_to entity first.""" + # EV014 EB010 EB022 EB023 + md = """ + | survey | + | | type | name | label | save_to | + | | begin_repeat | r1 | R1 | | e1 + | | begin_group | g1 | G1 | | e2 + | | text | q1 | Q1 | e2#e2p1 | + | | text | q2 | Q2 | | + | | end_group | | | | + | | begin_group | g2 | G2 | | + | | text | q3 | Q3 | | + | | end_group | | | | + | | end_repeat | | | | + + | entities | + | | list_name | label | + | | e2 | E2 | + | | e1 | concat(${q1}, ${q2}, ${q3}) | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_instance_meta( + "e1", "/x:r1", create=True, label=True, repeat=True + ), + xpe.model_instance_meta( + "e2", + "/x:r1[not(@jr:template)]/x:g1", + create=True, + label=True, + repeat=True, + ), + xpe.model_bind_question_saveto("/r1/g1/q1", "e2p1"), + xpe.model_bind_meta_label( + "concat( ../../../g1/q1 , ../../../g1/q2 , ../../../g2/q3 )", "/r1" + ), + ], + ) + + def test_unsolvable_meta_topology__sibling_request_candidates__var_only__error(self): + """Should raise an error if the candidate containers are siblings.""" + # EV014 EB010 EB022 + md = """ + | survey | + | | type | name | label | + | | text | q1 | Q1 | e1 + | | begin_group | g1 | G1 | e2 + | | text | q2 | Q2 | + | | end_group | | | + | | begin_group | g2 | G2 | e3 (possible, but actually error) + | | text | q3 | Q3 | + | | end_group | | | + + | entities | + | | list_name | label | unlike the above test, no sort possible + | | e1 | concat(${q1}, ${q2}, ${q3}) | + | | e2 | concat(${q1}, ${q2}, ${q3}) | + | | e3 | concat(${q1}, ${q2}, ${q3}) | + """ + self.assertPyxformXform( + md=md, + errored=True, + error__contains=[ + ErrorCode.ENTITY_009.value.format(row=4, scope="/survey", other_row=3) + ], + ) + 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 @@ -3666,6 +3769,52 @@ def test_save_to__multiple_entities__group_repeat(self): ], ) + def test_save_to__multiple_entites_props__split_groups__repeat__outer_group(self): + """Should find the saveto binding is output for save_to in a repeat/group container.""" + # ES004 ES005 ES006 + md = """ + | survey | + | | type | name | label | save_to | calculation | + | | begin_group | g1 | G1 | | | + | | text | q1 | Q1 | e2#e2p1 | | + | | geopoint | q2 | Q2 | e2#e2p2 | | + | | begin_repeat | r1 | R1 | | | + | | text | q3 | Q3 | e1#e1p1 | | + | | begin_group | g2 | G2 | | | + | | text | q4 | Q4 | e1#e1p2 | | + | | calculate | q5 | Q5 | e1#e1p3 | ${q1} | + | | end_group | g2 | | | | + | | calculate | q6 | Q6 | e1#e1p4 | ../../meta/entity/@id | + | | end_repeat | r1 | | | | + | | end_group | g1 | | | | + + | entities | + | | list_name | label | + | | e1 | concat(${q3}, " ", ${q4}, " (", ${q1}, ")") | + | | e2 | ${q1} | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpe.model_instance_meta( + "e1", "/x:g1/x:r1", repeat=True, create=True, label=True + ), + xpe.model_instance_meta( + "e2", "/x:g1", repeat=True, create=True, label=True + ), + xpe.model_bind_question_saveto("/g1/r1/q3", "e1p1"), + xpe.model_bind_question_saveto("/g1/r1/g2/q4", "e1p2"), + xpe.model_bind_question_saveto("/g1/r1/g2/q5", "e1p3"), + xpe.model_bind_question_saveto("/g1/r1/q6", "e1p4"), + xpe.model_bind_question_saveto("/g1/q1", "e2p1"), + xpe.model_bind_question_saveto("/g1/q2", "e2p2"), + xpe.model_bind_meta_label( + """concat( ../../../q3 , " ", ../../../g2/q4 , " (", /test_name/g1/q1 , ")")""", + "/g1/r1", + ), + ], + ) + def test_var__multiple_var__cross_boundary__before(self): """Should find the deepest scope preferenced and outside vars resolve to one item.""" # ES005 EB016 EB023