Commit 350387c0 by Colin-Fredericks

TNL-3197 Partial Credit (not Dropdown)

Updated templates

Updating responsetype tests.

Updating other tests for PC

Putting OptionInput back the way it was.

Acceptance tests for partial credit

Testing tools

pep8

scss name fix

Missing }

icon fix

icon fix
parent fac7b248
......@@ -10,7 +10,7 @@ class CorrectMap(object):
in a capa problem. The response evaluation result for each answer_id includes
(correctness, npoints, msg, hint, hintmode).
- correctness : either 'correct' or 'incorrect'
- correctness : 'correct', 'incorrect', or 'partially-correct'
- npoints : None, or integer specifying number of points awarded for this answer_id
- msg : string (may have HTML) giving extra message response
(displayed below textline or textbox)
......@@ -101,10 +101,23 @@ class CorrectMap(object):
self.set(k, **correct_map[k])
def is_correct(self, answer_id):
"""
Takes an answer_id
Returns true if the problem is correct OR partially correct.
"""
if answer_id in self.cmap:
return self.cmap[answer_id]['correctness'] in ['correct', 'partially-correct']
return None
def is_partially_correct(self, answer_id):
"""
Takes an answer_id
Returns true if the problem is partially correct.
"""
if answer_id in self.cmap:
return self.cmap[answer_id]['correctness'] == 'partially-correct'
return None
def is_queued(self, answer_id):
return answer_id in self.cmap and self.cmap[answer_id]['queuestate'] is not None
......
......@@ -85,6 +85,7 @@ class Status(object):
names = {
'correct': _('correct'),
'incorrect': _('incorrect'),
'partially-correct': _('partially correct'),
'incomplete': _('incomplete'),
'unanswered': _('unanswered'),
'unsubmitted': _('unanswered'),
......@@ -94,6 +95,7 @@ class Status(object):
# Translators: these are tooltips that indicate the state of an assessment question
'correct': _('This is correct.'),
'incorrect': _('This is incorrect.'),
'partially-correct': _('This is partially correct.'),
'unanswered': _('This is unanswered.'),
'unsubmitted': _('This is unanswered.'),
'queued': _('This is being processed.'),
......@@ -896,7 +898,7 @@ class MatlabInput(CodeInput):
Right now, we only want this button to show up when a problem has not been
checked.
"""
if self.status in ['correct', 'incorrect']:
if self.status in ['correct', 'incorrect', 'partially-correct']:
return False
else:
return True
......
......@@ -139,6 +139,8 @@ class LoncapaResponse(object):
tags = None
hint_tag = None
has_partial_credit = False
credit_type = []
max_inputfields = None
allowed_inputfields = []
......@@ -213,6 +215,18 @@ class LoncapaResponse(object):
self.default_answer_map[entry.get(
'id')] = contextualize_text(answer, self.context)
# Does this problem have partial credit?
# If so, what kind? Get it as a list of strings.
partial_credit = xml.xpath('.')[0].get('partial_credit', default=False)
if str(partial_credit).lower().strip() == 'false':
self.has_partial_credit = False
self.credit_type = []
else:
self.has_partial_credit = True
self.credit_type = partial_credit.split(',')
self.credit_type = [word.strip().lower() for word in self.credit_type]
if hasattr(self, 'setup_response'):
self.setup_response()
......@@ -261,7 +275,6 @@ class LoncapaResponse(object):
new_cmap = self.get_score(student_answers)
self.get_hints(convert_files_to_filenames(
student_answers), new_cmap, old_cmap)
# log.debug('new_cmap = %s' % new_cmap)
return new_cmap
def make_hint_div(self, hint_node, correct, student_answer, question_tag,
......@@ -814,11 +827,23 @@ class ChoiceResponse(LoncapaResponse):
def setup_response(self):
self.assign_choice_names()
correct_xml = self.xml.xpath('//*[@id=$id]//choice[@correct="true"]',
id=self.xml.get('id'))
correct_xml = self.xml.xpath(
'//*[@id=$id]//choice[@correct="true"]',
id=self.xml.get('id')
)
self.correct_choices = set([
choice.get('name') for choice in correct_xml
])
self.correct_choices = set([choice.get(
'name') for choice in correct_xml])
incorrect_xml = self.xml.xpath(
'//*[@id=$id]//choice[@correct="false"]',
id=self.xml.get('id')
)
self.incorrect_choices = set([
choice.get('name') for choice in incorrect_xml
])
def assign_choice_names(self):
"""
......@@ -832,26 +857,154 @@ class ChoiceResponse(LoncapaResponse):
if not choice.get('id'):
choice.set("id", chr(ord("A") + index))
def get_score(self, student_answers):
def grade_via_every_decision_counts(self, **kwargs):
"""
Calculates partial credit on the Every Decision Counts scheme.
For each correctly selected or correctly blank choice, score 1 point.
Divide by total number of choices.
Arguments:
all_choices, the full set of checkboxes
student_answer, what the student actually chose
student_non_answers, what the student didn't choose
Returns a CorrectMap.
"""
student_answer = student_answers.get(self.answer_id, [])
all_choices = kwargs['all_choices']
student_answer = kwargs['student_answer']
student_non_answers = kwargs['student_non_answers']
if not isinstance(student_answer, list):
student_answer = [student_answer]
edc_max_grade = len(all_choices)
edc_current_grade = 0
no_empty_answer = student_answer != []
student_answer = set(student_answer)
good_answers = sum([1 for answer in student_answer if answer in self.correct_choices])
good_non_answers = sum([1 for blank in student_non_answers if blank in self.incorrect_choices])
edc_current_grade = good_answers + good_non_answers
return_grade = round(self.get_max_score() * float(edc_current_grade) / float(edc_max_grade), 2)
if edc_current_grade == edc_max_grade:
return CorrectMap(self.answer_id, correctness='correct')
elif edc_current_grade > 0:
return CorrectMap(self.answer_id, correctness='partially-correct', npoints=return_grade)
else:
return CorrectMap(self.answer_id, correctness='incorrect', npoints=0)
def grade_via_halves(self, **kwargs):
"""
Calculates partial credit on the Halves scheme.
If no errors, full credit.
If one error, half credit as long as there are 3+ choices
If two errors, 1/4 credit as long as there are 5+ choices
(If not enough choices, no credit.)
Arguments:
all_choices, the full set of checkboxes
student_answer, what the student actually chose
student_non_answers, what the student didn't choose
Returns a CorrectMap
"""
all_choices = kwargs['all_choices']
student_answer = kwargs['student_answer']
student_non_answers = kwargs['student_non_answers']
halves_error_count = 0
incorrect_answers = sum([1 for answer in student_answer if answer in self.incorrect_choices])
missed_answers = sum([1 for blank in student_non_answers if blank in self.correct_choices])
halves_error_count = incorrect_answers + missed_answers
if halves_error_count == 0:
return_grade = self.get_max_score()
return CorrectMap(self.answer_id, correctness='correct', npoints=return_grade)
elif halves_error_count == 1 and len(all_choices) > 2:
return_grade = round(self.get_max_score() / 2.0, 2)
return CorrectMap(self.answer_id, correctness='partially-correct', npoints=return_grade)
elif halves_error_count == 2 and len(all_choices) > 4:
return_grade = round(self.get_max_score() / 4.0, 2)
return CorrectMap(self.answer_id, correctness='partially-correct', npoints=return_grade)
else:
return CorrectMap(self.answer_id, 'incorrect')
def grade_without_partial_credit(self, **kwargs):
"""
Standard grading for checkbox problems.
100% credit if all choices are correct; 0% otherwise
Arguments: student_answer, which is the items the student actually chose
"""
student_answer = kwargs['student_answer']
required_selected = len(self.correct_choices - student_answer) == 0
no_extra_selected = len(student_answer - self.correct_choices) == 0
correct = required_selected & no_extra_selected & no_empty_answer
correct = required_selected & no_extra_selected
if correct:
return CorrectMap(self.answer_id, 'correct')
else:
return CorrectMap(self.answer_id, 'incorrect')
def get_score(self, student_answers):
# Setting up answer sets:
# all_choices: the full set of checkboxes
# student_answer: what the student actually chose (note no "s")
# student_non_answers: what they didn't choose
# self.correct_choices: boxes that should be checked
# self.incorrect_choices: boxes that should NOT be checked
all_choices = self.correct_choices.union(self.incorrect_choices)
student_answer = student_answers.get(self.answer_id, [])
if not isinstance(student_answer, list):
student_answer = [student_answer]
# When a student leaves all the boxes unmarked, edX throws an error.
# This line checks for blank answers so that we can throw "false".
# This is not ideal. "None apply" should be a valid choice.
# Sadly, this is not the place where we can fix that problem.
empty_answer = student_answer == []
if empty_answer:
return CorrectMap(self.answer_id, 'incorrect')
student_answer = set(student_answer)
student_non_answers = all_choices - student_answer
# No partial credit? Get grade right now.
if not self.has_partial_credit:
return self.grade_without_partial_credit(student_answer=student_answer)
# This below checks to see whether we're using an alternate grading scheme.
# Set partial_credit="false" (or remove it) to require an exact answer for any credit.
# Set partial_credit="EDC" to count each choice for equal points (Every Decision Counts).
# Set partial_credit="halves" to take half credit off for each error.
# Translators: 'partial_credit' and the items in the 'graders' object
# are attribute names or values and should not be translated.
graders = {
'edc': self.grade_via_every_decision_counts,
'halves': self.grade_via_halves,
'false': self.grade_without_partial_credit
}
# Only one type of credit at a time.
if len(self.credit_type) > 1:
raise LoncapaProblemError('Only one type of partial credit is allowed for Checkbox problems.')
# Make sure we're using an approved style.
if self.credit_type[0] not in graders:
raise LoncapaProblemError('partial_credit attribute should be one of: ' + ','.join(graders))
# Run the appropriate grader.
return graders[self.credit_type[0]](
all_choices=all_choices,
student_answer=student_answer,
student_non_answers=student_non_answers
)
def get_answers(self):
return {self.answer_id: list(self.correct_choices)}
......@@ -997,6 +1150,14 @@ class MultipleChoiceResponse(LoncapaResponse):
multi_device_support = True
def setup_response(self):
"""
Collects information from the XML for later use.
correct_choices is a list of the correct choices.
partial_choices is a list of the partially-correct choices.
partial_values is a list of the scores that go with those
choices, defaulting to 0.5 if no value is specified.
"""
# call secondary setup for MultipleChoice questions, to set name
# attributes
self.mc_setup_response()
......@@ -1011,9 +1172,20 @@ class MultipleChoiceResponse(LoncapaResponse):
contextualize_text(choice.get('name'), self.context)
for choice in cxml
if contextualize_text(choice.get('correct'), self.context).upper() == "TRUE"
]
if self.has_partial_credit:
self.partial_choices = [
contextualize_text(choice.get('name'), self.context)
for choice in cxml
if contextualize_text(choice.get('correct'), self.context).lower() == 'partial'
]
self.partial_values = [
float(choice.get('point_value', default='0.5')) # Default partial credit: 50%
for choice in cxml
if contextualize_text(choice.get('correct'), self.context).lower() == 'partial'
]
def get_extended_hints(self, student_answer_dict, new_cmap):
"""
Extract any hints in a <choicegroup> matching the student's answers
......@@ -1083,16 +1255,80 @@ class MultipleChoiceResponse(LoncapaResponse):
self.do_shuffle(self.xml, problem)
self.do_answer_pool(self.xml, problem)
def get_score(self, student_answers):
def grade_via_points(self, **kwargs):
"""
grade student response.
Calculates partial credit based on the Points scheme.
Answer choices marked "partial" are given partial credit.
Default is 50%; other amounts may be set in point_value attributes.
Arguments: student_answers
Returns: a CorrectMap
"""
student_answers = kwargs['student_answers']
if (self.answer_id in student_answers
and student_answers[self.answer_id] in self.correct_choices):
return CorrectMap(self.answer_id, 'correct')
return CorrectMap(self.answer_id, correctness='correct')
elif (
self.answer_id in student_answers
and student_answers[self.answer_id] in self.partial_choices
):
choice_index = self.partial_choices.index(student_answers[self.answer_id])
credit_amount = self.partial_values[choice_index]
return CorrectMap(self.answer_id, correctness='partially-correct', npoints=credit_amount)
else:
return CorrectMap(self.answer_id, 'incorrect')
def grade_without_partial_credit(self, **kwargs):
"""
Standard grading for multiple-choice problems.
100% credit if choices are correct; 0% otherwise
Arguments: student_answers
Returns: a CorrectMap
"""
student_answers = kwargs['student_answers']
if (self.answer_id in student_answers
and student_answers[self.answer_id] in self.correct_choices):
return CorrectMap(self.answer_id, correctness='correct')
else:
return CorrectMap(self.answer_id, 'incorrect')
def get_score(self, student_answers):
"""
grade student response.
"""
# No partial credit? Grade it right away.
if not self.has_partial_credit:
return self.grade_without_partial_credit(student_answers=student_answers)
# This below checks to see whether we're using an alternate grading scheme.
# Set partial_credit="false" (or remove it) to require an exact answer for any credit.
# Set partial_credit="points" to set specific point values for specific choices.
# Translators: 'partial_credit' and the items in the 'graders' object
# are attribute names or values and should not be translated.
graders = {
'points': self.grade_via_points,
'false': self.grade_without_partial_credit
}
# Only one type of credit at a time.
if len(self.credit_type) > 1:
raise LoncapaProblemError('Only one type of partial credit is allowed for Multiple Choice problems.')
# Make sure we're using an approved style.
if self.credit_type[0] not in graders:
raise LoncapaProblemError('partial_credit attribute should be one of: ' + ','.join(graders))
# Run the appropriate grader.
return graders[self.credit_type[0]](
student_answers=student_answers
)
def get_answers(self):
return {self.answer_id: self.correct_choices}
......@@ -1492,6 +1728,14 @@ class NumericalResponse(LoncapaResponse):
if self.answer_id not in student_answers:
return CorrectMap(self.answer_id, 'incorrect')
# Make sure we're using an approved partial credit style.
# Currently implemented: 'close' and 'list'
if self.has_partial_credit:
graders = ['list', 'close']
for style in self.credit_type:
if style not in graders:
raise LoncapaProblemError('partial_credit attribute should be one of: ' + ','.join(graders))
student_answer = student_answers[self.answer_id]
_ = self.capa_system.i18n.ugettext
......@@ -1526,6 +1770,30 @@ class NumericalResponse(LoncapaResponse):
except Exception:
raise general_exception
# End `evaluator` block -- we figured out the student's answer!
tree = self.xml
# What multiple of the tolerance is worth partial credit?
has_partial_range = tree.xpath('responseparam[@partial_range]')
if has_partial_range:
partial_range = float(has_partial_range[0].get('partial_range', default='2'))
else:
partial_range = 2
# Take in alternative answers that are worth partial credit.
has_partial_answers = tree.xpath('responseparam[@partial_answers]')
if has_partial_answers:
partial_answers = has_partial_answers[0].get('partial_answers').split(',')
for index, word in enumerate(partial_answers):
partial_answers[index] = word.strip()
partial_answers[index] = self.get_staff_ans(partial_answers[index])
else:
partial_answers = False
partial_score = 0.5
is_correct = 'incorrect'
if self.range_tolerance:
if isinstance(student_float, complex):
raise StudentInputError(_(u"You may not use complex numbers in range tolerance problems"))
......@@ -1547,19 +1815,71 @@ class NumericalResponse(LoncapaResponse):
tolerance=float_info.epsilon,
relative_tolerance=True
):
correct = inclusion
is_correct = 'correct' if inclusion else 'incorrect'
break
else:
correct = boundaries[0] < student_float < boundaries[1]
if boundaries[0] < student_float < boundaries[1]:
is_correct = 'correct'
else:
if self.has_partial_credit is False:
pass
elif 'close' in self.credit_type:
# Partial credit: 50% if the student is outside the specified boundaries,
# but within an extended set of boundaries.
extended_boundaries = []
boundary_range = boundaries[1] - boundaries[0]
extended_boundaries.append(boundaries[0] - partial_range * boundary_range)
extended_boundaries.append(boundaries[1] + partial_range * boundary_range)
if extended_boundaries[0] < student_float < extended_boundaries[1]:
is_correct = 'partially-correct'
else:
correct_float = self.get_staff_ans(self.correct_answer)
correct = compare_with_tolerance(
student_float, correct_float, self.tolerance
)
if correct:
return CorrectMap(self.answer_id, 'correct')
# Partial credit is available in three cases:
# If the student answer is within expanded tolerance of the actual answer,
# the student gets 50% credit. (Currently set as the default.)
# Set via partial_credit="close" in the numericalresponse tag.
#
# If the student answer is within regular tolerance of an alternative answer,
# the student gets 50% credit. (Same default.)
# Set via partial_credit="list"
#
# If the student answer is within expanded tolerance of an alternative answer,
# the student gets 25%. (We take the 50% and square it, at the moment.)
# Set via partial_credit="list,close" or "close, list" or the like.
if str(self.tolerance).endswith('%'):
expanded_tolerance = str(partial_range * float(str(self.tolerance)[:-1])) + '%'
else:
expanded_tolerance = partial_range * float(self.tolerance)
if compare_with_tolerance(student_float, correct_float, self.tolerance):
is_correct = 'correct'
elif self.has_partial_credit is False:
pass
elif 'list' in self.credit_type:
for value in partial_answers:
if compare_with_tolerance(student_float, value, self.tolerance):
is_correct = 'partially-correct'
break
elif 'close' in self.credit_type:
if compare_with_tolerance(student_float, correct_float, expanded_tolerance):
is_correct = 'partially-correct'
break
elif compare_with_tolerance(student_float, value, expanded_tolerance):
is_correct = 'partially-correct'
partial_score = partial_score * partial_score
break
elif 'close' in self.credit_type:
if compare_with_tolerance(student_float, correct_float, expanded_tolerance):
is_correct = 'partially-correct'
if is_correct == 'partially-correct':
return CorrectMap(self.answer_id, is_correct, npoints=partial_score)
else:
return CorrectMap(self.answer_id, 'incorrect')
return CorrectMap(self.answer_id, is_correct)
def compare_answer(self, ans1, ans2):
"""
......@@ -1868,6 +2188,9 @@ class CustomResponse(LoncapaResponse):
code = None
expect = None
# Standard amount for partial credit if not otherwise specified:
default_pc = 0.5
def setup_response(self):
xml = self.xml
......@@ -2044,7 +2367,12 @@ class CustomResponse(LoncapaResponse):
if grade_decimals:
npoints = max_points * grade_decimals[k]
else:
npoints = max_points if correct[k] == 'correct' else 0
if correct[k] == 'correct':
npoints = max_points
elif correct[k] == 'partially-correct':
npoints = max_points * self.default_pc
else:
npoints = 0
correct_map.set(idset[k], correct[k], msg=messages[k],
npoints=npoints)
return correct_map
......@@ -2085,13 +2413,30 @@ class CustomResponse(LoncapaResponse):
)
if isinstance(ret, dict):
# One kind of dictionary the check function can return has the
# form {'ok': BOOLEAN, 'msg': STRING, 'grade_decimal' (optional): FLOAT (between 0.0 and 1.0)}
# form {'ok': BOOLEAN or STRING, 'msg': STRING, 'grade_decimal' (optional): FLOAT (between 0.0 and 1.0)}
# 'ok' will control the checkmark, while grade_decimal, if present, will scale
# the score the student receives on the response.
# If there are multiple inputs, they all get marked
# to the same correct/incorrect value
if 'ok' in ret:
correct = ['correct' if ret['ok'] else 'incorrect'] * len(idset)
# Returning any falsy value or the "false" string for "ok" gives incorrect.
# Returning any string that includes "partial" for "ok" gives partial credit.
# Returning any other truthy value for "ok" gives correct
ok_val = str(ret['ok']).lower().strip() if bool(ret['ok']) else 'false'
if ok_val == 'false':
correct = 'incorrect'
elif 'partial' in ok_val:
correct = 'partially-correct'
else:
correct = 'correct'
correct = [correct] * len(idset) # All inputs share the same mark.
# old version, no partial credit:
# correct = ['correct' if ret['ok'] else 'incorrect'] * len(idset)
msg = ret.get('msg', None)
msg = self.clean_message_html(msg)
......@@ -2103,9 +2448,14 @@ class CustomResponse(LoncapaResponse):
self.context['messages'][0] = msg
if 'grade_decimal' in ret:
decimal = ret['grade_decimal']
decimal = float(ret['grade_decimal'])
else:
decimal = 1.0 if ret['ok'] else 0.0
if correct[0] == 'correct':
decimal = 1.0
elif correct[0] == 'partially-correct':
decimal = self.default_pc
else:
decimal = 0.0
grade_decimals = [decimal] * len(idset)
self.context['grade_decimals'] = grade_decimals
......@@ -2113,7 +2463,11 @@ class CustomResponse(LoncapaResponse):
# the form:
# { 'overall_message': STRING,
# 'input_list': [
# { 'ok': BOOLEAN, 'msg': STRING, 'grade_decimal' (optional): FLOAT (between 0.0 and 1.0)},
# {
# 'ok': BOOLEAN or STRING,
# 'msg': STRING,
# 'grade_decimal' (optional): FLOAT (between 0.0 and 1.0)
# },
# ...
# ]
# }
......@@ -2130,16 +2484,35 @@ class CustomResponse(LoncapaResponse):
correct = []
messages = []
grade_decimals = []
# Returning any falsy value or the "false" string for "ok" gives incorrect.
# Returning any string that includes "partial" for "ok" gives partial credit.
# Returning any other truthy value for "ok" gives correct
for input_dict in input_list:
correct.append('correct'
if input_dict['ok'] else 'incorrect')
if str(input_dict['ok']).lower().strip() == "false" or not input_dict['ok']:
correct.append('incorrect')
elif 'partial' in str(input_dict['ok']).lower().strip():
correct.append('partially-correct')
else:
correct.append('correct')
# old version, no partial credit
# correct.append('correct'
# if input_dict['ok'] else 'incorrect')
msg = (self.clean_message_html(input_dict['msg'])
if 'msg' in input_dict else None)
messages.append(msg)
if 'grade_decimal' in input_dict:
decimal = input_dict['grade_decimal']
else:
decimal = 1.0 if input_dict['ok'] else 0.0
if str(input_dict['ok']).lower().strip() == 'true':
decimal = 1.0
elif 'partial' in str(input_dict['ok']).lower().strip():
decimal = self.default_pc
else:
decimal = 0.0
grade_decimals.append(decimal)
self.context['messages'] = messages
......@@ -2156,7 +2529,21 @@ class CustomResponse(LoncapaResponse):
)
else:
correct = ['correct' if ret else 'incorrect'] * len(idset)
# Returning any falsy value or the "false" string for "ok" gives incorrect.
# Returning any string that includes "partial" for "ok" gives partial credit.
# Returning any other truthy value for "ok" gives correct
if str(ret).lower().strip() == "false" or not bool(ret):
correct = 'incorrect'
elif 'partial' in str(ret).lower().strip():
correct = 'partially-correct'
else:
correct = 'correct'
correct = [correct] * len(idset)
# old version, no partial credit:
# correct = ['correct' if ret else 'incorrect'] * len(idset)
self.context['correct'] = correct
......
......@@ -17,7 +17,7 @@
<div id="input_${id}_preview" class="equation"></div>
<p id="answer_${id}" class="answer"></p>
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']:
% if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
</div>
% endif
</div>
......@@ -7,6 +7,8 @@
<%
if status == 'correct':
correctness = 'correct'
elif status == 'partially-correct':
correctness = 'partially-correct'
elif status == 'incorrect':
correctness = 'incorrect'
else:
......@@ -31,7 +33,7 @@
/> ${choice_description}
% if input_type == 'radio' and ( (isinstance(value, basestring) and (choice_id == value)) or (not isinstance(value, basestring) and choice_id in value) ):
% if status in ('correct', 'incorrect') and not show_correctness=='never':
% if status in ('correct', 'partially-correct', 'incorrect') and not show_correctness=='never':
<span class="sr status">${choice_description|h} - ${status.display_name}</span>
% endif
% endif
......@@ -60,4 +62,4 @@
% if msg:
<span class="message">${msg|n}</span>
% endif
</form>
</form>
\ No newline at end of file
......@@ -20,6 +20,8 @@
correctness = 'correct'
elif status == 'incorrect':
correctness = 'incorrect'
elif status == 'partially-correct':
correctness = 'partially-correct'
else:
correctness = None
%>
......
......@@ -9,7 +9,7 @@
<div class="script_placeholder" data-src="/static/js/sylvester.js"></div>
<div class="script_placeholder" data-src="/static/js/crystallography.js"></div>
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']:
% if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
<div class="status ${status.classname}" id="status_${id}">
% endif
......@@ -25,7 +25,7 @@
<span class="message">${msg|n}</span>
% endif
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']:
% if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
</div>
% endif
</section>
......@@ -2,7 +2,7 @@
<div class="script_placeholder" data-src="/static/js/capa/protex/protex.nocache.js?raw"/>
<div class="script_placeholder" data-src="${applet_loader}"/>
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']:
% if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
<div class="${status.classname}" id="status_${id}">
% endif
......@@ -15,7 +15,7 @@
</p>
<p id="answer_${id}" class="answer"></p>
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']:
% if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
</div>
% endif
</section>
......@@ -8,7 +8,7 @@
<div class="script_placeholder" data-src="${STATIC_URL}js/capa/drag_and_drop.js"></div>
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']:
% if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
<div class="${status.classname}" id="status_${id}">
% endif
......@@ -26,7 +26,7 @@
<span class="message">${msg|n}</span>
% endif
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']:
% if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
</div>
% endif
</div>
......@@ -2,7 +2,7 @@
<div class="script_placeholder" data-src="/static/js/capa/genex/genex.nocache.js?raw"/>
<div class="script_placeholder" data-src="${applet_loader}"/>
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']:
% if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
<div class="${status.classname}" id="status_${id}">
% endif
......@@ -16,7 +16,7 @@
</p>
<p id="answer_${id}" class="answer"></p>
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']:
% if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
</div>
% endif
</section>
......
<section id="editamoleculeinput_${id}" class="editamoleculeinput">
<div class="script_placeholder" data-src="${applet_loader}"/>
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']:
% if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
<div class="${status.classname}" id="status_${id}">
% endif
......@@ -23,7 +23,7 @@
<div class="error_message" style="padding: 5px 5px 5px 5px; background-color:#FA6666; height:60px;width:400px; display: none"></div>
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']:
% if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
</div>
% endif
</section>
......@@ -20,7 +20,7 @@
<div class="script_placeholder" data-src="${jschannel_loader}"/>
<div class="script_placeholder" data-src="${jsinput_loader}"/>
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']:
% if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
<div class="${status.classname}" id="status_${id}">
% endif
......@@ -47,7 +47,7 @@
<div class="error_message" style="padding: 5px 5px 5px 5px; background-color:#FA6666; height:60px;width:400px; display: none"></div>
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']:
% if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
</div>
% endif
......
......@@ -7,7 +7,7 @@
<div class="script_placeholder" data-src="${preprocessor['script_src']}"/>
% endif
% if status in ('unsubmitted', 'correct', 'incorrect', 'incomplete'):
% if status in ('unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete'):
<div class="${status.classname} ${doinline}" id="status_${id}">
% endif
% if hidden:
......@@ -50,7 +50,7 @@
% endif
% if status in ('unsubmitted', 'correct', 'incorrect', 'incomplete'):
% if status in ('unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete'):
</div>
% endif
......
......@@ -11,7 +11,7 @@
<div class="script_placeholder" data-src="/static/js/vsepr/vsepr.js"></div>
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']:
% if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
<div class="${status.classname}" id="status_${id}">
% endif
......@@ -28,7 +28,7 @@
% if msg:
<span class="message">${msg|n}</span>
% endif
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']:
% if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
</div>
% endif
</section>
......@@ -49,6 +49,9 @@ class ResponseXMLFactory(object):
*num_inputs*: The number of input elements
to create [DEFAULT: 1]
*credit_type*: String of comma-separated words specifying the
partial credit grading scheme.
Returns a string representation of the XML tree.
"""
......@@ -58,6 +61,7 @@ class ResponseXMLFactory(object):
script = kwargs.get('script', None)
num_responses = kwargs.get('num_responses', 1)
num_inputs = kwargs.get('num_inputs', 1)
credit_type = kwargs.get('credit_type', None)
# The root is <problem>
root = etree.Element("problem")
......@@ -75,6 +79,11 @@ class ResponseXMLFactory(object):
# Add the response(s)
for __ in range(int(num_responses)):
response_element = self.create_response_element(**kwargs)
# Set partial credit
if credit_type is not None:
response_element.set('partial_credit', str(credit_type))
root.append(response_element)
# Add input elements
......@@ -132,6 +141,10 @@ class ResponseXMLFactory(object):
*choice_names": List of strings identifying the choices.
If specified, you must ensure that
len(choice_names) == len(choices)
*points*: List of strings giving partial credit values (0-1)
for each choice. Interpreted as floats in problem.
If specified, ensure len(points) == len(choices)
"""
# Names of group elements
group_element_names = {
......@@ -144,15 +157,23 @@ class ResponseXMLFactory(object):
choices = kwargs.get('choices', [True])
choice_type = kwargs.get('choice_type', 'multiple')
choice_names = kwargs.get('choice_names', [None] * len(choices))
points = kwargs.get('points', [None] * len(choices))
# Create the <choicegroup>, <checkboxgroup>, or <radiogroup> element
assert choice_type in group_element_names
group_element = etree.Element(group_element_names[choice_type])
# Create the <choice> elements
for (correct_val, name) in zip(choices, choice_names):
for (correct_val, name, pointval) in zip(choices, choice_names, points):
choice_element = etree.SubElement(group_element, "choice")
choice_element.set("correct", "true" if correct_val else "false")
if correct_val is True:
correctness = 'true'
elif correct_val is False:
correctness = 'false'
elif 'partial' in correct_val:
correctness = 'partial'
choice_element.set('correct', correctness)
# Add a name identifying the choice, if one exists
# For simplicity, we use the same string as both the
......@@ -161,6 +182,10 @@ class ResponseXMLFactory(object):
choice_element.text = str(name)
choice_element.set("name", str(name))
# Add point values for partially-correct choices.
if pointval:
choice_element.set("point_value", str(pointval))
return group_element
......@@ -176,10 +201,22 @@ class NumericalResponseXMLFactory(ResponseXMLFactory):
*tolerance*: The tolerance within which a response
is considered correct. Can be a decimal (e.g. "0.01")
or percentage (e.g. "2%")
*credit_type*: String of comma-separated words specifying the
partial credit grading scheme.
*partial_range*: The multiplier for the tolerance that will
still provide partial credit in the "close" grading style
*partial_answers*: A string of comma-separated alternate
answers that will receive partial credit in the "list" style
"""
answer = kwargs.get('answer', None)
tolerance = kwargs.get('tolerance', None)
credit_type = kwargs.get('credit_type', None)
partial_range = kwargs.get('partial_range', None)
partial_answers = kwargs.get('partial_answers', None)
response_element = etree.Element('numericalresponse')
......@@ -193,6 +230,13 @@ class NumericalResponseXMLFactory(ResponseXMLFactory):
responseparam_element = etree.SubElement(response_element, 'responseparam')
responseparam_element.set('type', 'tolerance')
responseparam_element.set('default', str(tolerance))
if partial_range is not None and 'close' in credit_type:
responseparam_element.set('partial_range', str(partial_range))
if partial_answers is not None and 'list' in credit_type:
# The line below throws a false positive pylint violation, so it's excepted.
responseparam_element = etree.SubElement(response_element, 'responseparam') # pylint: disable=E1101
responseparam_element.set('partial_answers', partial_answers)
return response_element
......
......@@ -17,7 +17,7 @@ class CorrectMapTest(unittest.TestCase):
self.cmap = CorrectMap()
def test_set_input_properties(self):
# Set the correctmap properties for two inputs
# Set the correctmap properties for three inputs
self.cmap.set(
answer_id='1_2_1',
correctness='correct',
......@@ -41,15 +41,34 @@ class CorrectMapTest(unittest.TestCase):
queuestate=None
)
self.cmap.set(
answer_id='3_2_1',
correctness='partially-correct',
npoints=3,
msg=None,
hint=None,
hintmode=None,
queuestate=None
)
# Assert that each input has the expected properties
self.assertTrue(self.cmap.is_correct('1_2_1'))
self.assertFalse(self.cmap.is_correct('2_2_1'))
self.assertTrue(self.cmap.is_correct('3_2_1'))
self.assertTrue(self.cmap.is_partially_correct('3_2_1'))
self.assertFalse(self.cmap.is_partially_correct('2_2_1'))
# Intentionally testing an item that's not in cmap.
self.assertFalse(self.cmap.is_partially_correct('9_2_1'))
self.assertEqual(self.cmap.get_correctness('1_2_1'), 'correct')
self.assertEqual(self.cmap.get_correctness('2_2_1'), 'incorrect')
self.assertEqual(self.cmap.get_correctness('3_2_1'), 'partially-correct')
self.assertEqual(self.cmap.get_npoints('1_2_1'), 5)
self.assertEqual(self.cmap.get_npoints('2_2_1'), 0)
self.assertEqual(self.cmap.get_npoints('3_2_1'), 3)
self.assertEqual(self.cmap.get_msg('1_2_1'), 'Test message')
self.assertEqual(self.cmap.get_msg('2_2_1'), None)
......@@ -83,6 +102,8 @@ class CorrectMapTest(unittest.TestCase):
# 3) incorrect, 5 points
# 4) incorrect, None points
# 5) correct, 0 points
# 4) partially correct, 2.5 points
# 5) partially correct, None points
self.cmap.set(
answer_id='1_2_1',
correctness='correct',
......@@ -113,15 +134,30 @@ class CorrectMapTest(unittest.TestCase):
npoints=0
)
self.cmap.set(
answer_id='6_2_1',
correctness='partially-correct',
npoints=2.5
)
self.cmap.set(
answer_id='7_2_1',
correctness='partially-correct',
npoints=None
)
# Assert that we get the expected points
# If points assigned --> npoints
# If no points assigned and correct --> 1 point
# If no points assigned and partially correct --> 1 point
# If no points assigned and incorrect --> 0 points
self.assertEqual(self.cmap.get_npoints('1_2_1'), 5.3)
self.assertEqual(self.cmap.get_npoints('2_2_1'), 1)
self.assertEqual(self.cmap.get_npoints('3_2_1'), 5)
self.assertEqual(self.cmap.get_npoints('4_2_1'), 0)
self.assertEqual(self.cmap.get_npoints('5_2_1'), 0)
self.assertEqual(self.cmap.get_npoints('6_2_1'), 2.5)
self.assertEqual(self.cmap.get_npoints('7_2_1'), 1)
def test_set_overall_message(self):
......
......@@ -82,6 +82,23 @@ class ResponseTest(unittest.TestCase):
result = problem.grade_answers({'1_2_1': input_str}).get_correctness('1_2_1')
self.assertEqual(result, 'incorrect')
def assert_multiple_partial(self, problem, correct_answers, incorrect_answers, partial_answers):
"""
Runs multiple asserts for varying correct, incorrect,
and partially correct answers, all passed as lists.
"""
for input_str in correct_answers:
result = problem.grade_answers({'1_2_1': input_str}).get_correctness('1_2_1')
self.assertEqual(result, 'correct')
for input_str in incorrect_answers:
result = problem.grade_answers({'1_2_1': input_str}).get_correctness('1_2_1')
self.assertEqual(result, 'incorrect')
for input_str in partial_answers:
result = problem.grade_answers({'1_2_1': input_str}).get_correctness('1_2_1')
self.assertEqual(result, 'partially-correct')
def _get_random_number_code(self):
"""Returns code to be used to generate a random result."""
return "str(random.randint(0, 1e9))"
......@@ -103,6 +120,14 @@ class MultiChoiceResponseTest(ResponseTest):
self.assert_grade(problem, 'choice_1', 'correct')
self.assert_grade(problem, 'choice_2', 'incorrect')
def test_partial_multiple_choice_grade(self):
problem = self.build_problem(choices=[False, True, 'partial'], credit_type='points')
# Ensure that we get the expected grades
self.assert_grade(problem, 'choice_0', 'incorrect')
self.assert_grade(problem, 'choice_1', 'correct')
self.assert_grade(problem, 'choice_2', 'partially-correct')
def test_named_multiple_choice_grade(self):
problem = self.build_problem(choices=[False, True, False],
choice_names=["foil_1", "foil_2", "foil_3"])
......@@ -112,6 +137,38 @@ class MultiChoiceResponseTest(ResponseTest):
self.assert_grade(problem, 'choice_foil_2', 'correct')
self.assert_grade(problem, 'choice_foil_3', 'incorrect')
def test_multiple_choice_valid_grading_schemes(self):
# Multiple Choice problems only allow one partial credit scheme.
# Change this test if that changes.
problem = self.build_problem(choices=[False, True, 'partial'], credit_type='points,points')
with self.assertRaises(LoncapaProblemError):
input_dict = {'1_2_1': 'choice_1'}
problem.grade_answers(input_dict)
# 'bongo' is not a valid grading scheme.
problem = self.build_problem(choices=[False, True, 'partial'], credit_type='bongo')
with self.assertRaises(LoncapaProblemError):
input_dict = {'1_2_1': 'choice_1'}
problem.grade_answers(input_dict)
def test_partial_points_multiple_choice_grade(self):
problem = self.build_problem(
choices=['partial', 'partial', 'partial'],
credit_type='points',
points=['1', '0.6', '0']
)
# Ensure that we get the expected number of points
# Using assertAlmostEqual to avoid floating point issues
correct_map = problem.grade_answers({'1_2_1': 'choice_0'})
self.assertAlmostEqual(correct_map.get_npoints('1_2_1'), 1)
correct_map = problem.grade_answers({'1_2_1': 'choice_1'})
self.assertAlmostEqual(correct_map.get_npoints('1_2_1'), 0.6)
correct_map = problem.grade_answers({'1_2_1': 'choice_2'})
self.assertAlmostEqual(correct_map.get_npoints('1_2_1'), 0)
class TrueFalseResponseTest(ResponseTest):
xml_factory_class = TrueFalseResponseXMLFactory
......@@ -1111,6 +1168,112 @@ class ChoiceResponseTest(ResponseTest):
# No choice 3 exists --> mark incorrect
self.assert_grade(problem, 'choice_3', 'incorrect')
def test_checkbox_group_valid_grading_schemes(self):
# Checkbox-type problems only allow one partial credit scheme.
# Change this test if that changes.
problem = self.build_problem(
choice_type='checkbox',
choices=[False, False, True, True],
credit_type='edc,halves,bongo'
)
with self.assertRaises(LoncapaProblemError):
input_dict = {'1_2_1': 'choice_1'}
problem.grade_answers(input_dict)
# 'bongo' is not a valid grading scheme.
problem = self.build_problem(
choice_type='checkbox',
choices=[False, False, True, True],
credit_type='bongo'
)
with self.assertRaises(LoncapaProblemError):
input_dict = {'1_2_1': 'choice_1'}
problem.grade_answers(input_dict)
def test_checkbox_group_partial_credit_grade(self):
# First: Every Decision Counts grading style
problem = self.build_problem(
choice_type='checkbox',
choices=[False, False, True, True],
credit_type='edc'
)
# Check that we get the expected results
# (correct if and only if BOTH correct choices chosen)
# (partially correct if at least one choice is right)
# (incorrect if totally wrong)
self.assert_grade(problem, ['choice_0', 'choice_1'], 'incorrect')
self.assert_grade(problem, ['choice_2', 'choice_3'], 'correct')
self.assert_grade(problem, 'choice_0', 'partially-correct')
self.assert_grade(problem, 'choice_2', 'partially-correct')
self.assert_grade(problem, ['choice_0', 'choice_1', 'choice_2', 'choice_3'], 'partially-correct')
# Second: Halves grading style
problem = self.build_problem(
choice_type='checkbox',
choices=[False, False, True, True],
credit_type='halves'
)
# Check that we get the expected results
# (correct if and only if BOTH correct choices chosen)
# (partially correct on one error)
# (incorrect for more errors, at least with this # of choices.)
self.assert_grade(problem, ['choice_0', 'choice_1'], 'incorrect')
self.assert_grade(problem, ['choice_2', 'choice_3'], 'correct')
self.assert_grade(problem, 'choice_2', 'partially-correct')
self.assert_grade(problem, ['choice_1', 'choice_2', 'choice_3'], 'partially-correct')
self.assert_grade(problem, ['choice_0', 'choice_1', 'choice_2', 'choice_3'], 'incorrect')
# Third: Halves grading style with more options
problem = self.build_problem(
choice_type='checkbox',
choices=[False, False, True, True, False],
credit_type='halves'
)
# Check that we get the expected results
# (2 errors allowed with 5+ choices)
self.assert_grade(problem, ['choice_0', 'choice_1', 'choice_4'], 'incorrect')
self.assert_grade(problem, ['choice_2', 'choice_3'], 'correct')
self.assert_grade(problem, 'choice_2', 'partially-correct')
self.assert_grade(problem, ['choice_1', 'choice_2', 'choice_3'], 'partially-correct')
self.assert_grade(problem, ['choice_0', 'choice_1', 'choice_2', 'choice_3'], 'partially-correct')
self.assert_grade(problem, ['choice_0', 'choice_1', 'choice_2', 'choice_3', 'choice_4'], 'incorrect')
def test_checkbox_group_partial_points_grade(self):
# Ensure that we get the expected number of points
# Using assertAlmostEqual to avoid floating point issues
# First: Every Decision Counts grading style
problem = self.build_problem(
choice_type='checkbox',
choices=[False, False, True, True],
credit_type='edc'
)
correct_map = problem.grade_answers({'1_2_1': 'choice_2'})
self.assertAlmostEqual(correct_map.get_npoints('1_2_1'), 0.75)
# Second: Halves grading style
problem = self.build_problem(
choice_type='checkbox',
choices=[False, False, True, True],
credit_type='halves'
)
correct_map = problem.grade_answers({'1_2_1': 'choice_2'})
self.assertAlmostEqual(correct_map.get_npoints('1_2_1'), 0.5)
# Third: Halves grading style with more options
problem = self.build_problem(
choice_type='checkbox',
choices=[False, False, True, True, False],
credit_type='halves'
)
correct_map = problem.grade_answers({'1_2_1': 'choice_2,choice4'})
self.assertAlmostEqual(correct_map.get_npoints('1_2_1'), 0.25)
def test_grade_with_no_checkbox_selected(self):
"""
Test that answer marked as incorrect if no checkbox selected.
......@@ -1181,6 +1344,41 @@ class NumericalResponseTest(ResponseTest):
problem = self.build_problem(answer=given_answer)
self.assert_multiple_grade(problem, correct_responses, incorrect_responses)
def test_grade_range_tolerance_partial_credit(self):
problem_setup = [
# [given_answer,
# [list of correct responses],
# [list of incorrect responses],
# [list of partially correct responses]]
[
'[5, 7)',
['5', '6', '6.999'],
['0', '100'],
['4', '8']
],
[
'[1.6e-5, 1.9e24)',
['0.000016', '1.6*10^-5', '1.59e24'],
['-1e26', '1.9e26', '1.9*10^26'],
['0', '2e24']
],
[
'[0, 1.6e-5]',
['1.6*10^-5'],
['2'],
['1.9e-5', '-1e-6']
],
[
'(1.6e-5, 10]',
['2'],
['-20', '30'],
['-1', '12']
],
]
for given_answer, correct_responses, incorrect_responses, partial_responses in problem_setup:
problem = self.build_problem(answer=given_answer, credit_type='close')
self.assert_multiple_partial(problem, correct_responses, incorrect_responses, partial_responses)
def test_grade_range_tolerance_exceptions(self):
# no complex number in range tolerance staff answer
problem = self.build_problem(answer='[1j, 5]')
......@@ -1218,6 +1416,61 @@ class NumericalResponseTest(ResponseTest):
incorrect_responses = ["", "3.9", "4.1", "0"]
self.assert_multiple_grade(problem, correct_responses, incorrect_responses)
def test_grade_partial(self):
# First: "list"-style grading scheme.
problem = self.build_problem(
answer=4,
credit_type='list',
partial_answers='2,8,-4'
)
correct_responses = ["4", "4.0"]
incorrect_responses = ["1", "3", "4.1", "0", "-2"]
partial_responses = ["2", "2.0", "-4", "-4.0", "8", "8.0"]
self.assert_multiple_partial(problem, correct_responses, incorrect_responses, partial_responses)
# Second: "close"-style grading scheme. Default range is twice tolerance.
problem = self.build_problem(
answer=4,
tolerance=0.2,
credit_type='close'
)
correct_responses = ["4", "4.1", "3.9"]
incorrect_responses = ["1", "3", "4.5", "0", "-2"]
partial_responses = ["4.3", "3.7"]
self.assert_multiple_partial(problem, correct_responses, incorrect_responses, partial_responses)
# Third: "close"-style grading scheme with partial_range set.
problem = self.build_problem(
answer=4,
tolerance=0.2,
partial_range=3,
credit_type='close'
)
correct_responses = ["4", "4.1"]
incorrect_responses = ["1", "3", "0", "-2"]
partial_responses = ["4.5", "3.5"]
self.assert_multiple_partial(problem, correct_responses, incorrect_responses, partial_responses)
# Fourth: both "list"- and "close"-style grading schemes at once.
problem = self.build_problem(
answer=4,
tolerance=0.2,
partial_range=3,
credit_type='close,list',
partial_answers='2,8,-4'
)
correct_responses = ["4", "4.0"]
incorrect_responses = ["1", "3", "0", "-2"]
partial_responses = ["2", "2.1", "1.5", "8", "7.5", "8.1", "-4", "-4.15", "-3.5", "4.5", "3.5"]
self.assert_multiple_partial(problem, correct_responses, incorrect_responses, partial_responses)
def test_numerical_valid_grading_schemes(self):
# 'bongo' is not a valid grading scheme.
problem = self.build_problem(answer=4, tolerance=0.1, credit_type='bongo')
input_dict = {'1_2_1': '4'}
with self.assertRaises(LoncapaProblemError):
problem.grade_answers(input_dict)
def test_grade_decimal_tolerance(self):
problem = self.build_problem(answer=4, tolerance=0.1)
correct_responses = ["4.0", "4.00", "4.09", "3.91"]
......@@ -1444,11 +1697,18 @@ class CustomResponseTest(ResponseTest):
# or an ordered list of answers (if there are multiple inputs)
#
# The function should return a dict of the form
# { 'ok': BOOL, 'msg': STRING } (no 'grade_decimal' key to test that it's optional)
# { 'ok': BOOL or STRING, 'msg': STRING } (no 'grade_decimal' key to test that it's optional)
#
script = textwrap.dedent("""
def check_func(expect, answer_given):
return {'ok': answer_given == expect, 'msg': 'Message text'}
partial_credit = '21'
if answer_given == expect:
retval = True
elif answer_given == partial_credit:
retval = 'partial'
else:
retval = False
return {'ok': retval, 'msg': 'Message text'}
""")
problem = self.build_problem(script=script, cfn="check_func", expect="42")
......@@ -1465,6 +1725,18 @@ class CustomResponseTest(ResponseTest):
self.assertEqual(msg, "Message text")
self.assertEqual(npoints, 1)
# Partially Credit answer
input_dict = {'1_2_1': '21'}
correct_map = problem.grade_answers(input_dict)
correctness = correct_map.get_correctness('1_2_1')
msg = correct_map.get_msg('1_2_1')
npoints = correct_map.get_npoints('1_2_1')
self.assertEqual(correctness, 'partially-correct')
self.assertEqual(msg, "Message text")
self.assertTrue(0 <= npoints <= 1)
# Incorrect answer
input_dict = {'1_2_1': '0'}
correct_map = problem.grade_answers(input_dict)
......@@ -1486,14 +1758,24 @@ class CustomResponseTest(ResponseTest):
# or an ordered list of answers (if there are multiple inputs)
#
# The function should return a dict of the form
# { 'ok': BOOL, 'msg': STRING, 'grade_decimal': FLOAT }
# { 'ok': BOOL or STRING, 'msg': STRING, 'grade_decimal': FLOAT }
#
script = textwrap.dedent("""
def check_func(expect, answer_given):
partial_credit = '21'
if answer_given == expect:
retval = True
score = 0.9
elif answer_given == partial_credit:
retval = 'partial'
score = 0.5
else:
retval = False
score = 0.1
return {
'ok': answer_given == expect,
'ok': retval,
'msg': 'Message text',
'grade_decimal': 0.9 if answer_given == expect else 0.1,
'grade_decimal': score,
}
""")
......@@ -1511,16 +1793,28 @@ class CustomResponseTest(ResponseTest):
self.assertEqual(correct_map.get_npoints('1_2_1'), 0.1)
self.assertEqual(correct_map.get_correctness('1_2_1'), 'incorrect')
# Partially Correct answer
input_dict = {'1_2_1': '21'}
correct_map = problem.grade_answers(input_dict)
self.assertEqual(correct_map.get_npoints('1_2_1'), 0.5)
self.assertEqual(correct_map.get_correctness('1_2_1'), 'partially-correct')
def test_function_code_multiple_input_no_msg(self):
# Check functions also have the option of returning
# a single boolean value
# a single boolean or string value
# If true, mark all the inputs correct
# If one is true but not the other, mark all partially correct
# If false, mark all the inputs incorrect
script = textwrap.dedent("""
def check_func(expect, answer_given):
return (answer_given[0] == expect and
answer_given[1] == expect)
if answer_given[0] == expect and answer_given[1] == expect:
retval = True
elif answer_given[0] == expect or answer_given[1] == expect:
retval = 'partial'
else:
retval = False
return retval
""")
problem = self.build_problem(script=script, cfn="check_func",
......@@ -1536,11 +1830,23 @@ class CustomResponseTest(ResponseTest):
correctness = correct_map.get_correctness('1_2_2')
self.assertEqual(correctness, 'correct')
# One answer incorrect -- expect both inputs marked incorrect
# One answer incorrect -- expect both inputs marked partially correct
input_dict = {'1_2_1': '0', '1_2_2': '42'}
correct_map = problem.grade_answers(input_dict)
correctness = correct_map.get_correctness('1_2_1')
self.assertEqual(correctness, 'partially-correct')
self.assertTrue(0 <= correct_map.get_npoints('1_2_1') <= 1)
correctness = correct_map.get_correctness('1_2_2')
self.assertEqual(correctness, 'partially-correct')
self.assertTrue(0 <= correct_map.get_npoints('1_2_2') <= 1)
# Both answers incorrect -- expect both inputs marked incorrect
input_dict = {'1_2_1': '0', '1_2_2': '0'}
correct_map = problem.grade_answers(input_dict)
correctness = correct_map.get_correctness('1_2_1')
self.assertEqual(correctness, 'incorrect')
correctness = correct_map.get_correctness('1_2_2')
......@@ -1552,7 +1858,8 @@ class CustomResponseTest(ResponseTest):
# the check function can return a dict of the form:
#
# {'overall_message': STRING,
# 'input_list': [{'ok': BOOL, 'msg': STRING}, ...] } (no grade_decimal to test it's optional)
# 'input_list': [{'ok': BOOL or STRING, 'msg': STRING}, ...] }
# (no grade_decimal to test it's optional)
#
# 'overall_message' is displayed at the end of the response
#
......@@ -1563,18 +1870,23 @@ class CustomResponseTest(ResponseTest):
check1 = (int(answer_given[0]) == 1)
check2 = (int(answer_given[1]) == 2)
check3 = (int(answer_given[2]) == 3)
check4 = 'partial' if answer_given[3] == 'four' else False
return {'overall_message': 'Overall message',
'input_list': [
{'ok': check1, 'msg': 'Feedback 1'},
{'ok': check2, 'msg': 'Feedback 2'},
{'ok': check3, 'msg': 'Feedback 3'} ] }
{'ok': check3, 'msg': 'Feedback 3'},
{'ok': check4, 'msg': 'Feedback 4'} ] }
""")
problem = self.build_problem(script=script,
cfn="check_func", num_inputs=3)
problem = self.build_problem(
script=script,
cfn="check_func",
num_inputs=4
)
# Grade the inputs (one input incorrect)
input_dict = {'1_2_1': '-999', '1_2_2': '2', '1_2_3': '3'}
input_dict = {'1_2_1': '-999', '1_2_2': '2', '1_2_3': '3', '1_2_4': 'four'}
correct_map = problem.grade_answers(input_dict)
# Expect that we receive the overall message (for the whole response)
......@@ -1584,16 +1896,19 @@ class CustomResponseTest(ResponseTest):
self.assertEqual(correct_map.get_correctness('1_2_1'), 'incorrect')
self.assertEqual(correct_map.get_correctness('1_2_2'), 'correct')
self.assertEqual(correct_map.get_correctness('1_2_3'), 'correct')
self.assertEqual(correct_map.get_correctness('1_2_4'), 'partially-correct')
# Expect that the inputs were given correct npoints
self.assertEqual(correct_map.get_npoints('1_2_1'), 0)
self.assertEqual(correct_map.get_npoints('1_2_2'), 1)
self.assertEqual(correct_map.get_npoints('1_2_3'), 1)
self.assertTrue(0 <= correct_map.get_npoints('1_2_4') <= 1)
# Expect that we received messages for each individual input
self.assertEqual(correct_map.get_msg('1_2_1'), 'Feedback 1')
self.assertEqual(correct_map.get_msg('1_2_2'), 'Feedback 2')
self.assertEqual(correct_map.get_msg('1_2_3'), 'Feedback 3')
self.assertEqual(correct_map.get_msg('1_2_4'), 'Feedback 4')
def test_function_code_multiple_inputs_decimal_score(self):
......@@ -1601,7 +1916,8 @@ class CustomResponseTest(ResponseTest):
# the check function can return a dict of the form:
#
# {'overall_message': STRING,
# 'input_list': [{'ok': BOOL, 'msg': STRING, 'grade_decimal': FLOAT}, ...] }
# 'input_list': [{'ok': BOOL or STRING,
# 'msg': STRING, 'grade_decimal': FLOAT}, ...] }
# #
# 'input_list' contains dictionaries representing the correctness
# and message for each input.
......@@ -1610,42 +1926,60 @@ class CustomResponseTest(ResponseTest):
check1 = (int(answer_given[0]) == 1)
check2 = (int(answer_given[1]) == 2)
check3 = (int(answer_given[2]) == 3)
check4 = 'partial' if answer_given[3] == 'four' else False
score1 = 0.9 if check1 else 0.1
score2 = 0.9 if check2 else 0.1
score3 = 0.9 if check3 else 0.1
score4 = 0.7 if check4 == 'partial' else 0.1
return {
'input_list': [
{'ok': check1, 'grade_decimal': score1, 'msg': 'Feedback 1'},
{'ok': check2, 'grade_decimal': score2, 'msg': 'Feedback 2'},
{'ok': check3, 'grade_decimal': score3, 'msg': 'Feedback 3'},
{'ok': check4, 'grade_decimal': score4, 'msg': 'Feedback 4'},
]
}
""")
problem = self.build_problem(script=script, cfn="check_func", num_inputs=3)
problem = self.build_problem(script=script, cfn="check_func", num_inputs=4)
# Grade the inputs (one input incorrect)
input_dict = {'1_2_1': '-999', '1_2_2': '2', '1_2_3': '3'}
input_dict = {'1_2_1': '-999', '1_2_2': '2', '1_2_3': '3', '1_2_4': 'four'}
correct_map = problem.grade_answers(input_dict)
# Expect that the inputs were graded individually
self.assertEqual(correct_map.get_correctness('1_2_1'), 'incorrect')
self.assertEqual(correct_map.get_correctness('1_2_2'), 'correct')
self.assertEqual(correct_map.get_correctness('1_2_3'), 'correct')
self.assertEqual(correct_map.get_correctness('1_2_4'), 'partially-correct')
# Expect that the inputs were given correct npoints
self.assertEqual(correct_map.get_npoints('1_2_1'), 0.1)
self.assertEqual(correct_map.get_npoints('1_2_2'), 0.9)
self.assertEqual(correct_map.get_npoints('1_2_3'), 0.9)
self.assertEqual(correct_map.get_npoints('1_2_4'), 0.7)
def test_function_code_with_extra_args(self):
script = textwrap.dedent("""\
def check_func(expect, answer_given, options, dynamath):
assert options == "xyzzy", "Options was %r" % options
return {'ok': answer_given == expect, 'msg': 'Message text'}
partial_credit = '21'
if answer_given == expect:
retval = True
elif answer_given == partial_credit:
retval = 'partial'
else:
retval = False
return {'ok': retval, 'msg': 'Message text'}
""")
problem = self.build_problem(script=script, cfn="check_func", expect="42", options="xyzzy", cfn_extra_args="options dynamath")
problem = self.build_problem(
script=script,
cfn="check_func",
expect="42",
options="xyzzy",
cfn_extra_args="options dynamath"
)
# Correct answer
input_dict = {'1_2_1': '42'}
......@@ -1657,6 +1991,16 @@ class CustomResponseTest(ResponseTest):
self.assertEqual(correctness, 'correct')
self.assertEqual(msg, "Message text")
# Partially Correct answer
input_dict = {'1_2_1': '21'}
correct_map = problem.grade_answers(input_dict)
correctness = correct_map.get_correctness('1_2_1')
msg = correct_map.get_msg('1_2_1')
self.assertEqual(correctness, 'partially-correct')
self.assertEqual(msg, "Message text")
# Incorrect answer
input_dict = {'1_2_1': '0'}
correct_map = problem.grade_answers(input_dict)
......@@ -1683,8 +2027,12 @@ class CustomResponseTest(ResponseTest):
check1 = (int(answer_given[0]) == 1)
check2 = (int(answer_given[1]) == 2)
check3 = (int(answer_given[2]) == 3)
return {'ok': (check1 and check2 and check3),
'msg': 'Message text'}
if (int(answer_given[0]) == -1) and check2 and check3:
return {'ok': 'partial',
'msg': 'Message text'}
else:
return {'ok': (check1 and check2 and check3),
'msg': 'Message text'}
""")
problem = self.build_problem(script=script,
......@@ -1699,6 +2047,15 @@ class CustomResponseTest(ResponseTest):
self.assertEqual(correct_map.get_correctness('1_2_2'), 'incorrect')
self.assertEqual(correct_map.get_correctness('1_2_3'), 'incorrect')
# Grade the inputs (one input partially correct)
input_dict = {'1_2_1': '-1', '1_2_2': '2', '1_2_3': '3'}
correct_map = problem.grade_answers(input_dict)
# Everything marked partially correct
self.assertEqual(correct_map.get_correctness('1_2_1'), 'partially-correct')
self.assertEqual(correct_map.get_correctness('1_2_2'), 'partially-correct')
self.assertEqual(correct_map.get_correctness('1_2_3'), 'partially-correct')
# Grade the inputs (everything correct)
input_dict = {'1_2_1': '1', '1_2_2': '2', '1_2_3': '3'}
correct_map = problem.grade_answers(input_dict)
......
......@@ -24,6 +24,7 @@
$annotation-yellow: rgba(255,255,10,0.3);
$color-copy-tip: rgb(100,100,100);
$correct: $green-d1;
$partiallycorrect: $green-d1;
$incorrect: $red;
// +Extends - Capa
......@@ -75,6 +76,11 @@ h2 {
color: $correct;
}
.feedback-hint-partially-correct {
margin-top: ($baseline/2);
color: $partiallycorrect;
}
.feedback-hint-incorrect {
margin-top: ($baseline/2);
color: $incorrect;
......@@ -174,6 +180,16 @@ div.problem {
}
}
&.choicegroup_partially-correct {
@include status-icon($partiallycorrect, "\f069");
border: 2px solid $partiallycorrect;
// keep green for correct answers on hover.
&:hover {
border-color: $partiallycorrect;
}
}
&.choicegroup_incorrect {
@include status-icon($incorrect, "\f00d");
border: 2px solid $incorrect;
......@@ -227,6 +243,11 @@ div.problem {
@include status-icon($correct, "\f00c");
}
// CASE: partially correct answer
&.partially-correct {
@include status-icon($partiallycorrect, "\f069");
}
// CASE: incorrect answer
&.incorrect {
@include status-icon($incorrect, "\f00d");
......@@ -338,6 +359,19 @@ div.problem {
}
}
&.partially-correct, &.ui-icon-check {
p.status {
display: inline-block;
width: 25px;
height: 20px;
background: url('../images/partially-correct-icon.png') center center no-repeat;
}
input {
border-color: $partiallycorrect;
}
}
&.processing {
p.status {
display: inline-block;
......@@ -735,6 +769,18 @@ div.problem {
}
}
// CASE: partially correct answer
> .partially-correct {
input {
border: 2px solid $partiallycorrect;
}
.status {
@include status-icon($partiallycorrect, "\f069");
}
}
// CASE: correct answer
> .correct {
......@@ -776,7 +822,7 @@ div.problem {
.indicator-container {
display: inline-block;
.status.correct:after, .status.incorrect:after, .status.unanswered:after {
.status.correct:after, .status.partially-correct:after, .status.incorrect:after, .status.unanswered:after {
@include margin-left(0);
}
}
......@@ -942,6 +988,20 @@ div.problem {
}
}
.detailed-targeted-feedback-partially-correct {
> p:first-child {
@extend %t-strong;
color: $partiallycorrect;
text-transform: uppercase;
font-style: normal;
font-size: 0.9em;
}
p:last-child {
margin-bottom: 0;
}
}
.detailed-targeted-feedback-correct {
> p:first-child {
@extend %t-strong;
......@@ -1136,6 +1196,14 @@ div.problem {
}
}
.result-partially-correct {
background: url('../images/partially-correct-icon.png') left 20px no-repeat;
.result-actual-output {
color: #090;
}
}
.result-incorrect {
background: url('#{$static-path}/images/incorrect-icon.png') left 20px no-repeat;
......@@ -1341,6 +1409,14 @@ div.problem {
}
}
label.choicetextgroup_partially-correct, section.choicetextgroup_partially-correct {
@extend label.choicegroup_partially-correct;
input[type="text"] {
border-color: $partiallycorrect;
}
}
label.choicetextgroup_incorrect, section.choicetextgroup_incorrect {
@extend label.choicegroup_incorrect;
}
......
......@@ -64,6 +64,12 @@ class ProblemPage(PageObject):
"""
self.q(css='div.problem div.capa_inputtype.textline input').fill(text)
def fill_answer_numerical(self, text):
"""
Fill in the answer to a numerical problem.
"""
self.q(css='div.problem section.inputtype input').fill(text)
def click_check(self):
"""
Click the Check button!
......@@ -84,6 +90,24 @@ class ProblemPage(PageObject):
"""
return self.q(css="div.problem div.capa_inputtype.textline div.correct span.status").is_present()
def simpleprob_is_correct(self):
"""
Is there a "correct" status showing? Works with simple problem types.
"""
return self.q(css="div.problem section.inputtype div.correct span.status").is_present()
def simpleprob_is_partially_correct(self):
"""
Is there a "partially correct" status showing? Works with simple problem types.
"""
return self.q(css="div.problem section.inputtype div.partially-correct span.status").is_present()
def simpleprob_is_incorrect(self):
"""
Is there an "incorrect" status showing? Works with simple problem types.
"""
return self.q(css="div.problem section.inputtype div.incorrect span.status").is_present()
def click_clarification(self, index=0):
"""
Click on an inline icon that can be included in problem text using an HTML <clarification> element:
......
......@@ -288,3 +288,35 @@ class ProblemWithMathjax(ProblemsTest):
self.assertIn("Hint (2 of 2): mathjax should work2", problem_page.hint_text)
self.assertTrue(problem_page.mathjax_rendered_in_hint, "MathJax did not rendered in problem hint")
class ProblemPartialCredit(ProblemsTest):
"""
Makes sure that the partial credit is appearing properly.
"""
def get_problem(self):
"""
Create a problem with partial credit.
"""
xml = dedent("""
<problem>
<p>The answer is 1. Partial credit for -1.</p>
<numericalresponse answer="1" partial_credit="list">
<formulaequationinput label="How many miles away from Earth is the sun? Use scientific notation to answer." />
<responseparam type="tolerance" default="0.01" />
<responseparam partial_answers="-1" />
</numericalresponse>
</problem>
""")
return XBlockFixtureDesc('problem', 'PARTIAL CREDIT TEST PROBLEM', data=xml)
def test_partial_credit(self):
"""
Test that we can see the partial credit value and feedback.
"""
self.courseware_page.visit()
problem_page = ProblemPage(self.browser)
self.assertEqual(problem_page.problem_name, 'PARTIAL CREDIT TEST PROBLEM')
problem_page.fill_answer_numerical('-1')
problem_page.click_check()
self.assertTrue(problem_page.simpleprob_is_partially_correct())
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