From 59c37e0e2d1313c5dabe6becf71f4e5a44e9de97 Mon Sep 17 00:00:00 2001 From: Lindsay Stevens Date: Wed, 19 Jun 2024 08:37:24 +1000 Subject: [PATCH] fix: instance expressions with double quotes not escaped / converted (#709) - like other functions, argument value(s) (instance name) should be allowed to be wrapped in single or double quotes. Previously only singles quotes worked. --- pyxform/parsing/instance_expression.py | 7 ++- tests/test_notes.py | 59 ++++++++++++++++++++++---- 2 files changed, 56 insertions(+), 10 deletions(-) diff --git a/pyxform/parsing/instance_expression.py b/pyxform/parsing/instance_expression.py index 5a913c37..09ee91c8 100644 --- a/pyxform/parsing/instance_expression.py +++ b/pyxform/parsing/instance_expression.py @@ -1,7 +1,7 @@ import re from typing import TYPE_CHECKING -from pyxform.utils import BRACKETED_TAG_REGEX, EXPRESSION_LEXER, ExpLexerToken +from pyxform.utils import BRACKETED_TAG_REGEX, EXPRESSION_LEXER, ExpLexerToken, node if TYPE_CHECKING: from pyxform.survey import Survey @@ -116,7 +116,10 @@ def replace_with_output(xml_text: str, context: "SurveyElement", survey: "Survey lambda m: survey._var_repl_function(m, context), old_str, ) - new_strings.append((start, end, old_str, f'')) + # Generate a node so that character escapes are applied. + new_strings.append( + (start, end, old_str, node("output", value=new_str).toxml()) + ) # Position-based replacement avoids strings which are substrings of other # replacements being inserted incorrectly. Offset tracking deals with changing # expression positions due to incremental replacement. diff --git a/tests/test_notes.py b/tests/test_notes.py index 6cbb9a55..4c76991b 100644 --- a/tests/test_notes.py +++ b/tests/test_notes.py @@ -2,7 +2,7 @@ Test the "note" question type. """ -from dataclasses import dataclass +from dataclasses import dataclass, field from tests.pyxform_test_case import PyxformTestCase from tests.xpath_helpers.questions import xpq @@ -15,8 +15,8 @@ class Case: """ label: str - xpath: str match: set[str] + xpath: str = field(default_factory=lambda: xpq.body_input_label_output_value("note")) class TestNotes(PyxformTestCase): @@ -77,45 +77,88 @@ def test_instance_expression__permutations(self): | | c2 | b | Big | | | c2 | s | Small | """ + # It's a bit confusing, but although double quotes are literally HTML in entity + # form (i.e. `"`) in the output, for pyxform test comparisons they get + # converted back, so the expected output strings are double quotes not `"`. cases = [ # A pyxform token. Case( "${text}", - xpq.body_input_label_output_value("note"), {" /test_name/text "}, ), # Instance expression with predicate using pyxform token and equals. Case( "instance('c1')/root/item[name = ${q1}]/label", - xpq.body_input_label_output_value("note"), {"instance('c1')/root/item[name = /test_name/q1 ]/label"}, ), + # Instance expression with predicate using pyxform token and equals (double quotes). + Case( + """instance("c1")/root/item[name = ${q1}]/label""", + {"""instance("c1")/root/item[name = /test_name/q1 ]/label"""}, + ), # Instance expression with predicate using pyxform token and function. Case( "instance('c2')/root/item[contains(name, ${q2})]/label", - xpq.body_input_label_output_value("note"), {"instance('c2')/root/item[contains(name, /test_name/q2 )]/label"}, ), + # Instance expression with predicate using pyxform token and function (double quotes). + Case( + """instance("c2")/root/item[contains("name", ${q2})]/label""", + {"""instance("c2")/root/item[contains("name", /test_name/q2 )]/label"""}, + ), + # Instance expression with predicate using pyxform token and function (mixed quotes). + Case( + """instance('c2')/root/item[contains("name", ${q2})]/label""", + {"""instance('c2')/root/item[contains("name", /test_name/q2 )]/label"""}, + ), # Instance expression with predicate using pyxform token and equals. Case( "instance('c2')/root/item[contains(name, instance('c1')/root/item[name = ${q1}]/label)]/label", - xpq.body_input_label_output_value("note"), { "instance('c2')/root/item[contains(name, instance('c1')/root/item[name = /test_name/q1 ]/label)]/label" }, ), + # Instance expression with predicate using pyxform token and equals (double quotes). + Case( + """instance("c2")/root/item[contains(name, instance("c1")/root/item[name = ${q1}]/label)]/label""", + { + """instance("c2")/root/item[contains(name, instance("c1")/root/item[name = /test_name/q1 ]/label)]/label""" + }, + ), + # Instance expression with predicate using pyxform token and equals (mixed quotes). + Case( + """instance('c2')/root/item[contains(name, instance("c1")/root/item[name = ${q1}]/label)]/label""", + { + """instance('c2')/root/item[contains(name, instance("c1")/root/item[name = /test_name/q1 ]/label)]/label""" + }, + ), # Instance expression with predicate not using a pyxform token. Case( "instance('c1')/root/item[name = 'y']/label", - xpq.body_input_label_output_value("note"), {"instance('c1')/root/item[name = 'y']/label"}, ), + # Instance expression with predicate not using a pyxform token (double quotes). + Case( + """instance("c1")/root/item[name = "y"]/label""", + {"""instance("c1")/root/item[name = "y"]/label"""}, + ), + # Instance expression with predicate not using a pyxform token (mixed quotes). + Case( + """instance("c1")/root/item[name = 'y']/label""", + {"""instance("c1")/root/item[name = 'y']/label"""}, + ), + # Instance expression with predicate not using a pyxform token (all escaped). + Case( + """instance("c1")/root/item[name <> 1 and "<>&" = "1"]/label""", + { + """instance("c1")/root/item[name <> 1 and "<>&" = "1"]/label""" + }, + ), ] wrap_scenarios = ("{}", "Text {}", "{} text", "Text {} text") # All cases together in one. combo_case = Case( " ".join(c.label for c in cases), - xpq.body_input_label_output_value("note"), {m for c in cases for m in c.match}, ) cases.append(combo_case)