From 4f6fe5644575c3a7c1094fffe1da50dfec29577d Mon Sep 17 00:00:00 2001 From: lindsay stevens Date: Tue, 24 Mar 2026 21:01:29 +1100 Subject: [PATCH] add: new range appearance params, replace older tests with xpath tests --- pyxform/errors.py | 94 ++ pyxform/question_type_dictionary.py | 6 +- .../validators/pyxform/parameters_generic.py | 5 +- pyxform/validators/pyxform/question_types.py | 141 +++ pyxform/xls2json.py | 57 +- tests/test_range.py | 951 +++++++++++++++--- tests/xpath_helpers/questions.py | 17 +- 7 files changed, 1099 insertions(+), 172 deletions(-) diff --git a/pyxform/errors.py b/pyxform/errors.py index bcb118ccb..53dc435af 100644 --- a/pyxform/errors.py +++ b/pyxform/errors.py @@ -390,6 +390,100 @@ class ErrorCode(Enum): "'{q}' appears more than once." ), ) + RANGE_001 = Detail( + name="Range type - parameter is not a number", + msg=( + "[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. " + "For the 'range' question type, the parameter '{name}' must be a number." + ), + ) + RANGE_002 = Detail( + name="Range type - parameter is zero", + msg=( + "[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. " + "For the 'range' question type, the parameter '{name}' must not be '0' (zero)." + ), + ) + RANGE_003 = Detail( + name="Range type - parameter larger than range", + msg=( + "[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. " + "For the 'range' question type, the parameter '{name}' must not be larger than " + "the range (the difference between 'start' and 'end')." + ), + ) + RANGE_004 = Detail( + name="Range type - parameter not a multiple of tick", + msg=( + "[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. " + "For the 'range' question type, the parameter '{name}' must be a multiple of 'step'." + ), + ) + RANGE_005 = Detail( + name="Range type - parameter outside range", + msg=( + "[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. " + "For the 'range' question type, the parameter '{name}' must be a within " + "the range (between the 'start' and 'end' values, inclusive)." + ), + ) + RANGE_006 = Detail( + name="Range type - tick_labelset not found", + msg=( + "[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. " + "For the 'range' question type, the parameter 'tick_labelset' must be a " + "choice list name from the 'list_name' column on the choices sheet." + ), + ) + RANGE_007 = Detail( + name="Range type - tick_labelset too many choices with no-ticks", + msg=( + "[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. " + "For the 'range' question type, the parameter 'tick_labelset' choice list " + "must have only 2 items when the 'appearance' is 'no-ticks'." + ), + ) + RANGE_008 = Detail( + name="Range type - parameter not compatible with appearance", + msg=( + "[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. " + "For the 'range' question type, the parameters 'tick_interval', 'placeholder', " + "and 'tick_labelset' are only supported for the appearances 'vertical', 'no-ticks' " + "and the default (empty) horizontal." + ), + ) + RANGE_009 = Detail( + name="Range type - tick_labelset choice is not a number", + msg=( + "[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. " + "For the 'range' question type, the parameter '{tick_labelset}' choices must " + "all be numbers." + ), + ) + RANGE_010 = Detail( + name="Range type - tick_labelset choice outside range", + msg=( + "[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. " + "For the 'range' question type, the parameter 'tick_labelset' choices must " + "be a within the range (between the 'start' and 'end' values, inclusive)." + ), + ) + RANGE_011 = Detail( + name="Range type - tick_labelset choice not a multiple of tick", + msg=( + "[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. " + "For the 'range' question type, the parameter 'tick_labelset' choices' must " + "be a multiple of '{name}'." + ), + ) + RANGE_012 = Detail( + name="Range type - tick_labelset choices not start/end for no-ticks", + msg=( + "[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. " + "For the 'range' question type, the parameter 'tick_labelset' choice list " + "match the range 'start' and 'end' values when the 'appearance' is 'no-ticks'." + ), + ) SURVEY_001 = Detail( name="Survey sheet - unmatched group/repeat/loop end", msg=( diff --git a/pyxform/question_type_dictionary.py b/pyxform/question_type_dictionary.py index b934322ca..66021a974 100644 --- a/pyxform/question_type_dictionary.py +++ b/pyxform/question_type_dictionary.py @@ -356,7 +356,11 @@ "control": {"tag": "upload", "mediatype": "application/*"}, "bind": {"type": "binary"}, }, - "range": {"control": {"tag": "range"}, "bind": {"type": "int"}}, + "range": { + "control": {"tag": "range"}, + "bind": {"type": "int"}, + "parameters": {"start": "1", "end": "10", "step": "1"}, + }, "audit": {"bind": {"type": "binary"}}, "xml-external": { # Only effect is to add an external instance. diff --git a/pyxform/validators/pyxform/parameters_generic.py b/pyxform/validators/pyxform/parameters_generic.py index ee5769a69..4647aaa2b 100644 --- a/pyxform/validators/pyxform/parameters_generic.py +++ b/pyxform/validators/pyxform/parameters_generic.py @@ -19,12 +19,13 @@ def parse(raw_parameters: str) -> PARAMETERS_TYPE: params = {} for param in parts: - if "=" not in param: + kv_split = param.split("=") + if "=" not in param or len(kv_split) != 2: raise PyXFormError( "Expecting parameters to be in the form of " "'parameter1=value parameter2=value'." ) - k, v = param.split("=")[:2] + k, v = kv_split key = maybe_strip(k.lower()) params[key] = v if key in CASE_SENSITIVE_VALUES else maybe_strip(v.lower()) diff --git a/pyxform/validators/pyxform/question_types.py b/pyxform/validators/pyxform/question_types.py index 5a9595244..a460317fd 100644 --- a/pyxform/validators/pyxform/question_types.py +++ b/pyxform/validators/pyxform/question_types.py @@ -3,9 +3,13 @@ """ from collections.abc import Collection, Iterable +from math import isinf +from typing import Any from pyxform import aliases from pyxform.errors import ErrorCode, PyXFormError +from pyxform.question_type_dictionary import QUESTION_TYPE_DICT +from pyxform.validators.pyxform import parameters_generic from pyxform.validators.pyxform.pyxform_reference import ( is_pyxform_reference_candidate, parse_pyxform_references, @@ -100,3 +104,140 @@ def validate_geo_parameter_incremental(value: str) -> None: raise PyXFormError( code=ErrorCode.SURVEY_003, ) + + +def process_range_question_type( + row_number: int, + row: dict[str, Any], + parameters: parameters_generic.PARAMETERS_TYPE, + appearance: str, + choices: dict[str, Any], +) -> dict[str, Any]: + """ + Returns a new row that includes the Range parameters start, end and step. + + Raises PyXFormError when invalid range parameters are used. + """ + parameters = parameters_generic.validate( + parameters=parameters, + allowed={"start", "end", "step", "tick_interval", "placeholder", "tick_labelset"}, + ) + if ( + appearance + and appearance not in {"vertical", "no-ticks"} + and any( + k in parameters for k in ("tick_interval", "placeholder", "tick_labelset") + ) + ): + raise PyXFormError(ErrorCode.RANGE_008.value.format(row=row_number)) + no_ticks_appearance = appearance and appearance == "no-ticks" + + defaults = QUESTION_TYPE_DICT["range"]["parameters"] + # set defaults + for key in defaults: + if key not in parameters: + parameters[key] = defaults[key] + + def process_parameter(name: str) -> float | None: + value = parameters.get(name) + if value is None: + return value + err = False + try: + value = float(value) + except ValueError: + err = True + + if err or isinf(value): + raise PyXFormError( + ErrorCode.RANGE_001.value.format(row=row_number, name=name) + ) + return value + + start = process_parameter(name="start") + end = process_parameter(name="end") + step = process_parameter(name="step") + tick_interval = process_parameter(name="tick_interval") + placeholder = process_parameter(name="placeholder") + tick_labelset = parameters.get("tick_labelset") + range_width = abs(end - start) + + if step == 0: + raise PyXFormError(ErrorCode.RANGE_002.value.format(row=row_number, name="step")) + if step > range_width: + raise PyXFormError(ErrorCode.RANGE_003.value.format(row=row_number, name="step")) + + if tick_interval is not None: + if tick_interval == 0: + raise PyXFormError( + ErrorCode.RANGE_002.value.format(row=row_number, name="tick_interval") + ) + if tick_interval > range_width: + raise PyXFormError( + ErrorCode.RANGE_003.value.format(row=row_number, name="tick_interval") + ) + if (tick_interval % step) != 0: + raise PyXFormError( + ErrorCode.RANGE_004.value.format(row=row_number, name="tick_interval") + ) + parameters["odk:tick-interval"] = parameters.pop("tick_interval") + + if placeholder is not None: + if (placeholder % step) != 0: + raise PyXFormError( + ErrorCode.RANGE_004.value.format(row=row_number, name="placeholder") + ) + if placeholder < start or placeholder > end: + raise PyXFormError( + ErrorCode.RANGE_005.value.format(row=row_number, name="placeholder") + ) + parameters["odk:placeholder"] = parameters.pop("placeholder") + + if tick_labelset: + tick_list = choices.get(tick_labelset) + if tick_list is None: + raise PyXFormError(ErrorCode.RANGE_006.value.format(row=row_number)) + + no_ticks_labels = set() + for label in tick_list: + errored = False + try: + label = float(label.get("name")) + except ValueError: + errored = True + + if errored or isinf(label): + raise PyXFormError(ErrorCode.RANGE_009.value.format(row=row_number)) + + if label < start or label > end: + raise PyXFormError(ErrorCode.RANGE_010.value.format(row=row_number)) + if tick_interval is not None and (label % tick_interval) != 0: + raise PyXFormError( + ErrorCode.RANGE_011.value.format(row=row_number, name="tick_interval") + ) + elif (label % step) != 0: + raise PyXFormError( + ErrorCode.RANGE_011.value.format(row=row_number, name="step") + ) + if no_ticks_appearance: + no_ticks_labels.add(label) + + if no_ticks_appearance: + if len(no_ticks_labels) > 2: + raise PyXFormError(ErrorCode.RANGE_007.value.format(row=row_number)) + if no_ticks_labels != {start, end}: + raise PyXFormError(ErrorCode.RANGE_012.value.format(row=row_number)) + + parameters["odk:tick-labelset"] = parameters.pop("tick_labelset") + + # Default is integer, but if the floats have decimals then change the bind type. + if any( + i is not None and not i.is_integer() + for i in (start, end, step, tick_interval, placeholder) + ): + row["bind"] = row.get("bind", {}) + row["bind"].update({"type": "decimal"}) + + row["parameters"] = parameters + + return row diff --git a/pyxform/xls2json.py b/pyxform/xls2json.py index 6a0b2022a..5d8c1bed4 100644 --- a/pyxform/xls2json.py +++ b/pyxform/xls2json.py @@ -132,47 +132,6 @@ def add_flat_annotations(prompt_list, parent_relevant="", name_prefix=""): # prompt['name'] = name_prefix + prompt['name'] -def process_range_question_type( - row: dict[str, Any], parameters: parameters_generic.PARAMETERS_TYPE -) -> dict[str, Any]: - """ - Returns a new row that includes the Range parameters start, end and step. - - Raises PyXFormError when invalid range parameters are used. - """ - new_dict = row.copy() - parameters = parameters_generic.validate( - parameters=parameters, allowed=("start", "end", "step") - ) - parameters_map = {"start": "start", "end": "end", "step": "step"} - defaults = {"start": "1", "end": "10", "step": "1"} - - # set defaults - for key in parameters_map.values(): - if key not in parameters: - parameters[key] = defaults[key] - - has_float = False - try: - # Check all parameters. - for x in parameters.values(): - if float(x) and "." in str(x): - has_float = True - except ValueError as range_err: - raise PyXFormError( - "Range parameters 'start', 'end' or 'step' must all be numbers." - ) from range_err - else: - # is integer by default, convert to decimal if it has any float values - if has_float: - new_dict["bind"] = new_dict.get("bind", {}) - new_dict["bind"].update({"type": "decimal"}) - - new_dict["parameters"] = parameters - - return new_dict - - def process_image_default(default_value): # prepend image files with the correct prefix, if they don't have it. image_jr_prefix = "jr://images/" @@ -519,6 +478,7 @@ def workbook_to_json( # Get question type question_type = row.get(constants.TYPE) + appearance = row.get("control", {}).get("appearance") if not question_type: # if name and label are also missing, @@ -888,10 +848,8 @@ def workbook_to_json( # Code to deal with table_list appearance flags # (for groups of selects) - ctrl_ap = new_json_dict.get("control", {}).get("appearance") - - if ctrl_ap: - appearance_mods_as_list = ctrl_ap.split() + if appearance: + appearance_mods_as_list = appearance.split() if constants.TABLE_LIST in appearance_mods_as_list: # Table List modifier should add field list to the new dict, # as well as appending other appearance modifiers. @@ -1195,7 +1153,13 @@ def workbook_to_json( # range question_type if question_type == "range": - new_dict = process_range_question_type(row=row, parameters=parameters) + new_dict = qt.process_range_question_type( + row_number=row_number, + row=row, + parameters=parameters, + appearance=appearance, + choices=choices, + ) parent_children_array.append(new_dict) continue @@ -1247,7 +1211,6 @@ def workbook_to_json( ) if "app" in parameters: - appearance = row.get("control", {}).get("appearance") if appearance is None or appearance == "annotate": app_package_name = str(parameters["app"]) validation_result = validate_android_package_name(app_package_name) diff --git a/tests/test_range.py b/tests/test_range.py index 10cd86828..59ca724cf 100644 --- a/tests/test_range.py +++ b/tests/test_range.py @@ -1,176 +1,885 @@ """ -Test range widget. +## Range control traceability + +Each test should reference one (or more) requirements from these lists. + +- Validation + - RC001: parameter delimiters must be one or more spaces optionally with a comma, or semicolon. + - RC002: parameter values must only be a known name. + - RC003: parameter names may in lower case or in mixed case. + - RC004: parameter names and values must be separated by a single equals sign. + - RC005: parameter values must be numeric (or for 'tick_labelset', the choices). + - RC006: appearance parameters are only valid with default, 'vertical' or 'no-ticks' appearance. + - RC007: parameters may specify ranges that are positive, negative, ascending, or descending. + - parameter 'step': + - RS001: must not be zero. + - RS002: must be less than or equal to abs(end - start). + - parameter 'tick_interval': + - RI001: must not be zero. + - RI002: must be less than or equal to abs(end - start). + - RI003: must be a multiple of 'step'. + - parameter 'placeholder': + - RP001: must be a multiple of 'step'. + - RP002: must be between 'start' and 'end' inclusive. + - parameter 'tick_labelset': + - RL001: must match one 'list_name' from the choices sheet. + - RL002: the referenced choice list must have one or more items. + - RL003: choice list values must each be a multiple of 'step' or 'tick_interval'. + - RL004: choice list values must each be between 'start' and 'end' inclusive. + - RL005: with the 'no-ticks' appearance, choices must match 'start' and 'end' only. +- Behaviour + - RB001: non-appearance parameters must be control attributes in the default xforms namespace. + - RB002: appearance parameters must be control attributes in the 'odk' namespace. + - RB003: the default data type must be 'integer'. + - RB004: if the range has values that are decimals the data type must be 'decimal'. """ +from pyxform.errors import ErrorCode + from tests.pyxform_test_case import PyxformTestCase +from tests.xpath_helpers.questions import xpq + +class TestRangeParsing(PyxformTestCase): + def test_parameter_delimiters__ok(self): + """Should accept delimiters: space, comma, semicolon.""" + # RC001 + md = """ + | survey | + | | type | name | label | parameters | + | | range | q1 | Q1 | start=2{sep}end=9 | + """ + cases = (" ", " ", ",", ";", ", ", ", ", " , ", "; ", " ;", " ; ") + for value in cases: + with self.subTest(value): + self.assertPyxformXform( + md=md.format(sep=value), + xml__xpath_match=[ + xpq.body_range("q1", {"start": "2", "end": "9"}), + ], + ) -class RangeWidgetTest(PyxformTestCase): - def test_range_type(self): - # parameters column + def test_parameter_list__error(self): + """Should raise an error for unknown parameters.""" + # RC002 + md = """ + | survey | + | | type | name | label | parameters | + | | range | q1 | Q1 | start=2 stop=9 | + """ self.assertPyxformXform( - name="data", - md=""" - | survey | | | | | - | | type | name | label | parameters | - | | range | level | Scale | start=1 end=10 step=1 | - """, - xml__contains=[ - '', - '', + md=md, + errored=True, + error__contains=[ + "Accepted parameters are 'end, placeholder, start, step, tick_interval, tick_labelset'. " + "The following are invalid parameter(s): 'stop'." ], ) - # mixed case parameters + def test_parameter_list__ok(self): + """Should not raise an error for known parameters.""" + # RC002 + md = """ + | survey | + | | type | name | label | parameters | + | | range | q1 | Q1 | start=2 end=9 step=1 tick_interval=2 placeholder=6 tick_labelset=c1 | + + | choices | + | | list_name | name | label | + | | c1 | 2 | N1 | + | | c1 | 4 | N2 | + """ self.assertPyxformXform( - name="data", - md=""" - | survey | | | | | - | | type | name | label | parameters | - | | range | level | Scale | Start=3 End=14 STEP=2 | - """, - xml__contains=[ - '', - '', + md=md, + xml__xpath_match=[ + xpq.body_range( + "q1", + { + "start": "2", + "end": "9", + "step": "1", + "odk:tick-interval": "2", + "odk:placeholder": "6", + "odk:tick-labelset": "c1", + }, + ), ], ) - def test_range_type_defaults(self): + def test_parameter_list__mixed_case__ok(self): + """Should not raise an error for known parameters in mixed case.""" + # RC003 + md = """ + | survey | + | | type | name | label | parameters | + | | range | q1 | Q1 | Start=2 eNd=9 SteP=1 TICK_interval=2 placeHOLDER=6 tick_LABELset=c1 | + + | choices | + | | list_name | name | label | + | | c1 | 2 | N1 | + | | c1 | 4 | N2 | + """ self.assertPyxformXform( - name="data", - md=""" - | survey | | | | | - | | type | name | label | parameters | - | | range | level | Scale | | - """, - xml__contains=[ - '', - '', + md=md, + xml__xpath_match=[ + xpq.body_range( + "q1", + { + "start": "2", + "end": "9", + "step": "1", + "odk:tick-interval": "2", + "odk:placeholder": "6", + "odk:tick-labelset": "c1", + }, + ), ], ) + def test_parameter_delimiter_invalid__error(self): + """Should raise an error for invalid delimiters.""" + # RC004 + md = """ + | survey | + | | type | name | label | parameters | + | | range | q1 | Q1 | start=2{sep}end=9 | + """ + cases = (" . ", " & ", "-") + for value in cases: + with self.subTest(value): + self.assertPyxformXform( + md=md.format(sep=value), + errored=True, + error__contains=[ + "Expecting parameters to be in the form of 'parameter1=value parameter2=value'." + ], + ) + + def test_parameter_malformed__error(self): + """Should raise an error if a parameter is malformed.""" + # RC004 + md = """ + | survey | + | | type | name | label | parameters | + | | range | q1 | Q1 | {name}{value} | + """ + params = ("start", "end", "step", "tick_interval", "placeholder") + cases = ("==1", "1", "==1") + for name in params: + for value in cases: + with self.subTest((name, value)): + self.assertPyxformXform( + md=md.format(name=name, value=value), + errored=True, + error__contains=[ + "Expecting parameters to be in the form of 'parameter1=value parameter2=value'." + ], + ) + + def test_parameter_not_a_number__error(self): + """Should raise an error if a numeric parameter has a non-numeric value.""" + # RC005 + md = """ + | survey | + | | type | name | label | parameters | + | | range | q1 | Q1 | {name}={value} | + """ + params = ("start", "end", "step", "tick_interval", "placeholder") + cases = ("", "one") + for name in params: + for value in cases: + with self.subTest((name, value)): + self.assertPyxformXform( + md=md.format(name=name, value=value), + errored=True, + error__contains=[ + ErrorCode.RANGE_001.value.format(row=2, name=name) + ], + ) + + def test_parameter_not_a_number__ok(self): + """Should not raise an error if a numeric parameter has a numeric value.""" + # RC005 + md = """ + | survey | + | | type | name | label | parameters | + | | range | q1 | Q1 | {name}=1 | + """ + # "end=1" is invalid but "end=2 step=1" is ok + params = ( + ("start", {"start": "1"}), + ("end=2 step", {"end": "2", "step": "1"}), + ("step", {"step": "1"}), + ("tick_interval", {"odk:tick-interval": "1"}), + ("placeholder", {"odk:placeholder": "1"}), + ) + for name, attrs in params: + with self.subTest((name, attrs)): + self.assertPyxformXform( + md=md.format(name=name), + xml__xpath_match=[ + xpq.body_range("q1", attrs), + ], + ) + + def test_parameter_is_zero__error(self): + """Should raise an error if the relevant ticks parameter is zero.""" + # RS001 RI001 + md = """ + | survey | + | | type | name | label | parameters | + | | range | q1 | Q1 | {name}=0 | + """ + params = ("step", "tick_interval") + for name in params: + with self.subTest(name): + self.assertPyxformXform( + md=md.format(name=name), + errored=True, + error__contains=[ErrorCode.RANGE_002.value.format(row=2, name=name)], + ) + + def test_parameter_is_zero__ok(self): + """Should not raise an error if the relevant ticks parameter is not zero.""" + # RS001 RI001 + md = """ + | survey | + | | type | name | label | parameters | + | | range | q1 | Q1 | {name}=1 | + """ + params = (("step", "step"), ("tick_interval", "odk:tick-interval")) + for name, attr in params: + with self.subTest((name, attr)): + self.assertPyxformXform( + md=md.format(name=name), + xml__xpath_match=[ + xpq.body_range("q1", {attr: "1"}), + ], + ) + + def test_parameter_greater_than_range__error(self): + """Should raise an error if the relevant ticks parameter is larger than the range.""" + # RS002 RI002 + md = """ + | survey | + | | type | name | label | parameters | + | | range | q1 | Q1 | start=0 end=10 {name}=11 | + """ + params = ("step", "tick_interval") + for name in params: + with self.subTest(name): + self.assertPyxformXform( + md=md.format(name=name), + errored=True, + error__contains=[ErrorCode.RANGE_003.value.format(row=2, name=name)], + ) + + def test_parameter_greater_than_range__ok(self): + """Should not raise an error if the relevant ticks parameter is not larger than the range.""" + # RS002 RI002 + md = """ + | survey | + | | type | name | label | parameters | + | | range | q1 | Q1 | start=1 end=10 {name}={value} | + """ + params = (("step", "step"), ("tick_interval", "odk:tick-interval")) + cases = ("1", "2") + for name, attr in params: + for value in cases: + with self.subTest((name, attr, value)): + self.assertPyxformXform( + md=md.format(name=name, value=value), + xml__xpath_match=[ + xpq.body_range("q1", {attr: value}), + ], + ) + + def test_parameter_not_a_multiple_of_step__error(self): + """Should raise an error if the relevant ticks parameter is not a multiple of 'step'.""" + # RI003 RP001 + md = """ + | survey | + | | type | name | label | parameters | + | | range | q1 | Q1 | start=-3 end=3 step=2 {name}={value} | + """ + params = ("tick_interval", "placeholder") + cases = ("3", "-3") + for name in params: + for value in cases: + with self.subTest((name, value)): + self.assertPyxformXform( + md=md.format(name=name, value=value), + errored=True, + error__contains=[ + ErrorCode.RANGE_004.value.format(row=2, name=name) + ], + ) + + def test_parameter_not_a_multiple_of_step__ok(self): + """Should not raise an error if the relevant ticks parameter is a multiple of 'step'.""" + # RI003 RP001 + md = """ + | survey | + | | type | name | label | parameters | + | | range | q1 | Q1 | start=-1 end=1 step=1 {name}={value} | + """ + params = ( + ("tick_interval", "odk:tick-interval"), + ("placeholder", "odk:placeholder"), + ) + cases = ("1", "-1") + for name, attr in params: + for value in cases: + with self.subTest((name, attr, value)): + self.assertPyxformXform( + md=md.format(name=name, value=value), + xml__xpath_match=[ + xpq.body_range( + "q1", + {"start": "-1", "end": "1", "step": "1", attr: value}, + ), + ], + ) + + def test_placeholder_outside_range__error(self): + """Should raise an error if the placeholder is outside the range.""" + # RP002 + md = """ + | survey | + | | type | name | label | parameters | + | | range | q1 | Q1 | start=3 end=7 step=2 placeholder={value} | + """ + cases = ("2", "8") + for value in cases: + with self.subTest(value): + self.assertPyxformXform( + md=md.format(value=value), + errored=True, + error__contains=[ + ErrorCode.RANGE_005.value.format(row=2, name="placeholder") + ], + ) + + def test_placeholder_outside_range__ok(self): + """Should not raise an error if the placeholder is inside the range.""" + # RP002 + md = """ + | survey | + | | type | name | label | parameters | + | | range | q1 | Q1 | start=0 end=7 step=2 placeholder={value} | + """ + cases = ("2", "6") + for value in cases: + with self.subTest(value): + self.assertPyxformXform( + md=md.format(value=value), + xml__xpath_match=[ + xpq.body_range( + "q1", + { + "start": "0", + "end": "7", + "step": "2", + "odk:placeholder": value, + }, + ), + ], + ) + + def test_tick_labelset_not_found__error(self): + """Should raise an error if the tick_labelset choice list does not exist.""" + # RL001 + md = """ + | survey | + | | type | name | label | parameters | + | | range | q1 | Q1 | tick_labelset=c1 | + """ self.assertPyxformXform( - name="data", - md=""" - | survey | | | | | - | | type | name | label | parameters | - | | range | level | Scale | end=20 | - """, - xml__contains=[ - '', - '', - ], + md=md, + errored=True, + error__contains=[ErrorCode.RANGE_006.value.format(row=2)], ) + def test_tick_labelset_not_found__ok(self): + """Should not raise an error if the tick_labelset choice list exists.""" + # RL001 + md = """ + | survey | + | | type | name | label | parameters | + | | range | q1 | Q1 | tick_labelset=c1 | + + | choices | + | | list_name | name | label | + | | c1 | 1 | N1 | + """ self.assertPyxformXform( - name="data", - md=""" - | survey | | | | - | | type | name | label | - | | range | level | Scale | - """, - xml__contains=[ - '', - '', + md=md, + xml__xpath_match=[ + xpq.body_range("q1", {"odk:tick-labelset": "c1"}), ], ) - def test_range_type_float(self): + def test_tick_labelset_empty__error(self): + """Should raise an error if the tick_labelset choice list is empty.""" + # RL002 + # Not exactly possible to have an empty list but this shows what happens. + md = """ + | survey | + | | type | name | label | parameters | + | | range | q1 | Q1 | tick_labelset=c1 | + + | choices | + | | list_name | name | label | + | | c1 | | N1 | + """ self.assertPyxformXform( - name="data", - md=""" - | survey | | | | | - | | type | name | label | parameters | - | | range | level | Scale | start=0.5 end=5.0 step=0.5 | - """, - xml__contains=[ - '', - '', - ], + md=md, + errored=True, + error__contains=[ErrorCode.NAMES_006.value.format(row=2)], ) - def test_range_type_invalid_parameters(self): - # 'increment' is an invalid property + def test_tick_labelset_no_ticks_too_many_choices__error(self): + """Should raise an error if the tick_labelset choices has >2 items with no-ticks.""" + # RL005 + md = """ + | survey | + | | type | name | label | parameters | appearance | + | | range | q1 | Q1 | step=1 tick_labelset=c1 | no-ticks | + + | choices | + | | list_name | name | label | + | | c1 | 1 | N1 | + | | c1 | 2 | N2 | + | | c1 | 3 | N3 | + """ self.assertPyxformXform( - name="data", - md=""" - | survey | | | | | - | | type | name | label | parameters | - | | range | level | Scale | increment=0.5 end=21.5 | - """, + md=md, errored=True, + error__contains=[ErrorCode.RANGE_007.value.format(row=2)], + ) + + def test_tick_labelset_no_ticks_too_many_choices__ok(self): + """Should not raise an error if 2 choices with no-ticks are start/end.""" + # RL005 + md = """ + | survey | + | | type | name | label | parameters | appearance | + | | range | q1 | Q1 | tick_labelset=c1 | no-ticks | + + | choices | + | | list_name | name | label | + | | c1 | 1 | N1 | + | | c1 | 10 | N2 | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpq.body_range( + "q1", {"odk:tick-labelset": "c1", "appearance": "no-ticks"} + ), + ], ) + def test_tick_labelset_no_ticks_too_many_choices__no_duplicates__error(self): + """Should raise an error for >2 choices with no-ticks when duplicates are not allowed.""" + # RL005 + md = """ + | survey | + | | type | name | label | parameters | appearance | + | | range | q1 | Q1 | step=1 tick_labelset=c1 | no-ticks | + + | choices | + | | list_name | name | label | + | | c1 | 1 | N1 | + | | c1 | 1 | N2 | + | | c1 | 10 | N3 | + """ self.assertPyxformXform( - name="data", - md=""" - | survey | | | | | - | | type | name | label | parameters | - | | range | level | Scale | start=0.5 end=X step=0.5 | - """, + md=md, errored=True, + error__contains=[ErrorCode.NAMES_007.value.format(row=3)], + ) + + def test_tick_labelset_no_ticks_too_many_choices__allow_duplicates__ok(self): + """Should not raise an error for >2 choices with no-ticks when duplicates are allowed.""" + # RL005 + md = """ + | settings | + | | allow_choice_duplicates | + | | yes | + + | survey | + | | type | name | label | parameters | appearance | + | | range | q1 | Q1 | step=1 tick_labelset=c1 | no-ticks | + + | choices | + | | list_name | name | label | + | | c1 | 1 | N1 | + | | c1 | 1 | N2 | + | | c1 | 10 | N3 | + """ + self.assertPyxformXform( + md=md, + xml__xpath_match=[ + xpq.body_range( + "q1", {"odk:tick-labelset": "c1", "appearance": "no-ticks"} + ), + ], ) + def test_tick_labelset_no_ticks_choices_not_start_end__error(self): + """Should raise an error if the tick_labelset choices with no-ticks are not start/end.""" + # RL005 + md = """ + | survey | + | | type | name | label | parameters | appearance | + | | range | q1 | Q1 | tick_labelset=c1 | no-ticks | + + | choices | + | | list_name | name | label | + | | c1 | 1 | N1 | + | | c1 | 9 | N2 | + """ self.assertPyxformXform( - name="data", - md=""" - | survey | | | | | - | | type | name | label | parameters | - | | range | level | Scale | start | - """, + md=md, errored=True, + error__contains=[ErrorCode.RANGE_012.value.format(row=2)], + ) + + def test_parameters_not_compatible_with_appearance__error(self): + """Should raise an error if the appearance parameters are not supported.""" + # RC006 + md = """ + | survey | + | | type | name | label | parameters | appearance | + | | range | q1 | Q1 | step=6 {param} | {value} | + + | choices | + | | list_name | name | label | + | | c1 | 1 | N1 | + """ + params = ("tick_interval=1", "placeholder=1", "tick_labelset=c1") + cases = ("picker", "rating", "someting-new") + for param in params: + for value in cases: + with self.subTest((param, value)): + self.assertPyxformXform( + md=md.format(param=param, value=value), + errored=True, + error__contains=[ErrorCode.RANGE_008.value.format(row=2)], + ) + + def test_parameters_not_compatible_with_appearance__ok(self): + """Should not raise an error if the appearance parameters are supported.""" + # RC006 + md = """ + | survey | + | | type | name | label | parameters | appearance | + | | range | q1 | Q1 | step=1 {param} | {value} | + + | choices | + | | list_name | name | label | + | | c1 | 1 | N1 | + | | c1 | 10 | N2 | + """ + params = ( + ("tick_interval=2", {"odk:tick-interval": "2"}), + ("placeholder=3", {"odk:placeholder": "3"}), + ("tick_labelset=c1", {"odk:tick-labelset": "c1"}), ) + cases = ("", "vertical", "no-ticks") + for param, attr in params: + for value in cases: + with self.subTest((param, attr, value)): + self.assertPyxformXform( + md=md.format(param=param, value=value), + xml__xpath_match=[ + xpq.body_range("q1", attr), + ], + ) + + def test_tick_labelset_choice_is_not_a_number__error(self): + """Should raise an error if any tick_labelset choices are not numeric.""" + # RC005 + md = """ + | survey | + | | type | name | label | parameters | + | | range | q1 | Q1 | tick_labelset=c1 | + + | choices | + | | list_name | name | label | + | | c1 | {value} | N1 | + """ + cases = ("n1", "one", "1n", "infinity") + for value in cases: + with self.subTest(value): + self.assertPyxformXform( + md=md.format(value=value), + errored=True, + error__contains=[ErrorCode.RANGE_009.value.format(row=2)], + ) - def test_range_semicolon_separator(self): + def test_tick_labelset_choice_is_not_a_number__ok(self): + """Should not raise an error if the tick_labelset choices are numeric.""" + # RC005 + md = """ + | survey | + | | type | name | label | parameters | + | | range | q1 | Q1 | tick_labelset=c1 | + + | choices | + | | list_name | name | label | + | | c1 | {value} | N1 | + """ + cases = ("1", "1.0") + for value in cases: + with self.subTest(value): + self.assertPyxformXform( + md=md.format(value=value), + xml__xpath_match=[ + xpq.model_instance_bind("q1", "int"), + xpq.body_range("q1", {"odk:tick-labelset": "c1"}), + ], + ) + + def test_tick_labelset_choice_outside_range__error(self): + """Should raise an error if any tick_labelset choices are outside the range.""" + # RL004 + md = """ + | survey | + | | type | name | label | parameters | + | | range | q1 | Q1 | start=3 end=7 step=2 tick_labelset=c1 | + + | choices | + | | list_name | name | label | + | | c1 | {value} | N1 | + """ + cases = ("1", "2") + for value in cases: + with self.subTest(value): + self.assertPyxformXform( + md=md.format(value=value), + errored=True, + error__contains=[ErrorCode.RANGE_010.value.format(row=2)], + ) + + def test_tick_labelset_choice_outside_range__ok(self): + """Should not raise an error if any tick_labelset choices are inside the range.""" + # RL004 + md = """ + | survey | + | | type | name | label | parameters | + | | range | q1 | Q1 | start=0 end=7 step=1 tick_labelset=c1 | + + | choices | + | | list_name | name | label | + | | c1 | {value} | N1 | + """ + cases = ("1", "2") + for value in cases: + with self.subTest(value): + self.assertPyxformXform( + md=md.format(value=value), + xml__xpath_match=[ + xpq.body_range( + "q1", + { + "start": "0", + "end": "7", + "step": "1", + "odk:tick-labelset": "c1", + }, + ), + ], + ) + + def test_tick_labelset_choice_not_a_multiple_of_tick__error(self): + """Should raise an error if the relevant ticks parameter is not a multiple of 'step'.""" + # RL003 + md = """ + | survey | + | | type | name | label | parameters | + | | range | q1 | Q1 | start=0 end=7 {name}=3 tick_labelset=c1 | + + | choices | + | | list_name | name | label | + | | c1 | {value} | N1 | + """ + params = ("step", "tick_interval") + cases = ("2", "4") + for name in params: + for value in cases: + with self.subTest((name, value)): + self.assertPyxformXform( + md=md.format(name=name, value=value), + errored=True, + error__contains=[ + ErrorCode.RANGE_011.value.format(row=2, name=name) + ], + ) + + def test_tick_labelset_choice_not_a_multiple_of_step__ok(self): + """Should not raise an error if the relevant ticks parameter is a multiple of 'step'.""" + # RL003 + md = """ + | survey | + | | type | name | label | parameters | + | | range | q1 | Q1 | start=0 end=7 {name}=1 tick_labelset=c1 | + + | choices | + | | list_name | name | label | + | | c1 | 1 | N1 | + """ + params = ("step", "tick_interval") + for name in params: + with self.subTest(name): + self.assertPyxformXform( + md=md.format(name=name), + xml__xpath_match=[ + xpq.body_range( + "q1", + { + "start": "0", + "end": "7", + "step": "1", + "odk:tick-labelset": "c1", + }, + ), + ], + ) + + def test_tick_labelset_choice_not_a_multiple_of_step__both__ok(self): + """Should not raise an error if the choice is a multiple of 'step'.""" + # RL003 + md = """ + | survey | + | | type | name | label | parameters | + | | range | q1 | Q1 | start=1 end=12 step=2 tick_interval=4 tick_labelset=c1 | + + | choices | + | | list_name | name | label | + | | c1 | 8 | N1 | + """ self.assertPyxformXform( - name="data", - md=""" - | survey | | | | | - | | type | name | label | parameters | - | | range | level | Scale | start=1;end=10;step=1 | - """, - xml__contains=[ - '', - '', + md=md, + xml__xpath_match=[ + xpq.body_range( + "q1", + { + "start": "1", + "end": "12", + "step": "2", + "odk:tick-interval": "4", + "odk:tick-labelset": "c1", + }, + ), ], ) - def test_range_comma_separator(self): + def test_range_spec_patterns__ok(self): + """Should not raise an error for valid positive/negative and/or asc/desc ranges.""" + # RC007 + md = """ + | survey | + | | type | name | label | parameters | + | | range | q1 | Q1 | {params} | + """ + cases = ( + {"start": "-10", "end": "-1", "step": "1"}, # neg/asc + {"start": "-10", "end": "-1", "step": "-1"}, # neg/empty + {"start": "-1", "end": "-10", "step": "1"}, # neg/empty + {"start": "-1", "end": "-10", "step": "-1"}, # neg/desc + {"start": "1", "end": "10", "step": "1"}, # pos/asc + {"start": "1", "end": "10", "step": "-1"}, # pos/empty + {"start": "10", "end": "1", "step": "1"}, # pos/empty + {"start": "10", "end": "1", "step": "-1"}, # pos/desc + ) + for params in cases: + with self.subTest(params): + self.assertPyxformXform( + md=md.format(params=" ".join(f"{k}={v}" for k, v in params.items())), + xml__xpath_match=[xpq.body_range("q1", params)], + ) + + +class TestRangeOutput(PyxformTestCase): + def test_defaults(self): + """Should find that default values are output as attributes of the range control.""" + # RB001 RB003 + md = """ + | survey | + | | type | name | label | parameters | + | | range | q1 | Q1 | | + """ self.assertPyxformXform( - name="data", - md=""" - | survey | | | | | - | | type | name | label | parameters | - | | range | level | Scale | start=1,end=10,step=1 | - """, - xml__contains=[ - '', - '', + md=md, + xml__xpath_match=[ + xpq.model_instance_item("q1"), + xpq.model_instance_bind("q1", "int"), + xpq.body_range("q1"), ], ) + def test_parameters__numeric__int(self): + """Should find that user values are output as attributes of the range control.""" + # RB001 RB002 RB003 + md = """ + | survey | + | | type | name | label | parameters | + | | range | q1 | Q1 | start=3 end=13 step=2 tick_interval=2 placeholder=6 tick_labelset=c1 | + + | choices | + | | list_name | name | label | + | | c1 | 4 | N1 | + | | c1 | 8 | N2 | + """ self.assertPyxformXform( - name="data", - md=""" - | survey | | | | | - | | type | name | label | parameters | - | | range | level | Scale | start=1 , end=10 , step=1 | - """, - xml__contains=[ - '', - '', + md=md, + xml__xpath_match=[ + xpq.model_instance_item("q1"), + xpq.model_instance_bind("q1", "int"), + xpq.body_range( + "q1", + { + "start": "3", + "end": "13", + "step": "2", + "odk:tick-interval": "2", + "odk:placeholder": "6", + "odk:tick-labelset": "c1", + }, + ), ], ) + def test_parameters__numeric__decimal(self): + """Should find that user values are output as attributes of the range control.""" + # RB001 RB002 RB004 + md = """ + | survey | + | | type | name | label | parameters | + | | range | q1 | Q1 | start=0.5 end=5.0 step=0.5 tick_interval=1.5 placeholder=2.5 tick_labelset=c1 | + + | choices | + | | list_name | name | label | + | | c1 | 1.5 | N1 | + | | c1 | 3.0 | N2 | + """ self.assertPyxformXform( - name="data", - md=""" - | survey | | | | | - | | type | name | label | parameters | - | | range | level | Scale | start = 1 , end = 10 , step = 2 | - """, - xml__contains=[ - '', - '', + md=md, + xml__xpath_match=[ + xpq.model_instance_item("q1"), + xpq.model_instance_bind("q1", "decimal"), + xpq.body_range( + "q1", + { + "start": "0.5", + "end": "5.0", + "step": "0.5", + "odk:tick-interval": "1.5", + "odk:placeholder": "2.5", + "odk:tick-labelset": "c1", + }, + ), ], ) diff --git a/tests/xpath_helpers/questions.py b/tests/xpath_helpers/questions.py index 3b9e80e59..4b536d92b 100644 --- a/tests/xpath_helpers/questions.py +++ b/tests/xpath_helpers/questions.py @@ -1,3 +1,6 @@ +from pyxform.question_type_dictionary import QUESTION_TYPE_DICT + + class XPathHelper: """ XPath expressions for questions assertions. @@ -170,7 +173,7 @@ def body_control( def body_upload_tags(qname: str, tags: tuple[tuple[str, ...], ...]) -> str: """Body has osm upload control with tags data inline.""" tags_xp = "\n and ".join( - (f"""./x:tag[@key='{k}']/x:label[text()='{v}']""" for k, v in tags) + f"""./x:tag[@key='{k}']/x:label[text()='{v}']""" for k, v in tags ) return f""" /h:html/h:body/x:upload[ @@ -180,5 +183,17 @@ def body_upload_tags(qname: str, tags: tuple[tuple[str, ...], ...]) -> str: ] """ + @staticmethod + def body_range(qname: str, attrs: dict[str, str] | None = None) -> str: + parameters = QUESTION_TYPE_DICT["range"]["parameters"].copy() + if attrs is not None: + parameters.update(attrs) + attrs = " and ".join(f"@{k}='{v}'" for k, v in parameters.items()) + return f""" + /h:html/h:body/x:range[ + @ref='/test_name/{qname}' and {attrs} + ] + """ + xpq = XPathHelper()