diff --git a/CHANGELOG.rst b/CHANGELOG.rst index efa70ee8b3..4207c376c1 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. +Blades: Give numerical response tolerance as a range. BLD-25. + Blades: Allow user with BetaTester role correctly use LTI. BLD-641. Blades: Video player persist speed preferences between videos. BLD-237. diff --git a/cms/templates/widgets/problem-edit.html b/cms/templates/widgets/problem-edit.html index e5942828de..fbc0184022 100644 --- a/cms/templates/widgets/problem-edit.html +++ b/cms/templates/widgets/problem-edit.html @@ -87,6 +87,7 @@ or= mouse
= 3.14 +- 2%
+
= [3.14, 3.15)
diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index f1ead37cf9..56f8a78c3f 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -26,6 +26,8 @@ import subprocess import textwrap import traceback import xml.sax.saxutils as saxutils +from cmath import isnan +from sys import float_info from collections import namedtuple from shapely.geometry import Point, MultiPoint @@ -36,8 +38,10 @@ from . import correctmap from .registry import TagRegistry from datetime import datetime from pytz import UTC -from .util import (compare_with_tolerance, contextualize_text, convert_files_to_filenames, - is_list_of_files, find_with_default, default_tolerance) +from .util import ( + compare_with_tolerance, contextualize_text, convert_files_to_filenames, + is_list_of_files, find_with_default, default_tolerance +) from lxml import etree from lxml.html.soupparser import fromstring as fromstring_bs # uses Beautiful Soup!!! FIXME? import capa.xqueue_interface as xqueue_interface @@ -846,39 +850,57 @@ class NumericalResponse(LoncapaResponse): def __init__(self, *args, **kwargs): self.correct_answer = '' self.tolerance = default_tolerance + self.range_tolerance = False + self.answer_range = self.inclusion = None super(NumericalResponse, self).__init__(*args, **kwargs) def setup_response(self): xml = self.xml context = self.context - self.correct_answer = contextualize_text(xml.get('answer'), context) + answer = xml.get('answer') - # Find the tolerance - tolerance_xml = xml.xpath( - '//*[@id=$id]//responseparam[@type="tolerance"]/@default', - id=xml.get('id') - ) - if tolerance_xml: # If it isn't an empty list... - self.tolerance = contextualize_text(tolerance_xml[0], context) + if answer.startswith(('[', '(')) and answer.endswith((']', ')')): # range tolerance case + self.range_tolerance = True + self.inclusion = ( + True if answer.startswith('[') else False, True if answer.endswith(']') else False + ) + try: + self.answer_range = [contextualize_text(x, context) for x in answer[1:-1].split(',')] + self.correct_answer = answer[0] + self.answer_range[0] + ', ' + self.answer_range[1] + answer[-1] + except Exception: + log.debug("Content error--answer '%s' is not a valid range tolerance answer", answer) + _ = self.capa_system.i18n.ugettext + raise StudentInputError( + _("There was a problem with the staff answer to this problem.") + ) + else: + self.correct_answer = contextualize_text(answer, context) - def get_staff_ans(self): + # Find the tolerance + tolerance_xml = xml.xpath( + '//*[@id=$id]//responseparam[@type="tolerance"]/@default', + id=xml.get('id') + ) + if tolerance_xml: # If it isn't an empty list... + self.tolerance = contextualize_text(tolerance_xml[0], context) + + def get_staff_ans(self, answer): """ Given the staff answer as a string, find its float value. Use `evaluator` for this, but for backward compatability, try the built-in method `complex` (which used to be the standard). """ - try: - correct_ans = complex(self.correct_answer) + correct_ans = complex(answer) except ValueError: # When `correct_answer` is not of the form X+Yj, it raises a # `ValueError`. Then test if instead it is a math expression. # `complex` seems to only generate `ValueErrors`, only catch these. try: - correct_ans = evaluator({}, {}, self.correct_answer) + correct_ans = evaluator({}, {}, answer) except Exception: - log.debug("Content error--answer '%s' is not a valid number", self.correct_answer) + log.debug("Content error--answer '%s' is not a valid number", answer) _ = self.capa_system.i18n.ugettext raise StudentInputError( _("There was a problem with the staff answer to this problem.") @@ -887,11 +909,11 @@ class NumericalResponse(LoncapaResponse): return correct_ans def get_score(self, student_answers): - """Grade a numeric response""" + """ + Grade a numeric response. + """ student_answer = student_answers[self.answer_id] - correct_float = self.get_staff_ans() - _ = self.capa_system.i18n.ugettext general_exception = StudentInputError( _(u"Could not interpret '{student_answer}' as a number.").format(student_answer=cgi.escape(student_answer)) @@ -924,10 +946,34 @@ class NumericalResponse(LoncapaResponse): except Exception: raise general_exception # End `evaluator` block -- we figured out the student's answer! - - correct = compare_with_tolerance( - student_float, correct_float, self.tolerance - ) + if self.range_tolerance: + if isinstance(student_float, complex): + raise StudentInputError(_(u"You may not use complex numbers in range tolerance problems")) + if isnan(student_float): + raise general_exception + boundaries = [] + for inclusion, answer in zip(self.inclusion, self.answer_range): + boundary = self.get_staff_ans(answer) + if boundary.imag != 0: + raise StudentInputError(_("There was a problem with the staff answer to this problem: complex boundary.")) + if isnan(boundary): + raise StudentInputError(_("There was a problem with the staff answer to this problem: empty boundary.")) + boundaries.append(boundary.real) + if compare_with_tolerance( + student_float, + boundary, + tolerance=float_info.epsilon, + relative_tolerance=True + ): + correct = inclusion + break + else: + correct = boundaries[0] < student_float < boundaries[1] + 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') else: @@ -1062,7 +1108,7 @@ class StringResponse(LoncapaResponse): if self.regexp: # regexp match flags = re.IGNORECASE if self.case_insensitive else 0 try: - regexp = re.compile('^'+ '|'.join(expected) + '$', flags=flags | re.UNICODE) + regexp = re.compile('^' + '|'.join(expected) + '$', flags=flags | re.UNICODE) result = re.search(regexp, given) except Exception as err: msg = '[courseware.capa.responsetypes.stringresponse] error: {}'.format(err.message) @@ -1075,7 +1121,6 @@ class StringResponse(LoncapaResponse): else: return given in expected - def check_hint_condition(self, hxml_set, student_answers): given = student_answers[self.answer_id].strip() hints_to_show = [] diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py index 2d27ee39b8..adba7f1faa 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -1061,6 +1061,48 @@ class NumericalResponseTest(ResponseTest): # We blend the line between integration (using evaluator) and exclusively # unit testing the NumericalResponse (mocking out the evaluator) # 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]] + ['[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"]], + ['(1.6e-5, 10]', ["2"], ['1.6*10^-5']], + ] + for given_answer, correct_responses, incorrect_responses in problem_setup: + problem = self.build_problem(answer=given_answer) + self.assert_multiple_grade(problem, correct_responses, incorrect_responses) + + def test_grade_range_tolerance_exceptions(self): + # no complex number in range tolerance staff answer + problem = self.build_problem(answer='[1j, 5]') + input_dict = {'1_2_1': '3'} + with self.assertRaises(StudentInputError): + problem.grade_answers(input_dict) + + # no complex numbers in student ansers to range tolerance problems + problem = self.build_problem(answer='(1, 5)') + input_dict = {'1_2_1': '1*J'} + with self.assertRaises(StudentInputError): + problem.grade_answers(input_dict) + + # test isnan variable + problem = self.build_problem(answer='(1, 5)') + input_dict = {'1_2_1': ''} + with self.assertRaises(StudentInputError): + problem.grade_answers(input_dict) + + # test invalid range tolerance answer + with self.assertRaises(StudentInputError): + problem = self.build_problem(answer='(1 5)') + + # test empty boundaries + problem = self.build_problem(answer='(1, ]') + input_dict = {'1_2_1': '3'} + with self.assertRaises(StudentInputError): + problem.grade_answers(input_dict) + def test_grade_exact(self): problem = self.build_problem(answer=4) correct_responses = ["4", "4.0", "4.00"] @@ -1084,17 +1126,17 @@ class NumericalResponseTest(ResponseTest): Default tolerance for all responsetypes is 1e-3%. """ problem_setup = [ - #[given_asnwer, [list of correct responses], [list of incorrect responses]] - [1, ["1"], ["1.1"],], - [2.0, ["2.0"], ["1.0"],], - [4, ["4.0", "4.00004"], ["4.00005"]], + # [given_answer, [list of correct responses], [list of incorrect responses]] + [1, ["1"], ["1.1"]], + [2.0, ["2.0"], ["1.0"]], + [4, ["4.0", "4.00004"], ["4.00005"]], [0.00016, ["1.6*10^-4"], [""]], [0.000016, ["1.6*10^-5"], ["0.000165"]], [1.9e24, ["1.9*10^24"], ["1.9001*10^24"]], [2e-15, ["2*10^-15"], [""]], [3141592653589793238., ["3141592653589793115."], [""]], - [0.1234567, ["0.123456", "0.1234561"], ["0.123451"]], - [1e-5, ["1e-5", "1.0e-5"], ["-1e-5", "2*1e-5"]], + [0.1234567, ["0.123456", "0.1234561"], ["0.123451"]], + [1e-5, ["1e-5", "1.0e-5"], ["-1e-5", "2*1e-5"]], ] for given_answer, correct_responses, incorrect_responses in problem_setup: problem = self.build_problem(answer=given_answer) diff --git a/common/lib/capa/capa/util.py b/common/lib/capa/capa/util.py index 33e751da8b..c4f10ccb0e 100644 --- a/common/lib/capa/capa/util.py +++ b/common/lib/capa/capa/util.py @@ -7,19 +7,21 @@ from cmath import isinf default_tolerance = '0.001%' -def compare_with_tolerance(v1, v2, tol=default_tolerance): +def compare_with_tolerance(complex1, complex2, tolerance=default_tolerance, relative_tolerance=False): """ - Compare v1 to v2 with maximum tolerance tol. + Compare complex1 to complex2 with maximum tolerance tol. - tol is relative if it ends in %; otherwise, it is absolute. + If tolerance is type string, then it is counted as relative if it ends in %; otherwise, it is absolute. - - v1 : student result (float complex number) - - v2 : instructor result (float complex number) - - tol : tolerance (string representing a number) + - complex1 : student result (float complex number) + - complex2 : instructor result (float complex number) + - tolerance : string representing a number or float + - relative_tolerance: bool, used when`tolerance` is float to explicitly use passed tolerance as relative. - Default tolerance of 1e-3% is added to compare two floats for near-equality - (to handle machine representation errors). - It is relative, as the acceptable difference between two floats depends on the magnitude of the floats. + Default tolerance of 1e-3% is added to compare two floats for + near-equality (to handle machine representation errors). + Default tolerance is relative, as the acceptable difference between two + floats depends on the magnitude of the floats. (http://randomascii.wordpress.com/2012/02/25/comparing-floating-point-numbers-2012-edition/) Examples: In [183]: 0.000016 - 1.6*10**-5 @@ -27,22 +29,23 @@ def compare_with_tolerance(v1, v2, tol=default_tolerance): In [212]: 1.9e24 - 1.9*10**24 Out[212]: 268435456.0 """ - relative = tol.endswith('%') - if relative: - tolerance_rel = evaluator(dict(), dict(), tol[:-1]) * 0.01 - tolerance = tolerance_rel * max(abs(v1), abs(v2)) + if relative_tolerance: + tolerance = tolerance * max(abs(complex1), abs(complex2)) + elif tolerance.endswith('%'): + tolerance = evaluator(dict(), dict(), tolerance[:-1]) * 0.01 + tolerance = tolerance * max(abs(complex1), abs(complex2)) else: - tolerance = evaluator(dict(), dict(), tol) + tolerance = evaluator(dict(), dict(), tolerance) - if isinf(v1) or isinf(v2): - # If an input is infinite, we can end up with `abs(v1-v2)` and + if isinf(complex1) or isinf(complex2): + # If an input is infinite, we can end up with `abs(complex1-complex2)` and # `tolerance` both equal to infinity. Then, below we would have # `inf <= inf` which is a fail. Instead, compare directly. - return v1 == v2 + return complex1 == complex2 else: # v1 and v2 are, in general, complex numbers: # there are some notes about backward compatibility issue: see responsetypes.get_staff_ans()). - return abs(v1 - v2) <= tolerance + return abs(complex1 - complex2) <= tolerance def contextualize_text(text, context): # private 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 a1a6c7bd79..eaf28daba0 100644 --- a/common/lib/xmodule/xmodule/js/spec/problem/edit_spec.coffee +++ b/common/lib/xmodule/xmodule/js/spec/problem/edit_spec.coffee @@ -105,6 +105,14 @@ describe 'MarkdownEditingDescriptor', -> Enter the number of fingers on a human hand: = 5 + Range tolerance case + = [6, 7] + = (1, 2) + + If first and last symbols are not brackets, or they are not closed, stringresponse will appear. + = (7), 7 + = (1+2 + [Explanation] Pi, or the the ratio between a circle's circumference to its diameter, is an irrational number known to extreme precision. It is value is approximately equal to 3.14. @@ -135,6 +143,22 @@ describe 'MarkdownEditingDescriptor', -> +

Range tolerance case

+ + + + + + + +

If first and last symbols are not brackets, or they are not closed, stringresponse will appear.

+ + + + + + +

Explanation

diff --git a/common/lib/xmodule/xmodule/js/src/problem/edit.coffee b/common/lib/xmodule/xmodule/js/src/problem/edit.coffee index a1c008d5ca..f757469436 100644 --- a/common/lib/xmodule/xmodule/js/src/problem/edit.coffee +++ b/common/lib/xmodule/xmodule/js/src/problem/edit.coffee @@ -186,124 +186,175 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor #} # @markdownToXml: (markdown)-> - toXml = `function(markdown) { - var xml = markdown; + toXml = `function (markdown) { + var xml = markdown, + i, splits, scriptFlag; // replace headers xml = xml.replace(/(^.*?$)(?=\n\=\=+$)/gm, '

$1

'); xml = xml.replace(/\n^\=\=+$/gm, ''); // group multiple choice answers - xml = xml.replace(/(^\s*\(.?\).*?$\n*)+/gm, function(match, p) { - var groupString = '\n'; - groupString += ' \n'; - var options = match.split('\n'); - for(var i = 0; i < options.length; i++) { - if(options[i].length > 0) { - var value = options[i].split(/^\s*\(.?\)\s*/)[1]; - var correct = /^\s*\(x\)/i.test(options[i]); - groupString += ' ' + value + '\n'; + xml = xml.replace(/(^\s*\(.?\).*?$\n*)+/gm, function (match) { + var groupString = '\n', + value, correct, options; + + groupString += ' \n'; + options = match.split('\n'); + + for (i = 0; i < options.length; i += 1) { + if(options[i].length > 0) { + value = options[i].split(/^\s*\(.?\)\s*/)[1]; + correct = /^\s*\(x\)/i.test(options[i]); + groupString += ' ' + value + '\n'; + } } - } - groupString += ' \n'; - groupString += '\n\n'; - return groupString; + + groupString += ' \n'; + groupString += '\n\n'; + + return groupString; }); // group check answers - xml = xml.replace(/(^\s*\[.?\].*?$\n*)+/gm, function(match, p) { - var groupString = '\n'; - groupString += ' \n'; - var options = match.split('\n'); - for(var i = 0; i < options.length; i++) { - if(options[i].length > 0) { - var value = options[i].split(/^\s*\[.?\]\s*/)[1]; - var correct = /^\s*\[x\]/i.test(options[i]); - groupString += ' ' + value + '\n'; + xml = xml.replace(/(^\s*\[.?\].*?$\n*)+/gm, function(match) { + var groupString = '\n', + options, value, correct; + + groupString += ' \n'; + options = match.split('\n'); + + for (i = 0; i < options.length; i += 1) { + if(options[i].length > 0) { + value = options[i].split(/^\s*\[.?\]\s*/)[1]; + correct = /^\s*\[x\]/i.test(options[i]); + groupString += ' ' + value + '\n'; + } } - } - groupString += ' \n'; - groupString += '\n\n'; - return groupString; + + groupString += ' \n'; + groupString += '\n\n'; + + return groupString; }); // replace string and numerical xml = xml.replace(/(^\=\s*(.*?$)(\n*or\=\s*(.*?$))*)+/gm, function(match, p) { - var string, - answersList = p.replace(/^(or)?=\s*/gm, '').split('\n'), - floatValue = parseFloat(answersList[0]); + // Split answers + var answersList = p.replace(/^(or)?=\s*/gm, '').split('\n'), - if(!isNaN(floatValue)) { - // Tries to extract parameters from string like 'expr +- tolerance' - var params = /(.*?)\+\-\s*(.*?$)/.exec(answersList[0]), - answer = answersList[0].replace(/\s+/g, ''); - if(params) { - answer = params[1].replace(/\s+/g, ''); - string = '\n'; - string += ' \n'; - } else { - string = '\n'; - } - string += ' \n'; - string += '\n\n'; - } else { - var firstAnswer = answersList.shift(); - if (firstAnswer[0] === '|') { // this is regexp case - string = '\n' - } - else { - string = '\n' - } - for(var i = 0; i < answersList.length; i++) { - string += ' ' + answersList[i] + '\n' - } - string += ' \n\n\n'; - } - return string; - }); + processNumericalResponse = function (value) { + var params, answer, string; + + 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 += '\n\n'; + return string; + } + + if (isNaN(parseFloat(value))) { + 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'; + } + + string += ' \n'; + string += '\n\n'; + + return string; + }, + + processStringResponse = function (values) { + var firstAnswer = values.shift(), string; + + if (firstAnswer[0] === '|') { // this is regexp case + string = '\n'; + } else { + string = '\n'; + } + + for (i = 0; i < values.length; i += 1) { + string += ' ' + values[i] + '\n'; + } + + string += ' \n\n\n'; + + return string; + }; + + return processNumericalResponse(answersList[0]) || processStringResponse(answersList); + }); // replace selects xml = xml.replace(/\[\[(.+?)\]\]/g, function(match, p) { - var selectString = '\n\n'; - selectString += ' \n'; + return selectString; }); // replace code blocks xml = xml.replace(/\[code\]\n?([^\]]*)\[\/?code\]/gmi, function(match, p1) { var selectString = '
\n' + p1 + '
'; + return selectString; }); // split scripts and preformatted sections, and wrap paragraphs - var splits = xml.split(/(\<\/?(?:script|pre).*?\>)/g); - var scriptFlag = false; - for(var i = 0; i < splits.length; i++) { - if(/\<(script|pre)/.test(splits[i])) { - scriptFlag = true; - } - if(!scriptFlag) { - splits[i] = splits[i].replace(/(^(?!\s*\<|$).*$)/gm, '

$1

'); - } - if(/\<\/(script|pre)/.test(splits[i])) { - scriptFlag = false; - } + splits = xml.split(/(\<\/?(?:script|pre).*?\>)/g); + scriptFlag = false; + + for (i = 0; i < splits.length; i += 1) { + if(/\<(script|pre)/.test(splits[i])) { + scriptFlag = true; + } + + if(!scriptFlag) { + splits[i] = splits[i].replace(/(^(?!\s*\<|$).*$)/gm, '

$1

'); + } + + if(/\<\/(script|pre)/.test(splits[i])) { + scriptFlag = false; + } } + xml = splits.join(''); // rid white space @@ -313,7 +364,6 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor xml = '\n' + xml + '\n'; return xml; - } - ` + }` return toXml markdown diff --git a/rakelib/docs.rake b/rakelib/docs.rake index d1ddb1d7c2..8732b1c4b4 100644 --- a/rakelib/docs.rake +++ b/rakelib/docs.rake @@ -23,7 +23,7 @@ task :builddocs, [:type, :quiet] do |t, args| end end -desc "Show docs in browser (mac and ubuntu)." +desc "Show docs in browser: dev, author, data." task :showdocs, [:options] do |t, args| if args.options == 'dev' path = "docs/en_us/developers"