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 c487efed69..4c0517852a 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 0d7b830540..f1d79daea2 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, @@ -813,11 +826,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): """ @@ -831,25 +856,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)} @@ -996,6 +1149,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() @@ -1010,9 +1171,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 @@ -1082,15 +1254,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} @@ -1351,23 +1587,163 @@ class OptionResponse(LoncapaResponse): def setup_response(self): self.answer_fields = self.inputfields - def get_score(self, student_answers): + def grade_via_points(self, problem_map, student_answers): + """ + Grades dropdown problems with "points"-style partial credit. + Full credit for any fully correct answer. + Partial credit for any partially correct answer. + Amount is set by point_values attribute, defaults to 50%. + Returns a CorrectMap. + """ + + answer_map = problem_map['correct'] cmap = CorrectMap() - amap = self.get_answers() - for aid in amap: - if aid in student_answers and student_answers[aid] == amap[aid]: - cmap.set(aid, 'correct') - else: - cmap.set(aid, 'incorrect') + + for aid in answer_map: + # Set correct/incorrect first, check for partial credit later. + for word in answer_map[aid]: + if aid in student_answers and student_answers[aid] == word: + cmap.set(aid, 'correct') + break + else: + cmap.set(aid, 'incorrect') + + # For partial credit: + partial_map = problem_map['partial'] + points_map = problem_map['point_values'] + + if not cmap.is_correct(aid) and partial_map[aid] is not None: + for index, word in enumerate(partial_map[aid]): + # Set the correctness and point value + # for each answer id independently. + if aid in student_answers and student_answers[aid] == word: + cmap.set(aid, 'partially-correct') + cmap.set_property(aid, 'npoints', points_map[aid][index]) + break + else: + cmap.set(aid, 'incorrect') + answer_variable = self.get_student_answer_variable_name(student_answers, aid) if answer_variable: cmap.set_property(aid, 'answervariable', answer_variable) + return cmap + def grade_without_partial_credit(self, problem_map, student_answers): + """ + Grades dropdown problems without partial credit. + Full credit for any correct answer, no credit otherwise. + Returns a CorrectMap. + """ + + answer_map = problem_map['correct'] + cmap = CorrectMap() + + for aid in answer_map: + for word in answer_map[aid]: + if aid in student_answers and student_answers[aid] == word: + cmap.set(aid, 'correct') + break + else: + cmap.set(aid, 'incorrect') + + answer_variable = self.get_student_answer_variable_name(student_answers, aid) + if answer_variable: + cmap.set_property(aid, 'answervariable', answer_variable) + + return cmap + + def get_score(self, student_answers): + + problem_map = self.get_problem_attributes() + + # If no partial credit, grade it right now. + if not self.has_partial_credit: + return self.grade_without_partial_credit(problem_map, 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 allow credit for listed alternative answers. + + # 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 Dropdown 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]]( + problem_map=problem_map, + student_answers=student_answers + ) + + def get_problem_attributes(self): + """ + This returns a dict built of of three smaller dictionaries. + Keys are: + "correct": + A dictionary with problem ids as keys. + Entries are lists of the correct answers for that id. + "partial": + A dictionary with problem ids as keys. + Entries are lists of the partially-correct answers for that id. + "point_values": + Matches the "partial" one, but gives point values instead. + Defaults to 50% credit. + """ + + default_credit = 0.5 + + problem_map = dict() + + for target in ['correct', 'partial', 'point_values']: + + small_map = dict([ + (af.get('id'), contextualize_text( + af.get(target, default=None), + self.context + )) + for af in self.answer_fields + ]) + + for answer_id in small_map: + if small_map[answer_id] is not None: + # Split on commas and strip whitespace + # to allow for multiple options. + small_map[answer_id] = small_map[answer_id].split(',') + for index, word in enumerate(small_map[answer_id]): + # Pick out whether we're getting numbers or strings. + if target in ['point_values']: + small_map[answer_id][index] = float(word.strip()) + else: + small_map[answer_id][index] = str(word.strip()) + # If we find nothing and we're looking for points, return the default. + elif target == 'point_values': + if problem_map['partial'][answer_id] is not None: + num_partial = len(problem_map['partial'][answer_id]) + small_map[answer_id] = [default_credit] * num_partial + else: + small_map[answer_id] = [] + + # Add a copy of the in-loop map to the big map. + problem_map[target] = dict(small_map) + + return problem_map + def get_answers(self): - amap = dict([(af.get('id'), contextualize_text(af.get( - 'correct'), self.context)) for af in self.answer_fields]) - return amap + """ + Returns a dictionary with problem ids as keys. + Each entry is a list of the correct answers for that id. + """ + return self.get_problem_attributes()['correct'] def get_student_answer_variable_name(self, student_answers, aid): """ @@ -1491,6 +1867,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 @@ -1525,6 +1909,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")) @@ -1546,19 +1954,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): """ @@ -1867,6 +2327,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 @@ -2043,7 +2506,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 @@ -2084,13 +2552,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) @@ -2102,9 +2587,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 @@ -2112,7 +2602,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) + # }, # ... # ] # } @@ -2129,16 +2623,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 @@ -2155,7 +2668,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..619d7c2a02 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 @@ -629,15 +673,25 @@ class OptionResponseXMLFactory(ResponseXMLFactory): *options*: a list of possible options the user can choose from [REQUIRED] You must specify at least 2 options. - *correct_option*: the correct choice from the list of options [REQUIRED] + *correct_option*: a string with comma-separated correct choices [REQUIRED] + *partial_option*: a string with comma-separated partially-correct choices + *point_values*: a string with comma-separated values (0-1) that give the + partial credit values in the "points" grading scheme. + Must have one per partial option. + *credit_type*: String of comma-separated words specifying the + partial credit grading scheme. """ options_list = kwargs.get('options', None) correct_option = kwargs.get('correct_option', None) + partial_option = kwargs.get('partial_option', None) + point_values = kwargs.get('point_values', None) + credit_type = kwargs.get('credit_type', None) assert options_list and correct_option assert len(options_list) > 1 - assert correct_option in options_list + for option in correct_option.split(','): + assert option.strip() in options_list # Create the element optioninput_element = etree.Element("optioninput") @@ -651,6 +705,15 @@ class OptionResponseXMLFactory(ResponseXMLFactory): # Set the "correct" attribute optioninput_element.set('correct', str(correct_option)) + # If we have 'points'-style partial credit... + if 'points' in str(credit_type): + # Set the "partial" attribute + optioninput_element.set('partial', str(partial_option)) + + # Set the "point_values" attribute, if it's specified. + if point_values is not None: + optioninput_element.set('point_values', str(point_values)) + return optioninput_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..9ce056a687 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 @@ -352,6 +409,77 @@ class OptionResponseTest(ResponseTest): # Options not in the list should be marked incorrect self.assert_grade(problem, "invalid_option", "incorrect") + def test_grade_multiple_correct(self): + problem = self.build_problem( + options=["first", "second", "third"], + correct_option="second,third" + ) + + # Assert that we get the expected grades + self.assert_grade(problem, "first", "incorrect") + self.assert_grade(problem, "second", "correct") + self.assert_grade(problem, "third", "correct") + + def test_grade_partial_credit(self): + # Testing the "points" style. + problem = self.build_problem( + options=["first", "second", "third"], + correct_option="second", + credit_type="points", + partial_option="third" + ) + + # Assert that we get the expected grades + self.assert_grade(problem, "first", "incorrect") + self.assert_grade(problem, "second", "correct") + self.assert_grade(problem, "third", "partially-correct") + + def test_grade_partial_credit_with_points(self): + # Testing the "points" style with specified point values. + problem = self.build_problem( + options=["first", "second", "third"], + correct_option="second", + credit_type="points", + partial_option="third", + point_values="0.3" + ) + + # Assert that we get the expected grades and scores + self.assert_grade(problem, "first", "incorrect") + correct_map = problem.grade_answers({'1_2_1': 'first'}) + self.assertAlmostEqual(correct_map.get_npoints('1_2_1'), 0) + + self.assert_grade(problem, "second", "correct") + correct_map = problem.grade_answers({'1_2_1': 'second'}) + self.assertAlmostEqual(correct_map.get_npoints('1_2_1'), 1) + + self.assert_grade(problem, "third", "partially-correct") + correct_map = problem.grade_answers({'1_2_1': 'third'}) + self.assertAlmostEqual(correct_map.get_npoints('1_2_1'), 0.3) + + def test_grade_partial_credit_valid_scheme(self): + # Only one type of partial credit currently allowed. + problem = self.build_problem( + options=["first", "second", "third"], + correct_option="second", + credit_type="points,points", + partial_option="third" + ) + with self.assertRaises(LoncapaProblemError): + input_dict = {'1_2_1': 'second'} + problem.grade_answers(input_dict) + + # 'bongo' is not a valid grading scheme. + problem = self.build_problem( + options=["first", "second", "third"], + correct_option="second", + credit_type="bongo", + partial_option="third" + ) + with self.assertRaises(LoncapaProblemError): + input_dict = {'1_2_1': 'second'} + problem.grade_answers(input_dict) + def test_quote_option(self): # Test that option response properly escapes quotes inside options strings problem = self.build_problem(options=["hasnot", "hasn't", "has'nt"], @@ -383,6 +511,29 @@ class OptionResponseTest(ResponseTest): self.assertEqual(correct_map.get_correctness('1_2_1'), 'correct') self.assertEqual(correct_map.get_property('1_2_1', 'answervariable'), '$a') + def test_variable_options_partial_credit(self): + """ + Test that if variable are given in option response then correct map must contain answervariable value. + This is the partial-credit version. + """ + script = textwrap.dedent("""\ + a = 1000 + b = a*2 + c = a*3 + """) + problem = self.build_problem( + options=['$a', '$b', '$c'], + correct_option='$a', + partial_option='$b', + script=script, + credit_type='points', + ) + + input_dict = {'1_2_1': '2000'} + correct_map = problem.grade_answers(input_dict) + self.assertEqual(correct_map.get_correctness('1_2_1'), 'partially-correct') + self.assertEqual(correct_map.get_property('1_2_1', 'answervariable'), '$b') + class FormulaResponseTest(ResponseTest): """ @@ -1111,6 +1262,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. @@ -1171,7 +1428,7 @@ class NumericalResponseTest(ResponseTest): # For simple things its not worth the effort. def test_grade_range_tolerance(self): problem_setup = [ - # [given_asnwer, [list of correct responses], [list of incorrect responses]] + # [given_answer, [list of correct responses], [list of incorrect responses]] ['[5, 7)', ['5', '6', '6.999'], ['4.999', '7']], ['[1.6e-5, 1.9e24)', ['0.000016', '1.6*10^-5', '1.59e24'], ['1.59e-5', '1.9e24', '1.9*10^24']], ['[0, 1.6e-5]', ['1.6*10^-5'], ["2"]], @@ -1181,6 +1438,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 +1510,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 +1791,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 +1819,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 +1852,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 +1887,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 +1924,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 +1952,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 +1964,20 @@ 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) + 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 +1987,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 +2007,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,39 +2017,51 @@ 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") @@ -1657,6 +2076,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 +2112,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 +2132,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 cf441cb462..e769d48df6 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, "\f00c"); + 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, "\f00c"); + } + // 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; @@ -713,7 +747,7 @@ div.problem { height: 46px; } - > .incorrect, .correct, .unanswered { + > .incorrect, .partially-correct, .correct, .unanswered { .status { display: inline-block; @@ -734,6 +768,18 @@ div.problem { } } + // CASE: partially correct answer + > .partially-correct { + + input { + border: 2px solid $partiallycorrect; + } + + .status { + @include status-icon($partiallycorrect, "\f00c"); + } + } + // CASE: correct answer > .correct { @@ -775,7 +821,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); } } @@ -941,6 +987,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; @@ -1135,6 +1195,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('../images/incorrect-icon.png') left 20px no-repeat; @@ -1340,6 +1408,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 9ac0fd32f7..5ae7de2a14 100644 Binary files a/common/static/images/partially-correct-icon.png and b/common/static/images/partially-correct-icon.png differ diff --git a/common/test/acceptance/pages/lms/problem.py b/common/test/acceptance/pages/lms/problem.py index 5082fcf7b2..7b5f979fe3 100644 --- a/common/test/acceptance/pages/lms/problem.py +++ b/common/test/acceptance/pages/lms/problem.py @@ -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 element: diff --git a/common/test/acceptance/tests/lms/test_lms_problems.py b/common/test/acceptance/tests/lms/test_lms_problems.py index b38d7905ef..ba14a10208 100644 --- a/common/test/acceptance/tests/lms/test_lms_problems.py +++ b/common/test/acceptance/tests/lms/test_lms_problems.py @@ -213,3 +213,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())