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
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';
- selectString += '\n\n';
- return selectString;
+ var selectString = '\n\n',
+ correct, options;
+
+ selectString += ' \n';
+ selectString += '\n\n';
+
+ return selectString;
});
// replace explanations
xml = xml.replace(/\[explanation\]\n?([^\]]*)\[\/?explanation\]/gmi, function(match, p1) {
var selectString = '\n\nExplanation\n\n' + p1 + '\n
\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"