From 350387c0a9d50faaf671f2048a9ef1c89881353b Mon Sep 17 00:00:00 2001 From: Colin-Fredericks Date: Wed, 2 Sep 2015 14:35:47 -0400 Subject: [PATCH] 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 --- common/lib/capa/capa/correctmap.py | 15 +- common/lib/capa/capa/inputtypes.py | 4 +- common/lib/capa/capa/responsetypes.py | 461 ++++++++++++++++-- .../capa/templates/chemicalequationinput.html | 2 +- .../lib/capa/capa/templates/choicegroup.html | 6 +- .../lib/capa/capa/templates/choicetext.html | 2 + .../capa/capa/templates/crystallography.html | 4 +- .../capa/templates/designprotein2dinput.html | 4 +- .../capa/templates/drag_and_drop_input.html | 4 +- .../capa/capa/templates/editageneinput.html | 4 +- .../capa/capa/templates/editamolecule.html | 4 +- common/lib/capa/capa/templates/jsinput.html | 4 +- common/lib/capa/capa/templates/textline.html | 4 +- .../lib/capa/capa/templates/vsepr_input.html | 4 +- .../capa/capa/tests/response_xml_factory.py | 48 +- common/lib/capa/capa/tests/test_correctmap.py | 38 +- .../lib/capa/capa/tests/test_responsetypes.py | 399 ++++++++++++++- .../lib/xmodule/xmodule/css/capa/display.scss | 78 ++- .../static/images/partially-correct-icon.png | Bin 1230 -> 1141 bytes common/test/acceptance/pages/lms/problem.py | 24 + .../acceptance/tests/lms/test_lms_problems.py | 32 ++ 21 files changed, 1058 insertions(+), 83 deletions(-) diff --git a/common/lib/capa/capa/correctmap.py b/common/lib/capa/capa/correctmap.py index 9af728552a..26a6b60fba 100644 --- a/common/lib/capa/capa/correctmap.py +++ b/common/lib/capa/capa/correctmap.py @@ -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 diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index 3e8036ce30..ae88116f71 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -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 diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 8215a1a241..3954664516 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -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,25 +857,153 @@ class ChoiceResponse(LoncapaResponse): if not choice.get('id'): choice.set("id", chr(ord("A") + index)) + 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. + """ + + all_choices = kwargs['all_choices'] + student_answer = kwargs['student_answer'] + student_non_answers = kwargs['student_non_answers'] + + edc_max_grade = len(all_choices) + edc_current_grade = 0 + + 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 + + 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] - no_empty_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) - required_selected = len(self.correct_choices - student_answer) == 0 - no_extra_selected = len(student_answer - self.correct_choices) == 0 + student_non_answers = all_choices - student_answer - correct = required_selected & no_extra_selected & no_empty_answer + # No partial credit? Get grade right now. + if not self.has_partial_credit: + return self.grade_without_partial_credit(student_answer=student_answer) - if correct: - return CorrectMap(self.answer_id, 'correct') - else: - return CorrectMap(self.answer_id, 'incorrect') + # 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 matching the student's answers @@ -1083,15 +1255,79 @@ class MultipleChoiceResponse(LoncapaResponse): self.do_shuffle(self.xml, problem) self.do_answer_pool(self.xml, problem) + def grade_via_points(self, **kwargs): + """ + 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, 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. """ - if (self.answer_id in student_answers - and student_answers[self.answer_id] in self.correct_choices): - return CorrectMap(self.answer_id, 'correct') - else: - return CorrectMap(self.answer_id, 'incorrect') + + # 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 diff --git a/common/lib/capa/capa/templates/chemicalequationinput.html b/common/lib/capa/capa/templates/chemicalequationinput.html index 2ae29406b6..6a6c727bde 100644 --- a/common/lib/capa/capa/templates/chemicalequationinput.html +++ b/common/lib/capa/capa/templates/chemicalequationinput.html @@ -17,7 +17,7 @@

-% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: +% if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']: % endif diff --git a/common/lib/capa/capa/templates/choicegroup.html b/common/lib/capa/capa/templates/choicegroup.html index da5d372401..1b16470de3 100644 --- a/common/lib/capa/capa/templates/choicegroup.html +++ b/common/lib/capa/capa/templates/choicegroup.html @@ -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': ${choice_description|h} - ${status.display_name} % endif % endif @@ -60,4 +62,4 @@ % if msg: ${msg|n} % endif - + \ No newline at end of file diff --git a/common/lib/capa/capa/templates/choicetext.html b/common/lib/capa/capa/templates/choicetext.html index 448205c2aa..f07fa22665 100644 --- a/common/lib/capa/capa/templates/choicetext.html +++ b/common/lib/capa/capa/templates/choicetext.html @@ -20,6 +20,8 @@ correctness = 'correct' elif status == 'incorrect': correctness = 'incorrect' + elif status == 'partially-correct': + correctness = 'partially-correct' else: correctness = None %> diff --git a/common/lib/capa/capa/templates/crystallography.html b/common/lib/capa/capa/templates/crystallography.html index 3255046309..0442fb0fa6 100644 --- a/common/lib/capa/capa/templates/crystallography.html +++ b/common/lib/capa/capa/templates/crystallography.html @@ -9,7 +9,7 @@
- % if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: + % if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
% endif @@ -25,7 +25,7 @@ ${msg|n} % endif - % if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: + % if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
% endif diff --git a/common/lib/capa/capa/templates/designprotein2dinput.html b/common/lib/capa/capa/templates/designprotein2dinput.html index 066d796db3..bb3490f5af 100644 --- a/common/lib/capa/capa/templates/designprotein2dinput.html +++ b/common/lib/capa/capa/templates/designprotein2dinput.html @@ -2,7 +2,7 @@
- % if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: + % if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
% endif @@ -15,7 +15,7 @@

- % if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: + % if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
% endif diff --git a/common/lib/capa/capa/templates/drag_and_drop_input.html b/common/lib/capa/capa/templates/drag_and_drop_input.html index 29eb629d93..d9bb8da90f 100644 --- a/common/lib/capa/capa/templates/drag_and_drop_input.html +++ b/common/lib/capa/capa/templates/drag_and_drop_input.html @@ -8,7 +8,7 @@
- % if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: + % if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
% endif @@ -26,7 +26,7 @@ ${msg|n} % endif - % if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: + % if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
% endif
diff --git a/common/lib/capa/capa/templates/editageneinput.html b/common/lib/capa/capa/templates/editageneinput.html index 4ee3cc5d7e..11c947a955 100644 --- a/common/lib/capa/capa/templates/editageneinput.html +++ b/common/lib/capa/capa/templates/editageneinput.html @@ -2,7 +2,7 @@
- % if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: + % if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
% endif @@ -16,7 +16,7 @@

- % if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: + % if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
% endif diff --git a/common/lib/capa/capa/templates/editamolecule.html b/common/lib/capa/capa/templates/editamolecule.html index e5131b21d9..c51101dc9d 100644 --- a/common/lib/capa/capa/templates/editamolecule.html +++ b/common/lib/capa/capa/templates/editamolecule.html @@ -1,7 +1,7 @@
- % if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: + % if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
% endif @@ -23,7 +23,7 @@ - % if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: + % if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
% endif
diff --git a/common/lib/capa/capa/templates/jsinput.html b/common/lib/capa/capa/templates/jsinput.html index b798d5800a..9a9b03742d 100644 --- a/common/lib/capa/capa/templates/jsinput.html +++ b/common/lib/capa/capa/templates/jsinput.html @@ -20,7 +20,7 @@
- % if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: + % if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
% endif @@ -47,7 +47,7 @@ - % if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: + % if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
% endif diff --git a/common/lib/capa/capa/templates/textline.html b/common/lib/capa/capa/templates/textline.html index b37fb0d67e..ec172c37d5 100644 --- a/common/lib/capa/capa/templates/textline.html +++ b/common/lib/capa/capa/templates/textline.html @@ -7,7 +7,7 @@
% endif - % if status in ('unsubmitted', 'correct', 'incorrect', 'incomplete'): + % if status in ('unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete'):
% endif % if hidden: @@ -50,7 +50,7 @@ % endif -% if status in ('unsubmitted', 'correct', 'incorrect', 'incomplete'): +% if status in ('unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete'):
% endif diff --git a/common/lib/capa/capa/templates/vsepr_input.html b/common/lib/capa/capa/templates/vsepr_input.html index 72ad94654e..49812e3fb2 100644 --- a/common/lib/capa/capa/templates/vsepr_input.html +++ b/common/lib/capa/capa/templates/vsepr_input.html @@ -11,7 +11,7 @@
- % if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: + % if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
% endif @@ -28,7 +28,7 @@ % if msg: ${msg|n} % endif -% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: +% if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
% endif diff --git a/common/lib/capa/capa/tests/response_xml_factory.py b/common/lib/capa/capa/tests/response_xml_factory.py index a1b8f53554..c0c2a43c6a 100644 --- a/common/lib/capa/capa/tests/response_xml_factory.py +++ b/common/lib/capa/capa/tests/response_xml_factory.py @@ -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 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 , , or element assert choice_type in group_element_names group_element = etree.Element(group_element_names[choice_type]) # Create the 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 diff --git a/common/lib/capa/capa/tests/test_correctmap.py b/common/lib/capa/capa/tests/test_correctmap.py index f1aad56c79..168351aa9d 100644 --- a/common/lib/capa/capa/tests/test_correctmap.py +++ b/common/lib/capa/capa/tests/test_correctmap.py @@ -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): diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py index 751d6c08d6..788c551dc2 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -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,10 +1830,22 @@ 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') @@ -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) diff --git a/common/lib/xmodule/xmodule/css/capa/display.scss b/common/lib/xmodule/xmodule/css/capa/display.scss index 8bd8b551db..eedad527f9 100644 --- a/common/lib/xmodule/xmodule/css/capa/display.scss +++ b/common/lib/xmodule/xmodule/css/capa/display.scss @@ -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; } diff --git a/common/static/images/partially-correct-icon.png b/common/static/images/partially-correct-icon.png index 9ac0fd32f7a5c579fcb2e419486fc4443cccf6a5..deba924d6547fbf89ad53851c798a4e4862f28b7 100644 GIT binary patch delta 1133 zcmV-z1d{vC3H1n&7k?WF1^@s6i6JCt000C$NklKpAVTa^aiQ*PelGSw%p-9ucIfzTOfz>0p6m3MOVj@laQX zou;M~Fav;ykbkxDzE#`$x1Z7*_kR=K8eJrgWYOiI!V}2~t9_XVlL`O;&j5s+3$eB;7(prk00eE_ z9aF$G1;!xl)O-VvWLDROzZU2&ue%-Qx-`@BAWc=N2LbpQH8F4+f`ladRNJ&)e&sQR zT9`^Rf`3M1iU49?lX|oLQsc)1JfTS+J^W~Avh!8f_ou^?(a{J1Gqh4ILNrm=BuSk+#4)=fdVWc*&yspCIS0r(OeQ`T( zzJE{zSPH~9xhok0rfYsfO#m2?tNpPyF6bHGpjkIV4@yJ;G=rJ{o-zK;*TTWjk7+t2 zMmQKgcE|YJUSdU;pgC1Wnqg_{*z&vQ9^oAoP9hS>T>~R=Xa+0>DURDJu;Km*_sst3 z$@6as(4AH-2?8q6-}lakbU=R=YPM)u!2t49RK+ zotrQ=Qg^wc@e~0fXwu;=ukVZX#~(_$M46Bq3Los+vTPx>RJ#ID{^kc}cDczFKATC} z#oONMlZOD{#uvbr8+tpBz6=x`&kfU2xcuA zU_&g6Ppso7lkT6ehPYY`*L7QgYHzpf1?K<1=$1?}6WE1-z53j+#yIc^D00000NkvXXu0mjf!p|Mj literal 1230 zcmeAS@N?(olHy`uVBq!ia0vp^l0YoN!3HEN%BSrG36!`-lmzFem6RtIr7}3Cer{{GxPyLrY6bkQqisxQ#zd*q`*i1pgH!(Rg4gi&u1T;f0Gc(1?#KqXy$=Te++|t<8(9qS$$;r^o(bC!6)x^Nm zz|qkVrq?AuximL5uLPzy1)CgZF_RMP;xs%m}Z_d)MnB)FXziNZU38x!L2Pa;4mzg|8==`B0Ggmx~n!x(+Qr+sS z6`_^3DtG(O?>4_bW7RaBx(yaGDU9~o_BU)a{WQZ;x@W9NY@EO4zR?U;1BT5R=k8i= z^4YS1fo)y=t~xVU%cq8_(qd%Kl0iD-yh o^I#K)h`XZ0o`yS element: diff --git a/common/test/acceptance/tests/lms/test_lms_problems.py b/common/test/acceptance/tests/lms/test_lms_problems.py index b87c3e1ea8..f3a13a28e0 100644 --- a/common/test/acceptance/tests/lms/test_lms_problems.py +++ b/common/test/acceptance/tests/lms/test_lms_problems.py @@ -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(""" + +

The answer is 1. Partial credit for -1.

+ + + + + +
+ """) + 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())