This brings an important security improvement -- codejail won't default to running in unsafe mode, which can happen if certain configuration errors are present. Properly configured installations shouldn't be affected. We just need to adjust some unit tests to opt into unsafe mode. Changes: - Update `edx-codejail` dependency to [version 4.0.0](https://github.com/openedx/codejail/blob/master/CHANGELOG.rst#400---2025-06-13) - Define a `use_unsafe_codejail` decorator that allows running a unit test (or entire TestCase class) in unsafe mode - Use that decorator as needed, based on which tests started failing
846 lines
35 KiB
Python
846 lines
35 KiB
Python
"""
|
|
Test capa problem.
|
|
"""
|
|
import textwrap
|
|
import unittest
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
from django.conf import settings
|
|
from django.test import override_settings
|
|
import pytest
|
|
import ddt
|
|
from lxml import etree
|
|
from markupsafe import Markup
|
|
|
|
from xmodule.capa.correctmap import CorrectMap
|
|
from xmodule.capa.responsetypes import LoncapaProblemError
|
|
from xmodule.capa.tests.helpers import new_loncapa_problem
|
|
from xmodule.capa.tests.test_util import use_unsafe_codejail
|
|
from openedx.core.djangolib.markup import HTML
|
|
|
|
|
|
FEATURES_WITH_GRADING_METHOD_IN_PROBLEMS = settings.FEATURES.copy()
|
|
FEATURES_WITH_GRADING_METHOD_IN_PROBLEMS['ENABLE_GRADING_METHOD_IN_PROBLEMS'] = True
|
|
|
|
|
|
@ddt.ddt
|
|
@use_unsafe_codejail()
|
|
class CAPAProblemTest(unittest.TestCase):
|
|
""" CAPA problem related tests"""
|
|
|
|
@ddt.unpack
|
|
@ddt.data(
|
|
{'question': 'Select the correct synonym of paranoid?'},
|
|
{'question': 'Select the correct <em>synonym</em> of <strong>paranoid</strong>?'},
|
|
)
|
|
def test_label_and_description_inside_responsetype(self, question):
|
|
"""
|
|
Verify that
|
|
* label is extracted
|
|
* <label> tag is removed to avoid duplication
|
|
|
|
This is the case when we have a problem with single question or
|
|
problem with multiple-questions separated as per the new format.
|
|
"""
|
|
xml = """
|
|
<problem>
|
|
<choiceresponse>
|
|
<label>{question}</label>
|
|
<description>Only the paranoid survive.</description>
|
|
<checkboxgroup>
|
|
<choice correct="true">over-suspicious</choice>
|
|
<choice correct="false">funny</choice>
|
|
</checkboxgroup>
|
|
</choiceresponse>
|
|
</problem>
|
|
""".format(question=question)
|
|
problem = new_loncapa_problem(xml)
|
|
assert problem.problem_data ==\
|
|
{'1_2_1': {'label': question, 'descriptions': {'description_1_1_1': 'Only the paranoid survive.'}}}
|
|
assert len(problem.tree.xpath('//label')) == 0
|
|
|
|
@ddt.unpack
|
|
@ddt.data(
|
|
{
|
|
'question': 'Once we become predictable, we become ______?',
|
|
'label_attr': 'Once we become predictable, we become ______?'
|
|
},
|
|
{
|
|
'question': 'Once we become predictable, we become ______?<img src="img/src"/>',
|
|
'label_attr': 'Once we become predictable, we become ______?'
|
|
},
|
|
)
|
|
def test_legacy_problem(self, question, label_attr):
|
|
"""
|
|
Verify that legacy problem is handled correctly.
|
|
"""
|
|
xml = """
|
|
<problem>
|
|
<p>Be sure to check your spelling.</p>
|
|
<p>{}</p>
|
|
<stringresponse answer="vulnerable" type="ci">
|
|
<textline label="{}" size="40"/>
|
|
</stringresponse>
|
|
</problem>
|
|
""".format(question, label_attr)
|
|
problem = new_loncapa_problem(xml)
|
|
assert problem.problem_data == {'1_2_1': {'label': question, 'descriptions': {}}}
|
|
assert len(problem.tree.xpath("//*[normalize-space(text())='{}']".format(question))) == 0
|
|
|
|
@ddt.unpack
|
|
@ddt.data(
|
|
{
|
|
'question1': 'People who say they have nothing to ____ almost always do?',
|
|
'question2': 'Select the correct synonym of paranoid?'
|
|
},
|
|
{
|
|
'question1': '<b>People</b> who say they have <mark>nothing</mark> to ____ almost always do?',
|
|
'question2': 'Select the <sup>correct</sup> synonym of <mark>paranoid</mark>?'
|
|
},
|
|
)
|
|
def test_neither_label_tag_nor_attribute(self, question1, question2):
|
|
"""
|
|
Verify that label is extracted correctly.
|
|
|
|
This is the case when we have a markdown problem with multiple-questions.
|
|
In this case when markdown is converted to xml, there will be no label
|
|
tag and label attribute inside responsetype. But we have a label tag
|
|
before the responsetype.
|
|
"""
|
|
xml = """
|
|
<problem>
|
|
<p>Be sure to check your spelling.</p>
|
|
<label>{}</label>
|
|
<stringresponse answer="hide" type="ci">
|
|
<textline size="40"/>
|
|
</stringresponse>
|
|
<choiceresponse>
|
|
<label>{}</label>
|
|
<checkboxgroup>
|
|
<choice correct="true">over-suspicious</choice>
|
|
<choice correct="false">funny</choice>
|
|
</checkboxgroup>
|
|
</choiceresponse>
|
|
</problem>
|
|
""".format(question1, question2)
|
|
problem = new_loncapa_problem(xml)
|
|
assert problem.problem_data ==\
|
|
{'1_2_1': {'label': question1, 'descriptions': {}}, '1_3_1': {'label': question2, 'descriptions': {}}}
|
|
for question in (question1, question2):
|
|
assert len(problem.tree.xpath('//label[text()="{}"]'.format(question))) == 0
|
|
|
|
def test_multiple_descriptions(self):
|
|
"""
|
|
Verify that multiple descriptions are handled correctly.
|
|
"""
|
|
desc1 = "The problem with trying to be the <em>bad guy</em>, there's always someone <strong>worse</strong>."
|
|
desc2 = "Anyone who looks the world as if it was a game of chess deserves to lose."
|
|
xml = """
|
|
<problem>
|
|
<p>Be sure to check your spelling.</p>
|
|
<stringresponse answer="War" type="ci">
|
|
<label>___ requires sacrifices.</label>
|
|
<description>{}</description>
|
|
<description>{}</description>
|
|
<textline size="40"/>
|
|
</stringresponse>
|
|
</problem>
|
|
""".format(desc1, desc2)
|
|
problem = new_loncapa_problem(xml)
|
|
assert problem.problem_data ==\
|
|
{'1_2_1': {'label': '___ requires sacrifices.',
|
|
'descriptions': {'description_1_1_1': desc1, 'description_1_1_2': desc2}}}
|
|
|
|
def test_additional_answer_is_skipped_from_resulting_html(self):
|
|
"""Tests that additional_answer element is not present in transformed HTML"""
|
|
xml = """
|
|
<problem>
|
|
<p>Be sure to check your spelling.</p>
|
|
<stringresponse answer="War" type="ci">
|
|
<label>___ requires sacrifices.</label>
|
|
<description>Anyone who looks the world as if it was a game of chess deserves to lose.</description>
|
|
<additional_answer answer="optional acceptable variant of the correct answer"/>
|
|
<textline size="40"/>
|
|
</stringresponse>
|
|
</problem>
|
|
"""
|
|
problem = new_loncapa_problem(xml)
|
|
assert len(problem.extracted_tree.xpath('//additional_answer')) == 0
|
|
assert 'additional_answer' not in problem.get_html()
|
|
|
|
def test_non_accessible_inputtype(self):
|
|
"""
|
|
Verify that tag with question text is not removed when inputtype is not fully accessible.
|
|
"""
|
|
question = "Click the country which is home to the Pyramids."
|
|
# lint-amnesty, pylint: disable=duplicate-string-formatting-argument
|
|
xml = """
|
|
<problem>
|
|
<p>{}</p>
|
|
<imageresponse>
|
|
<imageinput label="{}"
|
|
src="/static/Africa.png" width="600" height="638" rectangle="(338,98)-(412,168)"/>
|
|
</imageresponse>
|
|
</problem>
|
|
""".format(question, question)
|
|
problem = new_loncapa_problem(xml)
|
|
assert problem.problem_data == {'1_2_1': {'label': question, 'descriptions': {}}}
|
|
# <p> tag with question text should not be deleted
|
|
assert problem.tree.xpath("string(p[text()='{}'])".format(question)) == question
|
|
|
|
def test_label_is_empty_if_no_label_attribute(self):
|
|
"""
|
|
Verify that label in response_data is empty string when label
|
|
attribute is missing and responsetype is not fully accessible.
|
|
"""
|
|
question = "Click the country which is home to the Pyramids."
|
|
xml = """
|
|
<problem>
|
|
<p>{}</p>
|
|
<imageresponse>
|
|
<imageinput
|
|
src="/static/Africa.png" width="600" height="638" rectangle="(338,98)-(412,168)"/>
|
|
</imageresponse>
|
|
</problem>
|
|
""".format(question)
|
|
problem = new_loncapa_problem(xml)
|
|
assert problem.problem_data == {'1_2_1': {'label': '', 'descriptions': {}}}
|
|
|
|
def test_multiple_questions_problem(self):
|
|
"""
|
|
For a problem with multiple questions verify that for each question
|
|
* label is extracted
|
|
* descriptions info is constructed
|
|
* <label> tag is removed to avoid duplication
|
|
"""
|
|
xml = """
|
|
<problem>
|
|
<choiceresponse>
|
|
<label>Select the correct synonym of paranoid?</label>
|
|
<description>Only the paranoid survive.</description>
|
|
<checkboxgroup>
|
|
<choice correct="true">over-suspicious</choice>
|
|
<choice correct="false">funny</choice>
|
|
</checkboxgroup>
|
|
</choiceresponse>
|
|
<multiplechoiceresponse>
|
|
<p>one more question</p>
|
|
<label>What Apple device competed with the portable CD player?</label>
|
|
<description>Device looks like an egg plant.</description>
|
|
<choicegroup type="MultipleChoice">
|
|
<choice correct="false">The iPad</choice>
|
|
<choice correct="false">Napster</choice>
|
|
<choice correct="true">The iPod</choice>
|
|
<choice correct="false">The vegetable peeler</choice>
|
|
</choicegroup>
|
|
</multiplechoiceresponse>
|
|
</problem>
|
|
"""
|
|
problem = new_loncapa_problem(xml)
|
|
assert problem.problem_data ==\
|
|
{'1_2_1': {'label': 'Select the correct synonym of paranoid?',
|
|
'descriptions': {'description_1_1_1': 'Only the paranoid survive.'}},
|
|
'1_3_1': {'label': 'What Apple device competed with the portable CD player?',
|
|
'descriptions': {'description_1_2_1': 'Device looks like an egg plant.'}}}
|
|
assert len(problem.tree.xpath('//label')) == 0
|
|
|
|
def test_question_title_not_removed_got_children(self):
|
|
"""
|
|
Verify that <p> question text before responsetype not deleted when
|
|
it contains other children and label is picked from label attribute of inputtype
|
|
|
|
This is the case when author updated the <p> immediately before
|
|
responsetype to contain other elements. We do not want to delete information in that case.
|
|
"""
|
|
question = 'Is egg plant a fruit?'
|
|
xml = """
|
|
<problem>
|
|
<p>Choose wisely.</p>
|
|
<p>Select the correct synonym of paranoid?</p>
|
|
<p><img src="" /></p>
|
|
<choiceresponse>
|
|
<checkboxgroup label="{}">
|
|
<choice correct="true">over-suspicious</choice>
|
|
<choice correct="false">funny</choice>
|
|
</checkboxgroup>
|
|
</choiceresponse>
|
|
</problem>
|
|
""".format(question)
|
|
problem = new_loncapa_problem(xml)
|
|
assert problem.problem_data == {'1_2_1': {'label': '', 'descriptions': {}}}
|
|
assert len(problem.tree.xpath('//p/img')) == 1
|
|
|
|
@ddt.unpack
|
|
@ddt.data(
|
|
{'group_label': 'Choose the correct color'},
|
|
{'group_label': 'Choose the <b>correct</b> <mark>color</mark>'},
|
|
)
|
|
def test_multiple_inputtypes(self, group_label):
|
|
"""
|
|
Verify that group label and labels for individual inputtypes are extracted correctly.
|
|
"""
|
|
input1_label = 'What color is the sky?'
|
|
input2_label = 'What color are pine needles?'
|
|
xml = """
|
|
<problem>
|
|
<optionresponse>
|
|
<label>{}</label>
|
|
<optioninput options="('yellow','blue','green')" correct="blue" label="{}"/>
|
|
<optioninput options="('yellow','blue','green')" correct="green" label="{}"/>
|
|
</optionresponse>
|
|
</problem>
|
|
""".format(group_label, input1_label, input2_label)
|
|
|
|
problem = new_loncapa_problem(xml)
|
|
assert problem.problem_data ==\
|
|
{'1_2_1': {'group_label': group_label, 'label': input1_label, 'descriptions': {}},
|
|
'1_2_2': {'group_label': group_label, 'label': input2_label, 'descriptions': {}}}
|
|
|
|
def test_single_inputtypes(self):
|
|
"""
|
|
Verify that HTML is correctly rendered when there is single inputtype.
|
|
"""
|
|
question = 'Enter sum of 1+2'
|
|
xml = textwrap.dedent("""
|
|
<problem>
|
|
<customresponse cfn="test_sum" expect="3">
|
|
<script type="loncapa/python">
|
|
def test_sum(expect, ans):
|
|
return int(expect) == int(ans)
|
|
</script>
|
|
<label>{}</label>
|
|
<textline size="20" correct_answer="3" />
|
|
</customresponse>
|
|
</problem>
|
|
""".format(question))
|
|
problem = new_loncapa_problem(xml, use_capa_render_template=True)
|
|
problem_html = etree.XML(problem.get_html())
|
|
|
|
# verify that only no multi input group div is present
|
|
multi_inputs_group = problem_html.xpath('//div[@class="multi-inputs-group"]')
|
|
assert len(multi_inputs_group) == 0
|
|
|
|
# verify that question is rendered only once
|
|
question = problem_html.xpath("//*[normalize-space(text())='{}']".format(question))
|
|
assert len(question) == 1
|
|
|
|
def assert_question_tag(self, question1, question2, tag, label_attr=False):
|
|
"""
|
|
Verify question tag correctness.
|
|
"""
|
|
question1_tag = '<{tag}>{}</{tag}>'.format(question1, tag=tag) if question1 else ''
|
|
question2_tag = '<{tag}>{}</{tag}>'.format(question2, tag=tag) if question2 else ''
|
|
question1_label_attr = 'label="{}"'.format(question1) if label_attr else ''
|
|
question2_label_attr = 'label="{}"'.format(question2) if label_attr else ''
|
|
xml = """
|
|
<problem>
|
|
{question1_tag}
|
|
<choiceresponse>
|
|
<checkboxgroup {question1_label_attr}>
|
|
<choice correct="true">choice1</choice>
|
|
<choice correct="false">choice2</choice>
|
|
</checkboxgroup>
|
|
</choiceresponse>
|
|
{question2_tag}
|
|
<multiplechoiceresponse>
|
|
<choicegroup type="MultipleChoice" {question2_label_attr}>
|
|
<choice correct="false">choice1</choice>
|
|
<choice correct="true">choice2</choice>
|
|
</choicegroup>
|
|
</multiplechoiceresponse>
|
|
</problem>
|
|
""".format(
|
|
question1_tag=question1_tag,
|
|
question2_tag=question2_tag,
|
|
question1_label_attr=question1_label_attr,
|
|
question2_label_attr=question2_label_attr,
|
|
)
|
|
problem = new_loncapa_problem(xml)
|
|
assert problem.problem_data ==\
|
|
{'1_2_1': {'label': question1, 'descriptions': {}}, '1_3_1': {'label': question2, 'descriptions': {}}}
|
|
assert len(problem.tree.xpath('//{}'.format(tag))) == 0
|
|
|
|
@ddt.unpack
|
|
@ddt.data(
|
|
{'question1': 'question 1 label', 'question2': 'question 2 label'},
|
|
{'question1': '', 'question2': 'question 2 label'},
|
|
{'question1': 'question 1 label', 'question2': ''}
|
|
)
|
|
def test_correct_question_tag_is_picked(self, question1, question2):
|
|
"""
|
|
For a problem with multiple questions verify that correct question tag is picked.
|
|
"""
|
|
self.assert_question_tag(question1, question2, tag='label', label_attr=False)
|
|
self.assert_question_tag(question1, question2, tag='p', label_attr=True)
|
|
|
|
def test_optionresponse_xml_compatibility(self):
|
|
"""
|
|
Verify that an optionresponse problem with multiple correct answers is not instantiated.
|
|
|
|
Scenario:
|
|
Given an optionresponse/Dropdown problem
|
|
If there are multiple correct answers
|
|
Then the problem is not instantiated
|
|
And Loncapa problem error exception is raised
|
|
If the problem is corrected by including only one correct answer
|
|
Then the problem is created successfully
|
|
"""
|
|
xml = """
|
|
<problem>
|
|
<optionresponse>
|
|
<p>You can use this template as a guide to the simple editor markdown and OLX markup to use for dropdown problems. Edit this component to replace this template with your own assessment.</p>
|
|
<label>Add the question text, or prompt, here. This text is required.</label>
|
|
<description>You can add an optional tip or note related to the prompt like this. </description>
|
|
<optioninput>
|
|
<option correct="False">an incorrect answer</option>
|
|
<option correct="True">the correct answer</option>
|
|
<option correct="{correctness}">an incorrect answer</option>
|
|
</optioninput>
|
|
</optionresponse>
|
|
</problem>
|
|
"""
|
|
with pytest.raises(LoncapaProblemError):
|
|
new_loncapa_problem(xml.format(correctness=True))
|
|
problem = new_loncapa_problem(xml.format(correctness=False))
|
|
assert problem is not None
|
|
|
|
def test_optionresponse_option_with_empty_text(self):
|
|
"""
|
|
Verify successful instantiation of an optionresponse problem
|
|
with an option with empty text
|
|
"""
|
|
xml = """
|
|
<problem>
|
|
<optionresponse>
|
|
<label>Select True or False</label>
|
|
<optioninput>
|
|
<option correct="False">True <optionhint>Not this one</optionhint></option>
|
|
<option correct="True">False</option>
|
|
<option correct="False"><optionhint>Not this empty one either</optionhint></option>
|
|
</optioninput>
|
|
</optionresponse>
|
|
</problem>
|
|
"""
|
|
problem = new_loncapa_problem(xml)
|
|
assert problem is not None
|
|
|
|
|
|
@ddt.ddt
|
|
@use_unsafe_codejail()
|
|
class CAPAMultiInputProblemTest(unittest.TestCase):
|
|
""" TestCase for CAPA problems with multiple inputtypes """
|
|
|
|
def capa_problem(self, xml):
|
|
"""
|
|
Create capa problem.
|
|
"""
|
|
return new_loncapa_problem(xml, use_capa_render_template=True)
|
|
|
|
def assert_problem_data(self, problem_data):
|
|
"""Verify problem data is in expected state"""
|
|
for problem_value in problem_data.values():
|
|
assert isinstance(problem_value['label'], Markup)
|
|
|
|
def assert_problem_html(self, problem_html, group_label, *input_labels):
|
|
"""
|
|
Verify that correct html is rendered for multiple inputtypes.
|
|
|
|
Arguments:
|
|
problem_html (str): problem HTML
|
|
group_label (str or None): multi input group label or None if label is not present
|
|
input_labels (tuple): individual input labels
|
|
"""
|
|
html = etree.XML(problem_html)
|
|
|
|
# verify that only one multi input group div is present at correct path
|
|
multi_inputs_group = html.xpath(
|
|
'//div[@class="wrapper-problem-response"]/div[@class="multi-inputs-group"]'
|
|
)
|
|
assert len(multi_inputs_group) == 1
|
|
|
|
if group_label is None:
|
|
# if multi inputs group label is not present then there shouldn't be `aria-labelledby` attribute
|
|
assert multi_inputs_group[0].attrib.get('aria-labelledby') is None
|
|
else:
|
|
# verify that multi input group label <p> tag exists and its
|
|
# id matches with correct multi input group aria-labelledby
|
|
multi_inputs_group_label_id = multi_inputs_group[0].attrib.get('aria-labelledby')
|
|
multi_inputs_group_label = html.xpath('//p[@id="{}"]'.format(multi_inputs_group_label_id))
|
|
assert len(multi_inputs_group_label) == 1
|
|
assert multi_inputs_group_label[0].text == group_label
|
|
|
|
# verify that label for each input comes only once
|
|
for input_label in input_labels:
|
|
# normalize-space is used to remove whitespace around the text
|
|
input_label_element = multi_inputs_group[0].xpath('//*[normalize-space(text())="{}"]'.format(input_label))
|
|
assert len(input_label_element) == 1
|
|
|
|
@ddt.unpack
|
|
@ddt.data(
|
|
{'label_html': '<label>Choose the correct color</label>', 'group_label': 'Choose the correct color'},
|
|
{'label_html': '', 'group_label': None}
|
|
)
|
|
def test_optionresponse(self, label_html, group_label):
|
|
"""
|
|
Verify that optionresponse problem with multiple inputtypes is rendered correctly.
|
|
"""
|
|
input1_label = 'What color is the sky?'
|
|
input2_label = 'What color are pine needles?'
|
|
xml = """
|
|
<problem>
|
|
<optionresponse>
|
|
{label_html}
|
|
<optioninput options="('yellow','blue','green')" correct="blue" label="{input1_label}"/>
|
|
<optioninput options="('yellow','blue','green')" correct="green" label="{input2_label}"/>
|
|
</optionresponse>
|
|
</problem>
|
|
""".format(label_html=label_html, input1_label=input1_label, input2_label=input2_label)
|
|
problem = self.capa_problem(xml)
|
|
self.assert_problem_html(problem.get_html(), group_label, input1_label, input2_label)
|
|
self.assert_problem_data(problem.problem_data)
|
|
|
|
@ddt.unpack
|
|
@ddt.data(
|
|
{'inputtype': 'textline'},
|
|
{'inputtype': 'formulaequationinput'}
|
|
)
|
|
def test_customresponse(self, inputtype):
|
|
"""
|
|
Verify that customresponse problem with multiple textline
|
|
and formulaequationinput inputtypes is rendered correctly.
|
|
"""
|
|
group_label = 'Enter two integers that sum to 10.'
|
|
input1_label = 'Integer 1'
|
|
input2_label = 'Integer 2'
|
|
xml = textwrap.dedent("""
|
|
<problem>
|
|
<customresponse cfn="test_add_to_ten">
|
|
<script type="loncapa/python">
|
|
def test_add_to_ten(expect, ans):
|
|
return test_add(10, ans)
|
|
</script>
|
|
<label>{}</label>
|
|
<{inputtype} size="40" correct_answer="3" label="{}" /><br/>
|
|
<{inputtype} size="40" correct_answer="7" label="{}" />
|
|
</customresponse>
|
|
</problem>
|
|
""".format(group_label, input1_label, input2_label, inputtype=inputtype))
|
|
problem = self.capa_problem(xml)
|
|
self.assert_problem_html(problem.get_html(), group_label, input1_label, input2_label)
|
|
self.assert_problem_data(problem.problem_data)
|
|
|
|
@ddt.unpack
|
|
@ddt.data(
|
|
{
|
|
'descriptions': ('desc1', 'desc2'),
|
|
'descriptions_html': '<description>desc1</description><description>desc2</description>'
|
|
},
|
|
{
|
|
'descriptions': (),
|
|
'descriptions_html': ''
|
|
}
|
|
)
|
|
def test_descriptions(self, descriptions, descriptions_html):
|
|
"""
|
|
Verify that groups descriptions are rendered correctly.
|
|
"""
|
|
xml = """
|
|
<problem>
|
|
<optionresponse>
|
|
<label>group label</label>
|
|
{descriptions_html}
|
|
<optioninput options="('yellow','blue','green')" correct="blue" label="first label"/>
|
|
<optioninput options="('yellow','blue','green')" correct="green" label="second label"/>
|
|
</optionresponse>
|
|
</problem>
|
|
""".format(descriptions_html=descriptions_html)
|
|
problem = self.capa_problem(xml)
|
|
problem_html = etree.XML(problem.get_html())
|
|
|
|
multi_inputs_group = problem_html.xpath('//div[@class="multi-inputs-group"]')[0]
|
|
description_ids = multi_inputs_group.attrib.get('aria-describedby', '').split()
|
|
|
|
# Verify that number of descriptions matches description_ids
|
|
assert len(description_ids) == len(descriptions)
|
|
|
|
# For each description, check its order and text is correct
|
|
for index, description_id in enumerate(description_ids):
|
|
description_element = multi_inputs_group.xpath('//p[@id="{}"]'.format(description_id))
|
|
assert len(description_element) == 1
|
|
assert description_element[0].text == descriptions[index]
|
|
|
|
|
|
@ddt.ddt
|
|
class CAPAProblemReportHelpersTest(unittest.TestCase):
|
|
""" TestCase for CAPA methods for finding question labels and answer text """
|
|
|
|
@ddt.data(
|
|
('answerid_2_1', 'label', 'label'),
|
|
('answerid_2_2', 'label <some>html</some>', 'label html'),
|
|
('answerid_2_2', '<more html="yes"/>label <some>html</some>', 'label html'),
|
|
('answerid_2_3', None, 'Question 1'),
|
|
('answerid_2_3', '', 'Question 1'),
|
|
('answerid_3_3', '', 'Question 2'),
|
|
)
|
|
@ddt.unpack
|
|
def test_find_question_label(self, answer_id, label, stripped_label):
|
|
problem = new_loncapa_problem(
|
|
'<problem><some-problem id="{}"/></problem>'.format(answer_id)
|
|
)
|
|
mock_problem_data = {
|
|
answer_id: {
|
|
'label': HTML(label) if label else ''
|
|
}
|
|
}
|
|
with patch.object(problem, 'problem_data', mock_problem_data):
|
|
assert problem.find_question_label(answer_id) == stripped_label
|
|
|
|
@ddt.data(None, {}, [None])
|
|
def test_find_answer_test_not_implemented(self, current_answer):
|
|
problem = new_loncapa_problem('<problem/>')
|
|
self.assertRaises(NotImplementedError, problem.find_answer_text, '', current_answer)
|
|
|
|
@ddt.data(
|
|
('1_2_1', 'choice_0', 'over-suspicious'),
|
|
('1_2_1', 'choice_1', 'funny'),
|
|
('1_3_1', 'choice_0', 'The iPad'),
|
|
('1_3_1', 'choice_2', 'The iPod'),
|
|
('1_3_1', ['choice_0', 'choice_1'], 'The iPad, Napster'),
|
|
('1_4_1', 'yellow', 'yellow'),
|
|
('1_4_1', 'blue', 'blue'),
|
|
)
|
|
@ddt.unpack
|
|
def test_find_answer_text_choices(self, answer_id, choice_id, answer_text):
|
|
problem = new_loncapa_problem(
|
|
"""
|
|
<problem>
|
|
<choiceresponse>
|
|
<checkboxgroup label="Select the correct synonym of paranoid?">
|
|
<choice correct="true">over-suspicious</choice>
|
|
<choice correct="false">funny</choice>
|
|
</checkboxgroup>
|
|
</choiceresponse>
|
|
<multiplechoiceresponse>
|
|
<choicegroup type="MultipleChoice">
|
|
<choice correct="false">The iPad</choice>
|
|
<choice correct="false">Napster</choice>
|
|
<choice correct="true">The iPod</choice>
|
|
</choicegroup>
|
|
</multiplechoiceresponse>
|
|
<optionresponse>
|
|
<optioninput options="('yellow','blue','green')" correct="blue" label="Color_1"/>
|
|
</optionresponse>
|
|
</problem>
|
|
"""
|
|
)
|
|
assert problem.find_answer_text(answer_id, choice_id) == answer_text
|
|
|
|
@ddt.data(
|
|
# Test for ChoiceResponse
|
|
('1_2_1', 'choice_0', 'Answer Text Missing'),
|
|
('1_2_1', 'choice_1', 'funny'),
|
|
# Test for MultipleChoiceResponse
|
|
('1_3_1', 'choice_0', 'The iPad'),
|
|
('1_3_1', 'choice_2', 'Answer Text Missing'),
|
|
('1_3_1', ['choice_0', 'choice_1'], 'The iPad, Answer Text Missing'),
|
|
# Test for OptionResponse
|
|
('1_4_1', '', 'Answer Text Missing'),
|
|
)
|
|
@ddt.unpack
|
|
def test_find_answer_text_choices_with_missing_text(self, answer_id, choice_id, answer_text):
|
|
problem = new_loncapa_problem(
|
|
"""
|
|
<problem>
|
|
<choiceresponse>
|
|
<checkboxgroup label="Select the correct synonym of paranoid?">
|
|
<choice correct="true"></choice>
|
|
<choice correct="false">funny</choice>
|
|
</checkboxgroup>
|
|
</choiceresponse>
|
|
<multiplechoiceresponse>
|
|
<choicegroup type="MultipleChoice">
|
|
<choice correct="false">The iPad</choice>
|
|
<choice correct="false"></choice>
|
|
<choice correct="true"></choice>
|
|
</choicegroup>
|
|
</multiplechoiceresponse>
|
|
<optionresponse>
|
|
<optioninput options="('yellow','blue','green')" correct="blue" label="Color_1"/>
|
|
</optionresponse>
|
|
</problem>
|
|
"""
|
|
)
|
|
assert problem.find_answer_text(answer_id, choice_id) == answer_text
|
|
|
|
@ddt.data(
|
|
# Test for ChoiceResponse
|
|
('1_2_1', 'over-suspicious'),
|
|
# Test for MultipleChoiceResponse
|
|
('1_3_1', 'The iPad, Napster'),
|
|
# Test for OptionResponse
|
|
('1_4_1', 'blue'),
|
|
)
|
|
@ddt.unpack
|
|
def test_find_correct_answer_text_choices(self, answer_id, answer_text):
|
|
"""
|
|
Verify that ``find_correct_answer_text`` can find the correct answer for
|
|
ChoiceResponse, MultipleChoiceResponse and OptionResponse problems.
|
|
"""
|
|
problem = new_loncapa_problem(
|
|
"""
|
|
<problem>
|
|
<choiceresponse>
|
|
<checkboxgroup label="Select the correct synonym of paranoid?">
|
|
<choice correct="true">over-suspicious</choice>
|
|
<choice correct="false">funny</choice>
|
|
</checkboxgroup>
|
|
</choiceresponse>
|
|
<multiplechoiceresponse>
|
|
<choicegroup type="MultipleChoice">
|
|
<choice correct="true">The iPad</choice>
|
|
<choice correct="true">Napster</choice>
|
|
<choice correct="false">The iPod</choice>
|
|
</choicegroup>
|
|
</multiplechoiceresponse>
|
|
<optionresponse>
|
|
<optioninput options="('yellow','blue','green')" correct="blue" label="Color_1"/>
|
|
</optionresponse>
|
|
</problem>
|
|
"""
|
|
)
|
|
assert problem.find_correct_answer_text(answer_id) == answer_text
|
|
|
|
def test_find_answer_text_textinput(self):
|
|
problem = new_loncapa_problem(
|
|
"""
|
|
<problem>
|
|
<stringresponse answer="hide" type="ci">
|
|
<textline size="40"/>
|
|
</stringresponse>
|
|
</problem>
|
|
"""
|
|
)
|
|
assert problem.find_answer_text('1_2_1', 'hide') == 'hide'
|
|
|
|
def test_get_question_answer(self):
|
|
problem = new_loncapa_problem(
|
|
"""
|
|
<problem>
|
|
<optionresponse>
|
|
<optioninput options="('yellow','blue','green')" correct="blue" label="Color_1"/>
|
|
</optionresponse>
|
|
<solution>
|
|
<div class="detailed-solution">
|
|
<p>Explanation</p>
|
|
<p>Blue is the answer.</p>
|
|
</div>
|
|
</solution>
|
|
</problem>
|
|
"""
|
|
)
|
|
|
|
# Ensure that the answer is a string so that the dict returned from this
|
|
# function can eventualy be serialized to json without issues.
|
|
assert isinstance(problem.get_question_answers()['1_solution_1'], str)
|
|
|
|
@override_settings(FEATURES=FEATURES_WITH_GRADING_METHOD_IN_PROBLEMS)
|
|
def test_get_grade_from_current_answers(self):
|
|
"""
|
|
Verify that `responder.evaluate_answers` is called with `student_answers`
|
|
and `correct_map` sent to `get_grade_from_current_answers`.
|
|
|
|
When both arguments are provided, means that the problem is being rescored.
|
|
"""
|
|
student_answers = {'1_2_1': 'over-suspicious'}
|
|
correct_map = CorrectMap(answer_id='1_2_1', correctness="correct", npoints=1)
|
|
problem = new_loncapa_problem(
|
|
"""
|
|
<problem>
|
|
<multiplechoiceresponse>
|
|
<choicegroup>
|
|
<choice correct="true">Answer1</choice>
|
|
<choice correct="false">Answer2</choice>
|
|
<choice correct="false">Answer3</choice>
|
|
<choice correct="false">Answer4</choice>
|
|
</choicegroup>
|
|
</multiplechoiceresponse>
|
|
</problem>
|
|
"""
|
|
)
|
|
responder_mock = MagicMock()
|
|
|
|
with patch.object(problem, 'responders', {'responder1': responder_mock}):
|
|
responder_mock.allowed_inputfields = ['choicegroup']
|
|
responder_mock.evaluate_answers.return_value = correct_map
|
|
|
|
result = problem.get_grade_from_current_answers(student_answers, correct_map)
|
|
self.assertDictEqual(result.get_dict(), correct_map.get_dict())
|
|
responder_mock.evaluate_answers.assert_called_once_with(student_answers, correct_map)
|
|
|
|
@override_settings(FEATURES=FEATURES_WITH_GRADING_METHOD_IN_PROBLEMS)
|
|
def test_get_grade_from_current_answers_without_student_answers(self):
|
|
"""
|
|
Verify that `responder.evaluate_answers` is called with appropriate arguments.
|
|
|
|
When `student_answers` is None, `responder.evaluate_answers` should be called with
|
|
the `self.student_answers` instead.
|
|
"""
|
|
correct_map = CorrectMap(answer_id='1_2_1', correctness="correct", npoints=1)
|
|
problem = new_loncapa_problem(
|
|
"""
|
|
<problem>
|
|
<multiplechoiceresponse>
|
|
<choicegroup>
|
|
<choice correct="true">Answer1</choice>
|
|
<choice correct="false">Answer2</choice>
|
|
<choice correct="false">Answer3</choice>
|
|
<choice correct="false">Answer4</choice>
|
|
</choicegroup>
|
|
</multiplechoiceresponse>
|
|
</problem>
|
|
"""
|
|
)
|
|
responder_mock = MagicMock()
|
|
|
|
with patch.object(problem, 'responders', {'responder1': responder_mock}):
|
|
problem.responders['responder1'].allowed_inputfields = ['choicegroup']
|
|
problem.responders['responder1'].evaluate_answers.return_value = correct_map
|
|
|
|
result = problem.get_grade_from_current_answers(None, correct_map)
|
|
|
|
self.assertDictEqual(result.get_dict(), correct_map.get_dict())
|
|
responder_mock.evaluate_answers.assert_called_once_with(None, correct_map)
|
|
|
|
@override_settings(FEATURES=FEATURES_WITH_GRADING_METHOD_IN_PROBLEMS)
|
|
def test_get_grade_from_current_answers_with_filesubmission(self):
|
|
"""
|
|
Verify that an exception is raised when `responder.evaluate_answers` is called
|
|
with `student_answers` as None and `correct_map` sent to `get_grade_from_current_answers`
|
|
|
|
This ensures that rescore is not allowed if the problem has a filesubmission.
|
|
"""
|
|
correct_map = CorrectMap(answer_id='1_2_1', correctness="correct", npoints=1)
|
|
problem = new_loncapa_problem(
|
|
"""
|
|
<problem>
|
|
<multiplechoiceresponse>
|
|
<choicegroup>
|
|
<choice correct="true">Answer1</choice>
|
|
<choice correct="false">Answer2</choice>
|
|
<choice correct="false">Answer3</choice>
|
|
<choice correct="false">Answer4</choice>
|
|
</choicegroup>
|
|
</multiplechoiceresponse>
|
|
</problem>
|
|
"""
|
|
)
|
|
responder_mock = MagicMock()
|
|
|
|
with patch.object(problem, 'responders', {'responder1': responder_mock}):
|
|
responder_mock.allowed_inputfields = ['filesubmission']
|
|
responder_mock.evaluate_answers.return_value = correct_map
|
|
|
|
with self.assertRaises(Exception):
|
|
problem.get_grade_from_current_answers(None, correct_map)
|
|
responder_mock.evaluate_answers.assert_not_called()
|