Commit 377f91e9 by Nick Parlante Committed by Jason Bau

Multiple-choice features

Features by Jeff Ericson and Nick Parlante squashed
down to one commit.

-shuffle of choices within a choicegroup (Nick)
-answer-pool subsetting within a choicegroup (Jeff)
-masking of choice names within a choicegroup, (Nick)
 used by shuffle and answer-pool
-targeted-feedback within a multiplechoiceresponse (Jeff)
-delay-between-submissions capa feature (Jeff)
parent efb0b20d
......@@ -13,7 +13,7 @@ MAXIMUM_ATTEMPTS = "Maximum Attempts"
PROBLEM_WEIGHT = "Problem Weight"
RANDOMIZATION = 'Randomization'
SHOW_ANSWER = "Show Answer"
TIMER_BETWEEN_ATTEMPTS = "Timer Between Attempts"
@step('I have created a Blank Common Problem$')
def i_created_blank_common_problem(step):
......@@ -44,6 +44,7 @@ def i_see_advanced_settings_with_values(step):
[PROBLEM_WEIGHT, "", False],
[RANDOMIZATION, "Never", False],
[SHOW_ANSWER, "Finished", False],
[TIMER_BETWEEN_ATTEMPTS, "0", False]
])
......
......@@ -178,6 +178,11 @@ class LoncapaProblem(object):
# input_id string -> InputType object
self.inputs = {}
# Run response late_transforms last (see MultipleChoiceResponse)
for response in self.responders.values():
if hasattr(response, 'late_transforms'):
response.late_transforms()
self.extracted_tree = self._extract_html(self.tree)
def do_reset(self):
......@@ -419,10 +424,85 @@ class LoncapaProblem(object):
answer_ids.append(results.keys())
return answer_ids
def do_targeted_feedback(self, tree):
"""
Implements the targeted-feedback=N in-place on <multiplechoiceresponse> --
choice-level explanations shown to a student after submission.
Does nothing if there is no targeted-feedback attribute.
"""
for mult_choice_response in tree.xpath('//multiplechoiceresponse[@targeted-feedback]'):
show_explanation = mult_choice_response.get('targeted-feedback') == 'alwaysShowCorrectChoiceExplanation'
# Avoid modifying the tree again if targeted_feedback has already run --
# do this by setting a targeted-done attribute in the tree.
if mult_choice_response.get('targeted-done') is not None:
continue
mult_choice_response.set('targeted-done', 'done')
# Grab the first choicegroup (there should only be one within each <multiplechoiceresponse> tag)
choicegroup = mult_choice_response.xpath('./choicegroup[@type="MultipleChoice"]')[0]
choices_list = list(choicegroup.iter('choice'))
# Find the student answer key that matches our <choicegroup> id
student_answer = self.student_answers.get(choicegroup.get('id'))
expl_id_for_student_answer = None
# Keep track of the explanation-id that corresponds to the student's answer
# Also, keep track of the solution-id
solution_id = None
for choice in choices_list:
if choice.get('name') == student_answer:
expl_id_for_student_answer = choice.get('explanation-id')
if choice.get('correct') == 'true':
solution_id = choice.get('explanation-id')
# Filter out targetedfeedback that doesn't correspond to the answer the student selected
# Note: following-sibling will grab all following siblings, so we just want the first in the list
targetedfeedbackset = mult_choice_response.xpath('./following-sibling::targetedfeedbackset')
if len(targetedfeedbackset) != 0:
targetedfeedbackset = targetedfeedbackset[0]
targetedfeedbacks = targetedfeedbackset.xpath('./targetedfeedback')
for targetedfeedback in targetedfeedbacks:
# Don't show targeted feedback if the student hasn't answer the problem
# or if the target feedback doesn't match the student's (incorrect) answer
if not self.done or targetedfeedback.get('explanation-id') != expl_id_for_student_answer:
targetedfeedbackset.remove(targetedfeedback)
# Do not displace the solution under these circumstances
if not show_explanation or not self.done:
continue
# The next element should either be <solution> or <solutionset>
next_element = targetedfeedbackset.getnext()
parent_element = tree
solution_element = None
if next_element is not None and next_element.tag == 'solution':
solution_element = next_element
elif next_element is not None and next_element.tag == 'solutionset':
solutions = next_element.xpath('./solution')
for solution in solutions:
if solution.get('explanation-id') == solution_id:
parent_element = next_element
solution_element = solution
# If could not find the solution element, then skip the remaining steps below
if solution_element is None:
continue
# Change our correct-choice explanation from a "solution explanation" to within
# the set of targeted feedback, which means the explanation will render on the page
# without the student clicking "Show Answer" or seeing a checkmark next to the correct choice
parent_element.remove(solution_element)
# Add our solution instead to the targetedfeedbackset and change its tag name
solution_element.tag = 'targetedfeedback'
targetedfeedbackset.append(solution_element)
def get_html(self):
"""
Main method called externally to get the HTML to be rendered for this capa Problem.
"""
self.do_targeted_feedback(self.tree)
html = contextualize_text(etree.tostring(self._extract_html(self.tree)), self.context)
return html
......@@ -598,8 +678,7 @@ class LoncapaProblem(object):
# other than to examine .tag to see if it's a string. :(
return
if (problemtree.tag == 'script' and problemtree.get('type')
and 'javascript' in problemtree.get('type')):
if (problemtree.tag == 'script' and problemtree.get('type') and 'javascript' in problemtree.get('type')):
# leave javascript intact.
return deepcopy(problemtree)
......
......@@ -98,3 +98,41 @@ class SolutionRenderer(object):
return etree.XML(html)
registry.register(SolutionRenderer)
#-----------------------------------------------------------------------------
class TargetedFeedbackRenderer(object):
'''
A targeted feedback is just a <span>...</span> that is used for displaying an
extended piece of feedback to students if they incorrectly answered a question.
'''
tags = ['targetedfeedback']
def __init__(self, system, xml):
self.system = system
self.xml = xml
def get_html(self):
"""
Return the contents of this tag, rendered to html, as an etree element.
"""
html = '<section class="targeted-feedback-span"><span>%s</span></section>' % (
etree.tostring(self.xml))
try:
xhtml = etree.XML(html)
except Exception as err:
if self.system.DEBUG:
msg = '<html><div class="inline-error"><p>Error %s</p>' % (
str(err).replace('<', '&lt;'))
msg += ('<p>Failed to construct targeted feedback from <pre>%s</pre></p>' %
html.replace('<', '&lt;'))
msg += "</div></html>"
log.error(msg)
return etree.XML(msg)
else:
raise
return xhtml
registry.register(TargetedFeedbackRenderer)
......@@ -742,19 +742,46 @@ class MultipleChoiceResponse(LoncapaResponse):
def mc_setup_response(self):
"""
Initialize name attributes in <choice> stanzas in the <choicegroup> in this response.
Masks the choice names if applicable.
"""
i = 0
for response in self.xml.xpath("choicegroup"):
# Is Masking enabled? -- check for shuffle or answer-pool features
ans_str = response.get("answer-pool")
if response.get("shuffle") == "true" or (ans_str is not None and ans_str != "0"):
self.is_masked = True
self.mask_dict = {}
rng = random.Random(self.context["seed"])
# e.g. mask_ids = [3, 1, 0, 2]
mask_ids = range(len(response))
rng.shuffle(mask_ids)
rtype = response.get('type')
if rtype not in ["MultipleChoice"]:
# force choicegroup to be MultipleChoice if not valid
response.set("type", "MultipleChoice")
for choice in list(response):
if choice.get("name") is None:
choice.set("name", "choice_" + str(i))
# The regular, non-masked name:
if choice.get("name") is not None:
name = "choice_" + choice.get("name")
else:
name = "choice_" + str(i)
i += 1
# If using the masked name, e.g. mask_0, save the regular name
# to support unmasking later (for the logs).
if hasattr(self, "is_masked"):
mask_name = "mask_" + str(mask_ids.pop())
self.mask_dict[mask_name] = name
choice.set("name", mask_name)
else:
choice.set("name", "choice_" + choice.get("name"))
choice.set("name", name)
def late_transforms(self):
"""Rearrangements run late in the __init__ process.
Cannot do these at response init time, as not enough
other stuff exists at that time.
"""
self.do_shuffle(self.xml)
self.do_answer_pool(self.xml)
def get_score(self, student_answers):
"""
......@@ -771,6 +798,189 @@ class MultipleChoiceResponse(LoncapaResponse):
def get_answers(self):
return {self.answer_id: self.correct_choices}
# Choice name Masking
# A feature where the regular names of multiplechoice choices
# choice_0 choice_1 ... are not used. Instead we use random masked names
# mask_2 mask_0 ... so that a view-source of the names reveals nothing about
# the original order. We introduce the masked names right at init time, so the
# whole software stack works with just the one system of naming.
# A response with masking has its .is_masked attribute set.
# We "unmask" the names, back to choice_0 style, only for the logs so they correspond
# to how the problems look to the author.
#
# Masking is enabled for the shuffle and answer-pool features.
def unmask_name(self, name):
"""
Given a masked name, e.g. mask_2, returns the regular name, e.g. choice_0.
Fails loudly if called for a response that is not masking.
"""
# We could check that masking is enabled, but I figure it's better to
# fail loudly so the upper layers are alerted to mis-use.
return self.mask_dict[name]
def unmask_order(self):
"""
Returns a list of the choice names in the order displayed to the user,
using the regular (non-masked) names.
Fails loudly if called on a response that is not masking.
"""
choicegroups = self.xml.xpath('//choicegroup')
return [self.unmask_name(choice.get("name")) for choice in choicegroups[0].getchildren()]
def do_shuffle(self, tree):
"""
For a choicegroup with shuffle="true", shuffles the choices in-place in the given tree
based on the seed. Otherwise does nothing.
Does nothing if the tree has already been processed.
"""
choicegroups = tree.xpath('//choicegroup[@shuffle="true"]')
if choicegroups:
if len(choicegroups) > 1:
raise LoncapaProblemError('We support at most one shuffled choicegroup')
choicegroup = choicegroups[0]
# Note that this tree has been processed.
if choicegroup.get('shuffle-done') is not None:
return
choicegroup.set('shuffle-done', 'done')
# Move elements from tree to list for shuffling, then put them back.
ordering = list(choicegroup.getchildren())
for choice in ordering:
choicegroup.remove(choice)
ordering = self.shuffle_choices(ordering)
for choice in ordering:
choicegroup.append(choice)
def shuffle_choices(self, choices):
"""
Returns a list of choice nodes with the shuffling done.
Uses the context seed for the randomness of the shuffle.
Choices with 'fixed'='true' are held back from the shuffle.
"""
# Separate out a list of the stuff to be shuffled
# vs. the head/tail of fixed==true choices to be held back from the shuffle.
# Rare corner case: A fixed==true choice "island" in the middle is lumped in
# with the tail group of fixed choices.
# Slightly tricky one-pass implementation using a state machine
head = []
middle = [] # only this one gets shuffled
tail = []
at_head = True
for choice in choices:
if at_head and choice.get('fixed') == 'true':
head.append(choice)
continue
at_head = False
if choice.get('fixed') == 'true':
tail.append(choice)
else:
middle.append(choice)
rng = random.Random(self.context['seed']) # make one vs. messing with the global one
rng.shuffle(middle)
return head + middle + tail
def do_answer_pool(self, tree):
"""
Implements the answer-pool subsetting operation in-place on the tree.
Allows for problem questions with a pool of answers, from which answer options shown to the student
and randomly selected so that there is always 1 correct answer and n-1 incorrect answers,
where the author specifies n as the value of the attribute "answer-pool" within <choicegroup>
The <choicegroup> tag must have an attribute 'answer-pool' giving the desired
pool size. If that attribute is zero or not present, no operation is performed.
Calling this a second time does nothing.
"""
choicegroups = tree.xpath("//choicegroup[@answer-pool]")
# Uses self.seed -- but want to randomize every time reaches this problem,
# so problem's "randomization" should be set to "always"
rnd = random.Random(self.context['seed'])
for choicegroup in choicegroups:
num_str = choicegroup.get('answer-pool')
if num_str == '0':
break
# Note that this tree has been processed
if choicegroup.get('answer-pool-done') is not None:
break
choicegroup.set('answer-pool-done', 'done')
try:
num_choices = int(num_str)
except ValueError:
raise LoncapaProblemError("answer-pool value should be an integer")
choices_list = list(choicegroup.getchildren())
# Remove all choices in the choices_list (we will add some back in later)
for choice in choices_list:
choicegroup.remove(choice)
# Sample from the answer pool to get the subset choices and solution id
(solution_id, subset_choices) = self.sample_from_answer_pool(choices_list, rnd, num_choices)
# Add back in randomly selected choices
for choice in subset_choices:
choicegroup.append(choice)
# Filter out solutions that don't correspond to the correct answer we selected to show
# Note that this means that if the user simply provides a <solution> tag, nothing is filtered
solutionset = choicegroup.xpath('../following-sibling::solutionset')
if len(solutionset) != 0:
solutionset = solutionset[0]
solutions = solutionset.xpath('./solution')
for solution in solutions:
if solution.get('explanation-id') != solution_id:
solutionset.remove(solution)
def sample_from_answer_pool(self, choices, rnd, num_pool):
"""
Takes in:
1. list of choices
2. random number generator
3. the requested size "answer-pool" number, in effect a max
Returns a tuple with 2 items:
1. the solution_id corresponding with the chosen correct answer
2. (subset) list of choice nodes with num-1 incorrect and 1 correct
"""
correct_choices = []
incorrect_choices = []
for choice in choices:
if choice.get('correct') == 'true':
correct_choices.append(choice)
else:
incorrect_choices.append(choice)
# TODO: check if we should require correct == "false"
# We throw an error if the problem is highly ill-formed.
# There must be at least one correct and one incorrect choice.
# TODO: perhaps this makes more sense for *all* problems, not just down in this corner.
if len(correct_choices) < 1 or len(incorrect_choices) < 1:
raise responsetypes.LoncapaProblemError("Choicegroup must include at last 1 correct and 1 incorrect choice")
# Limit the number of incorrect choices to what we actually have
num_incorrect = num_pool - 1
num_incorrect = min(num_incorrect, len(incorrect_choices))
# Select the one correct choice
index = rnd.randint(0, len(correct_choices) - 1)
correct_choice = correct_choices[index]
solution_id = correct_choice.get('explanation-id')
# Put together the result, pushing most of the work onto rnd.shuffle()
subset_choices = [correct_choice]
rnd.shuffle(incorrect_choices)
subset_choices += incorrect_choices[:num_incorrect]
rnd.shuffle(subset_choices)
return (solution_id, subset_choices)
@registry.register
class TrueFalseResponse(MultipleChoiceResponse):
......
......@@ -52,6 +52,6 @@ def test_capa_system():
return the_system
def new_loncapa_problem(xml, capa_system=None):
def new_loncapa_problem(xml, capa_system=None, seed=723):
"""Construct a `LoncapaProblem` suitable for unit tests."""
return LoncapaProblem(xml, id='1', seed=723, capa_system=capa_system or test_capa_system())
return LoncapaProblem(xml, id='1', seed=seed, capa_system=capa_system or test_capa_system())
"""
Tests the logic of the "answer-pool" attribute, e.g.
<choicegroup answer-pool="4"
"""
import unittest
import textwrap
from . import test_capa_system, new_loncapa_problem
from capa.responsetypes import LoncapaProblemError
class CapaAnswerPoolTest(unittest.TestCase):
'''
Testing class
'''
def setUp(self):
super(CapaAnswerPoolTest, self).setUp()
self.system = test_capa_system()
def test_answer_pool_4_choices_1_multiplechoiceresponse_seed1(self):
xml_str = textwrap.dedent("""
<problem>
<p>What is the correct answer?</p>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice" answer-pool="4">
<choice correct="false">wrong-1</choice>
<choice correct="false">wrong-2</choice>
<choice correct="true" explanation-id="solution1">correct-1</choice>
<choice correct="false">wrong-3</choice>
<choice correct="false">wrong-4</choice>
<choice correct="true" explanation-id="solution2">correct-2</choice>
</choicegroup>
</multiplechoiceresponse>
<solutionset>
<solution explanation-id="solution1">
<div class="detailed-solution">
<p>Explanation</p>
<p>This is the 1st solution</p>
<p>Not much to explain here, sorry!</p>
</div>
</solution>
<solution explanation-id="solution2">
<div class="detailed-solution">
<p>Explanation</p>
<p>This is the 2nd solution</p>
</div>
</solution>
</solutionset>
</problem>
""")
problem = new_loncapa_problem(xml_str, seed=723)
the_html = problem.get_html()
# [('choice_3', u'wrong-3'), ('choice_5', u'correct-2'), ('choice_1', u'wrong-2'), ('choice_4', u'wrong-4')]
self.assertRegexpMatches(the_html, r"<div>.*\[.*'wrong-3'.*'correct-2'.*'wrong-2'.*'wrong-4'.*\].*</div>")
self.assertRegexpMatches(the_html, r"<div>\{.*'1_solution_2'.*\}</div>")
self.assertEqual(the_html, problem.get_html(), 'should be able to call get_html() twice')
self.assertIsNotNone(problem.tree.xpath('//choicegroup[@answer-pool-done="done"]'))
# Check about masking
response = problem.responders.values()[0]
self.assertTrue(hasattr(response, 'is_masked'))
self.assertEqual(response.unmask_order(), ['choice_3', 'choice_5', 'choice_1', 'choice_4'])
def test_answer_pool_4_choices_1_multiplechoiceresponse_seed2(self):
xml_str = textwrap.dedent("""
<problem>
<p>What is the correct answer?</p>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice" answer-pool="4">
<choice correct="false">wrong-1</choice>
<choice correct="false">wrong-2</choice>
<choice correct="true" explanation-id="solution1">correct-1</choice>
<choice correct="false">wrong-3</choice>
<choice correct="false">wrong-4</choice>
<choice correct="true" explanation-id="solution2">correct-2</choice>
</choicegroup>
</multiplechoiceresponse>
<solutionset>
<solution explanation-id="solution1">
<div class="detailed-solution">
<p>Explanation</p>
<p>This is the 1st solution</p>
<p>Not much to explain here, sorry!</p>
</div>
</solution>
<solution explanation-id="solution2">
<div class="detailed-solution">
<p>Explanation</p>
<p>This is the 2nd solution</p>
</div>
</solution>
</solutionset>
</problem>
""")
problem = new_loncapa_problem(xml_str, seed=9)
the_html = problem.get_html()
# [('choice_0', u'wrong-1'), ('choice_4', u'wrong-4'), ('choice_3', u'wrong-3'), ('choice_2', u'correct-1')]
self.assertRegexpMatches(the_html, r"<div>.*\[.*'wrong-1'.*'wrong-4'.*'wrong-3'.*'correct-1'.*\].*</div>")
self.assertRegexpMatches(the_html, r"<div>\{.*'1_solution_1'.*\}</div>")
self.assertIsNotNone(problem.tree.xpath('//choicegroup[@answer-pool-done="done"]'))
# Check about masking
response = problem.responders.values()[0]
self.assertTrue(hasattr(response, 'is_masked'))
self.assertEqual(response.unmask_order(), ['choice_0', 'choice_4', 'choice_3', 'choice_2'])
def test_no_answer_pool_4_choices_1_multiplechoiceresponse(self):
xml_str = textwrap.dedent("""
<problem>
<p>What is the correct answer?</p>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice">
<choice correct="false">wrong-1</choice>
<choice correct="false">wrong-2</choice>
<choice correct="true" explanation-id="solution1">correct-1</choice>
<choice correct="false">wrong-3</choice>
<choice correct="false">wrong-4</choice>
<choice correct="true" explanation-id="solution2">correct-2</choice>
</choicegroup>
</multiplechoiceresponse>
<solutionset>
<solution explanation-id="solution1">
<div class="detailed-solution">
<p>Explanation</p>
<p>This is the 1st solution</p>
<p>Not much to explain here, sorry!</p>
</div>
</solution>
<solution explanation-id="solution2">
<div class="detailed-solution">
<p>Explanation</p>
<p>This is the 2nd solution</p>
</div>
</solution>
</solutionset>
</problem>
""")
problem = new_loncapa_problem(xml_str)
the_html = problem.get_html()
self.assertRegexpMatches(the_html, r"<div>.*\[.*'wrong-1'.*'wrong-2'.*'correct-1'.*'wrong-3'.*'wrong-4'.*'correct-2'.*\].*</div>")
self.assertRegexpMatches(the_html, r"<div>\{.*'1_solution_1'.*'1_solution_2'.*\}</div>")
self.assertEqual(the_html, problem.get_html(), 'should be able to call get_html() twice')
self.assertEquals(problem.tree.xpath('//choicegroup[@answer-pool-done="done"]'), [])
# Check about masking
response = problem.responders.values()[0]
self.assertFalse(hasattr(response, 'is_masked'))
def test_0_answer_pool_4_choices_1_multiplechoiceresponse(self):
xml_str = textwrap.dedent("""
<problem>
<p>What is the correct answer?</p>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice" answer-pool="0">
<choice correct="false">wrong-1</choice>
<choice correct="false">wrong-2</choice>
<choice correct="true" explanation-id="solution1">correct-1</choice>
<choice correct="false">wrong-3</choice>
<choice correct="false">wrong-4</choice>
<choice correct="true" explanation-id="solution2">correct-2</choice>
</choicegroup>
</multiplechoiceresponse>
<solutionset>
<solution explanation-id="solution1">
<div class="detailed-solution">
<p>Explanation</p>
<p>This is the 1st solution</p>
<p>Not much to explain here, sorry!</p>
</div>
</solution>
<solution explanation-id="solution2">
<div class="detailed-solution">
<p>Explanation</p>
<p>This is the 2nd solution</p>
</div>
</solution>
</solutionset>
</problem>
""")
problem = new_loncapa_problem(xml_str)
the_html = problem.get_html()
self.assertRegexpMatches(the_html, r"<div>.*\[.*'wrong-1'.*'wrong-2'.*'correct-1'.*'wrong-3'.*'wrong-4'.*'correct-2'.*\].*</div>")
self.assertRegexpMatches(the_html, r"<div>\{.*'1_solution_1'.*'1_solution_2'.*\}</div>")
self.assertEquals(problem.tree.xpath('//choicegroup[@answer-pool-done="done"]'), [])
response = problem.responders.values()[0]
self.assertFalse(hasattr(response, 'is_masked'))
def test_invalid_answer_pool(self):
xml_str = textwrap.dedent("""
<problem>
<p>What is the correct answer?</p>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice" answer-pool="2.3">
<choice correct="false">wrong-1</choice>
<choice correct="false">wrong-2</choice>
<choice correct="true" explanation-id="solution1">correct-1</choice>
<choice correct="false">wrong-3</choice>
<choice correct="false">wrong-4</choice>
<choice correct="true" explanation-id="solution2">correct-2</choice>
</choicegroup>
</multiplechoiceresponse>
<solutionset>
<solution explanation-id="solution1">
<div class="detailed-solution">
<p>Explanation</p>
<p>This is the 1st solution</p>
<p>Not much to explain here, sorry!</p>
</div>
</solution>
<solution explanation-id="solution2">
<div class="detailed-solution">
<p>Explanation</p>
<p>This is the 2nd solution</p>
</div>
</solution>
</solutionset>
</problem>
""")
with self.assertRaises(LoncapaProblemError):
problem = new_loncapa_problem(xml_str)
problem.get_html()
def test_answer_pool_5_choices_1_multiplechoiceresponse_seed1(self):
xml_str = textwrap.dedent("""
<problem>
<p>What is the correct answer?</p>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice" answer-pool="5">
<choice correct="false">wrong-1</choice>
<choice correct="false">wrong-2</choice>
<choice correct="true" explanation-id="solution1">correct-1</choice>
<choice correct="false">wrong-3</choice>
<choice correct="false">wrong-4</choice>
<choice correct="true" explanation-id="solution2">correct-2</choice>
</choicegroup>
</multiplechoiceresponse>
<solutionset>
<solution explanation-id="solution1">
<div class="detailed-solution">
<p>Explanation</p>
<p>This is the 1st solution</p>
<p>Not much to explain here, sorry!</p>
</div>
</solution>
<solution explanation-id="solution2">
<div class="detailed-solution">
<p>Explanation</p>
<p>This is the 2nd solution</p>
</div>
</solution>
</solutionset>
</problem>
""")
problem = new_loncapa_problem(xml_str, seed=723)
the_html = problem.get_html()
self.assertRegexpMatches(the_html, r"<div>.*\[.*'correct-2'.*'wrong-1'.*'wrong-2'.*.*'wrong-3'.*'wrong-4'.*\].*</div>")
self.assertRegexpMatches(the_html, r"<div>\{.*'1_solution_2'.*\}</div>")
response = problem.responders.values()[0]
self.assertTrue(hasattr(response, 'is_masked'))
self.assertEqual(response.unmask_order(), ['choice_5', 'choice_0', 'choice_1', 'choice_3', 'choice_4'])
def test_answer_pool_2_multiplechoiceresponses_seed1(self):
xml_str = textwrap.dedent("""
<problem>
<p>What is the correct answer?</p>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice" answer-pool="4">
<choice correct="false">wrong-1</choice>
<choice correct="false">wrong-2</choice>
<choice correct="true" explanation-id="solution1">correct-1</choice>
<choice correct="false">wrong-3</choice>
<choice correct="false">wrong-4</choice>
<choice correct="true" explanation-id="solution2">correct-2</choice>
</choicegroup>
</multiplechoiceresponse>
<solutionset>
<solution explanation-id="solution1">
<div class="detailed-solution">
<p>Explanation</p>
<p>This is the 1st solution</p>
<p>Not much to explain here, sorry!</p>
</div>
</solution>
<solution explanation-id="solution2">
<div class="detailed-solution">
<p>Explanation</p>
<p>This is the 2nd solution</p>
</div>
</solution>
</solutionset>
<p>What is the correct answer?</p>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice" answer-pool="3">
<choice correct="false">wrong-1</choice>
<choice correct="false">wrong-2</choice>
<choice correct="true" explanation-id="solution1">correct-1</choice>
<choice correct="false">wrong-3</choice>
<choice correct="false">wrong-4</choice>
<choice correct="true" explanation-id="solution2">correct-2</choice>
</choicegroup>
</multiplechoiceresponse>
<solutionset>
<solution explanation-id="solution1">
<div class="detailed-solution">
<p>Explanation</p>
<p>This is the 1st solution</p>
<p>Not much to explain here, sorry!</p>
</div>
</solution>
<solution explanation-id="solution2">
<div class="detailed-solution">
<p>Explanation</p>
<p>This is the 2nd solution</p>
</div>
</solution>
</solutionset>
</problem>
""")
problem = new_loncapa_problem(xml_str, seed=723)
the_html = problem.get_html()
str1 = r"<div>.*\[.*'wrong-3'.*'correct-2'.*'wrong-2'.*'wrong-4'.*\].*</div>"
str2 = r"<div>.*\[.*'wrong-2'.*'wrong-1'.*'correct-2'.*\].*</div>"
str3 = r"<div>\{.*'1_solution_2'.*\}</div>"
str4 = r"<div>\{.*'1_solution_4'.*\}</div>"
self.assertRegexpMatches(the_html, str1)
self.assertRegexpMatches(the_html, str2)
self.assertRegexpMatches(the_html, str3)
self.assertRegexpMatches(the_html, str4)
without_new_lines = the_html.replace("\n", "")
self.assertRegexpMatches(without_new_lines, str1 + r".*" + str2)
self.assertRegexpMatches(without_new_lines, str3 + r".*" + str4)
def test_answer_pool_2_multiplechoiceresponses_seed2(self):
xml_str = textwrap.dedent("""
<problem>
<p>What is the correct answer?</p>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice" answer-pool="3">
<choice correct="false">wrong-1</choice>
<choice correct="false">wrong-2</choice>
<choice correct="true" explanation-id="solution1">correct-1</choice>
<choice correct="false">wrong-3</choice>
<choice correct="false">wrong-4</choice>
<choice correct="true" explanation-id="solution2">correct-2</choice>
</choicegroup>
</multiplechoiceresponse>
<solutionset>
<solution explanation-id="solution1">
<div class="detailed-solution">
<p>Explanation</p>
<p>This is the 1st solution</p>
<p>Not much to explain here, sorry!</p>
</div>
</solution>
<solution explanation-id="solution2">
<div class="detailed-solution">
<p>Explanation</p>
<p>This is the 2nd solution</p>
</div>
</solution>
</solutionset>
<p>What is the correct answer?</p>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice" answer-pool="4">
<choice correct="false">wrong-1</choice>
<choice correct="false">wrong-2</choice>
<choice correct="true" explanation-id="solution1">correct-1</choice>
<choice correct="false">wrong-3</choice>
<choice correct="false">wrong-4</choice>
<choice correct="true" explanation-id="solution2">correct-2</choice>
</choicegroup>
</multiplechoiceresponse>
<solutionset>
<solution explanation-id="solution1">
<div class="detailed-solution">
<p>Explanation</p>
<p>This is the 1st solution</p>
<p>Not much to explain here, sorry!</p>
</div>
</solution>
<solution explanation-id="solution2">
<div class="detailed-solution">
<p>Explanation</p>
<p>This is the 2nd solution</p>
</div>
</solution>
</solutionset>
</problem>
""")
problem = new_loncapa_problem(xml_str, seed=9)
the_html = problem.get_html()
str1 = r"<div>.*\[.*'wrong-4'.*'wrong-3'.*'correct-1'.*\].*</div>"
str2 = r"<div>.*\[.*'wrong-2'.*'wrong-3'.*'wrong-4'.*'correct-2'.*\].*</div>"
str3 = r"<div>\{.*'1_solution_1'.*\}</div>"
str4 = r"<div>\{.*'1_solution_4'.*\}</div>"
self.assertRegexpMatches(the_html, str1)
self.assertRegexpMatches(the_html, str2)
self.assertRegexpMatches(the_html, str3)
self.assertRegexpMatches(the_html, str4)
without_new_lines = the_html.replace("\n", "")
self.assertRegexpMatches(without_new_lines, str1 + r".*" + str2)
self.assertRegexpMatches(without_new_lines, str3 + r".*" + str4)
def test_answer_pool_and_no_answer_pool(self):
xml_str = textwrap.dedent("""
<problem>
<p>What is the correct answer?</p>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice">
<choice correct="false">wrong-1</choice>
<choice correct="false">wrong-2</choice>
<choice correct="true">correct-1</choice>
<choice correct="false">wrong-3</choice>
<choice correct="false">wrong-4</choice>
</choicegroup>
</multiplechoiceresponse>
<solution>
<div class="detailed-solution">
<p>Explanation</p>
<p>This is the solution</p>
<p>Not much to explain here, sorry!</p>
</div>
</solution>
<p>What is the correct answer?</p>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice" answer-pool="4">
<choice correct="false">wrong-1</choice>
<choice correct="false">wrong-2</choice>
<choice correct="true" explanation-id="solution1">correct-1</choice>
<choice correct="false">wrong-3</choice>
<choice correct="false">wrong-4</choice>
<choice correct="true" explanation-id="solution2">correct-2</choice>
</choicegroup>
</multiplechoiceresponse>
<solutionset>
<solution explanation-id="solution1">
<div class="detailed-solution">
<p>Explanation</p>
<p>This is the 1st solution</p>
<p>Not much to explain here, sorry!</p>
</div>
</solution>
<solution explanation-id="solution2">
<div class="detailed-solution">
<p>Explanation</p>
<p>This is the 2nd solution</p>
</div>
</solution>
</solutionset>
</problem>
""")
problem = new_loncapa_problem(xml_str, seed=723)
the_html = problem.get_html()
str1 = r"<div>.*\[.*'wrong-1'.*'wrong-2'.*'correct-1'.*'wrong-3'.*'wrong-4'.*\].*</div>"
str2 = r"<div>.*\[.*'wrong-3'.*'correct-2'.*'wrong-2'.*'wrong-4'.*\].*</div>"
str3 = r"<div>\{.*'1_solution_1'.*\}</div>"
str4 = r"<div>\{.*'1_solution_3'.*\}</div>"
self.assertRegexpMatches(the_html, str1)
self.assertRegexpMatches(the_html, str2)
self.assertRegexpMatches(the_html, str3)
self.assertRegexpMatches(the_html, str4)
without_new_lines = the_html.replace("\n", "")
self.assertRegexpMatches(without_new_lines, str1 + r".*" + str2)
self.assertRegexpMatches(without_new_lines, str3 + r".*" + str4)
def test_answer_pool_without_solutionset(self):
xml_str = textwrap.dedent("""
<problem>
<p>What is the correct answer?</p>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice" answer-pool="4">
<choice correct="false">wrong-1</choice>
<choice correct="false">wrong-2</choice>
<choice correct="true">correct-1</choice>
<choice correct="false">wrong-3</choice>
<choice correct="false">wrong-4</choice>
<choice correct="true">correct-2</choice>
</choicegroup>
</multiplechoiceresponse>
<solution>
<div class="detailed-solution">
<p>Explanation</p>
<p>This is the solution</p>
<p>Not much to explain here, sorry!</p>
</div>
</solution>
</problem>
""")
problem = new_loncapa_problem(xml_str, seed=723)
the_html = problem.get_html()
self.assertRegexpMatches(the_html, r"<div>.*\[.*'wrong-3'.*'correct-2'.*'wrong-2'.*'wrong-4'.*\].*</div>")
self.assertRegexpMatches(the_html, r"<div>\{.*'1_solution_1'.*\}</div>")
"""Tests the capa shuffle and name-masking."""
import unittest
import textwrap
from . import test_capa_system, new_loncapa_problem
class CapaShuffleTest(unittest.TestCase):
"""Capa problem tests for shuffling and choice-name masking."""
def setUp(self):
super(CapaShuffleTest, self).setUp()
self.system = test_capa_system()
def test_shuffle_4_choices(self):
xml_str = textwrap.dedent("""
<problem>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice" shuffle="true">
<choice correct="false">Apple</choice>
<choice correct="false">Banana</choice>
<choice correct="false">Chocolate</choice>
<choice correct ="true">Donut</choice>
</choicegroup>
</multiplechoiceresponse>
</problem>
""")
problem = new_loncapa_problem(xml_str, seed=0)
# shuffling 4 things with seed of 0 yields: B A C D
# Check that the choices are shuffled
the_html = problem.get_html()
self.assertRegexpMatches(the_html, r"<div>.*\[.*'Banana'.*'Apple'.*'Chocolate'.*'Donut'.*\].*</div>")
# Check that choice name masking is enabled and that unmasking works
response = problem.responders.values()[0]
self.assertTrue(hasattr(response, 'is_masked'))
self.assertEqual(response.unmask_order(), ['choice_1', 'choice_0', 'choice_2', 'choice_3'])
self.assertEqual(the_html, problem.get_html(), 'should be able to call get_html() twice')
self.assertIsNotNone(problem.tree.xpath('//choicegroup[@shuffle-done="done"]'))
def test_shuffle_custom_names(self):
xml_str = textwrap.dedent("""
<problem>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice" shuffle="true">
<choice correct="false" name="aaa">Apple</choice>
<choice correct="false">Banana</choice>
<choice correct="false">Chocolate</choice>
<choice correct ="true" name="ddd">Donut</choice>
</choicegroup>
</multiplechoiceresponse>
</problem>
""")
problem = new_loncapa_problem(xml_str, seed=0)
# B A C D
# Check that the custom name= names come through
response = problem.responders.values()[0]
self.assertTrue(hasattr(response, 'is_masked'))
self.assertEqual(response.unmask_order(), ['choice_0', 'choice_aaa', 'choice_1', 'choice_ddd'])
self.assertIsNotNone(problem.tree.xpath('//choicegroup[@shuffle-done="done"]'))
def test_shuffle_different_seed(self):
xml_str = textwrap.dedent("""
<problem>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice" shuffle="true">
<choice correct="false">Apple</choice>
<choice correct="false">Banana</choice>
<choice correct="false">Chocolate</choice>
<choice correct ="true">Donut</choice>
</choicegroup>
</multiplechoiceresponse>
</problem>
""")
problem = new_loncapa_problem(xml_str, seed=341) # yields D A B C
the_html = problem.get_html()
self.assertRegexpMatches(the_html, r"<div>.*\[.*'Donut'.*'Apple'.*'Banana'.*'Chocolate'.*\].*</div>")
def test_shuffle_1_choice(self):
xml_str = textwrap.dedent("""
<problem>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice" shuffle="true">
<choice correct="true">Apple</choice>
</choicegroup>
</multiplechoiceresponse>
</problem>
""")
problem = new_loncapa_problem(xml_str, seed=0)
the_html = problem.get_html()
self.assertRegexpMatches(the_html, r"<div>.*\[.*'Apple'.*\].*</div>")
response = problem.responders.values()[0]
self.assertEqual(response.unmask_order(), ['choice_0'])
self.assertEqual(response.unmask_name('mask_0'), 'choice_0')
self.assertIsNotNone(problem.tree.xpath('//choicegroup[@shuffle-done="done"]'))
def test_shuffle_6_choices(self):
xml_str = textwrap.dedent("""
<problem>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice" shuffle="true">
<choice correct="false">Apple</choice>
<choice correct="false">Banana</choice>
<choice correct="false">Chocolate</choice>
<choice correct ="true">Zonut</choice>
<choice correct ="false">Eggplant</choice>
<choice correct ="false">Filet Mignon</choice>
</choicegroup>
</multiplechoiceresponse>
</problem>
""")
problem = new_loncapa_problem(xml_str, seed=0) # yields: C E A B D F
# Donut -> Zonut to show that there is not some hidden alphabetic ordering going on
the_html = problem.get_html()
self.assertRegexpMatches(the_html, r"<div>.*\[.*'Chocolate'.*'Eggplant'.*'Apple'.*'Banana'.*'Zonut'.*'Filet Mignon'.*\].*</div>")
def test_shuffle_false(self):
xml_str = textwrap.dedent("""
<problem>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice" shuffle="false">
<choice correct="false">Apple</choice>
<choice correct="false">Banana</choice>
<choice correct="false">Chocolate</choice>
<choice correct ="true">Donut</choice>
</choicegroup>
</multiplechoiceresponse>
</problem>
""")
problem = new_loncapa_problem(xml_str)
problem.seed = 0
the_html = problem.get_html()
self.assertRegexpMatches(the_html, r"<div>.*\[.*'Apple'.*'Banana'.*'Chocolate'.*'Donut'.*\].*</div>")
self.assertEquals(problem.tree.xpath('//choicegroup[@shuffle-done="done"]'), [])
def test_shuffle_fixed_head_end(self):
xml_str = textwrap.dedent("""
<problem>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice" shuffle="true">
<choice correct="false" fixed="true">Alpha</choice>
<choice correct="false" fixed="true">Beta</choice>
<choice correct="false">A</choice>
<choice correct="false">B</choice>
<choice correct="false">C</choice>
<choice correct ="true">D</choice>
</choicegroup>
</multiplechoiceresponse>
</problem>
""")
problem = new_loncapa_problem(xml_str, seed=0)
the_html = problem.get_html()
# Alpha Beta held back from shuffle (head end)
self.assertRegexpMatches(the_html, r"<div>.*\[.*'Alpha'.*'Beta'.*'B'.*'A'.*'C'.*'D'.*\].*</div>")
def test_shuffle_fixed_tail_end(self):
xml_str = textwrap.dedent("""
<problem>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice" shuffle="true">
<choice correct="false">A</choice>
<choice correct="false">B</choice>
<choice correct="false">C</choice>
<choice correct ="true">D</choice>
<choice correct="false" fixed="true">Alpha</choice>
<choice correct="false" fixed="true">Beta</choice>
</choicegroup>
</multiplechoiceresponse>
</problem>
""")
problem = new_loncapa_problem(xml_str, seed=0)
the_html = problem.get_html()
# Alpha Beta held back from shuffle (tail end)
self.assertRegexpMatches(the_html, r"<div>.*\[.*'B'.*'A'.*'C'.*'D'.*'Alpha'.*'Beta'.*\].*</div>")
def test_shuffle_fixed_both_ends(self):
xml_str = textwrap.dedent("""
<problem>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice" shuffle="true">
<choice correct="false" fixed="true">Alpha</choice>
<choice correct="false" fixed="true">Beta</choice>
<choice correct="false">A</choice>
<choice correct="false">B</choice>
<choice correct="false">C</choice>
<choice correct ="true">D</choice>
<choice correct="false" fixed="true">Psi</choice>
<choice correct="false" fixed="true">Omega</choice>
</choicegroup>
</multiplechoiceresponse>
</problem>
""")
problem = new_loncapa_problem(xml_str, seed=0)
the_html = problem.get_html()
self.assertRegexpMatches(the_html, r"<div>.*\[.*'Alpha'.*'Beta'.*'B'.*'A'.*'C'.*'D'.*'Psi'.*'Omega'.*\].*</div>")
def test_shuffle_fixed_both_ends_thin(self):
xml_str = textwrap.dedent("""
<problem>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice" shuffle="true">
<choice correct="false" fixed="true">Alpha</choice>
<choice correct="false">A</choice>
<choice correct="true" fixed="true">Omega</choice>
</choicegroup>
</multiplechoiceresponse>
</problem>
""")
problem = new_loncapa_problem(xml_str, seed=0)
the_html = problem.get_html()
self.assertRegexpMatches(the_html, r"<div>.*\[.*'Alpha'.*'A'.*'Omega'.*\].*</div>")
def test_shuffle_fixed_all(self):
xml_str = textwrap.dedent("""
<problem>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice" shuffle="true">
<choice correct="false" fixed="true">A</choice>
<choice correct="false" fixed="true">B</choice>
<choice correct="true" fixed="true">C</choice>
</choicegroup>
</multiplechoiceresponse>
</problem>
""")
problem = new_loncapa_problem(xml_str, seed=0)
the_html = problem.get_html()
self.assertRegexpMatches(the_html, r"<div>.*\[.*'A'.*'B'.*'C'.*\].*</div>")
def test_shuffle_island(self):
"""A fixed 'island' choice not at the head or tail end gets lumped into the tail end."""
xml_str = textwrap.dedent("""
<problem>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice" shuffle="true">
<choice correct="false" fixed="true">A</choice>
<choice correct="false">Mid</choice>
<choice correct="true" fixed="true">C</choice>
<choice correct="False">Mid</choice>
<choice correct="false" fixed="true">D</choice>
</choicegroup>
</multiplechoiceresponse>
</problem>
""")
problem = new_loncapa_problem(xml_str, seed=0)
the_html = problem.get_html()
self.assertRegexpMatches(the_html, r"<div>.*\[.*'A'.*'Mid'.*'Mid'.*'C'.*'D'.*\].*</div>")
"""
Tests the logic of the "targeted-feedback" attribute for MultipleChoice questions,
i.e. those with the <multiplechoiceresponse> element
"""
import unittest
import textwrap
from . import test_capa_system, new_loncapa_problem
class CapaTargetedFeedbackTest(unittest.TestCase):
'''
Testing class
'''
def setUp(self):
super(CapaTargetedFeedbackTest, self).setUp()
self.system = test_capa_system()
def test_no_targeted_feedback(self):
xml_str = textwrap.dedent("""
<problem>
<p>What is the correct answer?</p>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice">
<choice correct="false" explanation-id="feedback1">wrong-1</choice>
<choice correct="false" explanation-id="feedback2">wrong-2</choice>
<choice correct="true" explanation-id="feedbackC">correct-1</choice>
<choice correct="false" explanation-id="feedback3">wrong-3</choice>
</choicegroup>
</multiplechoiceresponse>
<targetedfeedbackset>
<targetedfeedback explanation-id="feedback1">
<div class="detailed-targeted-feedback">
<p>Targeted Feedback</p>
<p>This is the 1st WRONG solution</p>
</div>
</targetedfeedback>
<targetedfeedback explanation-id="feedback2">
<div class="detailed-targeted-feedback">
<p>Targeted Feedback</p>
<p>This is the 2nd WRONG solution</p>
</div>
</targetedfeedback>
<targetedfeedback explanation-id="feedback3">
<div class="detailed-targeted-feedback">
<p>Targeted Feedback</p>
<p>This is the 3rd WRONG solution</p>
</div>
</targetedfeedback>
<targetedfeedback explanation-id="feedbackC">
<div class="detailed-targeted-feedback-correct">
<p>Targeted Feedback</p>
<p>Feedback on your correct solution...</p>
</div>
</targetedfeedback>
</targetedfeedbackset>
<solution explanation-id="feedbackC">
<div class="detailed-solution">
<p>Explanation</p>
<p>This is the solution explanation</p>
<p>Not much to explain here, sorry!</p>
</div>
</solution>
</problem>
""")
problem = new_loncapa_problem(xml_str)
the_html = problem.get_html()
without_new_lines = the_html.replace("\n", "")
self.assertRegexpMatches(without_new_lines, r"<div>.*'wrong-1'.*'wrong-2'.*'correct-1'.*'wrong-3'.*</div>")
self.assertRegexpMatches(without_new_lines, r"feedback1|feedback2|feedback3|feedbackC")
def test_targeted_feedback_not_finished(self):
xml_str = textwrap.dedent("""
<problem>
<p>What is the correct answer?</p>
<multiplechoiceresponse targeted-feedback="">
<choicegroup type="MultipleChoice">
<choice correct="false" explanation-id="feedback1">wrong-1</choice>
<choice correct="false" explanation-id="feedback2">wrong-2</choice>
<choice correct="true" explanation-id="feedbackC">correct-1</choice>
<choice correct="false" explanation-id="feedback3">wrong-3</choice>
</choicegroup>
</multiplechoiceresponse>
<targetedfeedbackset>
<targetedfeedback explanation-id="feedback1">
<div class="detailed-targeted-feedback">
<p>Targeted Feedback</p>
<p>This is the 1st WRONG solution</p>
</div>
</targetedfeedback>
<targetedfeedback explanation-id="feedback2">
<div class="detailed-targeted-feedback">
<p>Targeted Feedback</p>
<p>This is the 2nd WRONG solution</p>
</div>
</targetedfeedback>
<targetedfeedback explanation-id="feedback3">
<div class="detailed-targeted-feedback">
<p>Targeted Feedback</p>
<p>This is the 3rd WRONG solution</p>
</div>
</targetedfeedback>
<targetedfeedback explanation-id="feedbackC">
<div class="detailed-targeted-feedback-correct">
<p>Targeted Feedback</p>
<p>Feedback on your correct solution...</p>
</div>
</targetedfeedback>
</targetedfeedbackset>
<solution explanation-id="feedbackC">
<div class="detailed-solution">
<p>Explanation</p>
<p>This is the solution explanation</p>
<p>Not much to explain here, sorry!</p>
</div>
</solution>
</problem>
""")
problem = new_loncapa_problem(xml_str)
the_html = problem.get_html()
without_new_lines = the_html.replace("\n", "")
self.assertRegexpMatches(without_new_lines, r"<div>.*'wrong-1'.*'wrong-2'.*'correct-1'.*'wrong-3'.*</div>")
self.assertNotRegexpMatches(without_new_lines, r"feedback1|feedback2|feedback3|feedbackC")
self.assertEquals(the_html, problem.get_html(), "Should be able to call get_html() twice")
def test_targeted_feedback_student_answer1(self):
xml_str = textwrap.dedent("""
<problem>
<p>What is the correct answer?</p>
<multiplechoiceresponse targeted-feedback="">
<choicegroup type="MultipleChoice">
<choice correct="false" explanation-id="feedback1">wrong-1</choice>
<choice correct="false" explanation-id="feedback2">wrong-2</choice>
<choice correct="true" explanation-id="feedbackC">correct-1</choice>
<choice correct="false" explanation-id="feedback3">wrong-3</choice>
</choicegroup>
</multiplechoiceresponse>
<targetedfeedbackset>
<targetedfeedback explanation-id="feedback1">
<div class="detailed-targeted-feedback">
<p>Targeted Feedback</p>
<p>This is the 1st WRONG solution</p>
</div>
</targetedfeedback>
<targetedfeedback explanation-id="feedback2">
<div class="detailed-targeted-feedback">
<p>Targeted Feedback</p>
<p>This is the 2nd WRONG solution</p>
</div>
</targetedfeedback>
<targetedfeedback explanation-id="feedback3">
<div class="detailed-targeted-feedback">
<p>Targeted Feedback</p>
<p>This is the 3rd WRONG solution</p>
</div>
</targetedfeedback>
<targetedfeedback explanation-id="feedbackC">
<div class="detailed-targeted-feedback-correct">
<p>Targeted Feedback</p>
<p>Feedback on your correct solution...</p>
</div>
</targetedfeedback>
</targetedfeedbackset>
<solution explanation-id="feedbackC">
<div class="detailed-solution">
<p>Explanation</p>
<p>This is the solution explanation</p>
<p>Not much to explain here, sorry!</p>
</div>
</solution>
</problem>
""")
problem = new_loncapa_problem(xml_str)
problem.done = True
problem.student_answers = {'1_2_1': 'choice_3'}
the_html = problem.get_html()
without_new_lines = the_html.replace("\n", "")
self.assertRegexpMatches(without_new_lines, r"<targetedfeedback explanation-id=\"feedback3\">.*3rd WRONG solution")
self.assertNotRegexpMatches(without_new_lines, r"feedback1|feedback2|feedbackC")
# Check that calling it multiple times yields the same thing
the_html2 = problem.get_html()
self.assertEquals(the_html, the_html2)
def test_targeted_feedback_student_answer2(self):
xml_str = textwrap.dedent("""
<problem>
<p>What is the correct answer?</p>
<multiplechoiceresponse targeted-feedback="">
<choicegroup type="MultipleChoice">
<choice correct="false" explanation-id="feedback1">wrong-1</choice>
<choice correct="false" explanation-id="feedback2">wrong-2</choice>
<choice correct="true" explanation-id="feedbackC">correct-1</choice>
<choice correct="false" explanation-id="feedback3">wrong-3</choice>
</choicegroup>
</multiplechoiceresponse>
<targetedfeedbackset>
<targetedfeedback explanation-id="feedback1">
<div class="detailed-targeted-feedback">
<p>Targeted Feedback</p>
<p>This is the 1st WRONG solution</p>
</div>
</targetedfeedback>
<targetedfeedback explanation-id="feedback2">
<div class="detailed-targeted-feedback">
<p>Targeted Feedback</p>
<p>This is the 2nd WRONG solution</p>
</div>
</targetedfeedback>
<targetedfeedback explanation-id="feedback3">
<div class="detailed-targeted-feedback">
<p>Targeted Feedback</p>
<p>This is the 3rd WRONG solution</p>
</div>
</targetedfeedback>
<targetedfeedback explanation-id="feedbackC">
<div class="detailed-targeted-feedback-correct">
<p>Targeted Feedback</p>
<p>Feedback on your correct solution...</p>
</div>
</targetedfeedback>
</targetedfeedbackset>
<solution explanation-id="feedbackC">
<div class="detailed-solution">
<p>Explanation</p>
<p>This is the solution explanation</p>
<p>Not much to explain here, sorry!</p>
</div>
</solution>
</problem>
""")
problem = new_loncapa_problem(xml_str)
problem.done = True
problem.student_answers = {'1_2_1': 'choice_0'}
the_html = problem.get_html()
without_new_lines = the_html.replace("\n", "")
self.assertRegexpMatches(without_new_lines, r"<targetedfeedback explanation-id=\"feedback1\">.*1st WRONG solution")
self.assertRegexpMatches(without_new_lines, r"<div>\{.*'1_solution_1'.*\}</div>")
self.assertNotRegexpMatches(without_new_lines, r"feedback2|feedback3|feedbackC")
def test_targeted_feedback_show_solution_explanation(self):
xml_str = textwrap.dedent("""
<problem>
<p>What is the correct answer?</p>
<multiplechoiceresponse targeted-feedback="alwaysShowCorrectChoiceExplanation">
<choicegroup type="MultipleChoice">
<choice correct="false" explanation-id="feedback1">wrong-1</choice>
<choice correct="false" explanation-id="feedback2">wrong-2</choice>
<choice correct="true" explanation-id="feedbackC">correct-1</choice>
<choice correct="false" explanation-id="feedback3">wrong-3</choice>
</choicegroup>
</multiplechoiceresponse>
<targetedfeedbackset>
<targetedfeedback explanation-id="feedback1">
<div class="detailed-targeted-feedback">
<p>Targeted Feedback</p>
<p>This is the 1st WRONG solution</p>
</div>
</targetedfeedback>
<targetedfeedback explanation-id="feedback2">
<div class="detailed-targeted-feedback">
<p>Targeted Feedback</p>
<p>This is the 2nd WRONG solution</p>
</div>
</targetedfeedback>
<targetedfeedback explanation-id="feedback3">
<div class="detailed-targeted-feedback">
<p>Targeted Feedback</p>
<p>This is the 3rd WRONG solution</p>
</div>
</targetedfeedback>
<targetedfeedback explanation-id="feedbackC">
<div class="detailed-targeted-feedback-correct">
<p>Targeted Feedback</p>
<p>Feedback on your correct solution...</p>
</div>
</targetedfeedback>
</targetedfeedbackset>
<solution explanation-id="feedbackC">
<div class="detailed-solution">
<p>Explanation</p>
<p>This is the solution explanation</p>
<p>Not much to explain here, sorry!</p>
</div>
</solution>
</problem>
""")
problem = new_loncapa_problem(xml_str)
problem.done = True
problem.student_answers = {'1_2_1': 'choice_0'}
the_html = problem.get_html()
without_new_lines = the_html.replace("\n", "")
self.assertRegexpMatches(without_new_lines, r"<targetedfeedback explanation-id=\"feedback1\">.*1st WRONG solution")
self.assertRegexpMatches(without_new_lines, r"<targetedfeedback explanation-id=\"feedbackC\".*solution explanation")
self.assertNotRegexpMatches(without_new_lines, r"<div>\{.*'1_solution_1'.*\}</div>")
self.assertNotRegexpMatches(without_new_lines, r"feedback2|feedback3")
# Check that calling it multiple times yields the same thing
the_html2 = problem.get_html()
self.assertEquals(the_html, the_html2)
def test_targeted_feedback_no_show_solution_explanation(self):
xml_str = textwrap.dedent("""
<problem>
<p>What is the correct answer?</p>
<multiplechoiceresponse targeted-feedback="">
<choicegroup type="MultipleChoice">
<choice correct="false" explanation-id="feedback1">wrong-1</choice>
<choice correct="false" explanation-id="feedback2">wrong-2</choice>
<choice correct="true" explanation-id="feedbackC">correct-1</choice>
<choice correct="false" explanation-id="feedback3">wrong-3</choice>
</choicegroup>
</multiplechoiceresponse>
<targetedfeedbackset>
<targetedfeedback explanation-id="feedback1">
<div class="detailed-targeted-feedback">
<p>Targeted Feedback</p>
<p>This is the 1st WRONG solution</p>
</div>
</targetedfeedback>
<targetedfeedback explanation-id="feedback2">
<div class="detailed-targeted-feedback">
<p>Targeted Feedback</p>
<p>This is the 2nd WRONG solution</p>
</div>
</targetedfeedback>
<targetedfeedback explanation-id="feedback3">
<div class="detailed-targeted-feedback">
<p>Targeted Feedback</p>
<p>This is the 3rd WRONG solution</p>
</div>
</targetedfeedback>
<targetedfeedback explanation-id="feedbackC">
<div class="detailed-targeted-feedback-correct">
<p>Targeted Feedback</p>
<p>Feedback on your correct solution...</p>
</div>
</targetedfeedback>
</targetedfeedbackset>
<solution explanation-id="feedbackC">
<div class="detailed-solution">
<p>Explanation</p>
<p>This is the solution explanation</p>
<p>Not much to explain here, sorry!</p>
</div>
</solution>
</problem>
""")
problem = new_loncapa_problem(xml_str)
problem.done = True
problem.student_answers = {'1_2_1': 'choice_0'}
the_html = problem.get_html()
without_new_lines = the_html.replace("\n", "")
self.assertRegexpMatches(without_new_lines, r"<targetedfeedback explanation-id=\"feedback1\">.*1st WRONG solution")
self.assertNotRegexpMatches(without_new_lines, r"<targetedfeedback explanation-id=\"feedbackC\".*solution explanation")
self.assertRegexpMatches(without_new_lines, r"<div>\{.*'1_solution_1'.*\}</div>")
self.assertNotRegexpMatches(without_new_lines, r"feedback2|feedback3|feedbackC")
def test_targeted_feedback_with_solutionset_explanation(self):
xml_str = textwrap.dedent("""
<problem>
<p>What is the correct answer?</p>
<multiplechoiceresponse targeted-feedback="alwaysShowCorrectChoiceExplanation">
<choicegroup type="MultipleChoice">
<choice correct="false" explanation-id="feedback1">wrong-1</choice>
<choice correct="false" explanation-id="feedback2">wrong-2</choice>
<choice correct="true" explanation-id="feedbackC">correct-1</choice>
<choice correct="false" explanation-id="feedback3">wrong-3</choice>
<choice correct="true" explanation-id="feedbackC2">correct-2</choice>
</choicegroup>
</multiplechoiceresponse>
<targetedfeedbackset>
<targetedfeedback explanation-id="feedback1">
<div class="detailed-targeted-feedback">
<p>Targeted Feedback</p>
<p>This is the 1st WRONG solution</p>
</div>
</targetedfeedback>
<targetedfeedback explanation-id="feedback2">
<div class="detailed-targeted-feedback">
<p>Targeted Feedback</p>
<p>This is the 2nd WRONG solution</p>
</div>
</targetedfeedback>
<targetedfeedback explanation-id="feedback3">
<div class="detailed-targeted-feedback">
<p>Targeted Feedback</p>
<p>This is the 3rd WRONG solution</p>
</div>
</targetedfeedback>
<targetedfeedback explanation-id="feedbackC">
<div class="detailed-targeted-feedback-correct">
<p>Targeted Feedback</p>
<p>Feedback on your correct solution...</p>
</div>
</targetedfeedback>
<targetedfeedback explanation-id="feedbackC2">
<div class="detailed-targeted-feedback-correct">
<p>Targeted Feedback</p>
<p>Feedback on the other solution...</p>
</div>
</targetedfeedback>
</targetedfeedbackset>
<solutionset>
<solution explanation-id="feedbackC2">
<div class="detailed-solution">
<p>Explanation</p>
<p>This is the other solution explanation</p>
<p>Not much to explain here, sorry!</p>
</div>
</solution>
</solutionset>
</problem>
""")
problem = new_loncapa_problem(xml_str)
problem.done = True
problem.student_answers = {'1_2_1': 'choice_0'}
the_html = problem.get_html()
without_new_lines = the_html.replace("\n", "")
self.assertRegexpMatches(without_new_lines, r"<targetedfeedback explanation-id=\"feedback1\">.*1st WRONG solution")
self.assertRegexpMatches(without_new_lines, r"<targetedfeedback explanation-id=\"feedbackC2\".*other solution explanation")
self.assertNotRegexpMatches(without_new_lines, r"<div>\{.*'1_solution_1'.*\}</div>")
self.assertNotRegexpMatches(without_new_lines, r"feedback2|feedback3")
def test_targeted_feedback_no_feedback_for_selected_choice1(self):
xml_str = textwrap.dedent("""
<problem>
<p>What is the correct answer?</p>
<multiplechoiceresponse targeted-feedback="alwaysShowCorrectChoiceExplanation">
<choicegroup type="MultipleChoice">
<choice correct="false" explanation-id="feedback1">wrong-1</choice>
<choice correct="false" explanation-id="feedback2">wrong-2</choice>
<choice correct="true" explanation-id="feedbackC">correct-1</choice>
<choice correct="false" explanation-id="feedback3">wrong-3</choice>
</choicegroup>
</multiplechoiceresponse>
<targetedfeedbackset>
<targetedfeedback explanation-id="feedback1">
<div class="detailed-targeted-feedback">
<p>Targeted Feedback</p>
<p>This is the 1st WRONG solution</p>
</div>
</targetedfeedback>
<targetedfeedback explanation-id="feedback3">
<div class="detailed-targeted-feedback">
<p>Targeted Feedback</p>
<p>This is the 3rd WRONG solution</p>
</div>
</targetedfeedback>
<targetedfeedback explanation-id="feedbackC">
<div class="detailed-targeted-feedback-correct">
<p>Targeted Feedback</p>
<p>Feedback on your correct solution...</p>
</div>
</targetedfeedback>
</targetedfeedbackset>
<solutionset>
<solution explanation-id="feedbackC">
<div class="detailed-solution">
<p>Explanation</p>
<p>This is the solution explanation</p>
<p>Not much to explain here, sorry!</p>
</div>
</solution>
</solutionset>
</problem>
""")
problem = new_loncapa_problem(xml_str)
problem.done = True
problem.student_answers = {'1_2_1': 'choice_1'}
the_html = problem.get_html()
without_new_lines = the_html.replace("\n", "")
self.assertRegexpMatches(without_new_lines, r"<targetedfeedback explanation-id=\"feedbackC\".*solution explanation")
self.assertNotRegexpMatches(without_new_lines, r"<div>\{.*'1_solution_1'.*\}</div>")
self.assertNotRegexpMatches(without_new_lines, r"feedback1|feedback3")
def test_targeted_feedback_no_feedback_for_selected_choice2(self):
xml_str = textwrap.dedent("""
<problem>
<p>What is the correct answer?</p>
<multiplechoiceresponse targeted-feedback="">
<choicegroup type="MultipleChoice">
<choice correct="false" explanation-id="feedback1">wrong-1</choice>
<choice correct="false" explanation-id="feedback2">wrong-2</choice>
<choice correct="true" explanation-id="feedbackC">correct-1</choice>
<choice correct="false" explanation-id="feedback3">wrong-3</choice>
</choicegroup>
</multiplechoiceresponse>
<targetedfeedbackset>
<targetedfeedback explanation-id="feedback1">
<div class="detailed-targeted-feedback">
<p>Targeted Feedback</p>
<p>This is the 1st WRONG solution</p>
</div>
</targetedfeedback>
<targetedfeedback explanation-id="feedback3">
<div class="detailed-targeted-feedback">
<p>Targeted Feedback</p>
<p>This is the 3rd WRONG solution</p>
</div>
</targetedfeedback>
<targetedfeedback explanation-id="feedbackC">
<div class="detailed-targeted-feedback-correct">
<p>Targeted Feedback</p>
<p>Feedback on your correct solution...</p>
</div>
</targetedfeedback>
</targetedfeedbackset>
<solutionset>
<solution explanation-id="feedbackC">
<div class="detailed-solution">
<p>Explanation</p>
<p>This is the solution explanation</p>
<p>Not much to explain here, sorry!</p>
</div>
</solution>
</solutionset>
</problem>
""")
problem = new_loncapa_problem(xml_str)
problem.done = True
problem.student_answers = {'1_2_1': 'choice_1'}
the_html = problem.get_html()
without_new_lines = the_html.replace("\n", "")
self.assertNotRegexpMatches(without_new_lines, r"<targetedfeedback explanation-id=\"feedbackC\".*solution explanation")
self.assertRegexpMatches(without_new_lines, r"<div>\{.*'1_solution_1'.*\}</div>")
self.assertNotRegexpMatches(without_new_lines, r"feedback1|feedback3|feedbackC")
......@@ -147,6 +147,12 @@ class CapaFields(object):
student_answers = Dict(help="Dictionary with the current student responses", scope=Scope.user_state)
done = Boolean(help="Whether the student has answered the problem", scope=Scope.user_state)
seed = Integer(help="Random seed for this student", scope=Scope.user_state)
last_submission_time = Date(help="Last submission time", scope=Scope.user_state)
submission_wait_seconds = Integer(
display_name="Timer Between Attempts",
help="Seconds a student must wait between submissions for a problem with multiple attempts.",
scope=Scope.settings,
default=0)
weight = Float(
display_name="Problem Weight",
help=("Defines the number of points each problem is worth. "
......@@ -306,6 +312,12 @@ class CapaMixin(CapaFields):
self.student_answers = lcp_state['student_answers']
self.seed = lcp_state['seed']
def set_last_submission_time(self):
"""
Set the module's last submission time (when the problem was checked)
"""
self.last_submission_time = datetime.datetime.now(UTC())
def get_score(self):
"""
Access the problem's score
......@@ -853,7 +865,7 @@ class CapaMixin(CapaFields):
return {'grade': score['score'], 'max_grade': score['total']}
def check_problem(self, data):
def check_problem(self, data, override_time=False):
"""
Checks whether answers to a problem are correct
......@@ -868,6 +880,11 @@ class CapaMixin(CapaFields):
answers = self.make_dict_of_responses(data)
event_info['answers'] = convert_files_to_filenames(answers)
# Can override current time
current_time = datetime.datetime.now(UTC())
if override_time is not False:
current_time = override_time
_ = self.runtime.service(self, "i18n").ugettext
# Too late. Cannot submit
......@@ -884,23 +901,31 @@ class CapaMixin(CapaFields):
# Problem queued. Students must wait a specified waittime before they are allowed to submit
if self.lcp.is_queued():
current_time = datetime.datetime.now(UTC())
prev_submit_time = self.lcp.get_recentmost_queuetime()
waittime_between_requests = self.runtime.xqueue['waittime']
if (current_time - prev_submit_time).total_seconds() < waittime_between_requests:
msg = _(u"You must wait at least {wait} seconds between submissions.").format(
wait=waittime_between_requests)
return {'success': msg, 'html': ''} # Prompts a modal dialog in ajax callback
# Wait time between resets
if self.last_submission_time is not None and self.submission_wait_seconds != 0:
if (current_time - self.last_submission_time).total_seconds() < self.submission_wait_seconds:
seconds_left = int(self.submission_wait_seconds - (current_time - self.last_submission_time).total_seconds())
msg = u'You must wait at least {w} between submissions. {s} remaining.'.format(
w=self.pretty_print_seconds(self.submission_wait_seconds), s=self.pretty_print_seconds(seconds_left))
return {'success': msg, 'html': ''} # Prompts a modal dialog in ajax callback
try:
correct_map = self.lcp.grade_answers(answers)
self.attempts = self.attempts + 1
self.lcp.done = True
self.set_state_from_lcp()
self.set_last_submission_time()
except (StudentInputError, ResponseError, LoncapaProblemError) as inst:
log.warning("StudentInputError in capa_module:problem_check",
exc_info=True)
log.warning("StudentInputError in capa_module:problem_check", exc_info=True)
# Save the user's state before failing
self.set_state_from_lcp()
......@@ -944,6 +969,7 @@ class CapaMixin(CapaFields):
event_info['correct_map'] = correct_map.get_dict()
event_info['success'] = success
event_info['attempts'] = self.attempts
self.unmask_log(event_info)
self.runtime.track_function('problem_check', event_info)
if hasattr(self.runtime, 'psychometrics_handler'): # update PsychometricsData using callback
......@@ -952,10 +978,57 @@ class CapaMixin(CapaFields):
# render problem into HTML
html = self.get_problem_html(encapsulate=False)
return {
'success': success,
'contents': html,
}
return {'success': success, 'contents': html}
def unmask_log(self, event_info):
"""
Translate the logging event_info to account for masking
and record the display_order.
This only changes names for responses that are masked, otherwise a NOP.
"""
# answers is like: {u'i4x-Stanford-CS99-problem-dada976e76f34c24bc8415039dee1300_2_1': u'mask_0'}
# Each response values has an answer_id which matches the key in answers.
for response in self.lcp.responders.values():
if hasattr(response, 'is_masked'):
# Just programming defensively, we don't assume much about the structure of event_info,
# but check for the existence of each thing to unmask
# 1. answers/id
answer = event_info.get('answers', {}).get(response.answer_id)
if answer is not None:
event_info['answers'][response.answer_id] = response.unmask_name(answer)
# 2. state/student_answers/id
answer = event_info.get('state', {}).get('student_answers', {}).get(response.answer_id)
if answer is not None:
event_info['state']['student_answers'][response.answer_id] = response.unmask_name(answer)
# 3. Record the shuffled ordering
event_info['display_order'] = {response.answer_id: response.unmask_order()}
def pretty_print_seconds(self, num_seconds):
"""
Returns time formatted nicely.
"""
if(num_seconds < 60):
plural = "s" if num_seconds > 1 else ""
return "%i second%s" % (num_seconds, plural)
elif(num_seconds < 60 * 60):
min_display = int(num_seconds / 60)
sec_display = num_seconds % 60
plural = "s" if min_display > 1 else ""
if sec_display == 0:
return "%i minute%s" % (min_display, plural)
else:
return "%i min, %i sec" % (min_display, sec_display)
else:
hr_display = int(num_seconds / 3600)
min_display = int((num_seconds % 3600) / 60)
sec_display = num_seconds % 60
if sec_display == 0:
return "%i hr, %i min" % (hr_display, min_display)
else:
return "%i hr, %i min, %i sec" % (hr_display, min_display, sec_display)
def rescore_problem(self):
"""
......
......@@ -126,6 +126,23 @@ section.problem {
}
}
.targeted-feedback-span {
> span {
margin: $baseline 0;
display: block;
border: 1px solid #000;
padding: 9px 15px $baseline;
background: #fff;
position: relative;
box-shadow: inset 0 0 0 1px #eee;
border-radius: 3px;
&:empty {
display: none;
}
}
}
div {
p {
&.answer {
......@@ -628,6 +645,34 @@ section.problem {
}
}
.detailed-targeted-feedback {
> p:first-child {
color: red;
text-transform: uppercase;
font-weight: bold;
font-style: normal;
font-size: 0.9em;
}
p:last-child {
margin-bottom: 0;
}
}
.detailed-targeted-feedback-correct {
> p:first-child {
color: green;
text-transform: uppercase;
font-weight: bold;
font-style: normal;
font-size: 0.9em;
}
p:last-child {
margin-bottom: 0;
}
}
div.capa_alert {
margin-top: $baseline;
padding: 8px 12px;
......
......@@ -244,6 +244,49 @@ describe 'MarkdownEditingDescriptor', ->
</div>
</solution>
</problem>""")
it 'converts multiple choice shuffle to xml', ->
data = MarkdownEditingDescriptor.markdownToXml("""A multiple choice problem presents radio buttons for student input. Students can only select a single option presented. Multiple Choice questions have been the subject of many areas of research due to the early invention and adoption of bubble sheets.
One of the main elements that goes into a good multiple choice question is the existence of good distractors. That is, each of the alternate responses presented to the student should be the result of a plausible mistake that a student might make.
What Apple device competed with the portable CD player?
(!x@) The iPad
(@) Napster
() The iPod
( ) The vegetable peeler
( ) Android
(@) The Beatles
[Explanation]
The release of the iPod allowed consumers to carry their entire music library with them in a format that did not rely on fragile and energy-intensive spinning disks.
[Explanation]
""")
expect(data).toEqual("""<problem>
<p>A multiple choice problem presents radio buttons for student input. Students can only select a single option presented. Multiple Choice questions have been the subject of many areas of research due to the early invention and adoption of bubble sheets.</p>
<p>One of the main elements that goes into a good multiple choice question is the existence of good distractors. That is, each of the alternate responses presented to the student should be the result of a plausible mistake that a student might make.</p>
<p>What Apple device competed with the portable CD player?</p>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice" shuffle="true">
<choice correct="true" fixed="true">The iPad</choice>
<choice correct="false" fixed="true">Napster</choice>
<choice correct="false">The iPod</choice>
<choice correct="false">The vegetable peeler</choice>
<choice correct="false">Android</choice>
<choice correct="false" fixed="true">The Beatles</choice>
</choicegroup>
</multiplechoiceresponse>
<solution>
<div class="detailed-solution">
<p>Explanation</p>
<p>The release of the iPod allowed consumers to carry their entire music library with them in a format that did not rely on fragile and energy-intensive spinning disks.</p>
</div>
</solution>
</problem>""")
it 'converts OptionResponse to xml', ->
data = MarkdownEditingDescriptor.markdownToXml("""OptionResponse gives a limited set of options for students to respond with, and presents those options in a format that encourages them to search for a specific answer rather than being immediately presented with options from which to recognize the correct answer.
......
......@@ -195,25 +195,35 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor
xml = xml.replace(/\n^\=\=+$/gm, '');
// group multiple choice answers
xml = xml.replace(/(^\s*\(.?\).*?$\n*)+/gm, function (match) {
var groupString = '<multiplechoiceresponse>\n',
value, correct, options;
groupString += ' <choicegroup type="MultipleChoice">\n';
options = match.split('\n');
for (i = 0; i < options.length; i += 1) {
var choices = '';
var shuffle = false;
xml = xml.replace(/(^\s*\(.{0,3}\).*?$\n*)+/gm, function(match, p) {
var options = match.split('\n');
for(var i = 0; i < options.length; i++) {
if(options[i].length > 0) {
value = options[i].split(/^\s*\(.?\)\s*/)[1];
correct = /^\s*\(x\)/i.test(options[i]);
groupString += ' <choice correct="' + correct + '">' + value + '</choice>\n';
var value = options[i].split(/^\s*\(.{0,3}\)\s*/)[1];
var inparens = /^\s*\((.{0,3})\)\s*/.exec(options[i])[1];
var correct = /x/i.test(inparens);
var fixed = '';
if(/@/.test(inparens)) {
fixed = ' fixed="true"';
}
if(/!/.test(inparens)) {
shuffle = true;
}
groupString += ' </choicegroup>\n';
groupString += '</multiplechoiceresponse>\n\n';
return groupString;
choices += ' <choice correct="' + correct + '"' + fixed + '>' + value + '</choice>\n';
}
}
var result = '<multiplechoiceresponse>\n';
if(shuffle) {
result += ' <choicegroup type="MultipleChoice" shuffle="true">\n';
} else {
result += ' <choicegroup type="MultipleChoice">\n';
}
result += choices;
result += ' </choicegroup>\n';
result += '</multiplechoiceresponse>\n\n';
return result;
});
// group check answers
......
"""
Tests the logic of problems with a delay between attempt submissions.
Note that this test file is based off of test_capa_module.py and as
such, uses the same CapaFactory problem setup to test the functionality
of the check_problem method of a capa module when the "delay between quiz
submissions" setting is set to different values
"""
import unittest
import textwrap
import datetime
from mock import Mock
import xmodule
from xmodule.capa_module import CapaModule
from xmodule.modulestore import Location
from xblock.field_data import DictFieldData
from xblock.fields import ScopeIds
from . import get_test_system
from pytz import UTC
class XModuleQuizAttemptsDelayTest(unittest.TestCase):
'''
Actual class to test delay between quiz attempts
'''
def test_first_submission(self):
# Not attempted yet
num_attempts = 0
# Many attempts remaining
module = CapaFactory.create(attempts=num_attempts, max_attempts=99, last_submission_time=None)
# Simulate problem is not completed yet
module.done = False
# Expect that we can submit
get_request_dict = {CapaFactory.input_key(): '3.14'}
result = module.check_problem(get_request_dict)
# Successfully submitted and answered
# Also, the number of attempts should increment by 1
self.assertEqual(result['success'], 'correct')
self.assertEqual(module.attempts, num_attempts + 1)
def test_no_wait_time(self):
# Already attempted once (just now) and thus has a submitted time
num_attempts = 1
last_submitted_time = datetime.datetime.now(UTC)
# Many attempts remaining
module = CapaFactory.create(attempts=num_attempts, max_attempts=99,
last_submission_time=last_submitted_time, submission_wait_seconds=0)
# Simulate problem is not completed yet
module.done = False
# Expect that we can submit
get_request_dict = {CapaFactory.input_key(): '3.14'}
result = module.check_problem(get_request_dict)
# Successfully submitted and answered
# Also, the number of attempts should increment by 1
self.assertEqual(result['success'], 'correct')
self.assertEqual(module.attempts, num_attempts + 1)
def test_submit_quiz_in_rapid_succession(self):
# Already attempted once (just now) and thus has a submitted time
num_attempts = 1
last_submitted_time = datetime.datetime.now(UTC)
# Many attempts remaining
module = CapaFactory.create(attempts=num_attempts, max_attempts=99,
last_submission_time=last_submitted_time, submission_wait_seconds=123)
# Simulate problem is not completed yet
module.done = False
# Check the problem
get_request_dict = {CapaFactory.input_key(): '3.14'}
result = module.check_problem(get_request_dict)
# You should get a dialog that tells you to wait
# Also, the number of attempts should not be incremented
self.assertRegexpMatches(result['success'], r"You must wait at least.*")
self.assertEqual(module.attempts, num_attempts)
def test_submit_quiz_too_soon(self):
# Already attempted once (just now)
num_attempts = 1
# Specify two times
last_submitted_time = datetime.datetime(2013, 12, 6, 0, 17, 36)
considered_now = datetime.datetime(2013, 12, 6, 0, 18, 36)
# Many attempts remaining
module = CapaFactory.create(attempts=num_attempts, max_attempts=99,
last_submission_time=last_submitted_time, submission_wait_seconds=180)
# Simulate problem is not completed yet
module.done = False
# Check the problem
get_request_dict = {CapaFactory.input_key(): '3.14'}
result = module.check_problem(get_request_dict, considered_now)
# You should get a dialog that tells you to wait 2 minutes
# Also, the number of attempts should not be incremented
self.assertRegexpMatches(result['success'], r"You must wait at least 3 minutes between submissions. 2 minutes remaining\..*")
self.assertEqual(module.attempts, num_attempts)
def test_submit_quiz_1_second_too_soon(self):
# Already attempted once (just now)
num_attempts = 1
# Specify two times
last_submitted_time = datetime.datetime(2013, 12, 6, 0, 17, 36)
considered_now = datetime.datetime(2013, 12, 6, 0, 20, 35)
# Many attempts remaining
module = CapaFactory.create(attempts=num_attempts, max_attempts=99,
last_submission_time=last_submitted_time, submission_wait_seconds=180)
# Simulate problem is not completed yet
module.done = False
# Check the problem
get_request_dict = {CapaFactory.input_key(): '3.14'}
result = module.check_problem(get_request_dict, considered_now)
# You should get a dialog that tells you to wait 2 minutes
# Also, the number of attempts should not be incremented
self.assertRegexpMatches(result['success'], r"You must wait at least 3 minutes between submissions. 1 second remaining\..*")
self.assertEqual(module.attempts, num_attempts)
def test_submit_quiz_as_soon_as_allowed(self):
# Already attempted once (just now)
num_attempts = 1
# Specify two times
last_submitted_time = datetime.datetime(2013, 12, 6, 0, 17, 36)
considered_now = datetime.datetime(2013, 12, 6, 0, 20, 36)
# Many attempts remaining
module = CapaFactory.create(attempts=num_attempts, max_attempts=99,
last_submission_time=last_submitted_time, submission_wait_seconds=180)
# Simulate problem is not completed yet
module.done = False
# Expect that we can submit
get_request_dict = {CapaFactory.input_key(): '3.14'}
result = module.check_problem(get_request_dict, considered_now)
# Successfully submitted and answered
# Also, the number of attempts should increment by 1
self.assertEqual(result['success'], 'correct')
self.assertEqual(module.attempts, num_attempts + 1)
def test_submit_quiz_after_delay_expired(self):
# Already attempted once (just now)
num_attempts = 1
# Specify two times
last_submitted_time = datetime.datetime(2013, 12, 6, 0, 17, 36)
considered_now = datetime.datetime(2013, 12, 6, 0, 24, 0)
# Many attempts remaining
module = CapaFactory.create(attempts=num_attempts, max_attempts=99,
last_submission_time=last_submitted_time, submission_wait_seconds=180)
# Simulate problem is not completed yet
module.done = False
# Expect that we can submit
get_request_dict = {CapaFactory.input_key(): '3.14'}
result = module.check_problem(get_request_dict, considered_now)
# Successfully submitted and answered
# Also, the number of attempts should increment by 1
self.assertEqual(result['success'], 'correct')
self.assertEqual(module.attempts, num_attempts + 1)
def test_still_cannot_submit_after_max_attempts(self):
# Already attempted once (just now) and thus has a submitted time
num_attempts = 99
# Specify two times
last_submitted_time = datetime.datetime(2013, 12, 6, 0, 17, 36)
considered_now = datetime.datetime(2013, 12, 6, 0, 24, 0)
# Many attempts remaining
module = CapaFactory.create(attempts=num_attempts, max_attempts=99,
last_submission_time=last_submitted_time, submission_wait_seconds=180)
# Simulate problem is not completed yet
module.done = False
# Expect that we cannot submit
with self.assertRaises(xmodule.exceptions.NotFoundError):
get_request_dict = {CapaFactory.input_key(): '3.14'}
module.check_problem(get_request_dict, considered_now)
# Expect that number of attempts NOT incremented
self.assertEqual(module.attempts, num_attempts)
def test_submit_quiz_with_long_delay(self):
# Already attempted once (just now)
num_attempts = 1
# Specify two times
last_submitted_time = datetime.datetime(2013, 12, 6, 0, 17, 36)
considered_now = datetime.datetime(2013, 12, 6, 2, 15, 35)
# Many attempts remaining
module = CapaFactory.create(attempts=num_attempts, max_attempts=99,
last_submission_time=last_submitted_time, submission_wait_seconds=60 * 60 * 2)
# Simulate problem is not completed yet
module.done = False
# Check the problem
get_request_dict = {CapaFactory.input_key(): '3.14'}
result = module.check_problem(get_request_dict, considered_now)
# You should get a dialog that tells you to wait 2 minutes
# Also, the number of attempts should not be incremented
self.assertRegexpMatches(result['success'], r"You must wait at least 2 hr, 0 min between submissions. 2 min, 1 sec remaining\..*")
self.assertEqual(module.attempts, num_attempts)
def test_submit_quiz_with_involved_pretty_print(self):
# Already attempted once (just now)
num_attempts = 1
# Specify two times
last_submitted_time = datetime.datetime(2013, 12, 6, 0, 17, 36)
considered_now = datetime.datetime(2013, 12, 6, 1, 15, 40)
# Many attempts remaining
module = CapaFactory.create(attempts=num_attempts, max_attempts=99,
last_submission_time=last_submitted_time, submission_wait_seconds=60 * 60 * 2 + 63)
# Simulate problem is not completed yet
module.done = False
# Check the problem
get_request_dict = {CapaFactory.input_key(): '3.14'}
result = module.check_problem(get_request_dict, considered_now)
# You should get a dialog that tells you to wait 2 minutes
# Also, the number of attempts should not be incremented
self.assertRegexpMatches(result['success'], r"You must wait at least 2 hr, 1 min, 3 sec between submissions. 1 hr, 2 min, 59 sec remaining\..*")
self.assertEqual(module.attempts, num_attempts)
def test_submit_quiz_with_nonplural_pretty_print(self):
# Already attempted once (just now)
num_attempts = 1
# Specify two times
last_submitted_time = datetime.datetime(2013, 12, 6, 0, 17, 36)
considered_now = last_submitted_time
# Many attempts remaining
module = CapaFactory.create(attempts=num_attempts, max_attempts=99,
last_submission_time=last_submitted_time, submission_wait_seconds=60)
# Simulate problem is not completed yet
module.done = False
# Check the problem
get_request_dict = {CapaFactory.input_key(): '3.14'}
result = module.check_problem(get_request_dict, considered_now)
# You should get a dialog that tells you to wait 2 minutes
# Also, the number of attempts should not be incremented
self.assertRegexpMatches(result['success'], r"You must wait at least 1 minute between submissions. 1 minute remaining\..*")
self.assertEqual(module.attempts, num_attempts)
class CapaFactory(object):
"""
A helper class to create problem modules with various parameters for testing.
"""
sample_problem_xml = textwrap.dedent("""\
<?xml version="1.0"?>
<problem>
<text>
<p>What is pi, to two decimal places?</p>
</text>
<numericalresponse answer="3.14">
<textline math="1" size="30"/>
</numericalresponse>
</problem>
""")
num = 0
@classmethod
def next_num(cls):
"""
Return the next cls number
"""
cls.num += 1
return cls.num
@classmethod
def input_key(cls, input_num=2):
"""
Return the input key to use when passing GET parameters
"""
return ("input_" + cls.answer_key(input_num))
@classmethod
def answer_key(cls, input_num=2):
"""
Return the key stored in the capa problem answer dict
"""
return (
"%s_%d_1" % (
"-".join(['i4x', 'edX', 'capa_test', 'problem', 'SampleProblem%d' % cls.num]),
input_num,
)
)
@classmethod
def create(cls,
graceperiod=None,
due=None,
max_attempts=None,
showanswer=None,
rerandomize=None,
force_save_button=None,
attempts=None,
problem_state=None,
correct=False,
done=None,
text_customization=None,
last_submission_time=None,
submission_wait_seconds=None
):
"""
All parameters are optional, and are added to the created problem if specified.
Arguments:
graceperiod:
due:
max_attempts:
showanswer:
force_save_button:
rerandomize: all strings, as specified in the policy for the problem
problem_state: a dict to to be serialized into the instance_state of the
module.
attempts: also added to instance state. Will be converted to an int.
"""
location = Location(["i4x", "edX", "capa_test", "problem",
"SampleProblem{0}".format(cls.next_num())])
field_data = {'data': cls.sample_problem_xml}
if graceperiod is not None:
field_data['graceperiod'] = graceperiod
if due is not None:
field_data['due'] = due
if max_attempts is not None:
field_data['max_attempts'] = max_attempts
if showanswer is not None:
field_data['showanswer'] = showanswer
if force_save_button is not None:
field_data['force_save_button'] = force_save_button
if rerandomize is not None:
field_data['rerandomize'] = rerandomize
if done is not None:
field_data['done'] = done
if text_customization is not None:
field_data['text_customization'] = text_customization
if last_submission_time is not None:
field_data['last_submission_time'] = last_submission_time
if submission_wait_seconds is not None:
field_data['submission_wait_seconds'] = submission_wait_seconds
descriptor = Mock(weight="1")
if problem_state is not None:
field_data.update(problem_state)
if attempts is not None:
# converting to int here because I keep putting "0" and "1" in the tests
# since everything else is a string.
field_data['attempts'] = int(attempts)
system = get_test_system()
system.render_template = Mock(return_value="<div>Test Template HTML</div>")
module = CapaModule(
descriptor,
system,
DictFieldData(field_data),
ScopeIds(None, None, location, location),
)
if correct:
# TODO: probably better to actually set the internal state properly, but...
module.get_score = lambda: {'score': 1, 'total': 1}
else:
module.get_score = lambda: {'score': 0, 'total': 1}
return module
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