Repository: XLSForm/pyxform
Pull request #764 (2025-04-30 09:26:09)

fix: no labels visible for randomized selects with translations
Per report in https://forum.getodk.org/t/54492

- regression seems to be from #746 where the choices may be referenced by the survey only and the select question does not have a reference. This happens if the question is randomized, or external select, or search appearance.
  - this would suggest that maybe the choices should be in-line for randomized choices - however the existing tests for randomize expect an itemset in the body. Also, these new tests pass on tag 3.0.1 (prior to #746), and fail on master (e0b350b5)
  - since the user noticed this via https://getodk.org/xlsform/ and it seems that the converter uses https://staging.getodk.cloud I assume that means master is deployed to staging (i.e. not just tagged releases)
  - The form posted in the forum converts as expected (choice labels shown in enketo preview) without warnings/errors when uploaded to a project on https://staging.getodk.cloud

#### Why is this the best possible solution? Were any other approaches considered?

As mentioned above I wasn't totally sure that these combinations are supported, considering https://github.com/enketo/enketo/issues/322 but it seems like it works in Collect (per https://forum.getodk.org/t/42506/4) and presumably will in Webforms too. Also I wasn't able to locally reproduce the ODK Validate warnings mentioned in the reporting forum thread but maybe the Validate version on xlsform-online is out of sync?

#### What are the regression risks?

This should be a fix since the existing tests pass and the new code satisfies tests that worked on earlier code (as described above), and the code committed in between didn't intend to change randomize or choice_filter behaviour for users.

#### Does this change require updates to documentation? If so, please file an issue [here](https://github.com/XLSForm/xlsform.github.io) and include the link below.

No

#### Before submitting this PR, please make sure you have:
- [x] included test cases for core behavior and edge cases in `tests`
- [x] run `python -m unittest` and verified all tests pass
- [x] run `ruff format pyxform tests` and `ruff check pyxform tests` to lint code
- [x] verified that any code or assets from external sources are properly credited in comments


Patch:
diff --git a/pyxform/question.py b/pyxform/question.py
index 6ebac4c..7e3442d 100644
--- a/pyxform/question.py
+++ b/pyxform/question.py
@@ -370,6 +370,11 @@ class MultipleChoiceQuestion(Question):
             raise PyXFormError("""Invalid value for `self.bind["type"]`.""")
 
         result = self._build_xml(survey=survey)
+        choices = None
+        if survey.choices:
+            choices = survey.choices.get(self.itemset, None)
+        if not choices:
+            choices = self.choices
 
         # itemset are only supposed to be strings,
         # check to prevent the rare dicts that show up
@@ -390,7 +395,7 @@ class MultipleChoiceQuestion(Question):
 
             if file_extension in EXTERNAL_INSTANCE_EXTENSIONS:
                 pass
-            elif self.choices and self.choices.requires_itext:
+            elif choices and choices.requires_itext:
                 itemset = self.itemset
                 itemset_label_ref = "jr:itext(itextId)"
             else:
@@ -448,13 +453,12 @@ class MultipleChoiceQuestion(Question):
                     nodeset=nodeset,
                 )
             )
-        elif self.choices:
+        elif choices:
             # Options processing specific to XLSForms using the "search()" function.
             # The _choice_itext_ref is prepared by Survey._redirect_is_search_itext.
-            itemset = self.choices
-            if itemset.used_by_search:
-                for option in itemset.options:
-                    if itemset.requires_itext:
+            if choices.used_by_search:
+                for option in choices.options:
+                    if choices.requires_itext:
                         label_node = node("label", ref=option._choice_itext_ref)
                     elif self.label:
                         label, output_inserted = survey.insert_output_values(
diff --git a/pyxform/survey.py b/pyxform/survey.py
index aa6ca19..12607ed 100644
--- a/pyxform/survey.py
+++ b/pyxform/survey.py
@@ -783,12 +783,16 @@ class Survey(Section):
                 )
                 raise PyXFormError(msg)
 
+            choices = None
+            if self.choices:
+                choices = self.choices.get(element.itemset, None)
+            if not choices:
+                choices = element.choices
             element.itemset = ""
-            itemset = element.choices
-            if not itemset.used_by_search:
-                itemset.used_by_search = True
-                for i, opt in enumerate(itemset.options):
-                    opt._choice_itext_ref = f"jr:itext('{itemset.name}-{i}')"
+            if not choices.used_by_search:
+                choices.used_by_search = True
+                for i, opt in enumerate(choices.options):
+                    opt._choice_itext_ref = f"jr:itext('{choices.name}-{i}')"
         return is_search
 
     def _setup_translations(self):


Test patch:
diff --git a/tests/test_randomize_itemsets.py b/tests/test_randomize_itemsets.py
index b3e41d7..bbc4282 100644
--- a/tests/test_randomize_itemsets.py
+++ b/tests/test_randomize_itemsets.py
@@ -3,6 +3,8 @@ Test randomize itemsets.
 """
 
 from tests.pyxform_test_case import PyxformTestCase
+from tests.xpath_helpers.choices import xpc
+from tests.xpath_helpers.questions import xpq
 
 
 class RandomizeItemsetsTest(PyxformTestCase):
@@ -203,3 +205,68 @@ class RandomizeItemsetsTest(PyxformTestCase):
             """,
             error__contains=["Parameters must include randomize=true to use a seed."],
         )
+
+    def test_randomized_select_one_translated(self):
+        """Should find itemset label refers to itext func for randomized select1."""
+        md = """
+        | survey  |
+        |         | type          | name | label::English (en) | parameters     |
+        |         | select_one c1 | q1   | Q1 en               | randomize=True |
+        | choices |
+        |         | list_name | name | label::English (en) |
+        |         | c1        | a    | A                   |
+        |         | c1        | b    | B                   |
+        """
+        self.assertPyxformXform(
+            md=md,
+            xml__xpath_match=[
+                xpq.body_select1_itemset("q1"),
+                xpc.model_itext_choice_text_label_by_pos(
+                    "English (en)",
+                    "c1",
+                    ("A", "B"),
+                ),
+                # Nodeset ref to choices instance wrapped in randomize().
+                """
+                /h:html/h:body/x:select1[@ref='/test_name/q1']
+                  /x:itemset[
+                    @nodeset="randomize(instance('c1')/root/item)"
+                      and child::x:label[@ref='jr:itext(itextId)']
+                      and child::x:value[@ref='name']
+                  ]
+                """,
+            ],
+        )
+
+    def test_randomized_select_one_translated_filtered(self):
+        """Should find itemset label refers to itext func for randomized select1 + filter."""
+        md = """
+        | survey  |
+        |         | type          | name | label::English (en) | parameters     | choice_filter |
+        |         | text          | q0   | Question 0          |                |               |
+        |         | select_one c1 | q1   | Question 1          | randomize=True | ${q0} = cf    |
+        | choices |           |      |       |
+        |         | list_name | name | label::English (en) | cf |
+        |         | c1        | a    | A                   | 1  |
+        |         | c1        | b    | B                   | 2  |
+        """
+        self.assertPyxformXform(
+            md=md,
+            xml__xpath_match=[
+                xpq.body_select1_itemset("q1"),
+                xpc.model_itext_choice_text_label_by_pos(
+                    "English (en)",
+                    "c1",
+                    ("A", "B"),
+                ),
+                # Nodeset ref to choices instance + filter predicate wrapped in randomize().
+                """
+                /h:html/h:body/x:select1[@ref='/test_name/q1']
+                  /x:itemset[
+                    @nodeset="randomize(instance('c1')/root/item[ /test_name/q0  = cf])"
+                      and child::x:label[@ref='jr:itext(itextId)']
+                      and child::x:value[@ref='name']
+                  ]
+                """,
+            ],
+        )