diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index ffd0d2cec0..2720f0e28a 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1710,6 +1710,8 @@ class NumericalResponse(LoncapaResponse): def __init__(self, *args, **kwargs): self.correct_answer = '' + self.additional_answers = [] + self.additional_answer_index = -1 self.tolerance = default_tolerance self.range_tolerance = False self.answer_range = self.inclusion = None @@ -1720,6 +1722,10 @@ class NumericalResponse(LoncapaResponse): context = self.context answer = xml.get('answer') + self.additional_answers = ( + [element.get('answer') for element in xml.findall('additional_answer')] + ) + if answer.startswith(('[', '(')) and answer.endswith((']', ')')): # range tolerance case self.range_tolerance = True self.inclusion = ( @@ -1930,6 +1936,20 @@ class NumericalResponse(LoncapaResponse): if compare_with_tolerance(student_float, correct_float, expanded_tolerance): is_correct = 'partially-correct' + # Reset self.additional_answer_index to -1 so that we always have a fresh index to look up. + self.additional_answer_index = -1 + + # Compare with additional answers. + if is_correct == 'incorrect': + temp_additional_answer_idx = 0 + for additional_answer in self.additional_answers: + staff_answer = self.get_staff_ans(additional_answer) + if complex(student_float) == staff_answer: + is_correct = 'correct' + self.additional_answer_index = temp_additional_answer_idx + break + temp_additional_answer_idx += 1 + if is_correct == 'partially-correct': return CorrectMap(self.answer_id, is_correct, npoints=partial_score) else: @@ -1957,7 +1977,38 @@ class NumericalResponse(LoncapaResponse): return False def get_answers(self): - return {self.answer_id: self.correct_answer} + _ = self.capa_system.i18n.ugettext + # Example: "Answer: Answer_1 or Answer_2 or Answer_3". + separator = Text(' {b_start}{or_separator}{b_end} ').format( + # Translators: Separator used in NumericalResponse to display multiple answers. + or_separator=_('or'), + b_start=HTML(''), + b_end=HTML(''), + ) + return {self.answer_id: separator.join([self.correct_answer] + self.additional_answers)} + + def set_cmap_msg(self, student_answers, new_cmap, hint_type, hint_index): + """ + Sets feedback to correct hint node in correct map. + + Arguments: + student_answers (dict): Dict containing student input. + new_cmap (dict): Dict containing correct map properties. + hint_type (str): Hint type, either `correcthint` or `additional_answer` + hint_index (int): Index of the hint node + """ + # Note: using self.id here, not the more typical self.answer_id + hint_nodes = self.xml.xpath('//numericalresponse[@id=$id]/' + hint_type, id=self.id) + if hint_nodes: + hint_node = hint_nodes[hint_index] + if hint_type == 'additional_answer': + hint_node = hint_nodes[hint_index].find('./correcthint') + new_cmap[self.answer_id]['msg'] += self.make_hint_div( + hint_node, + True, + [student_answers[self.answer_id]], + self.tags[0] + ) def get_extended_hints(self, student_answers, new_cmap): """ @@ -1966,16 +2017,11 @@ class NumericalResponse(LoncapaResponse): """ if self.answer_id in student_answers: if new_cmap.cmap[self.answer_id]['correctness'] == 'correct': # if the grader liked the student's answer - # Note: using self.id here, not the more typical self.answer_id - hints = self.xml.xpath('//numericalresponse[@id=$id]/correcthint', id=self.id) - if hints: - hint_node = hints[0] - new_cmap[self.answer_id]['msg'] += self.make_hint_div( - hint_node, - True, - [student_answers[self.answer_id]], - self.tags[0] - ) + # Answer is not an additional answer. + if self.additional_answer_index == -1: + self.set_cmap_msg(student_answers, new_cmap, 'correcthint', 0) + else: + self.set_cmap_msg(student_answers, new_cmap, 'additional_answer', self.additional_answer_index) #----------------------------------------------------------------------------- diff --git a/common/lib/capa/capa/tests/response_xml_factory.py b/common/lib/capa/capa/tests/response_xml_factory.py index 9ded588c6c..076cba3315 100644 --- a/common/lib/capa/capa/tests/response_xml_factory.py +++ b/common/lib/capa/capa/tests/response_xml_factory.py @@ -204,6 +204,10 @@ class NumericalResponseXMLFactory(ResponseXMLFactory): *answer*: The correct answer (e.g. "5") + *correcthint*: The feedback describing correct answer. + + *additional_answers*: A dict of additional answers along with their correcthint. + *tolerance*: The tolerance within which a response is considered correct. Can be a decimal (e.g. "0.01") or percentage (e.g. "2%") @@ -219,6 +223,8 @@ class NumericalResponseXMLFactory(ResponseXMLFactory): """ answer = kwargs.get('answer', None) + correcthint = kwargs.get('correcthint', '') + additional_answers = kwargs.get('additional_answers', {}) tolerance = kwargs.get('tolerance', None) credit_type = kwargs.get('credit_type', None) partial_range = kwargs.get('partial_range', None) @@ -232,6 +238,13 @@ class NumericalResponseXMLFactory(ResponseXMLFactory): else: response_element.set('answer', str(answer)) + for additional_answer, additional_correcthint in additional_answers.items(): + additional_element = etree.SubElement(response_element, 'additional_answer') + additional_element.set('answer', str(additional_answer)) + if additional_correcthint: + correcthint_element = etree.SubElement(additional_element, 'correcthint') + correcthint_element.text = str(additional_correcthint) + if tolerance: responseparam_element = etree.SubElement(response_element, 'responseparam') responseparam_element.set('type', 'tolerance') @@ -244,6 +257,10 @@ class NumericalResponseXMLFactory(ResponseXMLFactory): responseparam_element = etree.SubElement(response_element, 'responseparam') responseparam_element.set('partial_answers', partial_answers) + if correcthint: + correcthint_element = etree.SubElement(response_element, 'correcthint') + correcthint_element.text = str(correcthint) + return response_element def create_input_element(self, **kwargs): @@ -732,7 +749,7 @@ class StringResponseXMLFactory(ResponseXMLFactory): *regexp*: Whether the response is regexp - *additional_answers*: list of additional asnwers. + *additional_answers*: list of additional answers. *non_attribute_answers*: list of additional answers to be coded in the non-attribute format diff --git a/common/lib/capa/capa/tests/test_files/extended_hints_numeric_input.xml b/common/lib/capa/capa/tests/test_files/extended_hints_numeric_input.xml index 6c074b0266..4c88c9ff3d 100644 --- a/common/lib/capa/capa/tests/test_files/extended_hints_numeric_input.xml +++ b/common/lib/capa/capa/tests/test_files/extended_hints_numeric_input.xml @@ -3,6 +3,9 @@ What value when squared is approximately equal to 2 (give your answer to 2 decimal places)? + + This is an additional hint. + The square root of two turns up in the strangest places. diff --git a/common/lib/capa/capa/tests/test_hint_functionality.py b/common/lib/capa/capa/tests/test_hint_functionality.py index d890193a5a..5aaff6a2ed 100644 --- a/common/lib/capa/capa/tests/test_hint_functionality.py +++ b/common/lib/capa/capa/tests/test_hint_functionality.py @@ -236,6 +236,9 @@ class NumericInputHintsTest(HintTest): @data( {'problem_id': u'1_2_1', 'choice': '1.141', 'expected_string': u'AnswerNice The square root of two turns up in the strangest places.'}, + # additional answer + {'problem_id': u'1_2_1', 'choice': '10', + 'expected_string': u'AnswerCorrect: This is an additional hint.'}, {'problem_id': u'1_3_1', 'choice': '4', 'expected_string': u'AnswerCorrect: Pretty easy, uh?.'}, # should get hint, when correct via numeric-tolerance diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py index 0cf4cb8473..1f92b6f915 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -1390,7 +1390,7 @@ class NumericalResponseTest(ResponseTest): # pylint: disable=missing-docstring # 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"]], @@ -1400,6 +1400,54 @@ class NumericalResponseTest(ResponseTest): # pylint: disable=missing-docstring problem = self.build_problem(answer=given_answer) self.assert_multiple_grade(problem, correct_responses, incorrect_responses) + def test_additional_answer_grading(self): + """ + Test additional answers are graded correct with their associated correcthint. + """ + primary_answer = '100' + primary_correcthint = 'primary feedback' + additional_answers = { + '1': '1. additional feedback', + '2': '2. additional feedback', + '4': '4. additional feedback', + '5': '' + } + problem = self.build_problem( + answer=primary_answer, + additional_answers=additional_answers, + correcthint=primary_correcthint + ) + + # Assert primary answer is graded correctly. + correct_map = problem.grade_answers({'1_2_1': primary_answer}) + self.assertEqual(correct_map.get_correctness('1_2_1'), 'correct') + self.assertIn(primary_correcthint, correct_map.get_msg('1_2_1')) + + # Assert additional answers are graded correct + for answer, correcthint in additional_answers.items(): + correct_map = problem.grade_answers({'1_2_1': answer}) + self.assertEqual(correct_map.get_correctness('1_2_1'), 'correct') + self.assertIn(correcthint, correct_map.get_msg('1_2_1')) + + def test_additional_answer_get_score(self): + """ + Test `get_score` is working for additional answers. + """ + problem = self.build_problem(answer='100', additional_answers={'1': ''}) + responder = problem.responders.values()[0] + + # Check primary answer. + new_cmap = responder.get_score({'1_2_1': '100'}) + self.assertEqual(new_cmap.get_correctness('1_2_1'), 'correct') + + # Check additional answer. + new_cmap = responder.get_score({'1_2_1': '1'}) + self.assertEqual(new_cmap.get_correctness('1_2_1'), 'correct') + + # Check any wrong answer. + new_cmap = responder.get_score({'1_2_1': '2'}) + self.assertEqual(new_cmap.get_correctness('1_2_1'), 'incorrect') + def test_grade_range_tolerance_partial_credit(self): problem_setup = [ # [given_answer, diff --git a/common/lib/xmodule/xmodule/js/spec/problem/edit_spec.coffee b/common/lib/xmodule/xmodule/js/spec/problem/edit_spec.coffee index 37c641494a..04e7044c7d 100644 --- a/common/lib/xmodule/xmodule/js/spec/problem/edit_spec.coffee +++ b/common/lib/xmodule/xmodule/js/spec/problem/edit_spec.coffee @@ -187,21 +187,80 @@ describe 'MarkdownEditingDescriptor', -> """) - it 'markup with multiple answers doesn\'t break numerical response', -> + it 'markup with additional answer does not break numerical response', -> data = MarkdownEditingDescriptor.markdownToXml(""" Enter 1 with a tolerance: = 1 +- .02 - or= 2 +- 5% + or= 2 """) expect(data).toXMLEqual(""" Enter 1 with a tolerance: - + + + """ + ) + it 'markup for numerical with multiple additional answers renders correctly', -> + data = MarkdownEditingDescriptor.markdownToXml(""" + Enter 1 with a tolerance: + = 1 +- .02 + or= 2 + or= 3 + """) + expect(data).toXMLEqual(""" + + Enter 1 with a tolerance: + + + + + - """) + """ + ) + it 'Do not render ranged/tolerance/alphabetical additional answers for numerical response', -> + data = MarkdownEditingDescriptor.markdownToXml(""" + Enter 1 with a tolerance: + = 1 +- .02 + or= 2 + or= 3 +- 0.1 + or= [4,6] + or= ABC + or= 7 + """) + expect(data).toXMLEqual(""" + + Enter 1 with a tolerance: + + + + + + + """ + ) + it 'markup with feedback renders correctly in additional answer for numerical response', -> + data = MarkdownEditingDescriptor.markdownToXml(""" + Enter 1 with a tolerance: + = 100 +- .02 {{ main feedback }} + or= 10 {{ additional feedback }} + """) + expect(data).toXMLEqual(""" + + Enter 1 with a tolerance: + + + additional feedback + + + main feedback + + + """ + ) it 'converts multiple choice to xml', -> data = MarkdownEditingDescriptor.markdownToXml("""A multiple choice problem presents radio buttons for student input. Students can only select a single option presented. Multiple Choice questions have been the subject of many areas of research due to the early invention and adoption of bubble sheets. diff --git a/common/lib/xmodule/xmodule/js/src/problem/edit.js b/common/lib/xmodule/xmodule/js/src/problem/edit.js index 9c4c9cf082..b0d6922596 100644 --- a/common/lib/xmodule/xmodule/js/src/problem/edit.js +++ b/common/lib/xmodule/xmodule/js/src/problem/edit.js @@ -567,50 +567,108 @@ // Line split here, trim off leading xxx= in each function var answersList = p.split('\n'), - processNumericalResponse = function(val) { - var params, answer, string, textHint, hintLine, value; - // Numeric case is just a plain leading = with a single answer - value = val.replace(/^\=\s*/, ''); + isRangeToleranceCase = function(answer) { + return _.contains( + ['[', '('], answer[0]) && _.contains([']', ')'], answer[answer.length - 1] + ); + }, - textHint = extractHint(value); - hintLine = ''; - if (textHint.hint) { - value = textHint.nothint; - hintLine = ' ' + textHint.hint + - '\n'; + getAnswerData = function(answerValue) { + var answerData = {}, + answerParams = /(.*?)\+\-\s*(.*?$)/.exec(answerValue); + if (answerParams) { + answerData.answer = answerParams[1].replace(/\s+/g, ''); // inputs like 5*2 +- 10 + answerData.default = answerParams[2]; + } else { + answerData.answer = answerValue.replace(/\s+/g, ''); // inputs like 5*2 } + return answerData; + }, - if (_.contains(['[', '('], value[0]) && _.contains([']', ')'], value[value.length - 1])) { - // [5, 7) or (5, 7), or (1.2345 * (2+3), 7*4 ] - range tolerance case - // = (5*2)*3 should not be used as range tolerance - string = '\n'; - string += ' \n'; - string += hintLine; - string += '\n\n'; - return string; - } + processNumericalResponse = function(answerValues) { + var firstAnswer, answerData, numericalResponseString, additionalAnswerString, + textHint, hintLine, additionalTextHint, additionalHintLine, orMatch, hasTolerance; - if (isNaN(parseFloat(value))) { + // First string case is s?= [e.g. = 100] + firstAnswer = answerValues[0].replace(/^\=\s*/, ''); + + // If answer is not numerical + if (isNaN(parseFloat(firstAnswer)) && !isRangeToleranceCase(firstAnswer)) { return false; } - // Tries to extract parameters from string like 'expr +- tolerance' - params = /(.*?)\+\-\s*(.*?$)/.exec(value); - - if (params) { - answer = params[1].replace(/\s+/g, ''); // support inputs like 5*2 +- 10 - string = '\n'; - string += ' \n'; - } else { - answer = value.replace(/\s+/g, ''); // support inputs like 5*2 - string = '\n'; + textHint = extractHint(firstAnswer); + hintLine = ''; + if (textHint.hint) { + firstAnswer = textHint.nothint; + // safe-lint: disable=javascript-concat-html + hintLine = ' ' + + // safe-lint: disable=javascript-concat-html + textHint.hint + '\n'; } - string += ' \n'; - string += hintLine; - string += '\n\n'; + // Range case + if (isRangeToleranceCase(firstAnswer)) { + // [5, 7) or (5, 7), or (1.2345 * (2+3), 7*4 ] - range tolerance case + // = (5*2)*3 should not be used as range tolerance + // safe-lint: disable=javascript-concat-html + numericalResponseString = '\n'; + } else { + answerData = getAnswerData(firstAnswer); + // safe-lint: disable=javascript-concat-html + numericalResponseString = '\n'; + if (answerData.default) { + // safe-lint: disable=javascript-concat-html + numericalResponseString += ' \n'; + } + } - return string; + // Additional answer case or= [e.g. or= 10] + // Since answerValues[0] is firstAnswer, so we will not include this in additional answers. + additionalAnswerString = ''; + for (i = 1; i < answerValues.length; i++) { + additionalHintLine = ''; + additionalTextHint = extractHint(answerValues[i]); + orMatch = /^or\=\s*(.*)/.exec(additionalTextHint.nothint); + if (orMatch) { + hasTolerance = /(.*?)\+\-\s*(.*?$)/.exec(orMatch[1]); + // Do not add additional_answer if additional answer is not numerical (eg. or= ABC) + // or contains range tolerance case (eg. or= (5,7) + // or has tolerance (eg. or= 10 +- 0.02) + if (isNaN(parseFloat(orMatch[1])) || + isRangeToleranceCase(orMatch[1]) || + hasTolerance) { + continue; + } + + if (additionalTextHint.hint) { + // safe-lint: disable=javascript-concat-html + additionalHintLine = '' + + // safe-lint: disable=javascript-concat-html + additionalTextHint.hint + ''; + } + + // safe-lint: disable=javascript-concat-html + additionalAnswerString += ' '; + additionalAnswerString += additionalHintLine; + additionalAnswerString += '\n'; + } + } + + // Add additional answers string to numerical problem string. + if (additionalAnswerString) { + numericalResponseString += additionalAnswerString; + } + + numericalResponseString += ' \n'; + numericalResponseString += hintLine; + numericalResponseString += '\n\n'; + + return numericalResponseString; }, processStringResponse = function(values) { @@ -657,7 +715,7 @@ return string; }; - return processNumericalResponse(answersList[0]) || processStringResponse(answersList); + return processNumericalResponse(answersList) || processStringResponse(answersList); });
Enter 1 with a tolerance: