diff --git a/pyxform/errors.py b/pyxform/errors.py
index bcb118cc..25123527 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 "
+ "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 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 equal to the start of the range plus 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 b934322c..66021a97 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 ee5769a6..4647aaa2 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 5a959524..f8be04d8 100644
--- a/pyxform/validators/pyxform/question_types.py
+++ b/pyxform/validators/pyxform/question_types.py
@@ -3,9 +3,14 @@
"""
from collections.abc import Collection, Iterable
+from decimal import Decimal, InvalidOperation
+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 +105,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) -> Decimal | None:
+ value = parameters.get(name)
+ if value is None:
+ return value
+ err = False
+ try:
+ value = Decimal(value)
+ except InvalidOperation:
+ 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 - start) % step != 0:
+ raise PyXFormError(
+ ErrorCode.RANGE_004.value.format(row=row_number, name="placeholder")
+ )
+ if placeholder < min(start, end) or placeholder > max(start, 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 item in tick_list:
+ errored = False
+ try:
+ value = Decimal(item.get("name"))
+ except InvalidOperation:
+ errored = True
+
+ if errored or isinf(value):
+ raise PyXFormError(ErrorCode.RANGE_009.value.format(row=row_number))
+
+ if value < min(start, end) or value > max(start, end):
+ raise PyXFormError(ErrorCode.RANGE_010.value.format(row=row_number))
+ if tick_interval is not None and (value - start) % tick_interval != 0:
+ raise PyXFormError(
+ ErrorCode.RANGE_011.value.format(row=row_number, name="tick_interval")
+ )
+ elif (value - start) % step != 0:
+ raise PyXFormError(
+ ErrorCode.RANGE_011.value.format(row=row_number, name="step")
+ )
+ if no_ticks_appearance:
+ no_ticks_labels.add(value)
+
+ 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 values have decimals then change the bind type.
+ if any(
+ i is not None and not i == i.to_integral_value()
+ 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 6a0b2022..5d8c1bed 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 10cd8682..4b15189d 100644
--- a/tests/test_range.py
+++ b/tests/test_range.py
@@ -1,176 +1,1026 @@
"""
-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 RangeWidgetTest(PyxformTestCase):
- def test_range_type(self):
- # parameters column
+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"}),
+ ],
+ )
+
+ 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_tick_interval_not_a_multiple_of_step__error(self):
+ """Should raise an error if tick interval is not a multiple of 'step'."""
+ # RI003 RP001
+ md = """
+ | survey |
+ | | type | name | label | parameters |
+ | | range | q1 | Q1 | start=-3 end=3 step=2 tick_interval={value} |
+ """
+ cases = ("-3", "3", "-1", "1")
+ for value in cases:
+ with self.subTest(("tick_interval", value)):
+ self.assertPyxformXform(
+ md=md.format(name="tick_interval", value=value),
+ errored=True,
+ error__contains=[
+ ErrorCode.RANGE_004.value.format(row=2, name="tick_interval")
+ ],
+ )
+
+ def test_placeholder_not_a_multiple_of_step__error(self):
+ """Should raise an error if the placeholder is not a multiple of 'step' starting at 'start'."""
+ # RI003 RP001
+ md = """
+ | survey |
+ | | type | name | label | parameters |
+ | | range | q1 | Q1 | start=-3 end=3 step=2 placeholder={value} |
+ """
+ cases = ("-2", "2", "0")
+ for value in cases:
+ with self.subTest(("placeholder", value)):
+ self.assertPyxformXform(
+ md=md.format(name="placeholder", value=value),
+ errored=True,
+ error__contains=[
+ ErrorCode.RANGE_004.value.format(row=2, name="placeholder")
+ ],
+ )
+
+ 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_parameter_not_a_multiple_of_step_decimal__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=0.1 {name}={value} |
+ """
+ params = (
+ ("tick_interval", "odk:tick-interval"),
+ ("placeholder", "odk:placeholder"),
+ )
+ cases = ("0.6", "-0.6")
+ 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": "0.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 = ("1", "9")
+ 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_inverted_range__error(self):
+ """Should raise an error if the placeholder is outside an inverted range."""
+ # RP002
+ md = """
+ | survey |
+ | | type | name | label | parameters |
+ | | range | q1 | Q1 | start=7 end=3 step=2 placeholder={value} |
+ """
+ cases = ("9", "1")
+ 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_inside_inverted_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=7 end=3 step=2 placeholder={value} |
+ """
+ cases = ("7", "5", "3")
+ for value in cases:
+ with self.subTest(value):
+ self.assertPyxformXform(
+ md=md.format(value=value),
+ xml__xpath_match=[
+ xpq.body_range(
+ "q1",
+ {
+ "start": "7",
+ "end": "3",
+ "step": "2",
+ "odk:placeholder": value,
+ },
+ ),
+ ],
+ )
+
+ def test_placeholder_inside_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=1 end=7 step=2 placeholder={value} |
+ """
+ cases = ("1", "3", "5", "7")
+ for value in cases:
+ with self.subTest(value):
+ self.assertPyxformXform(
+ md=md.format(value=value),
+ xml__xpath_match=[
+ xpq.body_range(
+ "q1",
+ {
+ "start": "1",
+ "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(
- name="data",
- md="""
- | survey | | | | |
- | | type | name | label | parameters |
- | | range | level | Scale | start=0.5 end=X step=0.5 |
- """,
+ 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(
+ 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_range_semicolon_separator(self):
+ 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_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_inverted_range__error(self):
+ """Should raise an error if any tick_labelset choices are outside the inverted range."""
+ # RL004
+ md = """
+ | survey |
+ | | type | name | label | parameters |
+ | | range | q1 | Q1 | start=7 end=3 step=2 tick_labelset=c1 |
+
+ | choices |
+ | | list_name | name | label |
+ | | c1 | {value} | N1 |
+ """
+ cases = ("9", "1")
+ 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_outside_inverted_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=7 end=3 step=2 tick_labelset=c1 |
+
+ | choices |
+ | | list_name | name | label |
+ | | c1 | {value} | N1 |
+ """
+ cases = ("7", "5", "3")
+ for value in cases:
+ with self.subTest(value):
+ self.assertPyxformXform(
+ md=md.format(value=value),
+ xml__xpath_match=[
+ xpq.body_range(
+ "q1",
+ {
+ "start": "7",
+ "end": "3",
+ "step": "2",
+ "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_aligned_with_tick_interval__both__ok(self):
+ """Should not raise an error if the choice is aligned with ticks."""
+ # 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 | 1 | N1 |
+ | | c1 | 5 | N2 |
+ | | c1 | 9 | N3 |
+ """
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=7 tick_labelset=c1 |
+
+ | choices |
+ | | list_name | name | label |
+ | | c1 | 5 | N1 |
+ | | c1 | 11 | 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": "7",
+ "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 | 2.0 | N1 |
+ | | c1 | 3.5 | 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 3b9e80e5..4b536d92 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()