Commit 46ae2f9c by RobertMarks

Added support for a new problem type: ChoicetextResponse

parent c789642e
...@@ -81,3 +81,4 @@ Felix Sun <felixsun@mit.edu> ...@@ -81,3 +81,4 @@ Felix Sun <felixsun@mit.edu>
Adam Palay <adam@edx.org> Adam Palay <adam@edx.org>
Ian Hoover <ihoover@edx.org> Ian Hoover <ihoover@edx.org>
Mukul Goyal <miki@edx.org> Mukul Goyal <miki@edx.org>
Robert Marks <rmarks@edx.org>
...@@ -5,6 +5,7 @@ These are notable changes in edx-platform. This is a rolling list of changes, ...@@ -5,6 +5,7 @@ These are notable changes in edx-platform. This is a rolling list of changes,
in roughly chronological order, most recent first. Add your entries at or near in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected. the top. Include a label indicating the component affected.
Common: Added *experimental* support for jsinput type. Common: Added *experimental* support for jsinput type.
Common: Added setting to specify Celery Broker vhost Common: Added setting to specify Celery Broker vhost
...@@ -21,6 +22,8 @@ Studio: Added support for uploading and managing PDF textbooks ...@@ -21,6 +22,8 @@ Studio: Added support for uploading and managing PDF textbooks
Common: Student information is now passed to the tracking log via POST instead of GET. Common: Student information is now passed to the tracking log via POST instead of GET.
Blades: Added functionality and tests for new capa input type: choicetextresponse.
Common: Add tests for documentation generation to test suite Common: Add tests for documentation generation to test suite
Blades: User answer now preserved (and changeable) after clicking "show answer" in choice problems Blades: User answer now preserved (and changeable) after clicking "show answer" in choice problems
...@@ -43,7 +46,7 @@ history of background tasks for a given problem and student. ...@@ -43,7 +46,7 @@ history of background tasks for a given problem and student.
Blades: Small UX fix on capa multiple-choice problems. Make labels only Blades: Small UX fix on capa multiple-choice problems. Make labels only
as wide as the text to reduce accidental choice selections. as wide as the text to reduce accidental choice selections.
Studio: Studio:
- use xblock field defaults to initialize all new instances' fields and - use xblock field defaults to initialize all new instances' fields and
only use templates as override samples. only use templates as override samples.
- create new instances via in memory create_xmodule and related methods rather - create new instances via in memory create_xmodule and related methods rather
......
...@@ -1368,3 +1368,211 @@ class AnnotationInput(InputTypeBase): ...@@ -1368,3 +1368,211 @@ class AnnotationInput(InputTypeBase):
return extra_context return extra_context
registry.register(AnnotationInput) registry.register(AnnotationInput)
class ChoiceTextGroup(InputTypeBase):
"""
Groups of radiobutton/checkboxes with text inputs.
Allows for a "not enough information" option to be added
to problems with numerical answers.
Examples:
RadioButton problem
<problem>
<startouttext/>
A person rolls a standard die 100 times and records the results.
On the first roll they received a "1". Given this information
select the correct choice and fill in numbers to make it accurate.
<endouttext/>
<choicetextresponse>
<radiotextgroup>
<choice correct="false">The lowest number rolled was:
<decoy_input/> and the highest number rolled was:
<decoy_input/> .</choice>
<choice correct="true">The lowest number rolled was <numtolerance_input answer="1"/>
and there is not enough information to determine the highest number rolled.
</choice>
<choice correct="false">There is not enough information to determine the lowest
number rolled, and the highest number rolled was:
<decoy_input/> .
</choice>
</radiotextgroup>
</choicetextresponse>
</problem>
CheckboxProblem:
<problem>
<startouttext/>
A person randomly selects 100 times, with replacement, from the list of numbers \(\sqrt{2}\) , 2, 3, 4 ,5 ,6
and records the results. The first number they pick is \(\sqrt{2}\) Given this information
select the correct choices and fill in numbers to make them accurate.
<endouttext/>
<choicetextresponse>
<checkboxtextgroup>
<choice correct="true">
The lowest number selected was <numtolerance_input answer="1.4142" tolerance="0.01"/>
</choice>
<choice correct="false">
The highest number selected was <decoy_input/> .
</choice>
<choice correct="true">There is not enough information given to determine the highest number
which was selected.
</choice>
<choice correct="false">There is not enough information given to determine the lowest number
selected.
</choice>
</checkboxtextgroup>
</choicetextresponse>
</problem>
In the preceding examples the <decoy_input/> is used to generate a textinput html element
in the problem's display. Since it is inside of an incorrect choice, no answer given
for it will be correct, and thus specifying an answer for it is not needed.
"""
template = "choicetext.html"
tags = ['radiotextgroup', 'checkboxtextgroup']
def setup(self):
"""
Performs setup for the initial rendering of the problem.
`self.html_input_type` determines whether this problem is displayed
with radiobuttons or checkboxes
If the initial value of `self.value` is '' change it to {} so that
the template has an empty dictionary to work with.
sets the value of self.choices to be equal to the return value of
`self.extract_choices`
"""
self.text_input_values = {}
if self.tag == 'radiotextgroup':
self.html_input_type = "radio"
elif self.tag == 'checkboxtextgroup':
self.html_input_type = "checkbox"
else:
raise Exception("ChoiceGroup: unexpected tag {0}".format(self.tag))
if self.value == '':
# Make `value` an empty dictionary, if it currently has an empty
# value. This is necessary because the template expects a
# dictionary.
self.value = {}
self.choices = self.extract_choices(self.xml)
@classmethod
def get_attributes(cls):
"""
Returns a list of `Attribute` for this problem type
"""
return [
Attribute("show_correctness", "always"),
Attribute("submitted_message", "Answer received.")
]
def _extra_context(self):
"""
Returns a dictionary of extra content necessary for rendering this InputType.
`input_type` is either 'radio' or 'checkbox' indicating whether the choices for
this problem will have radiobuttons or checkboxes.
"""
return {
'input_type': self.html_input_type,
'choices': self.choices
}
@staticmethod
def extract_choices(element):
"""
Extracts choices from the xml for this problem type.
If we have xml that is as follows(choice names will have been assigned
by now)
<radiotextgroup>
<choice correct = "true" name ="1_2_1_choiceinput_0bc">
The number
<numtolerance_input name = "1_2_1_choiceinput0_numtolerance_input_0" answer="5"/>
Is the mean of the list.
</choice>
<choice correct = "false" name = "1_2_1_choiceinput_1bc>
False demonstration choice
</choice>
</radiotextgroup>
Choices are used for rendering the problem properly
The function will setup choices as follows:
choices =[
("1_2_1_choiceinput_0bc",
[{'type': 'text', 'contents': "The number", 'tail_text': '',
'value': ''
},
{'type': 'textinput',
'contents': "1_2_1_choiceinput0_numtolerance_input_0",
'tail_text': 'Is the mean of the list',
'value': ''
}
]
),
("1_2_1_choiceinput_1bc",
[{'type': 'text', 'contents': "False demonstration choice",
'tail_text': '',
'value': ''
}
]
)
]
"""
choices = []
for choice in element:
if choice.tag != 'choice':
raise Exception(
"[capa.inputtypes.extract_choices] Expected a <choice>" +
"tag; got {0} instead".format(choice.tag)
)
components = []
choice_text = ''
if choice.text is not None:
choice_text += choice.text
# Initialize our dict for the next content
adder = {
'type': 'text',
'contents': choice_text,
'tail_text': '',
'value': ''
}
components.append(adder)
for elt in choice:
# for elements in the choice e.g. <text> <numtolerance_input>
adder = {
'type': 'text',
'contents': '',
'tail_text': '',
'value': ''
}
tag_type = elt.tag
# If the current `elt` is a <numtolerance_input> set the
# `adder`type to 'numtolerance_input', and 'contents' to
# the `elt`'s name.
# Treat decoy_inputs and numtolerance_inputs the same in order
# to prevent students from reading the Html and figuring out
# which inputs are valid
if tag_type in ('numtolerance_input', 'decoy_input'):
# We set this to textinput, so that we get a textinput html
# element.
adder['type'] = 'textinput'
adder['contents'] = elt.get('name')
else:
adder['contents'] = elt.text
# Add any tail text("is the mean" in the example)
adder['tail_text'] = elt.tail if elt.tail else ''
components.append(adder)
# Add the tuple for the current choice to the list of choices
choices.append((choice.get("name"), components))
return choices
registry.register(ChoiceTextGroup)
<% element_checked = False %>
% for choice_id, _ in choices:
<%choice_id = choice_id %>
%if choice_id in value:
<% element_checked = True %>
%endif
%endfor
<section id="choicetextinput_${id}" class="choicetextinput">
<form class="choicetextgroup capa_inputtype" id="inputtype_${id}">
<div class="script_placeholder" data-src="/static/js/capa/choicetextinput.js"/>
<div class="indicator_container">
% if input_type == 'checkbox' or not element_checked:
% if status == 'unsubmitted':
<span class="unanswered" style="display:inline-block;" id="status_${id}"></span>
% elif status == 'correct':
<span class="correct" id="status_${id}"></span>
% elif status == 'incorrect':
<span class="incorrect" id="status_${id}"></span>
% elif status == 'incomplete':
<span class="incorrect" id="status_${id}"></span>
% endif
% endif
</div>
<fieldset>
% for choice_id, choice_description in choices:
<%choice_id= choice_id %>
<section id="forinput${choice_id}"
% if input_type == 'radio' and choice_id in value :
<%
if status == 'correct':
correctness = 'correct'
elif status == 'incorrect':
correctness = 'incorrect'
else:
correctness = None
%>
% if correctness:
class="choicetextgroup_${correctness}"
% endif
% endif
>
<input class="ctinput" type="${input_type}" name="choiceinput_${id}" id="${choice_id}" value="${choice_id}"
% if choice_id in value:
checked="true"
% endif
/>
% for content_node in choice_description:
% if content_node['type'] == 'text':
<span class="mock_label">
${content_node['contents']}
</span>
% else:
<% my_id = content_node.get('contents','') %>
<% my_val = value.get(my_id,'') %>
<input class="ctinput" type="text" name="${content_node['contents']}" id="${content_node['contents']}" value="${my_val|h} "/>
%endif
<span class="mock_label">
${content_node['tail_text']}
</span>
% endfor
<p id="answer_${choice_id}" class="answer"></p>
</section>
% endfor
<span id="answer_${id}"></span>
</fieldset>
<input class= "choicetextvalue" type="hidden" name="input_${id}{}" id="input_${id}" value="${value|h}" />
% if show_correctness == "never" and (value or status not in ['unsubmitted']):
<div class="capa_alert">${submitted_message}</div>
%endif
</form>
</section>
...@@ -779,3 +779,109 @@ class SymbolicResponseXMLFactory(ResponseXMLFactory): ...@@ -779,3 +779,109 @@ class SymbolicResponseXMLFactory(ResponseXMLFactory):
def create_input_element(self, **kwargs): def create_input_element(self, **kwargs):
return ResponseXMLFactory.textline_input_xml(**kwargs) return ResponseXMLFactory.textline_input_xml(**kwargs)
class ChoiceTextResponseXMLFactory(ResponseXMLFactory):
""" Factory for producing <choicetextresponse> xml """
def create_response_element(self, **kwargs):
""" Create a <choicetextresponse> element """
return etree.Element("choicetextresponse")
def create_input_element(self, **kwargs):
""" Create a <checkboxgroup> element.
choices can be specified in the following format:
[("true", [{"answer": "5", "tolerance": 0}]),
("false", [{"answer": "5", "tolerance": 0}])
]
This indicates that the first checkbox/radio is correct and it
contains a numtolerance_input with an answer of 5 and a tolerance of 0
It also indicates that the second has a second incorrect radiobutton
or checkbox with a numtolerance_input.
"""
choices = kwargs.get('choices', [("true", {})])
choice_inputs = []
# Ensure that the first element of choices is an ordered
# collection. It will start as a list, a tuple, or not a Container.
if type(choices[0]) not in [list, tuple]:
choices = [choices]
for choice in choices:
correctness, answers = choice
numtolerance_inputs = []
# If the current `choice` contains any("answer": number)
# elements, turn those into numtolerance_inputs
if answers:
# `answers` will be a list or tuple of answers or a single
# answer, representing the answers for numtolerance_inputs
# inside of this specific choice.
# Make sure that `answers` is an ordered collection for
# convenience.
if type(answers) not in [list, tuple]:
answers = [answers]
numtolerance_inputs = [
self._create_numtolerance_input_element(answer)
for answer in answers
]
choice_inputs.append(
self._create_choice_element(
correctness=correctness,
inputs=numtolerance_inputs
)
)
# Default type is 'radiotextgroup'
input_type = kwargs.get('type', 'radiotextgroup')
input_element = etree.Element(input_type)
for ind, choice in enumerate(choice_inputs):
# Give each choice text equal to it's position(0,1,2...)
choice.text = "choice_{0}".format(ind)
input_element.append(choice)
return input_element
def _create_choice_element(self, **kwargs):
"""
Creates a choice element for a choictextproblem.
Defaults to a correct choice with no numtolerance_input
"""
text = kwargs.get('text', '')
correct = kwargs.get('correctness', "true")
inputs = kwargs.get('inputs', [])
choice_element = etree.Element("choice")
choice_element.set("correct", correct)
choice_element.text = text
for inp in inputs:
# Add all of the inputs as children of this element
choice_element.append(inp)
return choice_element
def _create_numtolerance_input_element(self, params):
"""
Creates a <numtolerance_input/> element with optionally
specified tolerance and answer.
"""
answer = params['answer'] if 'answer' in params else None
# If there is not an answer specified, Then create a <decoy_input/>
# otherwise create a <numtolerance_input/> and set its tolerance
# and answer attributes.
if answer:
text_input = etree.Element("numtolerance_input")
text_input.set('answer', answer)
# If tolerance was specified, was specified use it, otherwise
# Set the tolerance to "0"
text_input.set(
'tolerance',
params['tolerance'] if 'tolerance' in params else "0"
)
else:
text_input = etree.Element("decoy_input")
return text_input
...@@ -714,3 +714,170 @@ class DragAndDropTemplateTest(TemplateTestCase): ...@@ -714,3 +714,170 @@ class DragAndDropTemplateTest(TemplateTestCase):
# escaping the HTML. We should be able to traverse the XML tree. # escaping the HTML. We should be able to traverse the XML tree.
xpath = "//div[@class='drag_and_drop_problem_json']/p/b" xpath = "//div[@class='drag_and_drop_problem_json']/p/b"
self.assert_has_text(xml, xpath, 'HTML') self.assert_has_text(xml, xpath, 'HTML')
class ChoiceTextGroupTemplateTest(TemplateTestCase):
"""Test mako template for `<choicetextgroup>` input"""
TEMPLATE_NAME = 'choicetext.html'
VALUE_DICT = {'1_choiceinput_0bc': '1_choiceinput_0bc', '1_choiceinput_0_textinput_0': '0',
'1_choiceinput_1_textinput_0': '0'}
EMPTY_DICT = {'1_choiceinput_0_textinput_0': '',
'1_choiceinput_1_textinput_0': ''}
BOTH_CHOICE_CHECKBOX = {'1_choiceinput_0bc': 'choiceinput_0',
'1_choiceinput_1bc': 'choiceinput_1',
'1_choiceinput_0_textinput_0': '0',
'1_choiceinput_1_textinput_0': '0'}
WRONG_CHOICE_CHECKBOX = {'1_choiceinput_1bc': 'choiceinput_1',
'1_choiceinput_0_textinput_0': '0',
'1_choiceinput_1_textinput_0': '0'}
def setUp(self):
choices = [('1_choiceinput_0bc',
[{'tail_text': '', 'type': 'text', 'value': '', 'contents': ''},
{'tail_text': '', 'type': 'textinput', 'value': '', 'contents': 'choiceinput_0_textinput_0'}]),
('1_choiceinput_1bc', [{'tail_text': '', 'type': 'text', 'value': '', 'contents': ''},
{'tail_text': '', 'type': 'textinput', 'value': '', 'contents': 'choiceinput_1_textinput_0'}])]
self.context = {'id': '1',
'choices': choices,
'status': 'correct',
'input_type': 'radio',
'value': self.VALUE_DICT}
super(ChoiceTextGroupTemplateTest, self).setUp()
def test_grouping_tag(self):
"""
Tests whether we are using a section or a label to wrap choice elements.
Section is used for checkbox, so inputting text does not deselect
"""
input_tags = ('radio', 'checkbox')
self.context['status'] = 'correct'
xpath = "//section[@id='forinput1_choiceinput_0bc']"
self.context['value'] = {}
for input_type in input_tags:
self.context['input_type'] = input_type
xml = self.render_to_xml(self.context)
self.assert_has_xpath(xml, xpath, self.context)
def test_problem_marked_correct(self):
"""Test conditions under which the entire problem
(not a particular option) is marked correct"""
self.context['status'] = 'correct'
self.context['input_type'] = 'checkbox'
self.context['value'] = self.VALUE_DICT
# Should mark the entire problem correct
xml = self.render_to_xml(self.context)
xpath = "//div[@class='indicator_container']/span[@class='correct']"
self.assert_has_xpath(xml, xpath, self.context)
# Should NOT mark individual options
self.assert_no_xpath(xml, "//label[@class='choicetextgroup_incorrect']",
self.context)
self.assert_no_xpath(xml, "//label[@class='choicetextgroup_correct']",
self.context)
def test_problem_marked_incorrect(self):
"""Test all conditions under which the entire problem
(not a particular option) is marked incorrect"""
grouping_tags = {'radio': 'label', 'checkbox': 'section'}
conditions = [
{'status': 'incorrect', 'input_type': 'radio', 'value': {}},
{'status': 'incorrect', 'input_type': 'checkbox', 'value': self.WRONG_CHOICE_CHECKBOX},
{'status': 'incorrect', 'input_type': 'checkbox', 'value': self.BOTH_CHOICE_CHECKBOX},
{'status': 'incorrect', 'input_type': 'checkbox', 'value': self.VALUE_DICT},
{'status': 'incomplete', 'input_type': 'radio', 'value': {}},
{'status': 'incomplete', 'input_type': 'checkbox', 'value': self.WRONG_CHOICE_CHECKBOX},
{'status': 'incomplete', 'input_type': 'checkbox', 'value': self.BOTH_CHOICE_CHECKBOX},
{'status': 'incomplete', 'input_type': 'checkbox', 'value': self.VALUE_DICT}]
for test_conditions in conditions:
self.context.update(test_conditions)
xml = self.render_to_xml(self.context)
xpath = "//div[@class='indicator_container']/span[@class='incorrect']"
self.assert_has_xpath(xml, xpath, self.context)
# Should NOT mark individual options
grouping_tag = grouping_tags[test_conditions['input_type']]
self.assert_no_xpath(xml,
"//{0}[@class='choicetextgroup_incorrect']".format(grouping_tag),
self.context)
self.assert_no_xpath(xml,
"//{0}[@class='choicetextgroup_correct']".format(grouping_tag),
self.context)
def test_problem_marked_unsubmitted(self):
"""Test all conditions under which the entire problem
(not a particular option) is marked unanswered"""
grouping_tags = {'radio': 'label', 'checkbox': 'section'}
conditions = [
{'status': 'unsubmitted', 'input_type': 'radio', 'value': {}},
{'status': 'unsubmitted', 'input_type': 'radio', 'value': self.EMPTY_DICT},
{'status': 'unsubmitted', 'input_type': 'checkbox', 'value': {}},
{'status': 'unsubmitted', 'input_type': 'checkbox', 'value': self.EMPTY_DICT},
{'status': 'unsubmitted', 'input_type': 'checkbox', 'value': self.VALUE_DICT},
{'status': 'unsubmitted', 'input_type': 'checkbox', 'value': self.BOTH_CHOICE_CHECKBOX}]
self.context['status'] = 'unanswered'
for test_conditions in conditions:
self.context.update(test_conditions)
xml = self.render_to_xml(self.context)
xpath = "//div[@class='indicator_container']/span[@class='unanswered']"
self.assert_has_xpath(xml, xpath, self.context)
# Should NOT mark individual options
grouping_tag = grouping_tags[test_conditions['input_type']]
self.assert_no_xpath(xml,
"//{0}[@class='choicetextgroup_incorrect']".format(grouping_tag),
self.context)
self.assert_no_xpath(xml,
"//{0}[@class='choicetextgroup_correct']".format(grouping_tag),
self.context)
def test_option_marked_correct(self):
"""Test conditions under which a particular option
(not the entire problem) is marked correct."""
conditions = [
{'input_type': 'radio', 'value': self.VALUE_DICT}]
self.context['status'] = 'correct'
for test_conditions in conditions:
self.context.update(test_conditions)
xml = self.render_to_xml(self.context)
xpath = "//section[@id='forinput1_choiceinput_0bc' and\
@class='choicetextgroup_correct']"
self.assert_has_xpath(xml, xpath, self.context)
# Should NOT mark the whole problem
xpath = "//div[@class='indicator_container']/span"
self.assert_no_xpath(xml, xpath, self.context)
def test_option_marked_incorrect(self):
"""Test conditions under which a particular option
(not the entire problem) is marked incorrect."""
conditions = [
{'input_type': 'radio', 'value': self.VALUE_DICT}]
self.context['status'] = 'incorrect'
for test_conditions in conditions:
self.context.update(test_conditions)
xml = self.render_to_xml(self.context)
xpath = "//section[@id='forinput1_choiceinput_0bc' and\
@class='choicetextgroup_incorrect']"
self.assert_has_xpath(xml, xpath, self.context)
# Should NOT mark the whole problem
xpath = "//div[@class='indicator_container']/span"
self.assert_no_xpath(xml, xpath, self.context)
...@@ -860,3 +860,94 @@ class AnnotationInputTest(unittest.TestCase): ...@@ -860,3 +860,94 @@ class AnnotationInputTest(unittest.TestCase):
self.maxDiff = None self.maxDiff = None
self.assertDictEqual(context, expected) self.assertDictEqual(context, expected)
class TestChoiceText(unittest.TestCase):
"""
Tests for checkboxtextgroup inputs
"""
@staticmethod
def build_choice_element(node_type, contents, tail_text, value):
"""
Builds a content node for a choice.
"""
# When xml is being parsed numtolerance_input and decoy_input tags map to textinput type
# in order to provide the template with correct rendering information.
if node_type in ('numtolerance_input', 'decoy_input'):
node_type = 'textinput'
choice = {'type': node_type, 'contents': contents, 'tail_text': tail_text, 'value': value}
return choice
def check_group(self, tag, choice_tag, expected_input_type):
"""
Build a radio or checkbox group, parse it and check the resuls against the
expected output.
`tag` should be 'checkboxtextgroup' or 'radiotextgroup'
`choice_tag` is either 'choice' for proper xml, or any other value to trigger an error.
`expected_input_type` is either 'radio' or 'checkbox'.
"""
xml_str = """
<{tag}>
<{choice_tag} correct="false" name="choiceinput_0">this is<numtolerance_input name="choiceinput_0_textinput_0"/>false</{choice_tag}>
<choice correct="true" name="choiceinput_1">Is a number<decoy_input name="choiceinput_1_textinput_0"/><text>!</text></choice>
</{tag}>
""".format(tag=tag, choice_tag=choice_tag)
element = etree.fromstring(xml_str)
state = {
'value': '{}',
'id': 'choicetext_input',
'status': 'answered'
}
first_input = self.build_choice_element('numtolerance_input', 'choiceinput_0_textinput_0', 'false', '')
second_input = self.build_choice_element('decoy_input', 'choiceinput_1_textinput_0', '', '')
first_choice_content = self.build_choice_element('text', 'this is', '', '')
second_choice_content = self.build_choice_element('text', 'Is a number', '', '')
second_choice_text = self.build_choice_element('text', "!", '', '')
choices = [
('choiceinput_0', [first_choice_content, first_input]),
('choiceinput_1', [second_choice_content, second_input, second_choice_text])
]
expected = {
'msg': '',
'input_type': expected_input_type,
'choices': choices,
'show_correctness': 'always',
'submitted_message': 'Answer received.'
}
expected.update(state)
the_input = lookup_tag(tag)(test_system(), element, state)
context = the_input._get_render_context()
self.assertEqual(context, expected)
def test_radiotextgroup(self):
"""
Test that a properly formatted radiotextgroup problem generates
expected ouputs
"""
self.check_group('radiotextgroup', 'choice', 'radio')
def test_checkboxtextgroup(self):
"""
Test that a properly formatted checkboxtextgroup problem generates
expected ouput
"""
self.check_group('checkboxtextgroup', 'choice', 'checkbox')
def test_invalid_tag(self):
"""
Test to ensure that an unrecognized inputtype tag causes an error
"""
with self.assertRaises(Exception):
self.check_group('invalid', 'choice', 'checkbox')
def test_invalid_input_tag(self):
"""
Test to ensure having a tag other than <choice> inside of
a checkbox or radiotextgroup problem raises an error.
"""
with self.assertRaisesRegexp(Exception, "Error in xml"):
self.check_group('checkboxtextgroup', 'invalid', 'checkbox')
...@@ -776,6 +776,13 @@ class CapaModule(CapaFields, XModule): ...@@ -776,6 +776,13 @@ class CapaModule(CapaFields, XModule):
then the output dict would contain {'1': ['test'] } then the output dict would contain {'1': ['test'] }
(the value is a list). (the value is a list).
Some other inputs such as ChoiceTextInput expect a dict of values in the returned
dict If the key ends with '{}' then we will assume that the value is a json
encoded dict and deserialize it.
For example, if the `data` dict contains {'input_1{}': '{"1_2_1": 1}'}
then the output dict would contain {'1': {"1_2_1": 1} }
(the value is a dictionary)
Raises an exception if: Raises an exception if:
-A key in the `data` dictionary does not contain at least one underscore -A key in the `data` dictionary does not contain at least one underscore
...@@ -802,11 +809,21 @@ class CapaModule(CapaFields, XModule): ...@@ -802,11 +809,21 @@ class CapaModule(CapaFields, XModule):
# the same form input (e.g. checkbox inputs). The convention is that # the same form input (e.g. checkbox inputs). The convention is that
# if the name ends with '[]' (which looks like an array), then the # if the name ends with '[]' (which looks like an array), then the
# answer will be an array. # answer will be an array.
# if the name ends with '{}' (Which looks like a dict),
# then the answer will be a dict
is_list_key = name.endswith('[]') is_list_key = name.endswith('[]')
name = name[:-2] if is_list_key else name is_dict_key = name.endswith('{}')
name = name[:-2] if is_list_key or is_dict_key else name
if is_list_key: if is_list_key:
val = data.getlist(key) val = data.getlist(key)
elif is_dict_key:
try:
val = json.loads(data[key])
except(KeyError, ValueError):
# Send this information along to be reported by
# The grading method
val = {"error": "error"}
else: else:
val = data[key] val = data[key]
......
...@@ -929,4 +929,32 @@ section.problem { ...@@ -929,4 +929,32 @@ section.problem {
} }
} }
} }
.choicetextgroup{
input[type="text"]{
margin-bottom: 0.5em;
}
@extend .choicegroup;
label.choicetextgroup_correct, section.choicetextgroup_correct{
@extend label.choicegroup_correct;
input[type="text"] {
border-color: green;
}
}
label.choicetextgroup_incorrect, section.choicetextgroup_incorrect{
@extend label.choicegroup_incorrect;
}
label.choicetextgroup_show_correct, section.choicetextgroup_show_correct{
&:after{
content: url('../images/correct-icon.png');
margin-left:15px;
}
}
span.mock_label{
cursor : default;
}
}
} }
...@@ -223,6 +223,58 @@ describe 'Problem', -> ...@@ -223,6 +223,58 @@ describe 'Problem', ->
expect($('label[for="input_1_1_3"]')).toHaveAttr 'correct_answer', 'true' expect($('label[for="input_1_1_3"]')).toHaveAttr 'correct_answer', 'true'
expect($('label[for="input_1_2_1"]')).not.toHaveAttr 'correct_answer', 'true' expect($('label[for="input_1_2_1"]')).not.toHaveAttr 'correct_answer', 'true'
describe 'radio text question', ->
radio_text_xml='''
<section class="problem">
<div><p></p><span><section id="choicetextinput_1_2_1" class="choicetextinput">
<form class="choicetextgroup capa_inputtype" id="inputtype_1_2_1">
<div class="indicator_container">
<span class="unanswered" style="display:inline-block;" id="status_1_2_1"></span>
</div>
<fieldset>
<section id="forinput1_2_1_choiceinput_0bc">
<input class="ctinput" type="radio" name="choiceinput_1_2_1" id="1_2_1_choiceinput_0bc" value="choiceinput_0"">
<input class="ctinput" type="text" name="choiceinput_0_textinput_0" id="1_2_1_choiceinput_0_textinput_0" value=" ">
<p id="answer_1_2_1_choiceinput_0bc" class="answer"></p>
</>
<section id="forinput1_2_1_choiceinput_1bc">
<input class="ctinput" type="radio" name="choiceinput_1_2_1" id="1_2_1_choiceinput_1bc" value="choiceinput_1" >
<input class="ctinput" type="text" name="choiceinput_1_textinput_0" id="1_2_1_choiceinput_1_textinput_0" value=" " >
<p id="answer_1_2_1_choiceinput_1bc" class="answer"></p>
</section>
<section id="forinput1_2_1_choiceinput_2bc">
<input class="ctinput" type="radio" name="choiceinput_1_2_1" id="1_2_1_choiceinput_2bc" value="choiceinput_2" >
<input class="ctinput" type="text" name="choiceinput_2_textinput_0" id="1_2_1_choiceinput_2_textinput_0" value=" " >
<p id="answer_1_2_1_choiceinput_2bc" class="answer"></p>
</section></fieldset><input class="choicetextvalue" type="hidden" name="input_1_2_1" id="input_1_2_1"></form>
</section></span></div>
</section>
'''
beforeEach ->
# Append a radiotextresponse problem to the problem, so we can check it's javascript functionality
@problem.el.prepend(radio_text_xml)
it 'sets the correct class on the section for the correct choice', ->
spyOn($, 'postWithPrefix').andCallFake (url, callback) ->
callback answers: "1_2_1": ["1_2_1_choiceinput_0bc"], "1_2_1_choiceinput_0bc": "3"
@problem.show()
expect($('#forinput1_2_1_choiceinput_0bc').attr('class')).toEqual(
'choicetextgroup_show_correct')
expect($('#answer_1_2_1_choiceinput_0bc').text()).toEqual('3')
expect($('#answer_1_2_1_choiceinput_1bc').text()).toEqual('')
expect($('#answer_1_2_1_choiceinput_2bc').text()).toEqual('')
it 'Should not disable input fields', ->
spyOn($, 'postWithPrefix').andCallFake (url, callback) ->
callback answers: "1_2_1": ["1_2_1_choiceinput_0bc"], "1_2_1_choiceinput_0bc": "3"
@problem.show()
expect($('input#1_2_1_choiceinput_0bc').attr('disabled')).not.toEqual('disabled')
expect($('input#1_2_1_choiceinput_1bc').attr('disabled')).not.toEqual('disabled')
expect($('input#1_2_1_choiceinput_2bc').attr('disabled')).not.toEqual('disabled')
expect($('input#1_2_1').attr('disabled')).not.toEqual('disabled')
describe 'when the answers are already shown', -> describe 'when the answers are already shown', ->
beforeEach -> beforeEach ->
@problem.el.addClass 'showed' @problem.el.addClass 'showed'
......
...@@ -403,6 +403,14 @@ class @Problem ...@@ -403,6 +403,14 @@ class @Problem
answer = JSON.parse(answers[answer_id]) answer = JSON.parse(answers[answer_id])
display.showAnswer(answer) display.showAnswer(answer)
choicetextgroup: (element, display, answers) =>
element = $(element)
input_id = element.attr('id').replace(/inputtype_/,'')
answer = answers[input_id]
for choice in answer
element.find("section#forinput#{choice}").addClass 'choicetextgroup_show_correct'
inputtypeHideAnswerMethods: inputtypeHideAnswerMethods:
choicegroup: (element, display) => choicegroup: (element, display) =>
element = $(element) element = $(element)
...@@ -410,3 +418,7 @@ class @Problem ...@@ -410,3 +418,7 @@ class @Problem
javascriptinput: (element, display) => javascriptinput: (element, display) =>
display.hideAnswer() display.hideAnswer()
choicetextgroup: (element, display) =>
element = $(element)
element.find("section[id^='forinput']").removeClass('choicetextgroup_show_correct')
(function () {
var update = function () {
// Whenever a value changes create a new serialized version of this
// problem's inputs and set the hidden input fields value to equal it.
var parent = $(this).closest('.problems-wrapper');
// find the closest parent problems-wrapper and use that as the problem
// grab the input id from the input
// real_input is the hidden input field
var real_input = $('input.choicetextvalue', parent);
var all_inputs = $('.choicetextinput .ctinput', parent);
var user_inputs = {};
$(all_inputs).each(function (index, elt) {
var node = $(elt);
var name = node.attr('id');
var val = node.val();
var radio_value = node.attr('value');
var type = node.attr('type');
var is_checked = node.attr('checked');
if (type === "radio" || type === "checkbox") {
if (is_checked === "checked" || is_checked === "true") {
user_inputs[name] = radio_value;
}
} else {
user_inputs[name] = val;
}
});
var val_string = JSON.stringify(user_inputs);
//this is what gets submitted as the answer, we deserialize it later
real_input.val(val_string);
};
var check_parent = function (event) {
// This looks for the containing choice of a textinput
// and sets it to be checked.
var elt = $(event.target);
var parent_container = elt.closest('section[id^="forinput"]');
var choice = parent_container.find("input[type='checkbox'], input[type='radio']");
choice.attr("checked", "checked");
choice.change();
//need to check it then trigger the change event
};
var imitate_label = function (event) {
// This causes a section to check and uncheck
// a radiobutton/checkbox whenever a user clicks on it
// If the button/checkbox is disabled, nothing happens
var elt = $(event.target);
var parent_container = elt.closest('section[id^="forinput"]');
var choice = parent_container.find("input[type='checkbox'], input[type='radio']");
if (choice.attr("type") === "radio") {
choice.attr("checked", "checked");
} else {
if (choice.attr('checked')) {
choice.prop("checked", false);
} else {
choice.prop("checked", true);
}
}
choice.change();
update();
};
var choices = $('.mock_label');
var inputs = $('.choicetextinput .ctinput');
var text_inputs = $('.choicetextinput .ctinput[type="text"]');
// update on load
inputs.each(update);
// and on every change
// This allows text inside of choices to behave as if they were part of
// a label for the choice's button/checkbox
choices.click(imitate_label);
inputs.bind("change", update);
text_inputs.click(check_parent);
}).call(this);
...@@ -21,6 +21,8 @@ Feature: Answer problems ...@@ -21,6 +21,8 @@ Feature: Answer problems
| formula | | formula |
| script | | script |
| code | | code |
| radio_text |
| checkbox_text |
Scenario: I can answer a problem incorrectly Scenario: I can answer a problem incorrectly
Given External graders respond "incorrect" Given External graders respond "incorrect"
...@@ -40,6 +42,8 @@ Feature: Answer problems ...@@ -40,6 +42,8 @@ Feature: Answer problems
| formula | | formula |
| script | | script |
| code | | code |
| radio_text |
| checkbox_text |
Scenario: I can submit a blank answer Scenario: I can submit a blank answer
Given I am viewing a "<ProblemType>" problem Given I am viewing a "<ProblemType>" problem
...@@ -57,6 +61,8 @@ Feature: Answer problems ...@@ -57,6 +61,8 @@ Feature: Answer problems
| numerical | | numerical |
| formula | | formula |
| script | | script |
| radio_text |
| checkbox_text |
Scenario: I can reset a problem Scenario: I can reset a problem
...@@ -84,6 +90,10 @@ Feature: Answer problems ...@@ -84,6 +90,10 @@ Feature: Answer problems
| formula | incorrect | | formula | incorrect |
| script | correct | | script | correct |
| script | incorrect | | script | incorrect |
| radio_text | correct |
| radio_text | incorrect |
| checkbox_text | correct |
| checkbox_text | incorrect |
Scenario: I can answer a problem with one attempt correctly and not reset Scenario: I can answer a problem with one attempt correctly and not reset
......
...@@ -18,7 +18,7 @@ from capa.tests.response_xml_factory import OptionResponseXMLFactory, \ ...@@ -18,7 +18,7 @@ from capa.tests.response_xml_factory import OptionResponseXMLFactory, \
ChoiceResponseXMLFactory, MultipleChoiceResponseXMLFactory, \ ChoiceResponseXMLFactory, MultipleChoiceResponseXMLFactory, \
StringResponseXMLFactory, NumericalResponseXMLFactory, \ StringResponseXMLFactory, NumericalResponseXMLFactory, \
FormulaResponseXMLFactory, CustomResponseXMLFactory, \ FormulaResponseXMLFactory, CustomResponseXMLFactory, \
CodeResponseXMLFactory CodeResponseXMLFactory, ChoiceTextResponseXMLFactory
from nose.tools import assert_true from nose.tools import assert_true
...@@ -131,6 +131,32 @@ PROBLEM_DICT = { ...@@ -131,6 +131,32 @@ PROBLEM_DICT = {
'grader_payload': '{"grader": "ps1/Spring2013/test_grader.py"}', }, 'grader_payload': '{"grader": "ps1/Spring2013/test_grader.py"}', },
'correct': ['span.correct'], 'correct': ['span.correct'],
'incorrect': ['span.incorrect'], 'incorrect': ['span.incorrect'],
'unanswered': ['span.unanswered']},
'radio_text': {
'factory': ChoiceTextResponseXMLFactory(),
'kwargs': {
'question_text': 'The correct answer is Choice 0 and input 8',
'type': 'radiotextgroup',
'choices': [("true", {"answer": "8", "tolerance": "1"}),
("false", {"answer": "8", "tolerance": "1"})
]
},
'correct': ['section.choicetextgroup_correct'],
'incorrect': ['span.incorrect', 'section.choicetextgroup_incorrect'],
'unanswered': ['span.unanswered']},
'checkbox_text': {
'factory': ChoiceTextResponseXMLFactory(),
'kwargs': {
'question_text': 'The correct answer is Choice 0 and input 8',
'type': 'checkboxtextgroup',
'choices': [("true", {"answer": "8", "tolerance": "1"}),
("false", {"answer": "8", "tolerance": "1"})
]
},
'correct': ['span.correct'],
'incorrect': ['span.incorrect'],
'unanswered': ['span.unanswered']} 'unanswered': ['span.unanswered']}
} }
...@@ -196,6 +222,19 @@ def answer_problem(problem_type, correctness): ...@@ -196,6 +222,19 @@ def answer_problem(problem_type, correctness):
# (configured in the problem XML above) # (configured in the problem XML above)
pass pass
elif problem_type == 'radio_text' or problem_type == 'checkbox_text':
input_value = "8" if correctness == 'correct' else "5"
choice = "choiceinput_0bc" if correctness == 'correct' else "choiceinput_1bc"
world.css_check(inputfield(problem_type, choice=choice))
world.css_fill(
inputfield(
problem_type,
choice="choiceinput_0_numtolerance_input_0"
),
input_value
)
def problem_has_answer(problem_type, answer_class): def problem_has_answer(problem_type, answer_class):
if problem_type == "drop down": if problem_type == "drop down":
...@@ -244,6 +283,17 @@ def problem_has_answer(problem_type, answer_class): ...@@ -244,6 +283,17 @@ def problem_has_answer(problem_type, answer_class):
expected = "x^2+2*x+y" if answer_class == 'correct' else 'x^2' expected = "x^2+2*x+y" if answer_class == 'correct' else 'x^2'
assert_textfield('formula', expected) assert_textfield('formula', expected)
elif problem_type in ("radio_text", "checkbox_text"):
if answer_class == 'blank':
expected = ('', '')
assert_choicetext_values(problem_type, (), expected)
elif answer_class == 'incorrect':
expected = ('5', '')
assert_choicetext_values(problem_type, ["choiceinput_1bc"], expected)
else:
expected = ('8', '')
assert_choicetext_values(problem_type, ["choiceinput_0bc"], expected)
else: else:
# The other response types use random data, # The other response types use random data,
# which would be difficult to check # which would be difficult to check
...@@ -292,6 +342,12 @@ def inputfield(problem_type, choice=None, input_num=1): ...@@ -292,6 +342,12 @@ def inputfield(problem_type, choice=None, input_num=1):
sel = ("input#input_i4x-edx-model_course-problem-%s_2_%s" % sel = ("input#input_i4x-edx-model_course-problem-%s_2_%s" %
(problem_type.replace(" ", "_"), str(input_num))) (problem_type.replace(" ", "_"), str(input_num)))
# this is necessary due to naming requirement for this problem type
if problem_type in ("radio_text", "checkbox_text"):
sel = "input#i4x-edx-model_course-problem-{0}_2_{1}".format(
problem_type.replace(" ", "_"), str(input_num)
)
if choice is not None: if choice is not None:
base = "_choice_" if problem_type == "multiple choice" else "_" base = "_choice_" if problem_type == "multiple choice" else "_"
sel = sel + base + str(choice) sel = sel + base + str(choice)
...@@ -325,3 +381,28 @@ def assert_checked(problem_type, choices): ...@@ -325,3 +381,28 @@ def assert_checked(problem_type, choices):
def assert_textfield(problem_type, expected_text, input_num=1): def assert_textfield(problem_type, expected_text, input_num=1):
element_value = world.css_value(inputfield(problem_type, input_num=input_num)) element_value = world.css_value(inputfield(problem_type, input_num=input_num))
assert element_value == expected_text assert element_value == expected_text
def assert_choicetext_values(problem_type, choices, expected_values):
"""
Asserts that only the given choices are checked, and given
text fields have a desired value
"""
all_choices = ['choiceinput_0bc', 'choiceinput_1bc']
all_inputs = [
"choiceinput_0_numtolerance_input_0",
"choiceinput_1_numtolerance_input_0"
]
for this_choice in all_choices:
element = world.css_find(inputfield(problem_type, choice=this_choice))
if this_choice in choices:
assert element.checked
else:
assert not element.checked
for (name, expected) in zip(all_inputs, expected_values):
element = world.css_find(inputfield(problem_type, name))
# Remove any trailing spaces that may have been added
assert element.value.strip() == expected
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment