From 9897157327c209a0a38e107ab9e15a0b165f2d6d Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Tue, 4 Jul 2017 15:52:25 -0500 Subject: [PATCH 1/9] Fix: Provide a more functional, usable fill-in-the-blank question. --- runestone/assess/multiplechoice.py | 1 - runestone/fitb/fitb.py | 355 ++++++++++++++++------------- runestone/fitb/js/fitb.js | 192 ++++++---------- 3 files changed, 275 insertions(+), 273 deletions(-) diff --git a/runestone/assess/multiplechoice.py b/runestone/assess/multiplechoice.py index 662715327..8c5ddfb19 100644 --- a/runestone/assess/multiplechoice.py +++ b/runestone/assess/multiplechoice.py @@ -206,7 +206,6 @@ def run(self): # .. code-block:: # :number-lines: # - # # mcNode = MChoiceNode() # Item 1 of problem text # ... diff --git a/runestone/fitb/fitb.py b/runestone/fitb/fitb.py index dd86606f0..8d2887650 100644 --- a/runestone/fitb/fitb.py +++ b/runestone/fitb/fitb.py @@ -15,24 +15,24 @@ # __author__ = 'isaiahmayerchak' +import json +import ast +from numbers import Number from docutils import nodes from docutils.parsers.rst import directives -from docutils.parsers.rst import Directive -#from runestone.assess.assessbase import * -import json -import random from runestone.server.componentdb import addQuestionToDB from runestone.common import RunestoneDirective def setup(app): app.add_directive('fillintheblank', FillInTheBlank) - app.add_directive('blank', Blank) + app.add_role('blank', BlankRole) app.add_stylesheet('fitb.css') app.add_javascript('fitb.js') app.add_javascript('timedfitb.js') app.add_node(FITBNode, html=(visit_fitb_node, depart_fitb_node)) app.add_node(BlankNode, html=(visit_blank_node, depart_blank_node)) + app.add_node(FITBFeedbackNode, html=(visit_fitb_feedback_node, depart_fitb_feedback_node)) class FITBNode(nodes.General, nodes.Element): @@ -45,42 +45,50 @@ def __init__(self,content): """ super(FITBNode,self).__init__() self.fitb_options = content + # Create a data structure of feedback. + self.feedbackArray = [] def visit_fitb_node(self,node): - res = "" - - if 'casei' in node.fitb_options: - node.fitb_options['casei'] = 'true' - else: - node.fitb_options['casei'] = 'false' res = node.template_start % node.fitb_options - self.body.append(res) -def depart_fitb_node(self,node): - res = "" - - res += node.template_end % node.fitb_options +def depart_fitb_node(self, node): + # If there were fewer blanks than feedback items, add blanks at the end of the question. + blankCount = 0 + for _ in node.traverse(BlankNode): + blankCount += 1 + while blankCount < len(node.feedbackArray): + visit_blank_node(self, None) + blankCount += 1 + + # Warn if there are fewer feedback items than blanks. + # TODO: node.source, node.line aren't defined. + #print(node.source, node.line) + if len(node.feedbackArray) < blankCount: + print('Warning at {} line {}: there'' not enough feedback for the number of blanks supplied.'.format(node.source, node.line)) + + # Generate the HTML. + node.fitb_options['json'] = json.dumps(node.feedbackArray) + res = node.template_end % node.fitb_options self.body.append(res) class FillInTheBlank(RunestoneDirective): """ - .. fillintheblank:: fill1412 - - .. blank:: blank1345 - :correct: \\bred\\b - :feedback1: (".*", "Try 'red'") - - Fill in the blanks to make the following sentence: "The red car drove away" The [blank here] - - .. blank:: blank52532 - :correct: \\baway\\b - :feedback1: (".*", "Try 'away'") - - car drove [blank here] + .. fillintheblank:: some_unique_id_here + + Complete the sentence: |blank| had a |blank| lamb. One plus one is: (note that if there aren't enough blanks for the feedback given, they're added to the end of the problem. So, we don't **need** to specify a blank here.) + + - :Mary: Is the correct answer. + :Sue: Is wrong. + :x: Try again. (Note: the last item of feedback matches anything, regardless of the string it's given.) + - :little: That's right. + :.*: Nope. + - :2: Right on! + :2 1: Close.... (The second number is a tolerance, so this matches 1 or 3.) + :x: Nope. (As earlier, this matches anything.) """ required_arguments = 1 optional_arguments = 0 @@ -89,7 +97,6 @@ class FillInTheBlank(RunestoneDirective): option_spec = RunestoneDirective.option_spec.copy() option_spec.update( {'blankid':directives.unchanged, - 'iscode':directives.flag, 'casei':directives.flag # case insensitive matching }) @@ -97,20 +104,21 @@ def run(self): """ process the fillintheblank directive and generate html for output. :param self: - :return: - .. fillintheblank:: qname - :iscode: boolean - :casei: Case insensitive boolean + :return: Nodes resulting from this directive. ... """ TEMPLATE_START = '''
-

+

''' TEMPLATE_END = ''' -

+ + +
''' @@ -118,129 +126,170 @@ def run(self): self.options['divid'] = self.arguments[0] + # TODO: How to include self.lineno in the directive? fitbNode = FITBNode(self.options) fitbNode.template_start = TEMPLATE_START fitbNode.template_end = TEMPLATE_END self.state.nested_parse(self.content, self.content_offset, fitbNode) - return [fitbNode] - - - -class BlankNode(nodes.General, nodes.Element): - def __init__(self,content): - """ - - Arguments: - - `self`: - - `content`: - """ - super(BlankNode,self).__init__() - self.blank_options = content - - -def visit_blank_node(self,node): - res = "" - - res = node.template_blank_start % node.blank_options - - self.body.append(res) - - -def depart_blank_node(self,node): - fbl = [] - res = "" - feedCounter = 0 - - for k in sorted(node.blank_options.keys()): - if 'feedback' in k: - feedCounter += 1 - node.blank_options['feedLabel'] = "feedback" + str(feedCounter) - pair = eval(node.blank_options[k]) - p0 = pair[0] - p1 = pair[1] - node.blank_options['feedExp'] = p0 - node.blank_options['feedText'] = p1 - res += node.template_blank_option % node.blank_options - - node.blank_options['fbl'] = json.dumps(fbl).replace('"',"'") - - res += node.template_option_end % node.blank_options + # Expected _`structure`, with assigned variable names and transformations made: + # + # .. code-block:: + # :number-lines: + # + # fitbNode = FITBNode() + # Item 1 of problem text + # ... + # Item n of problem text + # feedback_bullet_list = bullet_list() <-- The last element in fitbNode. + # feedback_list_item = list_item() <-- Feedback for the first blank. + # feedback_field_list = field_list() + # feedback_field = field() + # feedback_field_name = field_name() <-- Contains an answer. + # feedback_field_body = field_body() <-- Contains feedback for this answer. + # feedback_field = field() <-- Another answer/feedback pair. + # feedback_list_item = bullet_item() <-- Feedback for the second blank. + # ...etc. ... + # + # This becomes a data structure: + # + # .. code-block:: + # :number-lines: + # + # self.feedbackArray = [ + # [ # blankArray + # { # blankFeedbackDict: feedback 1 + # "regex" : feedback_field_name # (An answer, as a regex, including + # "regexFlags" : "x" # "i" if ``:casei:`` was specified, otherwise "".) OR + # "number" : [min, max] # a range of correct numeric answers. + # "feedback": feedbback_field_body (after being rendered as HTML) # Provides feedback for this answer. + # }, + # { # Feedback 2 + # Same as above. + # } + # ], + # [ # Blank 2, same as above. + # ] + # ] + # + # ...and a transformed node structure: + # + # .. code-block:: + # :number-lines: + # + # fitbNode = FITBNode() + # Item 1 of problem text + # ... + # Item n of problem text + # FITBFeedbackNode(), which contains all the nodes in blank 1's feedback_field_body + # ... + # FITBFeedbackNode(), which contains all the nodes in blank n's feedback_field_body + # + feedback_bullet_list = fitbNode.pop() + if not isinstance(feedback_bullet_list, nodes.bullet_list): + self.error('The last item in a fill-in-the-blank question must be a bulleted list.') + for feedback_list_item in feedback_bullet_list.children: + assert isinstance(feedback_list_item, nodes.list_item) + feedback_field_list = feedback_list_item[0] + if len(feedback_list_item) != 1 or not isinstance(feedback_field_list, nodes.field_list): + self.error('Each list item in a fill-in-the-blank problems must contain only one item, a field list.') + blankArray = [] + for feedback_field in feedback_field_list: + assert isinstance(feedback_field, nodes.field) + + feedback_field_name = feedback_field[0] + assert isinstance(feedback_field_name, nodes.field_name) + feedback_field_name_raw = feedback_field_name.rawsource + # See if this is a number, optinonally followed by a tolerance. + try: + # Parse the number. + str_num, *list_tol = feedback_field_name_raw.split() + num = ast.literal_eval(str_num) + assert isinstance(num, Number) + # If no tolerance is given, use a tolarance of 0. + if len(list_tol) == 0: + tol = 0 + else: + assert len(list_tol) == 1 + tol = ast.literal_eval(list_tol[0]) + assert isinstance(tol, Number) + # We have the number and a tolerance. Save that. + blankFeedbackDict = {"number": [num - tol, num + tol]} + except (SyntaxError, ValueError, AssertionError): + # We can't parse this as a number, so assume it's a regex. + blankFeedbackDict = { + 'regex': + # The given regex must match the entire string, from the beginning (which may be preceeded by spaces) ... + '^ *' + + # ... to the contents (where a single space in the provided pattern is treated as one or more spaces in the student's anwer) ... + feedback_field_name.rawsource.replace(' ', ' +') + # ... to the end (also with optional whitespace). + + ' *$', + 'regexFlags': + 'i' if 'casei' in self.options else '', + } + blankArray.append(blankFeedbackDict) + + feedback_field_body = feedback_field[1] + assert isinstance(feedback_field_body, nodes.field_body) + # Append feedback for this asnwer to the end of the fitbNode. + ffn = FITBFeedbackNode(feedback_field_body.rawsource, *feedback_field_body.children, **feedback_field_body.attributes) + ffn.blankFeedbackDict = blankFeedbackDict + fitbNode += ffn + + # Add all the feedback for this blank to the feedbackArray. + fitbNode.feedbackArray.append(blankArray) + return [fitbNode] - self.body.append(res) - - - - -class Blank(RunestoneDirective): - """ -.. blank:: blank52532 - :correct: \\baway\\b - :feedback1: (".*", "Try 'away'") - - car drove [the blank will be here] - - """ - required_arguments = 1 - optional_arguments = 0 - final_argument_whitespace = True - has_content = True - option_spec = {'correct':directives.unchanged, - 'feedback1':directives.unchanged, - 'feedback2':directives.unchanged, - 'feedback3':directives.unchanged, - 'feedback4':directives.unchanged, - } - - def run(self): - """ - process the fillintheblank directive and generate html for output. - :param self: - :return: - .. blank:: qname - :correct: regular expression - :feedback1: ('.*', 'this is the message') - :feedback2: (RegEx, MessageString) - :feedback3: (RegEx, MessageString) - :feedback4: (RegEx, MessageString) - - - - Question text - ... - """ - - self.options['divid'] = self.arguments[0] - if self.content: - if 'iscode' in self.options: - self.options['bodytext'] = '
' + "\n".join(self.content) + '
' - else: - self.options['bodytext'] = "\n".join(self.content) - else: - self.options['bodytext'] = '\n' - - if 'correct' not in self.options: - raise ValueError("missing correct value in %s"%self.options['divid']) - - TEMPLATE_BLANK_START = ''' - - ''' - TEMPLATE_BLANK_OPTION = ''' - - %(feedText)s - ''' - TEMPLATE_BLANK_END = ''' - - - ''' - - blankNode = BlankNode(self.options) - blankNode.template_blank_start = TEMPLATE_BLANK_START - blankNode.template_blank_option = TEMPLATE_BLANK_OPTION - blankNode.template_option_end = TEMPLATE_BLANK_END - - self.state.nested_parse(self.content, self.content_offset, blankNode) - return [blankNode] +# BlankRole +# --------- +# Create role representing the blank in a fill-in-the-blank question. This function returns a tuple of two values: +# +# 0. A list of nodes which will be inserted into the document tree at the point where the interpreted role was encountered (can be an empty list). +# #. A list of system messages, which will be inserted into the document tree immediately after the end of the current block (can also be empty). +def BlankRole( + # _`roleName`: the local name of the interpreted role, the role name actually used in the document. + roleName, + # _`rawtext` is a string containing the enitre interpreted text input, including the role and markup. Return it as a problematic node linked to a system message if a problem is encountered. + rawtext, + # The interpreted _`text` content. + text, + # The line number (_`lineno`) where the interpreted text begins. + lineno, + # _`inliner` is the docutils.parsers.rst.states.Inliner object that called this function. It contains the several attributes useful for error reporting and document tree access. + inliner, + # A dictionary of directive _`options` for customization (from the "role" directive), to be interpreted by this function. Used for additional attributes for the generated elements and other functionality. + options={}, + # A list of strings, the directive _`content` for customization (from the "role" directive). To be interpreted by the role function. + content=[]): + + # Blanks ignore all arguments, just inserting a blank. + return [BlankNode(rawtext)], [] + +class BlankNode(nodes.Inline, nodes.TextElement): + pass + +def visit_blank_node(self, node): + self.body.append('') + +def depart_blank_node(self, node): + pass + + +# Contains feedback for one answer. +class FITBFeedbackNode(nodes.General, nodes.Element): + pass + +def visit_fitb_feedback_node(self, node): + # Save the HTML generated thus far. Anything generated under this node will be placed in JSON. + self.context.append(self.body) + self.body = [] + +def depart_fitb_feedback_node(self, node): + # Place all the HTML generated for this node and its children into the feedbackArray. + node.blankFeedbackDict['feedback'] = ''.join(self.body) + # Restore HTML generated thus far. + self.body = self.context.pop() diff --git a/runestone/fitb/js/fitb.js b/runestone/fitb/js/fitb.js index b5f786362..22c015841 100644 --- a/runestone/fitb/js/fitb.js +++ b/runestone/fitb/js/fitb.js @@ -32,91 +32,41 @@ FITB.prototype.init = function (opts) { this.useRunestoneServices = opts.useRunestoneServices; this.origElem = orig; this.divid = orig.id; - this.questionArray = []; this.correct = null; - this.feedbackArray = []; - /* this.feedbackArray is an array of array of arrays--each outside element is a blank. Each middle element is a different "incorrect" feedback - that is tailored for how the question is incorrectly answered. Each inside array contains 2 elements: the regular expression, then text */ - this.children = []; // this contains all of the child elements of the entire tag... - this.correctAnswerArray = []; // This array contains the regular expressions of the correct answers - - this.adoptChildren(); - this.populateCorrectAnswerArray(); - this.populateQuestionArray(); + // The format of this array: + // + // .. code-block:: + // :number-lines: + // + // [ // Overall data structure is an array. + // [ // blank1 + // [ // correct answer + // 'regex1.1', + // 'text1.1' + // ], [ // incorrect1 + // 'regex1.2', + // 'text1.2' + // ], // Followed by more incorrects. + // ], [ // blank2 + // etc. + // ] + // ] + // + // Find the script tag containing JSON and parse it. See `SO `_. + this.feedbackArray = JSON.parse(this.scriptSelector(this.origElem).html()); this.casei = false; // Case insensitive--boolean if ($(this.origElem).data("casei") === true) { this.casei = true; } - this.populateFeedbackArray(); this.createFITBElement(); this.checkServer("fillb"); }; -/*==================================== -==== Functions parsing data ==== -==== out of intermediate HTML ==== -====================================*/ - -FITB.prototype.adoptChildren = function () { - // populates this.children - var children = this.origElem.childNodes; - for (var i = 0; i < this.origElem.childNodes.length; i++) { - if ($(this.origElem.childNodes[i]).is("[data-blank]")) { - this.children.push(this.origElem.childNodes[i]); - } - } -}; - -FITB.prototype.populateCorrectAnswerArray = function () { - for (var i = 0; i < this.children.length; i++) { - for (var j=0; j < this.children[i].childNodes.length; j++) { - if ($(this.children[i].childNodes[j]).is("[data-answer]")) { - this.correctAnswerArray.push($([this.children[i].childNodes[j]]).text().replace(/\\\\/g,"\\")); - } - } - } -}; - -FITB.prototype.populateQuestionArray = function () { - for (var i = 0; i < this.children.length; i++) { - for (var j = 0; j < this.children[i].childNodes.length; j++) { - if ($(this.children[i].childNodes[j]).is("[data-answer]")) { - var delimiter = this.children[i].childNodes[j].outerHTML; - - var fulltext = $(this.children[i]).html(); - var temp = fulltext.split(delimiter); - this.questionArray.push(temp[0]); - break; - } - } - } -}; - -FITB.prototype.populateFeedbackArray = function () { - for (var i = 0; i < this.children.length; i++) { - var AnswerNodeList = []; - var tmpContainArr = []; - for (var j = 0; j < this.children[i].childNodes.length; j++) { - if ($(this.children[i].childNodes[j]).is("[data-feedback=text]")) { - - AnswerNodeList.push(this.children[i].childNodes[j]); - for (var k = 0; k < this.children[i].childNodes.length; k++) { - if ($(this.children[i].childNodes[k]).is("[data-feedback=regex]")) { - if ($(this.children[i].childNodes[j]).attr("for") === this.children[i].childNodes[k].id) { - var tempArr = []; - tempArr.push(this.children[i].childNodes[k].innerHTML.replace(/\\\\/g, "\\")); - tempArr.push(this.children[i].childNodes[j].innerHTML); - tmpContainArr.push(tempArr); - break; - } - } - } - } - } - this.feedbackArray.push(tmpContainArr); - } -}; +// Find the script tag containing JSON in a given root DOM node. +FITB.prototype.scriptSelector = function (root_node) { + return $(root_node).find('script[type="application/json"]'); +} /*=========================================== ==== Functions generating final HTML ==== @@ -138,22 +88,14 @@ FITB.prototype.renderFITBInput = function () { $(this.containerDiv).addClass("alert alert-warning"); this.containerDiv.id = this.divid; - this.blankArray = []; - for (var i = 0; i < this.children.length; i++) { - var question = document.createElement("span"); - question.innerHTML = this.questionArray[i]; - this.containerDiv.appendChild(question); - - var blank = document.createElement("input"); - $(blank).attr({ - "type": "text", - "id": this.divid + "_blank" + i, - "class": "form form-control selectwidthauto" - }); - this.containerDiv.appendChild(blank); - this.blankArray.push(blank); - } - + // Copy the original elements to the container holding what the user will see. + $(this.origElem).clone().appendTo(this.containerDiv); + // Remove the script tag. + this.scriptSelector(this.containerDiv).remove(); + // Set the class for the text inputs, then store references to them. + let ba = $(this.containerDiv).find(':input'); + ba.attr('class', 'form form-control selectwidthauto'); + this.blankArray = ba.toArray(); }; FITB.prototype.renderFITBButtons = function () { @@ -268,24 +210,49 @@ FITB.prototype.startEvaluation = function (logFlag) { }; FITB.prototype.evaluateAnswers = function () { - for (var i = 0; i < this.children.length; i++) { + for (var i = 0; i < this.blankArray.length; i++) { var given = this.blankArray[i].value; var modifiers = ""; if (this.casei) { modifiers = "i"; } - var patt = RegExp(this.correctAnswerArray[i], modifiers); - if (given !== "") { - this.isCorrectArray.push(patt.test(given)); - } else { + // If this blank is empty, provide no feedback for it. + if (given === "") { this.isCorrectArray.push(""); - } - - if (!this.isCorrectArray[i]) { - this.populateDisplayFeed(i, given); + } else { + // Look through all feedback for this blank. The last element in the array always matches. + var fbl = this.feedbackArray[i]; + for (var j = 0; j < fbl.length; j++) { + // The last item of feedback always matches. + if (j === fbl.length - 1) { + this.displayFeed.push(fbl[j]['feedback']); + break; + } + // If this is a regexp... + if ('regex' in fbl[j]) { + var patt = RegExp(fbl[j]['regex'], fbl[j]['regexFlags']); + if (patt.test(given)) { + this.displayFeed.push(fbl[j]['feedback']); + break; + } + } else { + // This is a number. + console.assert('number' in fbl[j]); + var [min, max] = fbl[j]['number']; + // Convert the given string to a number. While there are `lots of ways `_ to do this,, this version supports other bases (hex/binary/octal) as well as floats. + var actual = +given; + if (actual >= min && actual <= max) { + this.displayFeed.push(fbl[j]['feedback']); + break; + } + } + } + // The answer is correct if it matched the first element in the array. + this.isCorrectArray.push(j === 0); } } + if ($.inArray("", this.isCorrectArray) < 0 && $.inArray(false, this.isCorrectArray) < 0) { this.correct = true; } else if (this.isCompletelyBlank()) { @@ -306,22 +273,9 @@ FITB.prototype.isCompletelyBlank = function () { return true; }; -FITB.prototype.populateDisplayFeed = function (index, given) { - var fbl = this.feedbackArray[index]; - for (var j = 0; j < fbl.length; j++) { - for (var k = 0; k < fbl[j].length; k++) { - var patt = RegExp(fbl[j][k]); - if (patt.test(given)) { - this.displayFeed.push(fbl[j][1]); - return 0; - } - } - } -}; - FITB.prototype.renderFITBFeedback = function () { if (this.correct) { - $(this.feedBackDiv).html("You are Correct!"); + $(this.feedBackDiv).html("Correct.
"); $(this.feedBackDiv).attr("class", "alert alert-success"); for (var j = 0; j < this.blankArray.length; j++) { $(this.blankArray[j]).removeClass("input-validation-error"); @@ -330,7 +284,7 @@ FITB.prototype.renderFITBFeedback = function () { if (this.displayFeed === null) { this.displayFeed = ""; } - $(this.feedBackDiv).html("Incorrect. "); + $(this.feedBackDiv).html("Incorrect.
"); for (var j = 0; j < this.blankArray.length; j++) { if (!this.isCorrectArray[j]) { $(this.blankArray[j]).addClass("input-validation-error"); @@ -338,12 +292,12 @@ FITB.prototype.renderFITBFeedback = function () { $(this.blankArray[j]).removeClass("input-validation-error"); } } - for (var i = 0; i < this.displayFeed.length; i++) { - this.feedBackDiv.innerHTML += this.displayFeed[i]; - this.feedBackDiv.appendChild(document.createElement("br")); - } $(this.feedBackDiv).attr("class", "alert alert-danger"); } + for (var i = 0; i < this.displayFeed.length; i++) { + this.feedBackDiv.innerHTML += this.displayFeed[i]; + this.feedBackDiv.appendChild(document.createElement("br")); + } }; /*================================== From 73881f85937e6a8db4fc7f74301f562285de43e8 Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Tue, 4 Jul 2017 23:18:28 -0500 Subject: [PATCH 2/9] Fix: Proivde a definition for |blank|. --- runestone/common/project_template/conf.tmpl | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/runestone/common/project_template/conf.tmpl b/runestone/common/project_template/conf.tmpl index 2ee49dfe3..c4c271098 100644 --- a/runestone/common/project_template/conf.tmpl +++ b/runestone/common/project_template/conf.tmpl @@ -92,10 +92,20 @@ pygments_style = 'sphinx' # `keep_warnings `_: # If true, keep warnings as “system message” paragraphs in the built documents. -# Regardless of this setting, warnings are always written to the standard error +# Regardless of this setting, warnings are always written to the standard error # stream when sphinx-build is run. keep_warnings = True +# `rst_prolog `_: +# A string of reStructuredText that will be included at the beginning of every +# source file that is read. +rst_prolog = ( +# For fill-in-the-blank questions, provide a convenient means to indicate a blank. +""" + +.. |blank| replace:: :blank:`x` +""" +) # -- Options for HTML output --------------------------------------------------- From c65678cecb9ae9a949ded68e40e1cad2585f7df2 Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Tue, 4 Jul 2017 23:18:53 -0500 Subject: [PATCH 3/9] Clean: Remove unused references to casei. Docs: Clarify, remove old material. --- runestone/fitb/fitb.py | 6 +++--- runestone/fitb/js/fitb.js | 27 +-------------------------- 2 files changed, 4 insertions(+), 29 deletions(-) diff --git a/runestone/fitb/fitb.py b/runestone/fitb/fitb.py index 8d2887650..ea809f47e 100644 --- a/runestone/fitb/fitb.py +++ b/runestone/fitb/fitb.py @@ -86,7 +86,7 @@ class FillInTheBlank(RunestoneDirective): :x: Try again. (Note: the last item of feedback matches anything, regardless of the string it's given.) - :little: That's right. :.*: Nope. - - :2: Right on! + - :2: Right on! Numbers can be given in decimal, hex (0x10 == 16), octal (0o10 == 8), binary (0b10 == 2), or using scientific notation (1e1 == 10), both here and by the user when answering the question. :2 1: Close.... (The second number is a tolerance, so this matches 1 or 3.) :x: Nope. (As earlier, this matches anything.) """ @@ -160,10 +160,10 @@ def run(self): # self.feedbackArray = [ # [ # blankArray # { # blankFeedbackDict: feedback 1 - # "regex" : feedback_field_name # (An answer, as a regex, including + # "regex" : feedback_field_name # (An answer, as a regex; # "regexFlags" : "x" # "i" if ``:casei:`` was specified, otherwise "".) OR # "number" : [min, max] # a range of correct numeric answers. - # "feedback": feedbback_field_body (after being rendered as HTML) # Provides feedback for this answer. + # "feedback": feedback_field_body (after being rendered as HTML) # Provides feedback for this answer. # }, # { # Feedback 2 # Same as above. diff --git a/runestone/fitb/js/fitb.js b/runestone/fitb/js/fitb.js index 22c015841..499cd1358 100644 --- a/runestone/fitb/js/fitb.js +++ b/runestone/fitb/js/fitb.js @@ -33,32 +33,11 @@ FITB.prototype.init = function (opts) { this.origElem = orig; this.divid = orig.id; this.correct = null; - // The format of this array: - // - // .. code-block:: - // :number-lines: - // - // [ // Overall data structure is an array. - // [ // blank1 - // [ // correct answer - // 'regex1.1', - // 'text1.1' - // ], [ // incorrect1 - // 'regex1.2', - // 'text1.2' - // ], // Followed by more incorrects. - // ], [ // blank2 - // etc. - // ] - // ] + // See comments in fitb.py for the format of ``feedbackArray`` (which is identical in both files). // // Find the script tag containing JSON and parse it. See `SO `_. this.feedbackArray = JSON.parse(this.scriptSelector(this.origElem).html()); - this.casei = false; // Case insensitive--boolean - if ($(this.origElem).data("casei") === true) { - this.casei = true; - } this.createFITBElement(); this.checkServer("fillb"); }; @@ -212,10 +191,6 @@ FITB.prototype.startEvaluation = function (logFlag) { FITB.prototype.evaluateAnswers = function () { for (var i = 0; i < this.blankArray.length; i++) { var given = this.blankArray[i].value; - var modifiers = ""; - if (this.casei) { - modifiers = "i"; - } // If this blank is empty, provide no feedback for it. if (given === "") { From 6ccdfa86be979c0bcf6f5d582c49c5597dd07c07 Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Wed, 5 Jul 2017 11:28:51 -0500 Subject: [PATCH 4/9] Fix: Add |blank| definition to test conf.py files. --- runestone/activecode/test/conf.py | 10 ++++++++++ runestone/assess/test/conf.py | 11 +++++++++++ runestone/clickableArea/test/conf.py | 11 +++++++++++ runestone/dragndrop/test/conf.py | 11 +++++++++++ runestone/fitb/test/conf.py | 11 +++++++++++ runestone/poll/test/conf.py | 11 +++++++++++ runestone/question/test/conf.py | 11 +++++++++++ runestone/reveal/test/conf.py | 11 +++++++++++ runestone/shortanswer/test/conf.py | 11 +++++++++++ runestone/tabbedStuff/test/conf.py | 11 +++++++++++ 10 files changed, 109 insertions(+) diff --git a/runestone/activecode/test/conf.py b/runestone/activecode/test/conf.py index 143a13814..360962857 100644 --- a/runestone/activecode/test/conf.py +++ b/runestone/activecode/test/conf.py @@ -90,6 +90,16 @@ # A list of ignored prefixes for module index sorting. #modindex_common_prefix = [] +# `rst_prolog `_: +# A string of reStructuredText that will be included at the beginning of every +# source file that is read. +rst_prolog = ( +# For fill-in-the-blank questions, provide a convenient means to indicate a blank. +""" + +.. |blank| replace:: :blank:`x` +""" +) # -- Options for HTML output --------------------------------------------------- diff --git a/runestone/assess/test/conf.py b/runestone/assess/test/conf.py index 51418f10b..e0906278c 100644 --- a/runestone/assess/test/conf.py +++ b/runestone/assess/test/conf.py @@ -90,6 +90,17 @@ # A list of ignored prefixes for module index sorting. #modindex_common_prefix = [] +# `rst_prolog `_: +# A string of reStructuredText that will be included at the beginning of every +# source file that is read. +rst_prolog = ( +# For fill-in-the-blank questions, provide a convenient means to indicate a blank. +""" + +.. |blank| replace:: :blank:`x` +""" +) + # -- Options for HTML output --------------------------------------------------- diff --git a/runestone/clickableArea/test/conf.py b/runestone/clickableArea/test/conf.py index 767bb064f..b6c5deba4 100644 --- a/runestone/clickableArea/test/conf.py +++ b/runestone/clickableArea/test/conf.py @@ -90,6 +90,17 @@ # A list of ignored prefixes for module index sorting. #modindex_common_prefix = [] +# `rst_prolog `_: +# A string of reStructuredText that will be included at the beginning of every +# source file that is read. +rst_prolog = ( +# For fill-in-the-blank questions, provide a convenient means to indicate a blank. +""" + +.. |blank| replace:: :blank:`x` +""" +) + # -- Options for HTML output --------------------------------------------------- diff --git a/runestone/dragndrop/test/conf.py b/runestone/dragndrop/test/conf.py index 767bb064f..b6c5deba4 100644 --- a/runestone/dragndrop/test/conf.py +++ b/runestone/dragndrop/test/conf.py @@ -90,6 +90,17 @@ # A list of ignored prefixes for module index sorting. #modindex_common_prefix = [] +# `rst_prolog `_: +# A string of reStructuredText that will be included at the beginning of every +# source file that is read. +rst_prolog = ( +# For fill-in-the-blank questions, provide a convenient means to indicate a blank. +""" + +.. |blank| replace:: :blank:`x` +""" +) + # -- Options for HTML output --------------------------------------------------- diff --git a/runestone/fitb/test/conf.py b/runestone/fitb/test/conf.py index d54c3be68..44d6b97fc 100644 --- a/runestone/fitb/test/conf.py +++ b/runestone/fitb/test/conf.py @@ -90,6 +90,17 @@ # A list of ignored prefixes for module index sorting. #modindex_common_prefix = [] +# `rst_prolog `_: +# A string of reStructuredText that will be included at the beginning of every +# source file that is read. +rst_prolog = ( +# For fill-in-the-blank questions, provide a convenient means to indicate a blank. +""" + +.. |blank| replace:: :blank:`x` +""" +) + # -- Options for HTML output --------------------------------------------------- diff --git a/runestone/poll/test/conf.py b/runestone/poll/test/conf.py index f7f41a42a..dba7d3935 100644 --- a/runestone/poll/test/conf.py +++ b/runestone/poll/test/conf.py @@ -90,6 +90,17 @@ # A list of ignored prefixes for module index sorting. #modindex_common_prefix = [] +# `rst_prolog `_: +# A string of reStructuredText that will be included at the beginning of every +# source file that is read. +rst_prolog = ( +# For fill-in-the-blank questions, provide a convenient means to indicate a blank. +""" + +.. |blank| replace:: :blank:`x` +""" +) + # -- Options for HTML output --------------------------------------------------- diff --git a/runestone/question/test/conf.py b/runestone/question/test/conf.py index 6721f5064..814a802c9 100644 --- a/runestone/question/test/conf.py +++ b/runestone/question/test/conf.py @@ -90,6 +90,17 @@ # A list of ignored prefixes for module index sorting. #modindex_common_prefix = [] +# `rst_prolog `_: +# A string of reStructuredText that will be included at the beginning of every +# source file that is read. +rst_prolog = ( +# For fill-in-the-blank questions, provide a convenient means to indicate a blank. +""" + +.. |blank| replace:: :blank:`x` +""" +) + # -- Options for HTML output --------------------------------------------------- diff --git a/runestone/reveal/test/conf.py b/runestone/reveal/test/conf.py index 767bb064f..b6c5deba4 100644 --- a/runestone/reveal/test/conf.py +++ b/runestone/reveal/test/conf.py @@ -90,6 +90,17 @@ # A list of ignored prefixes for module index sorting. #modindex_common_prefix = [] +# `rst_prolog `_: +# A string of reStructuredText that will be included at the beginning of every +# source file that is read. +rst_prolog = ( +# For fill-in-the-blank questions, provide a convenient means to indicate a blank. +""" + +.. |blank| replace:: :blank:`x` +""" +) + # -- Options for HTML output --------------------------------------------------- diff --git a/runestone/shortanswer/test/conf.py b/runestone/shortanswer/test/conf.py index 767bb064f..b6c5deba4 100644 --- a/runestone/shortanswer/test/conf.py +++ b/runestone/shortanswer/test/conf.py @@ -90,6 +90,17 @@ # A list of ignored prefixes for module index sorting. #modindex_common_prefix = [] +# `rst_prolog `_: +# A string of reStructuredText that will be included at the beginning of every +# source file that is read. +rst_prolog = ( +# For fill-in-the-blank questions, provide a convenient means to indicate a blank. +""" + +.. |blank| replace:: :blank:`x` +""" +) + # -- Options for HTML output --------------------------------------------------- diff --git a/runestone/tabbedStuff/test/conf.py b/runestone/tabbedStuff/test/conf.py index 767bb064f..b6c5deba4 100644 --- a/runestone/tabbedStuff/test/conf.py +++ b/runestone/tabbedStuff/test/conf.py @@ -90,6 +90,17 @@ # A list of ignored prefixes for module index sorting. #modindex_common_prefix = [] +# `rst_prolog `_: +# A string of reStructuredText that will be included at the beginning of every +# source file that is read. +rst_prolog = ( +# For fill-in-the-blank questions, provide a convenient means to indicate a blank. +""" + +.. |blank| replace:: :blank:`x` +""" +) + # -- Options for HTML output --------------------------------------------------- From 59339e84248ac5cc4cce0a4cda7fe467fdd50a57 Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Thu, 6 Jul 2017 15:09:40 -0500 Subject: [PATCH 5/9] Fix, following Brad's edits in e639d9b083a553552557c7d5f8e277ddd3afaed0. Requested in https://github.com/RunestoneInteractive/RunestoneComponents/pull/412#discussion_r125890897. --- runestone/fitb/js/fitb.js | 1 + 1 file changed, 1 insertion(+) diff --git a/runestone/fitb/js/fitb.js b/runestone/fitb/js/fitb.js index 499cd1358..4509109cb 100644 --- a/runestone/fitb/js/fitb.js +++ b/runestone/fitb/js/fitb.js @@ -28,6 +28,7 @@ FITB.prototype = new RunestoneBase(); FITB.prototype.init = function (opts) { RunestoneBase.apply(this, arguments); + RunestoneBase.prototype.init.apply(this, arguments); var orig = opts.orig; // entire

element this.useRunestoneServices = opts.useRunestoneServices; this.origElem = orig; From 28958664c6c82066b5904399ae8043fa0e93ad17 Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Fri, 7 Jul 2017 23:28:44 -0500 Subject: [PATCH 6/9] Fix: Copy children of original div, not div itself. --- runestone/fitb/js/fitb.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runestone/fitb/js/fitb.js b/runestone/fitb/js/fitb.js index 4509109cb..61bbf7076 100644 --- a/runestone/fitb/js/fitb.js +++ b/runestone/fitb/js/fitb.js @@ -69,7 +69,7 @@ FITB.prototype.renderFITBInput = function () { this.containerDiv.id = this.divid; // Copy the original elements to the container holding what the user will see. - $(this.origElem).clone().appendTo(this.containerDiv); + $(this.origElem).children().clone().appendTo(this.containerDiv); // Remove the script tag. this.scriptSelector(this.containerDiv).remove(); // Set the class for the text inputs, then store references to them. From 63bf26e4cbd3dd771e69544e35bde94678979f03 Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Fri, 7 Jul 2017 16:51:09 -0500 Subject: [PATCH 7/9] Refactor: Place common test code in a single location. Fix: Make tests run cross-platform, instead of relying on a bash script. --- runTests.sh | 27 +--------- runestone/__init__.py | 3 +- runestone/activecode/test/__init__.py | 0 runestone/activecode/test/test_activecode.py | 25 ++------- runestone/assess/test/__init__.py | 0 runestone/assess/test/test_assess.py | 27 ++-------- runestone/clickableArea/test/__init__.py | 0 .../clickableArea/test/test_clickableArea.py | 48 +++++------------ runestone/dragndrop/test/__init__.py | 0 runestone/dragndrop/test/test_dragndrop.py | 26 ++------- runestone/fitb/test/__init__.py | 0 runestone/fitb/test/test_fitb.py | 30 ++--------- runestone/poll/test/__init__.py | 0 runestone/poll/test/test_poll.py | 25 ++------- runestone/question/test/test_question.py | 27 ++-------- runestone/reveal/test/__init__.py | 0 runestone/reveal/test/test_reveal.py | 27 ++-------- runestone/shortanswer/test/__init__.py | 0 .../shortanswer/test/test_shortanswer.py | 28 ++-------- runestone/tabbedStuff/test/__init__.py | 0 .../tabbedStuff/test/test_tabbedStuff.py | 35 +++--------- runestone/unittest_base.py | 53 +++++++++++++++++++ 22 files changed, 104 insertions(+), 277 deletions(-) create mode 100644 runestone/activecode/test/__init__.py create mode 100644 runestone/assess/test/__init__.py create mode 100644 runestone/clickableArea/test/__init__.py create mode 100644 runestone/dragndrop/test/__init__.py create mode 100644 runestone/fitb/test/__init__.py create mode 100644 runestone/poll/test/__init__.py create mode 100644 runestone/reveal/test/__init__.py create mode 100644 runestone/shortanswer/test/__init__.py create mode 100644 runestone/tabbedStuff/test/__init__.py create mode 100644 runestone/unittest_base.py diff --git a/runTests.sh b/runTests.sh index 19124189a..323ab1a30 100755 --- a/runTests.sh +++ b/runTests.sh @@ -1,27 +1,2 @@ #!/usr/bin/env bash - -set -e -testhome=`pwd` -port=8081 -for t in 'activecode' 'assess' 'clickableArea' 'dragndrop' 'fitb' 'poll' 'question' 'reveal' 'shortanswer' 'tabbedStuff'; do - cd runestone/$t/test - runestone build --all - runestone serve --port $port & - SERVE_PID=$! - echo "Running test_${t}.py" $port - set -x - python "test_${t}.py" - if [ $? -ne 0 ]; then - echo "Test failed" - pgrep -lf '.*runestone serve.*' | awk '{ print $1 }' | xargs kill - exit 1 - else - echo "killing server" $SERVE_PID - kill $SERVE_PID - fi - set -e - cd $testhome - #port=$((port+1)) -done - -exit 0 +python -m unittest discover diff --git a/runestone/__init__.py b/runestone/__init__.py index dbaa59fda..66fc61aff 100644 --- a/runestone/__init__.py +++ b/runestone/__init__.py @@ -27,7 +27,7 @@ def runestone_static_dirs(): module_static_js = ['%s/js' % os.path.join(basedir,x) for x in module_paths if os.path.exists('%s/js' % os.path.join(basedir,x))] module_static_css = ['%s/css' % os.path.join(basedir,x) for x in module_paths if os.path.exists('%s/css' % os.path.join(basedir,x))] module_static_image = ['%s/images' % os.path.join(basedir,x) for x in module_paths if os.path.exists('%s/images' % os.path.join(basedir,x))] - module_static_bootstrap = ['%s/bootstrap' % os.path.join(basedir,x) for x in module_paths if os.path.exists('%s/bootstrap' % os.path.join(basedir,x))] + module_static_bootstrap = ['%s/bootstrap' % os.path.join(basedir,x) for x in module_paths if os.path.exists('%s/bootstrap' % os.path.join(basedir,x))] return module_static_js + module_static_css + module_static_image + module_static_bootstrap @@ -102,7 +102,6 @@ def build(options): cmap = {'activecode': ActiveCode, 'mchoice': MChoice, 'fillintheblank': FillInTheBlank, - 'blank': Blank, 'timed': TimedDirective, 'qnum': QuestionNumber, 'codelens': Codelens, diff --git a/runestone/activecode/test/__init__.py b/runestone/activecode/test/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/runestone/activecode/test/test_activecode.py b/runestone/activecode/test/test_activecode.py index 46eca55dd..fc6c67fd4 100644 --- a/runestone/activecode/test/test_activecode.py +++ b/runestone/activecode/test/test_activecode.py @@ -1,17 +1,8 @@ -from selenium import webdriver -from selenium.webdriver import ActionChains -from selenium.common.exceptions import WebDriverException -import unittest -import sys +from ...unittest_base import module_fixture_maker, RunestoneTestCase -PORT = '8081' - -class ActiveCodeTests(unittest.TestCase): - def setUp(self): - #self.driver = webdriver.Firefox() # good for development - self.driver = webdriver.PhantomJS() # use this for Jenkins auto testing - self.host = 'http://127.0.0.1:' + PORT +setUpModule, tearDownModule = module_fixture_maker(__file__) +class ActiveCodeTests(RunestoneTestCase): def test_hello(self): ''' 1. Get the outer div id of the activecode component @@ -58,13 +49,3 @@ def test_history(self): rb.click() output = t1.find_element_by_class_name("ac_output") self.assertEqual(output.text.strip(), "Hello World") - - def tearDown(self): - self.driver.quit() - - - -if __name__ == '__main__': - if len(sys.argv) > 1: - PORT = sys.argv.pop() - unittest.main() \ No newline at end of file diff --git a/runestone/assess/test/__init__.py b/runestone/assess/test/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/runestone/assess/test/test_assess.py b/runestone/assess/test/test_assess.py index 4091679f6..333b21e1d 100644 --- a/runestone/assess/test/test_assess.py +++ b/runestone/assess/test/test_assess.py @@ -4,20 +4,11 @@ __author__ = 'yasinovskyy' -from selenium import webdriver -from selenium.webdriver import ActionChains -from selenium.common.exceptions import WebDriverException -import unittest -import sys +from ...unittest_base import module_fixture_maker, RunestoneTestCase -PORT = '8081' - -class MultipleChoiceQuestion_Tests(unittest.TestCase): - def setUp(self): - #self.driver = webdriver.Firefox() # good for development - self.driver = webdriver.PhantomJS() # use this for Jenkins auto testing - self.host = 'http://127.0.0.1:' + PORT +setUpModule, tearDownModule = module_fixture_maker(__file__) +class MultipleChoiceQuestion_Tests(RunestoneTestCase): def test_ma1(self): '''Multiple Answer: Nothing selected, Check button clicked''' self.driver.get(self.host + "/index.html") @@ -130,7 +121,7 @@ def test_mc3(self): '''Multiple Choice: Incorrect answer selected''' self.driver.get(self.host + "/index.html") t1 = self.driver.find_element_by_id("question2") - + t1.find_element_by_id("question2_opt_1").click() btn_check = t1.find_element_by_tag_name('button') @@ -161,13 +152,3 @@ def test_mc4(self): self.assertIsNotNone(fb) cnamestr = fb.get_attribute("class") self.assertIn("alert-danger", cnamestr) - - - def tearDown(self): - self.driver.quit() - - -if __name__ == '__main__': - if len(sys.argv) > 1: - PORT = sys.argv.pop() - unittest.main(verbosity=2) diff --git a/runestone/clickableArea/test/__init__.py b/runestone/clickableArea/test/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/runestone/clickableArea/test/test_clickableArea.py b/runestone/clickableArea/test/test_clickableArea.py index 35011f888..def66339a 100644 --- a/runestone/clickableArea/test/test_clickableArea.py +++ b/runestone/clickableArea/test/test_clickableArea.py @@ -4,22 +4,12 @@ __author__ = 'yasinovskyy' -from selenium import webdriver -from selenium.webdriver import ActionChains -from selenium.common.exceptions import WebDriverException -import unittest -import sys +from ...unittest_base import module_fixture_maker, RunestoneTestCase -PORT = '8081' +setUpModule, tearDownModule = module_fixture_maker(__file__) ANSWERS = ["Red Orange Yellow", "Red", "Orange", "Yellow", "Green", "Blue", "Indigo", "Violet"] -class ClickableAreaQuestion_Tests(unittest.TestCase): - def setUp(self): - #self.driver = webdriver.Firefox() # good for development - self.driver = webdriver.PhantomJS() # use this for Jenkins auto testing - self.host = 'http://127.0.0.1:' + PORT - - +class ClickableAreaQuestion_Tests(RunestoneTestCase): def test_ca1(self): '''Text/Code: Nothing selected''' self.driver.get(self.host + "/index.html") @@ -33,7 +23,7 @@ def test_ca1(self): cnamestr = fb.get_attribute("class") self.assertIn("alert-danger", cnamestr) - + def test_ca2(self): '''Text/Code: Correct answer(s) selected''' self.driver.get(self.host + "/index.html") @@ -56,8 +46,8 @@ def test_ca2(self): self.assertIsNotNone(fb) cnamestr = fb.get_attribute("class") self.assertIn("alert-success", cnamestr) - - + + def test_ca3(self): '''Text/Code: Incorrect answer selected''' self.driver.get(self.host + "/index.html") @@ -81,7 +71,7 @@ def test_ca3(self): cnamestr = fb.get_attribute("class") self.assertIn("alert-danger", cnamestr) - + def test_ca4(self): '''Text/Code: All options clicked one by one''' self.driver.get(self.host + "/index.html") @@ -106,7 +96,7 @@ def test_ca4(self): cnamestr = fb.get_attribute("class") self.assertIn("alert-danger", cnamestr) - + def test_ca5(self): '''Text/Code: Correct answer selected and unselected''' self.driver.get(self.host + "/index.html") @@ -122,7 +112,7 @@ def test_ca5(self): if target.text in ANSWERS: cnamestr = target.get_attribute("class") self.assertNotIn("clickable-clicked", cnamestr) - + def test_ca6(self): '''Table: Nothing selected''' @@ -136,8 +126,8 @@ def test_ca6(self): self.assertIsNotNone(fb) cnamestr = fb.get_attribute("class") self.assertIn("alert-danger", cnamestr) - - + + def test_ca7(self): '''Table: Correct answer(s) selected''' self.driver.get(self.host + "/index.html") @@ -160,7 +150,7 @@ def test_ca7(self): self.assertIsNotNone(fb) cnamestr = fb.get_attribute("class") self.assertIn("alert-success", cnamestr) - + def test_ca8(self): '''Table: Incorrect answer selected''' @@ -184,7 +174,7 @@ def test_ca8(self): self.assertIsNotNone(fb) cnamestr = fb.get_attribute("class") self.assertIn("alert-danger", cnamestr) - + def test_ca9(self): '''Table: All options clicked one by one''' @@ -209,7 +199,7 @@ def test_ca9(self): self.assertIsNotNone(fb) cnamestr = fb.get_attribute("class") self.assertIn("alert-danger", cnamestr) - + def test_ca10(self): '''Table: Correct answer selected and unselected''' @@ -226,13 +216,3 @@ def test_ca10(self): if target.text in ANSWERS: cnamestr = target.get_attribute("class") self.assertNotIn("clickable-clicked", cnamestr) - - - def tearDown(self): - self.driver.quit() - - -if __name__ == '__main__': - if len(sys.argv) > 1: - PORT = sys.argv.pop() - unittest.main(verbosity=2) diff --git a/runestone/dragndrop/test/__init__.py b/runestone/dragndrop/test/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/runestone/dragndrop/test/test_dragndrop.py b/runestone/dragndrop/test/test_dragndrop.py index 77739c668..0cdc4a74e 100644 --- a/runestone/dragndrop/test/test_dragndrop.py +++ b/runestone/dragndrop/test/test_dragndrop.py @@ -10,21 +10,14 @@ __author__ = 'yasinovskyy' -from selenium import webdriver -from selenium.webdriver import ActionChains -from selenium.common.exceptions import WebDriverException -import unittest -import sys +from ...unittest_base import module_fixture_maker, RunestoneTestCase -PORT = '8081' +setUpModule, tearDownModule = module_fixture_maker(__file__) jquery_url = "http://code.jquery.com/jquery-1.12.4.min.js" -class DragAndDropQuestion_Tests(unittest.TestCase): +class DragAndDropQuestion_Tests(RunestoneTestCase): def setUp(self): - #self.driver = webdriver.Chrome() - #self.driver = webdriver.Firefox() # good for development - self.driver = webdriver.PhantomJS() # use this for Jenkins auto testing - self.host = 'http://127.0.0.1:' + PORT + super(DragAndDropQuestion_Tests, self).setUp() self.driver.set_script_timeout(5) with open("jquery_load_helper.js") as f: self.load_jquery_js = f.read() @@ -32,7 +25,6 @@ def setUp(self): with open("drag_and_drop_helper.js") as f: self.js = f.read() - def test_dnd1(self): '''No selection. Button clicked''' self.driver.get(self.host + "/index.html") @@ -140,13 +132,3 @@ def test_dnd4(self): self.assertIsNotNone(fb) cnamestr = fb.get_attribute("class") self.assertIn("alert-success", cnamestr) - - - def tearDown(self): - self.driver.quit() - - -if __name__ == '__main__': - if len(sys.argv) > 1: - PORT = sys.argv.pop() - unittest.main(verbosity=2) diff --git a/runestone/fitb/test/__init__.py b/runestone/fitb/test/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/runestone/fitb/test/test_fitb.py b/runestone/fitb/test/test_fitb.py index e055f9d90..4949608c4 100644 --- a/runestone/fitb/test/test_fitb.py +++ b/runestone/fitb/test/test_fitb.py @@ -1,18 +1,8 @@ -from selenium import webdriver -from selenium.webdriver import ActionChains -from selenium.common.exceptions import WebDriverException -import unittest -import sys +from ...unittest_base import module_fixture_maker, RunestoneTestCase -PORT = '8081' - -class FITBtests(unittest.TestCase): - def setUp(self): - #self.driver = webdriver.Firefox() # good for development - # self.driver = webdriver.PhantomJS() # use this for Jenkins auto testing - self.driver = webdriver.PhantomJS() - self.host = 'http://127.0.0.1:' + PORT +setUpModule, tearDownModule = module_fixture_maker(__file__) +class FITBtests(RunestoneTestCase): # One of two correct answers def test_fitb(self): ''' @@ -39,7 +29,7 @@ def test_fitb2(self): self.assertIn("Incorrect",feedback.text) - # Both correct answers + # Both correct answers def test_fitb3(self): self.driver.get(self.host + "/index.html") # Access page quest = self.driver.find_element_by_id("fill-in-the-blank") @@ -66,15 +56,3 @@ def test_fitb4(self): checkme.click() feedback = quest.find_element_by_id("fill1412_feedback") self.assertIn("Correct",feedback.text) - - - def tearDown(self): - self.driver.quit() - - - - -if __name__ == '__main__': - if len(sys.argv) > 1: - PORT = sys.argv.pop() - unittest.main(verbosity=2) diff --git a/runestone/poll/test/__init__.py b/runestone/poll/test/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/runestone/poll/test/test_poll.py b/runestone/poll/test/test_poll.py index c867aac55..617c3fd0d 100644 --- a/runestone/poll/test/test_poll.py +++ b/runestone/poll/test/test_poll.py @@ -1,20 +1,8 @@ -from selenium import webdriver -import unittest -import sys +from ...unittest_base import module_fixture_maker, RunestoneTestCase -PORT = '8081' +setUpModule, tearDownModule = module_fixture_maker(__file__) -class PollTests(unittest.TestCase): - def setUp(self): - self.driver = webdriver.PhantomJS() # use this for Jenkins auto testing - self.host = 'http://127.0.0.1:' + PORT - - - def tearDown(self): - self.driver.quit() - - - ################################################################################################# +class PollTests(RunestoneTestCase): def test_poll(self): ''' test the poll directive ''' self.driver.get(self.host + '/index.html') @@ -35,10 +23,3 @@ def test_poll(self): # just make sure we can find the results div - an exception will be raised if the div cannot be found poll_div.find_element_by_id('pollid1_results') - - - -if __name__ == '__main__': - if len(sys.argv) > 1: - PORT = sys.argv.pop() - unittest.main() \ No newline at end of file diff --git a/runestone/question/test/test_question.py b/runestone/question/test/test_question.py index 222c05c93..d41a6b3cc 100644 --- a/runestone/question/test/test_question.py +++ b/runestone/question/test/test_question.py @@ -1,17 +1,8 @@ -from selenium import webdriver -from selenium.webdriver import ActionChains -from selenium.common.exceptions import WebDriverException -import unittest -import sys +from ...unittest_base import module_fixture_maker, RunestoneTestCase -PORT = '8081' - -class QuestionTests(unittest.TestCase): - def setUp(self): - #self.driver = webdriver.Firefox() # good for development - self.driver = webdriver.PhantomJS() # use this for Jenkins auto testing - self.host = 'http://127.0.0.1:' + PORT +setUpModule, tearDownModule = module_fixture_maker(__file__) +class QuestionTests(RunestoneTestCase): def test_hello(self): ''' 1. Get the outer div id of the activecode component @@ -68,15 +59,3 @@ def test_mc2(self): cnamestr = fb.get_attribute("class") self.assertEqual(cnamestr, "alert alert-success") - - - def tearDown(self): - self.driver.quit() - - - - -if __name__ == '__main__': - if len(sys.argv) > 1: - PORT = sys.argv.pop() - unittest.main() \ No newline at end of file diff --git a/runestone/reveal/test/__init__.py b/runestone/reveal/test/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/runestone/reveal/test/test_reveal.py b/runestone/reveal/test/test_reveal.py index 920ebf0f7..a28ac3422 100644 --- a/runestone/reveal/test/test_reveal.py +++ b/runestone/reveal/test/test_reveal.py @@ -4,22 +4,11 @@ __author__ = 'yasinovskyy' -from selenium import webdriver -from selenium.webdriver import ActionChains -from selenium.common.exceptions import WebDriverException -import unittest -import sys +from ...unittest_base import module_fixture_maker, RunestoneTestCase -PORT = '8081' +setUpModule, tearDownModule = module_fixture_maker(__file__) -class RevealQuestion_Tests(unittest.TestCase): - def setUp(self): - #self.driver = webdriver.Chrome() - #self.driver = webdriver.Firefox() # good for development - self.driver = webdriver.PhantomJS() # use this for Jenkins auto testing - self.host = 'http://127.0.0.1:' + PORT - - +class RevealQuestion_Tests(RunestoneTestCase): def test_r1(self): '''Initial view. Content is hidden''' self.driver.get(self.host + "/index.html") @@ -58,13 +47,3 @@ def test_r3(self): cnamestr = q1.get_attribute("style") self.assertEqual("display: none;", cnamestr) - - - def tearDown(self): - self.driver.quit() - - -if __name__ == '__main__': - if len(sys.argv) > 1: - PORT = sys.argv.pop() - unittest.main(verbosity=2) diff --git a/runestone/shortanswer/test/__init__.py b/runestone/shortanswer/test/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/runestone/shortanswer/test/test_shortanswer.py b/runestone/shortanswer/test/test_shortanswer.py index 0bd3915b0..d1006f6c5 100644 --- a/runestone/shortanswer/test/test_shortanswer.py +++ b/runestone/shortanswer/test/test_shortanswer.py @@ -4,21 +4,11 @@ __author__ = 'yasinovskyy' -from selenium import webdriver -from selenium.webdriver import ActionChains -from selenium.common.exceptions import WebDriverException -import unittest -import sys - -PORT = '8081' - -class ShortAnswerQuestion_Tests(unittest.TestCase): - def setUp(self): - #self.driver = webdriver.Firefox() # good for development - self.driver = webdriver.PhantomJS() # use this for Jenkins auto testing - self.host = 'http://127.0.0.1:' + PORT +from ...unittest_base import module_fixture_maker, RunestoneTestCase +setUpModule, tearDownModule = module_fixture_maker(__file__) +class ShortAnswerQuestion_Tests(RunestoneTestCase): def test_sa1(self): '''No input. Button not clicked''' self.driver.get(self.host + "/index.html") @@ -69,20 +59,10 @@ def test_sa4(self): btn_check = t1.find_element_by_tag_name('button') btn_check.click() - + ta.clear() fb = t1.find_element_by_id("question1_feedback") self.assertIsNotNone(fb) cnamestr = fb.get_attribute("class") self.assertIn("alert-success", cnamestr) - - - def tearDown(self): - self.driver.quit() - - -if __name__ == '__main__': - if len(sys.argv) > 1: - PORT = sys.argv.pop() - unittest.main(verbosity=2) diff --git a/runestone/tabbedStuff/test/__init__.py b/runestone/tabbedStuff/test/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/runestone/tabbedStuff/test/test_tabbedStuff.py b/runestone/tabbedStuff/test/test_tabbedStuff.py index 88ce66122..957582355 100644 --- a/runestone/tabbedStuff/test/test_tabbedStuff.py +++ b/runestone/tabbedStuff/test/test_tabbedStuff.py @@ -4,22 +4,11 @@ __author__ = 'yasinovskyy' -from selenium import webdriver -from selenium.webdriver import ActionChains -from selenium.common.exceptions import WebDriverException -import unittest -import sys - -PORT = '8081' - -class TabbedQuestion_Tests(unittest.TestCase): - def setUp(self): - #self.driver = webdriver.Chrome() - #self.driver = webdriver.Firefox() # good for development - self.driver = webdriver.PhantomJS() # use this for Jenkins auto testing - self.host = 'http://127.0.0.1:' + PORT - - +from ...unittest_base import module_fixture_maker, RunestoneTestCase + +setUpModule, tearDownModule = module_fixture_maker(__file__) + +class TabbedQuestion_Tests(RunestoneTestCase): def test_t1(self): '''Initial view. Tab 1 is visible, tab 2 is hidden''' self.driver.get(self.host + "/index.html") @@ -31,7 +20,7 @@ def test_t1(self): self.assertEqual("Tab 1", t1.text) self.assertEqual("Hello!", tp1.text) - + def test_t2(self): '''Tab 2 is visible, tab 1 is hidden''' self.driver.get(self.host + "/index.html") @@ -46,7 +35,7 @@ def test_t2(self): self.assertEqual("Tab 2", t1.text) self.assertEqual("Goodbye!", tp1.text) - + def test_t3(self): '''Tab 2 is selected, then tab 1''' self.driver.get(self.host + "/index.html") @@ -63,13 +52,3 @@ def test_t3(self): self.assertEqual("Tab 1", t1.text) self.assertEqual("Hello!", tp1.text) - - - def tearDown(self): - self.driver.quit() - - -if __name__ == '__main__': - if len(sys.argv) > 1: - PORT = sys.argv.pop() - unittest.main(verbosity=2) diff --git a/runestone/unittest_base.py b/runestone/unittest_base.py new file mode 100644 index 000000000..9c7b959a9 --- /dev/null +++ b/runestone/unittest_base.py @@ -0,0 +1,53 @@ +import unittest +import os +import subprocess +from selenium import webdriver + +# Select an unused port for serving web pages to the test suite. +PORT = '8081' + +# Define `module modules fixtures `_ to build the test Runestone project, run the server, then shut it down when the tests complete. +class ModuleFixture(object): + def __init__(self, + # The path to the Python module in which the test resides. This provides a simple way to determine the path in which to run runestone build/serve. + module_path): + + super(ModuleFixture, self).__init__() + self.base_path = os.path.dirname(module_path) + + def setUpModule(self): + # Change to this directory for running Runestone. + self.old_cwd = os.getcwd() + os.chdir(self.base_path) + # Compile the docs. + subprocess.check_call(['runestone', 'build', '--all']) + # Run the server. Simply calling ``runestone serve`` fails, since the process killed isn't the actual server, but probably a setuptools-created launcher. + self.runestone_server = subprocess.Popen(['python', '-m', 'runestone', 'serve', '--port', PORT]) + + def tearDownModule(self): + # Shut down the server. + self.runestone_server.kill() + # Restore the directory. + os.chdir(self.old_cwd) + +# Provide a simple way to instantiante a ModuleFixture in a test module. Typical use: +# +# .. code:: Python +# :number-lines: +# +# from unittest_base import module_fixture_maker +# setUpModule, tearDownModule = module_fixture_maker(__file__) +def module_fixture_maker(module_path): + mf = ModuleFixture(module_path) + return mf.setUpModule, mf.tearDownModule + +# Provide a base test case which sets up the `Selenium `_ driver. +class RunestoneTestCase(unittest.TestCase): + def setUp(self): + #self.driver = webdriver.Firefox() # good for development. + self.driver = webdriver.PhantomJS() # use this for Jenkins auto testing + self.host = 'http://127.0.0.1:' + PORT + + def tearDown(self): + self.driver.quit() + From 07d0e518a42599a87e67736dbf940ea896869460 Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Fri, 7 Jul 2017 23:29:11 -0500 Subject: [PATCH 8/9] Fix: Update and refactor fitb tests. --- runestone/fitb/test/_sources/index.rst | 15 ++--- runestone/fitb/test/test_fitb.py | 86 +++++++++++++++----------- 2 files changed, 56 insertions(+), 45 deletions(-) diff --git a/runestone/fitb/test/_sources/index.rst b/runestone/fitb/test/_sources/index.rst index d68d51400..1a9f31bdb 100644 --- a/runestone/fitb/test/_sources/index.rst +++ b/runestone/fitb/test/_sources/index.rst @@ -9,14 +9,11 @@ Fill in the Blank .. fillintheblank:: fill1412 - .. blank:: blank1345 - :correct: \\bred\\b - :feedback1: (".*", "Try 'red'") + Fill in the blanks to make the following sentence: "The red car drove away." - Fill in the blanks to make the following sentence: "The red car drove away" The + The |blank| car drove |blank|. - .. blank:: blank52532 - :correct: \\baway\\b - :feedback1: (".*", "Try 'away'") - - car drove \ No newline at end of file + - :red: Correct. + :x: Try 'red'. + - :car: Correct. + :x: Try 'away'. diff --git a/runestone/fitb/test/test_fitb.py b/runestone/fitb/test/test_fitb.py index 4949608c4..0c1bfbe9e 100644 --- a/runestone/fitb/test/test_fitb.py +++ b/runestone/fitb/test/test_fitb.py @@ -3,56 +3,70 @@ setUpModule, tearDownModule = module_fixture_maker(__file__) class FITBtests(RunestoneTestCase): + ## Helpers + ## ======= + def setUp(self): + super(FITBtests, self).setUp() + self.driver.get(self.host + "/index.html") # Access page + + # Return the DIV containing a FITB question. + def find_fitb(self): + self.fitb = self.driver.find_element_by_id("fill1412") + return self.fitb + + # Find one of the blanks, based on the given index. + def find_blank(self, index): + return self.fitb.find_elements_by_tag_name("input")[index] + + # Click the "Check me" button. + def click_checkme(self): + self.fitb.find_element_by_tag_name('button').click() + + # Find the question's feedback element. + def find_feedback(self): + return self.fitb.find_element_by_id("fill1412_feedback") + + ## Tests + ## ===== # One of two correct answers def test_fitb(self): ''' http://runestoneinteractive.org/build/html/directives.html#fill-in-the-blank for documentation ''' - self.driver.get(self.host + "/index.html") # Access page - quest = self.driver.find_element_by_id("fill-in-the-blank") - blank1 = quest.find_element_by_id("fill1412_blank0") - blank1.send_keys("red") - # inp = blank1.get_attribute("input") - checkme = quest.find_element_by_tag_name('button') - checkme.click() - feedback = quest.find_element_by_id("fill1412_feedback") + self.find_fitb() + self.find_blank(0).send_keys("red") + self.click_checkme() + feedback = self.find_feedback() self.assertIsNotNone(feedback.text) # No answers yet -- Incorrect feedback def test_fitb2(self): - self.driver.get(self.host + "/index.html") # Access page - quest = self.driver.find_element_by_id("fill-in-the-blank") - blank1 = quest.find_element_by_id("fill1412_blank0") - checkme = quest.find_element_by_tag_name('button') - checkme.click() - feedback = quest.find_element_by_id("fill1412_feedback") + self.find_fitb() + self.click_checkme() + feedback = self.find_feedback() + self.assertIsNotNone(feedback.text) self.assertIn("Incorrect",feedback.text) # Both correct answers def test_fitb3(self): - self.driver.get(self.host + "/index.html") # Access page - quest = self.driver.find_element_by_id("fill-in-the-blank") - - blank1 = quest.find_element_by_id("fill1412_blank0") - blank2 = quest.find_element_by_id("fill1412_blank1") - blank1.send_keys("red") - blank2.send_keys("away") - checkme = quest.find_element_by_tag_name('button') - checkme.click() - feedback = quest.find_element_by_id("fill1412_feedback") + self.find_fitb() + self.find_blank(0).send_keys("red") + self.find_blank(1).send_keys("away") + self.click_checkme() + feedback = self.find_feedback() self.assertIn("Correct", feedback.text) def test_fitb4(self): - self.driver.get(self.host + "/index.html") # Access page - quest = self.driver.find_element_by_id("fill-in-the-blank") - blank1 = quest.find_element_by_id("fill1412_blank0") - blank2 = quest.find_element_by_id("fill1412_blank1") - blank1.send_keys("reds") # Type something wrong - blank1.clear() # Delete the wrong thing - blank1.send_keys("red") # Type the right thing - blank2.send_keys("away") # Type another correct answer in another blank - checkme = quest.find_element_by_tag_name('button') - checkme.click() - feedback = quest.find_element_by_id("fill1412_feedback") - self.assertIn("Correct",feedback.text) + self.find_fitb() + blank0 = self.find_blank(0) + # Type an incorrect answer. + blank0.send_keys("red") + # Delete it. + blank0.clear() + # Type the correct answer. + blank0.send_keys("red") + self.find_blank(1).send_keys("away") + self.click_checkme() + feedback = self.find_feedback() + self.assertIn("Correct", feedback.text) From c0b4106ebb2b2d7e058f17b6f77a8d488efbe43d Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Mon, 10 Jul 2017 10:35:03 -0500 Subject: [PATCH 9/9] Fix: use Python 2.7 syntax, instead of Python 3. --- runestone/fitb/fitb.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/runestone/fitb/fitb.py b/runestone/fitb/fitb.py index ea809f47e..fda8c05ef 100644 --- a/runestone/fitb/fitb.py +++ b/runestone/fitb/fitb.py @@ -203,8 +203,10 @@ def run(self): feedback_field_name_raw = feedback_field_name.rawsource # See if this is a number, optinonally followed by a tolerance. try: - # Parse the number. - str_num, *list_tol = feedback_field_name_raw.split() + # Parse the number. In Python 3 syntax, this would be ``str_num, *list_tol = feedback_field_name_raw.split()``. + tmp = feedback_field_name_raw.split() + str_num = tmp[0] + list_tol = tmp[1:] num = ast.literal_eval(str_num) assert isinstance(num, Number) # If no tolerance is given, use a tolarance of 0.