diff --git a/package.json b/package.json index 0bbe8c470..2db3df5d9 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,9 @@ "author": "", "license": "ISC", "devDependencies": { + "@babel/core": "^7.0.0", + "@babel/preset-env": "^7.15.4", + "babel-loader": "^8.2.2", "compression-webpack-plugin": "^9.0.0", "copy-webpack-plugin": "^9.0.0", "css-loader": "^6.0.0", @@ -32,6 +35,7 @@ "dependencies": { "-": "0.0.1", "bootstrap": "3.4.1", + "btm-expressions": "^0.1.0", "codemirror": "^5.59.4", "handsontable": "7.2.2", "jexcel": "^3.9.1", diff --git a/runestone/conftest.py b/runestone/conftest.py index 9303aa2a5..fb4a1b2e9 100644 --- a/runestone/conftest.py +++ b/runestone/conftest.py @@ -56,11 +56,12 @@ # Run this once, before all tests, to update the webpacked JS. @pytest.fixture(scope="session", autouse=True) def run_webpack(): - # Note that Windows requires ``shell=True``, since the command to execute is ``npm.cmd``. - p = subprocess.run(["npm", "run", "build"], text=True, shell=IS_WINDOWS, capture_output=True) + # Note that Windows requires ``shell=True``, since the command to execute is ``npm.cmd``. Use the ``--`` to pass following args to the script (webpack), per the `npm docs `_. Use ``--env test`` to tell webpack to do a test build of the Runestone Components (see `RAND_FUNC `). + p = subprocess.run(["npm", "run", "build", "--", "--env", "test"], text=True, shell=IS_WINDOWS, capture_output=True) print(p.stderr + p.stdout) assert not p.returncode + # .. _selenium_module_fixture: # # ``selenium_module_fixture`` @@ -84,10 +85,21 @@ def selenium_driver_session(selenium_module_fixture): return selenium_module_fixture.driver +# Extend the Selenium driver with client-specific methods. +class _SeleniumClientUtils(_SeleniumUtils): + def inject_random_values(self, value_array): + self.driver.execute_script(""" + rs_test_rand = function() { + let index = 0; + return () => [%s][index++]; + }(); + """ % (", ".join([str(i) for i in value_array]))) + + # Present ``_SeleniumUser`` as a fixture. @pytest.fixture def selenium_utils(selenium_driver): # noqa: F811 - return _SeleniumUtils(selenium_driver, HOST_URL) + return _SeleniumClientUtils(selenium_driver, HOST_URL) # Provide a fixture which loads the ``index.html`` page. diff --git a/runestone/fitb/dynamic_problems.rst b/runestone/fitb/dynamic_problems.rst new file mode 100644 index 000000000..4d431ab9c --- /dev/null +++ b/runestone/fitb/dynamic_problems.rst @@ -0,0 +1,48 @@ +**************** +Dynamic problems +**************** +The fill-in-the-blank problem type supports standard (static) problem; it also supports dynamic problems, where a new problem is randomly generated based on a template provided by the dynamic problem. This document discusses the design of the dynamic problem additions. + +Types of dynamic problems +========================= +There are three cases for both traditional static problems and for dynamic problems: + +- Client-side (when the ``use_services`` in ``pavement.py`` is false): grading is done on the client and results stored only on the client. For dynamic problems, a random seed and the problem text is generated on the client. +- Server-side: (when ``use_services`` is true): grading is done on the client, but the student answer and the graded result are stored on the server (if available) and on the client. Problem state is restored first from the server (if available) then from the client. For dynamic problems, a random seed is generated on the server. Problem text is generated from this server-supplied seed on the client. +- Server-side graded (``use_services`` is true and ``runestone_server_side_grading`` in ``conf.py`` is True): grading is done on the server; the student answer and the graded result are stored on the server (if available) and on the client. Problem state is restored first from the server (if available) then from the client. Both the random seed and the problem text are generated on the server. + +Design +====== +The following principles guided the design of dynamic problems + +Server-side problem generation +------------------------------ +The purpose of server-side grading is to improve the security of grading problems, typically for high-stakes assessments such as a test. Client-side grading means the client both knows the correct answers and is responsible for correctly grading answers, both of which provide many opportunities for attack. + +Therefore, server-side grading of dynamic problems requires that all problem generation and grading occur on the server, since problem generation often begins with choosing a solution, then proceeds to compute the problem from this known solution. For example, a problem on the quadratic equation begins by selecting two roots, :math:`r_1` and :math:`r_2`. We therefore know that :math:`\left(x - r_1 \right) \left(x - r_2 \right) = 0`, giving :math:`x^2 - \left(r_1 + r_2 \right) x + r_1 r_2 = 0`. Assigning :math:`a = 1`, :math:`b = -\left(r_1 + r_2 \right)`, and :math:`c = r_1 r_2` provides a student-facing problem of :math:`ax^2 + bx + c = 0`. Starting from the solution of :math:`x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}` requires ensuring randomly chosen values for :math:`a`, :math:`b`, and :math:`c` produce real, integral roots, which is a more difficult task. + +Programming language for dynamic problems +----------------------------------------- +The extensive `WeBWorK system `_ contains 20,000 dynamic problems developed in Perl, making this an attractive option. However, Perl lacks much as a language; the 2021 Stack Overflow survey reports that 2.46% of the surveyed developers work in Perl, while JavaScript captures 65% and Python 48%. Perl v5 was released in 2000 and remains at v5 today (not counting Perl v6, since it became a separate language called Raku). In addition, there are few good options for executing Perl in the browser. + +While Python is attractive, the options for running it in the client are limited and require large downloads. JavaScript in a web browser; the `Js2Py `_ Python package provides a working JavaScript v5.1 engine that should be sufficient to run dynamic problems. Therefore, JavaScript was selected as the programming language for dynamic problems. + +Templates +--------- +Dynamic problems need the ability to insert generated values into the text of the problem and into problem feedback. A simple templating syntax was adopted, which frees authors from learning (yet another) template language. + +Summary +------- +Based on these choices: + +- Dynamic problems are authored in JavaScript, with text using simple templates. +- Dynamic problems are rendered and graded in the browser for client-side or server-side operation. They are rendered and graded on the server for server-side graded operation. + + +Architecture +============ +- The Python server must be able to evaluate JavaScript to generate problem text and grade problems. +- The same JavaScript code used to generate a problem and grade a problem run on both the client (when not doing server-side grading) and the server (for server-side grading). Webpack is used to build the same code into a client bundle and a server bundle. +- Per-problem random seeds are generated on the client for client-side operation; they are generated on the server for server-side operation. + +On the client side, a primary challenge is to create a coherent plan for what data is stored where and at what point in the lifecycle of a problem. See `js/fitb.js` for these details. \ No newline at end of file diff --git a/runestone/fitb/fitb.py b/runestone/fitb/fitb.py index 0c92658a1..1aa4ed88d 100644 --- a/runestone/fitb/fitb.py +++ b/runestone/fitb/fitb.py @@ -23,7 +23,6 @@ import ast from numbers import Number import re -import pdb from docutils import nodes from docutils.parsers.rst import directives @@ -70,53 +69,88 @@ class FITBNode(nodes.General, nodes.Element, RunestoneIdNode): def visit_fitb_html(self, node): - - node["delimiter"] = "_start__{}_".format(node["runestone_options"]["divid"]) - self.body.append(node["delimiter"]) - - res = node["template_start"] % node["runestone_options"] - self.body.append(res) + # Save the HTML that's been generated so far. We want to know only what's generated inside this directive. + self.context.append(self.body) + self.body = [] def depart_fitb_html(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"]): + # If there were fewer blanks than feedback items, add blanks at the end of the question. Also, determine the names of each blank. + blank_names = {} + blank_count = 0 + for blank_node in node.traverse(BlankNode): + # Empty blanks have a "name" of ``-``. + name = blank_node["input_name"] + if name != "-": + # Map from the blank's name to its index in the array of blanks. Don't include unnamed blanks. + blank_names[name] = blank_count + blank_count += 1 + while blank_count < len(node["feedbackArray"]): visit_blank_html(self, None) - blankCount += 1 + blank_count += 1 # Warn if there are fewer feedback items than blanks. - if len(node["feedbackArray"]) < blankCount: + if len(node["feedbackArray"]) < blank_count: # Taken from the example in the `logging API `_. logger = logging.getLogger(__name__) logger.warning( "Not enough feedback for the number of blanks supplied.", location=node ) - # Generate the HTML. - json_feedback = json.dumps(node["feedbackArray"]) + # Capture HTML generated inside this directive. + inner_html = self.body + self.body = self.context.pop() + + # Generate the HTML. Start with required JSON data. + db_dict = { + "problemHtml": "".join(inner_html), + "dyn_vars": node.dynamic, + "blankNames": blank_names, + "feedbackArray": node["feedbackArray"], + } + # Add in optional data. + if dyn_imports := node["runestone_options"].get("dyn_imports", "").split(): + db_dict["dyn_imports"] = dyn_imports + if (static_seed := node["runestone_options"].get("static_seed")) is not None: + db_dict["static_seed"] = static_seed + db_json = json.dumps(db_dict) + # Some nodes (for example, those in a timed node) have their ``document == None``. Find a valid ``document``. node_with_document = node while not node_with_document.document: node_with_document = node_with_document.parent - # Supply grading info (the ``json_feedback``) to the client if we're not grading on the server. - ssg = node_with_document.document.settings.env.config.runestone_server_side_grading - node["runestone_options"]["json"] = "false" if ssg else json_feedback - res = node["template_end"] % node["runestone_options"] - self.body.append(res) + # Supply client-side grading info if we're not grading on the server. + if node_with_document.document.settings.env.config.runestone_server_side_grading: + if node.dynamic: + # Server-side graded dynamic problems render and provide the problem's HTML on the server; just tell the client it's a dynamic problem. + client_json = json.dumps(dict(dyn_vars=True)) + else: + # Other server-side graded problems need the problem's HTML. + client_json = json.dumps(dict(problemHtml="".join(inner_html))) + else: + client_json = db_json + node["runestone_options"]["client_json"] = client_json + outer_html = ( + """ +
+ +
+ """ + % node["runestone_options"] + ) # add HTML to the Database and clean up addHTMLToDB( node["runestone_options"]["divid"], node["runestone_options"]["basecourse"], - "".join(self.body[self.body.index(node["delimiter"]) + 1 :]), - # Either provide grading info to enable server-side grading or pass ``None`` to select client-side grading. - json_feedback if ssg else None, + outer_html, + db_json, ) - - self.body.remove(node["delimiter"]) + self.body.append(outer_html) # @@ -233,7 +267,14 @@ class FillInTheBlank(RunestoneIdDirective): option_spec = RunestoneIdDirective.option_spec.copy() option_spec.update( { - "casei": directives.flag, # case insensitive matching + # For dynamic problems, this contains JavaScript code which defines the variables used in template substitution in the problem. If this option isn't present, the problem will be a static problem. + "dyn_vars": directives.unchanged, + # For dynamic problems, this contain a space-separated list of libraries needed by this problem. + "dyn_imports": directives.unchanged, + # For dynamic problems, this provides an optional static seed. + "static_seed": directives.unchanged, + # case insensitive matching + "casei": directives.flag, } ) @@ -247,20 +288,6 @@ def run(self): super(FillInTheBlank, self).run() - TEMPLATE_START = """ -
- -
- """ - addQuestionToDB(self) fitbNode = FITBNode() @@ -269,18 +296,21 @@ def run(self): fitbNode["source"], fitbNode["line"] = self.state_machine.get_source_and_line( self.lineno ) - fitbNode["template_start"] = TEMPLATE_START - fitbNode["template_end"] = TEMPLATE_END self.updateContent() - self.state.nested_parse(self.content, self.content_offset, fitbNode) + # Process dynamic problem content. env = self.state.document.settings.env + dyn_vars = self.options.get("dyn_vars") + # Store the dynamic code, or None if it's a static problem. + fitbNode.dynamic = dyn_vars + + self.state.nested_parse(self.content, self.content_offset, fitbNode) self.options["divclass"] = env.config.fitb_div_class # Expected _`structure`, with assigned variable names and transformations made: # # .. code-block:: - # :number-lines: + # :linenos: # # fitbNode = FITBNode() # Item 1 of problem text @@ -299,14 +329,16 @@ def run(self): # This becomes a data structure: # # .. code-block:: - # :number-lines: + # :linenos: # # self.feedbackArray = [ # [ # blankArray # { # blankFeedbackDict: feedback 1 - # "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. + # "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 OR + # "solution_code" : source_code, # For dynamic problems -- an expression which evaluates + # # to true or false to determine if the solution was correct. # "feedback": feedback_field_body (after being rendered as HTML) # Provides feedback for this answer. # }, # { # Feedback 2 @@ -314,13 +346,14 @@ def run(self): # } # ], # [ # Blank 2, same as above. - # ] + # ], + # ..., # ] # # ...and a transformed node structure: # # .. code-block:: - # :number-lines: + # :linenos: # # fitbNode = FITBNode() # Item 1 of problem text @@ -338,7 +371,7 @@ def run(self): get_node_line(feedback_bullet_list) ) ) - # Thelength of feedbback_list_item gives us the number of blanks. + # The length of feedbback_list_item gives us the number of blanks. # the number of feedback is len(feedback_bullet_list.children[x].children[0].children) for feedback_list_item in feedback_bullet_list.children: assert isinstance(feedback_list_item, nodes.list_item) @@ -358,46 +391,49 @@ def run(self): 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. 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. - 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. - regex = ( - # The given regex must match the entire string, from the beginning (which may be preceded by whitespaces) ... - r"^\s*" - # ... to the contents (where a single space in the provided pattern is treated as one or more whitespaces in the student's answer) ... - + feedback_field_name.rawsource.replace(" ", r"\s+") - # ... to the end (also with optional spaces). - + r"\s*$" - ) - blankFeedbackDict = { - "regex": regex, - "regexFlags": "i" if "casei" in self.options else "", - } - # Test out the regex to make sure it compiles without an error. + # Simply store the solution code for a dynamic problem. + if dyn_vars: + blankFeedbackDict = {"solution_code": feedback_field_name_raw} + else: + # See if this is a number, optionally followed by a tolerance. try: - re.compile(regex) - except Exception as ex: - raise self.error( - 'Error when compiling regex "{}": {}.'.format( - regex, str(ex) - ) + # 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 tolerance 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. + regex = ( + # The given regex must match the entire string, from the beginning (which may be preceded by whitespaces) ... + r"^\s*" + + + # ... to the contents (where a single space in the provided pattern is treated as one or more whitespaces in the student's answer) ... + feedback_field_name.rawsource.replace(" ", r"\s+") + # ... to the end (also with optional spaces). + + r"\s*$" ) + blankFeedbackDict = { + "regex": regex, + "regexFlags": "i" if "casei" in self.options else "", + } + # Test out the regex to make sure it compiles without an error. + try: + re.compile(regex) + except Exception as ex: + raise self.error( + 'Error when compiling regex "{}": {}.'.format( + regex, str(ex) + ) + ) blankArray.append(blankFeedbackDict) feedback_field_body = feedback_field[1] assert isinstance(feedback_field_body, nodes.field_body) @@ -429,7 +465,7 @@ def run(self): 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` is a string containing the entire 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, @@ -443,9 +479,7 @@ def BlankRole( content=[], ): - # Blanks ignore all arguments, just inserting a blank. - blank_node = BlankNode(rawtext) - blank_node.line = lineno + blank_node = BlankNode(rawtext, input_name=text) return [blank_node], [] @@ -454,7 +488,11 @@ class BlankNode(nodes.Inline, nodes.TextElement, RunestoneNode): def visit_blank_html(self, node): - self.body.append('') + # Note that the fitb visit code may call this with ``node = None``. + name = node["input_name"] if node else "" + # If the blank contained a name, use that as the name of the input element. A name of ``-`` (the default value for ``|blank|``, since there's no way to pass an empty value) is treated as an unnamed input element. + html_name = "" if name == "-" else f" name={repr(name)}" + self.body.append(f'') def visit_blank_xml(self, node): diff --git a/runestone/fitb/fitb_html_structure.html b/runestone/fitb/fitb_html_structure.html new file mode 100644 index 000000000..e4eff5cdf --- /dev/null +++ b/runestone/fitb/fitb_html_structure.html @@ -0,0 +1,286 @@ + + +
+ +
+ + + +
+ +
+ + + +
+ +
diff --git a/runestone/fitb/js/fitb-i18n.en.js b/runestone/fitb/js/fitb-i18n.en.js index cc949cd7f..9b7601585 100644 --- a/runestone/fitb/js/fitb-i18n.en.js +++ b/runestone/fitb/js/fitb-i18n.en.js @@ -3,5 +3,6 @@ $.i18n().load({ msg_no_answer: "No answer provided.", msg_fitb_check_me: "Check me", msg_fitb_compare_me: "Compare me", + msg_fitb_randomize: "Randomize", }, }); diff --git a/runestone/fitb/js/fitb-utils.js b/runestone/fitb/js/fitb-utils.js new file mode 100644 index 000000000..58691d6d6 --- /dev/null +++ b/runestone/fitb/js/fitb-utils.js @@ -0,0 +1,378 @@ +// ******************************************************** +// |docname| - grading-related utilities for FITB questions +// ******************************************************** +// This code runs both on the server (for server-side grading) and on the client. It's placed here as a set of functions specifically for this purpose. + +"use strict"; + +import { aleaPRNG } from "./libs/aleaPRNG-1.1.js"; + +// Includes +// ======== +// None. +// +// +// Globals +// ======= +function render_html(html_in, dyn_vars_eval) { + // Change the replacement tokens in the HTML into tags, so we can replace them using XML. The horrible regex is: + // + // Look for the characters ``[%=`` (the opening delimiter) + /// \[%= + // Followed by any amount of whitespace. + /// \s* + // Start a group that will capture the contents (excluding whitespace) of the tokens. For example, given ``[%= foo() %]``, the contents is ``foo()``. + /// ( + // Don't capture the contents of this group, since it's only a single character. Match any character... + /// ( + /// ?:. + /// ...that doesn't end with ``%]`` (the closing delimiter). + /// (?!%]) + /// ) + // Match this (anything but the closing delimiter) as much as we can. + /// *) + // Next, look for any whitespace. + /// \s* + // Finally, look for the closing delimiter ``%]``. + /// %\] + const html_replaced = html_in.replaceAll( + /\[%=\s*((?:.(?!%]))*)\s*%\]/g, + // Replace it with a `` tag. Quote the string, which will automatically escape any double quotes, using JSON. + (match, group1) => + `` + ); + // Given HTML, turn it into a DOM. Walk the ```` tags, performing the requested evaluation on them. + // + // See `DOMParser `_. + const parser = new DOMParser(); + // See `DOMParser.parseFromString() `_. + const doc = parser.parseFromString(html_replaced, "text/html"); + const script_eval_tags = doc.getElementsByTagName("script-eval"); + while (script_eval_tags.length) { + // Get the first tag. It will be removed from the collection after it's replaced with its value. + const script_eval_tag = script_eval_tags[0]; + // See if this ```` tag has as ``@expr`` attribute. + const expr = script_eval_tag.getAttribute("expr"); + // If so, evaluate it. + if (expr) { + const eval_result = window.Function( + "v", + ...Object.keys(dyn_vars_eval), + `"use strict;"\nreturn ${expr};` + )(dyn_vars_eval, ...Object.values(dyn_vars_eval)); + // Replace the tag with the resulting value. + script_eval_tag.replaceWith(eval_result); + } + } + + // Return the body contents. Note that the ``DOMParser`` constructs an entire document, not just the document fragment we passed it. Therefore, extract the desired fragment and return that. Note that we need to use `childNodes `_, which includes non-element children like text and comments; using ``children`` omits these non-element children. + return doc.body.childNodes; +} + +// Functions +// ========= +// Update the problem's description based on dynamically-generated content. +export function renderDynamicContent( + seed, + dyn_vars, + dyn_imports, + html_in, + divid, + prepareCheckAnswers +) { + // Initialize RNG with ``seed``. + const rand = aleaPRNG(seed); + + const dyn_vars_eval = window.Function( + "v", + "rand", + ...Object.keys(dyn_imports), + `"use strict";\n${dyn_vars};\nreturn v;` + )( + // We want v.divid = divid and v.prepareCheckAnswers = prepareCheckAnswers. In contrast, the key/values pairs of dyn_imports should be directly assigned to v, hence the Object.assign. + Object.assign({ divid, prepareCheckAnswers }, dyn_imports), + // See `RAND_FUNC `_, which refers to ``rand`` above. + RAND_FUNC, + // In addition to providing this in v, make it available in the function as well, since most problem authors will write ``foo = new BTM()`` (for example, assuming BTM is in dyn_imports) instead of ``foo = new v.BTM()`` (which is unusual syntax). + ...Object.values(dyn_imports) + ); + + let html_out; + if (typeof dyn_vars_eval.beforeContentRender === "function") { + try { + dyn_vars_eval.beforeContentRender(dyn_vars_eval); + } catch (err) { + console.assert( + false, + `Error in problem ${divid} invoking beforeContentRender` + ); + throw err; + } + } + try { + html_out = render_html(html_in, dyn_vars_eval); + } catch (err) { + console.assert(false, `Error rendering problem ${divid} text.`); + throw err; + } + + // the afterContentRender event will be called by the caller of this function (after it updated the HTML based on the contents of html_out). + return [html_out, dyn_vars_eval]; +} + +// Given student answers, grade them and provide feedback. +// +// Outputs: +// +// - ``displayFeed`` is an array of HTML feedback. +// - ``isCorrectArray`` is an array of true, false, or null (the question wasn't answered). +// - ``correct`` is true, false, or null (the question wasn't answered). +// - ``percent`` is the percentage of correct answers (from 0 to 1, not 0 to 100). +export function checkAnswersCore( + // _`blankNamesDict`: An dict of {blank_name, blank_index} specifying the name for each named blank. + blankNamesDict, + // _`given_arr`: An array of strings containing student-provided answers for each blank. + given_arr, + // A 2-D array of strings giving feedback for each blank. + feedbackArray, + // _`dyn_vars_eval`: A dict produced by evaluating the JavaScript for a dynamic exercise. + dyn_vars_eval +) { + if ( + dyn_vars_eval && + typeof dyn_vars_eval.beforeCheckAnswers === "function" + ) { + const [namedBlankValues, given_arr_converted] = parseAnswers( + blankNamesDict, + given_arr, + dyn_vars_eval + ); + const dve_blanks = Object.assign({}, dyn_vars_eval, namedBlankValues); + try { + dyn_vars_eval.beforeCheckAnswers(dve_blanks, given_arr_converted); + } catch (err) { + console.assert(false, "Error calling beforeCheckAnswers"); + throw err; + } + } + + // Keep track if all answers are correct or not. + let correct = true; + const isCorrectArray = []; + const displayFeed = []; + for (let i = 0; i < given_arr.length; i++) { + const given = given_arr[i]; + // If this blank is empty, provide no feedback for it. + if (given === "") { + isCorrectArray.push(null); + // TODO: was $.i18n("msg_no_answer"). + displayFeed.push("No answer provided."); + correct = false; + } else { + // Look through all feedback for this blank. The last element in the array always matches. If no feedback for this blank exists, use an empty list. + const fbl = feedbackArray[i] || []; + let j; + for (j = 0; j < fbl.length; j++) { + // The last item of feedback always matches. + if (j === fbl.length - 1) { + displayFeed.push(fbl[j]["feedback"]); + break; + } + // If this is a dynamic solution... + if (dyn_vars_eval) { + const [namedBlankValues, given_arr_converted] = + parseAnswers(blankNamesDict, given_arr, dyn_vars_eval); + // If there was a parse error, then it student's answer is incorrect. + if (given_arr_converted[i] instanceof TypeError) { + displayFeed.push(given_arr_converted[i].message); + // Count this as wrong by making j != 0 -- see the code that runs immediately after the executing the break. + j = 1; + break; + } + // Create a function to wrap the expression to evaluate. See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/Function. + // Pass the answer, array of all answers, then all entries in ``this.dyn_vars_eval`` dict as function parameters. + const is_equal = window.Function( + "ans", + "ans_array", + ...Object.keys(dyn_vars_eval), + ...Object.keys(namedBlankValues), + `"use strict;"\nreturn ${fbl[j]["solution_code"]};` + )( + given_arr_converted[i], + given_arr_converted, + ...Object.values(dyn_vars_eval), + ...Object.values(namedBlankValues) + ); + // If student's answer is equal to this item, then append this item's feedback. + if (is_equal) { + displayFeed.push( + typeof is_equal === "string" + ? is_equal + : fbl[j]["feedback"] + ); + break; + } + } else { + // If this is a regexp... + if ("regex" in fbl[j]) { + const patt = RegExp( + fbl[j]["regex"], + fbl[j]["regexFlags"] + ); + if (patt.test(given)) { + displayFeed.push(fbl[j]["feedback"]); + break; + } + } else { + // This is a number. + console.assert("number" in fbl[j]); + const [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. + const actual = +given; + if (actual >= min && actual <= max) { + displayFeed.push(fbl[j]["feedback"]); + break; + } + } + } + } + + // The answer is correct if it matched the first element in the array. A special case: if only one answer is provided, count it wrong; this is a misformed problem. + const is_correct = j === 0 && fbl.length > 1; + isCorrectArray.push(is_correct); + if (!is_correct) { + correct = false; + } + } + } + + if ( + dyn_vars_eval && + typeof dyn_vars_eval.afterCheckAnswers === "function" + ) { + const [namedBlankValues, given_arr_converted] = parseAnswers( + blankNamesDict, + given_arr, + dyn_vars_eval + ); + const dve_blanks = Object.assign({}, dyn_vars_eval, namedBlankValues); + try { + dyn_vars_eval.afterCheckAnswers(dve_blanks, given_arr_converted); + } catch (err) { + console.assert(false, "Error calling afterCheckAnswers"); + throw err; + } + } + + const percent = + isCorrectArray.filter(Boolean).length / isCorrectArray.length; + return [displayFeed, correct, isCorrectArray, percent]; +} + +// Use the provided parsers to convert a student's answers (as strings) to the type produced by the parser for each blank. +function parseAnswers( + // See blankNamesDict_. + blankNamesDict, + // See given_arr_. + given_arr, + // See `dyn_vars_eval`. + dyn_vars_eval +) { + // Provide a dict of {blank_name, converter_answer_value}. + const namedBlankValues = getNamedBlankValues( + given_arr, + blankNamesDict, + dyn_vars_eval + ); + // Invert blankNamedDict: compute an array of [blank_0_name, ...]. Note that the array may be sparse: it only contains values for named blanks. + const given_arr_names = []; + for (const [k, v] of Object.entries(blankNamesDict)) { + given_arr_names[v] = k; + } + // Compute an array of [converted_blank_0_val, ...]. Note that this re-converts all the values, rather than (possibly deep) copying the values from already-converted named blanks. + const given_arr_converted = given_arr.map((value, index) => + type_convert(given_arr_names[index], value, index, dyn_vars_eval) + ); + + return [namedBlankValues, given_arr_converted]; +} + +// Render the feedback for a dynamic problem. +export function renderDynamicFeedback( + // See blankNamesDict_. + blankNamesDict, + // See given_arr_. + given_arr, + // The index of this blank in given_arr_. + index, + // The feedback for this blank, containing a template to be rendered. + displayFeed_i, + // See dyn_vars_eval_. + dyn_vars_eval +) { + // Use the answer, an array of all answers, the value of all named blanks, and all solution variables for the template. + const namedBlankValues = getNamedBlankValues( + given_arr, + blankNamesDict, + dyn_vars_eval + ); + const sol_vars_plus = Object.assign( + { + ans: given_arr[index], + ans_array: given_arr, + }, + dyn_vars_eval, + namedBlankValues + ); + try { + displayFeed_i = render_html(displayFeed_i, sol_vars_plus); + } catch (err) { + console.assert(false, `Error evaluating feedback index ${index}.`); + throw err; + } + + return displayFeed_i; +} + +// Utilities +// --------- +// For each named blank, get the value for the blank: the value of each ``blankName`` gives the index of the blank for that name. +function getNamedBlankValues(given_arr, blankNamesDict, dyn_vars_eval) { + const namedBlankValues = {}; + for (const [blank_name, blank_index] of Object.entries(blankNamesDict)) { + namedBlankValues[blank_name] = type_convert( + blank_name, + given_arr[blank_index], + blank_index, + dyn_vars_eval + ); + } + return namedBlankValues; +} + +// Convert a value given its type. +function type_convert(name, value, index, dyn_vars_eval) { + // The converter can be defined by index, name, or by a single value (which applies to all blanks). If not provided, just pass the data through. + const types = dyn_vars_eval.types || pass_through; + const converter = types[name] || types[index] || types; + // ES5 hack: it doesn't support binary values, and js2py doesn't allow me to override the ``Number`` class. So, define the workaround class ``Number_`` and use it if available. + if (converter === Number && typeof Number_ !== "undefined") { + converter = Number_; + } + + // Return the converted type. If the converter raises a TypeError, return that; it will be displayed to the user, since we assume type errors are a way for the parser to explain to the user why the parse failed. For all other errors, re-throw it since something went wrong. + try { + return converter(value); + } catch (err) { + if (err instanceof TypeError) { + return err; + } else { + throw err; + } + } +} + +// A pass-through "converter". +function pass_through(val) { + return val; +} diff --git a/runestone/fitb/js/fitb.js b/runestone/fitb/js/fitb.js index ce4d4f034..47e25c63f 100644 --- a/runestone/fitb/js/fitb.js +++ b/runestone/fitb/js/fitb.js @@ -1,15 +1,83 @@ -// ********* -// |docname| -// ********* +// *********************************************** +// |docname| -- fill-in-the-blank client-side code +// *********************************************** // This file contains the JS for the Runestone fillintheblank component. It was created By Isaiah Mayerchak and Kirby Olson, 6/4/15 then revised by Brad Miller, 2/7/20. +// +// Data storage notes +// ================== +// +// Initial problem restore +// ----------------------- +// In the constructor, this code (the client) restores the problem by calling ``checkServer``. To do so, either the server sends or local storage has: +// +// - seed (used only for dynamic problems) +// - answer +// - displayFeed (server-side grading only; otherwise, this is generated locally by client code) +// - correct (SSG) +// - isCorrectArray (SSG) +// - problemHtml (SSG with dynamic problems only) +// +// If any of the answers are correct, then the client shows feedback. This is implemented in restoreAnswers_. +// +// Grading +// ------- +// When the user presses the "Check me" button, the logCurrentAnswer_ function: +// +// - Saves the following to local storage: +// +// - seed +// - answer +// - timestamp +// - problemHtml +// +// Note that there's no point in saving displayFeed, correct, or isCorrectArray, since these values applied to the previous answer, not the new answer just submitted. +// +// - Sends the following to the server; stop after this for client-side grading: +// +// - seed (ignored for server-side grading) +// - answer +// - correct (ignored for SSG) +// - percent (ignored for SSG) +// +// - Receives the following from the server: +// +// - timestamp +// - displayFeed +// - correct +// - isCorrectArray +// +// - Saves the following to local storage: +// +// - seed +// - answer +// - timestamp +// - displayFeed (SSG only) +// - correct (SSG only) +// - isCorrectArray (SSG only) +// - problemHtml +// +// Randomize +// --------- +// When the user presses the "Randomize" button (which is only available for dynamic problems), the randomize_ function: +// +// - For the client-side case, sets the seed to a new, random value. For the server-side case, requests a new seed and problemHtml from the server. +// - Sets the answer to an array of empty strings. +// - Saves the usual local data. + "use strict"; import RunestoneBase from "../../common/js/runestonebase.js"; +import { + renderDynamicContent, + checkAnswersCore, + renderDynamicFeedback, +} from "./fitb-utils.js"; import "./fitb-i18n.en.js"; import "./fitb-i18n.pt-br.js"; import "../css/fitb.css"; -export var FITBList = {}; // Object containing all instances of FITB that aren't a child of a timed assessment. +// Object containing all instances of FITB that aren't a child of a timed assessment. +export var FITBList = {}; // FITB constructor export default class FITB extends RunestoneBase { @@ -22,22 +90,127 @@ export default class FITB extends RunestoneBase { this.correct = null; // 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 `_. If this parses to ``false``, then no feedback is available; server-side grading will be performed. - this.feedbackArray = JSON.parse( - this.scriptSelector(this.origElem).html() - ); + // Find the script tag containing JSON and parse it. See `SO `__. If this tag doesn't exist, then no feedback is available; server-side grading will be performed. + // + // A destructuring assignment would be perfect, but they don't work with ``this.blah`` and ``with`` statements aren't supported in strict mode. + const json_element = this.scriptSelector(this.origElem); + const dict_ = JSON.parse(json_element.html()); + json_element.remove(); + this.problemHtml = dict_.problemHtml; + this.dyn_vars = dict_.dyn_vars; + this.blankNames = dict_.blankNames; + this.feedbackArray = dict_.feedbackArray; + this.createFITBElement(); + this.setupBlanks(); this.caption = "Fill in the Blank"; this.addCaption("runestone"); - this.checkServer("fillb", true); - if (typeof Prism !== "undefined") { - Prism.highlightAllUnder(this.containerDiv); + + // Define a promise which imports any libraries needed by dynamic problems. + this.dyn_imports = {}; + let imports_promise = Promise.resolve(); + if (dict_.dyn_imports !== undefined) { + // Collect all import promises. + let import_promises = []; + for (const import_ of dict_.dyn_imports) { + switch (import_) { + // For imports known at webpack build, bring these in. + case "BTM": + import_promises.push( + import("btm-expressions/src/BTM_root.js") + ); + break; + // Allow for local imports, usually from problems defined outside the Runestone Components. + default: + import_promises.push( + import(/* webpackIgnore: true */ import_) + ); + break; + } + } + + // Combine the resulting module namespace objects when these promises resolve. + imports_promise = Promise.all(import_promises) + .then( + (module_namespace_arr) => + (this.dyn_imports = Object.assign( + {}, + ...module_namespace_arr + )) + ) + .catch((err) => { + throw `Failed dynamic import: ${err}.`; + }); } + + // Resolve these promises. + imports_promise.then(() => { + this.checkServer("fillb", false).then(() => { + // One option for a dynamic problem is to produce a static problem by providing a fixed seed value. This is typically used when the goal is to render the problem as an image for inclusion in static content (a PDF, etc.). To support this, consider the following cases: + // + /// Case Has static seed? Is a client-side, dynamic problem? Has local seed? Result + /// 0 No No X No action needed. + /// 1 No Yes No this.randomize(). + /// 2 No Yes Yes No action needed -- problem already restored from local storage. + /// 3 Yes No X Warning: seed ignored. + /// 4 Yes Yes No Assign seed; this.renderDynamicContent(). + /// 5 Yes Yes Yes If seeds differ, issue warning. No additional action needed -- problem already restored from local storage. + + const has_static_seed = dict_.static_seed !== undefined; + const is_client_dynamic = typeof this.dyn_vars === "string"; + const has_local_seed = this.seed !== undefined; + + // Case 1 + if (!has_static_seed && is_client_dynamic && !has_local_seed) { + this.randomize(); + } + // Case 3 + else if (has_static_seed && !is_client_dynamic) { + console.assert( + false, + "Warning: the provided static seed was ignored, because it only affects client-side, dynamic problems." + ); + } + // Case 4 + else if ( + has_static_seed && + is_client_dynamic && + !has_local_seed + ) { + this.seed = dict_.static_seed; + this.renderDynamicContent(); + } + // Case 5 + else if ( + has_static_seed && + is_client_dynamic && + has_local_seed && + this.seed !== dict_.static_seed + ) { + console.assert( + false, + "Warning: the provided static seed was overridden by the seed found in local storage." + ); + } + // Cases 0 and 2 + else { + // No action needed. + } + + if (typeof Prism !== "undefined") { + Prism.highlightAllUnder(this.containerDiv); + } + + this.indicate_component_ready(); + }); + }); } + // Find the script tag containing JSON in a given root DOM node. scriptSelector(root_node) { return $(root_node).find(`script[type="application/json"]`); } + /*=========================================== ==== Functions generating final HTML ==== ===========================================*/ @@ -52,31 +225,22 @@ export default class FITB extends RunestoneBase { // The text [input] elements are created by the template. this.containerDiv = document.createElement("div"); this.containerDiv.id = this.divid; - // Copy the original elements to the container holding what the user will see. - $(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. - let ba = $(this.containerDiv).find(":input"); - ba.attr("class", "form form-control selectwidthauto"); - ba.attr("aria-label", "input area"); - this.blankArray = ba.toArray(); - // When a blank is changed mark this component as interacted with. - // And set a class on the component in case we want to render components that have been used - // differently - for (let blank of this.blankArray) { - $(blank).change(this.recordAnswered.bind(this)); + // Create another container which stores the problem description. + this.descriptionDiv = document.createElement("div"); + this.containerDiv.appendChild(this.descriptionDiv); + // Copy the original elements to the container holding what the user will see (client-side grading only). + if (this.problemHtml) { + this.descriptionDiv.innerHTML = this.problemHtml; + // Save original HTML (with templates) used in dynamic problems. + this.descriptionDiv.origInnerHTML = this.problemHtml; } } - recordAnswered() { - this.isAnswered = true; - //let rcontainer = this.containerDiv.closest(".runestone"); - //rcontainer.addClass("answered"); - } - renderFITBButtons() { - // "submit" button and "compare me" button + this.containerDiv.appendChild(document.createElement("br")); + this.containerDiv.appendChild(document.createElement("br")); + + // "submit" button this.submitButton = document.createElement("button"); this.submitButton.textContent = $.i18n("msg_fitb_check_me"); $(this.submitButton).attr({ @@ -86,15 +250,15 @@ export default class FITB extends RunestoneBase { }); this.submitButton.addEventListener( "click", - function () { + async function () { this.checkCurrentAnswer(); - this.logCurrentAnswer(); + await this.logCurrentAnswer(); }.bind(this), false ); - this.containerDiv.appendChild(document.createElement("br")); - this.containerDiv.appendChild(document.createElement("br")); this.containerDiv.appendChild(this.submitButton); + + // "compare me" button if (this.useRunestoneServices) { this.compareButton = document.createElement("button"); $(this.compareButton).attr({ @@ -113,6 +277,26 @@ export default class FITB extends RunestoneBase { ); this.containerDiv.appendChild(this.compareButton); } + + // Randomize button for dynamic problems. + if (this.dyn_vars) { + this.randomizeButton = document.createElement("button"); + $(this.randomizeButton).attr({ + class: "btn btn-default", + id: this.origElem.id + "_bcomp", + name: "randomize", + }); + this.randomizeButton.textContent = $.i18n("msg_fitb_randomize"); + this.randomizeButton.addEventListener( + "click", + function () { + this.randomize(); + }.bind(this), + false + ); + this.containerDiv.appendChild(this.randomizeButton); + } + this.containerDiv.appendChild(document.createElement("div")); } renderFITBFeedbackDiv() { @@ -121,10 +305,70 @@ export default class FITB extends RunestoneBase { this.containerDiv.appendChild(document.createElement("br")); this.containerDiv.appendChild(this.feedBackDiv); } + + clearFeedbackDiv() { + // Setting the ``outerHTML`` removes this from the DOM. Use an alternative process -- remove the class (which makes it red/green based on grading) and content. + this.feedBackDiv.innerHTML = ""; + this.feedBackDiv.className = ""; + } + + // Update the problem's description based on dynamically-generated content. + renderDynamicContent() { + // ``this.dyn_vars`` can be true; if so, don't render it, since the server does all the rendering. + if (typeof this.dyn_vars === "string") { + let html_nodes; + [html_nodes, this.dyn_vars_eval] = renderDynamicContent( + this.seed, + this.dyn_vars, + this.dyn_imports, + this.descriptionDiv.origInnerHTML, + this.divid, + this.prepareCheckAnswers.bind(this) + ); + this.descriptionDiv.replaceChildren(...html_nodes); + + if (typeof this.dyn_vars_eval.afterContentRender === "function") { + try { + this.dyn_vars_eval.afterContentRender(this.dyn_vars_eval); + } catch (err) { + console.assert( + false, + `Error in problem ${this.divid} invoking afterContentRender` + ); + throw err; + } + } + + this.queueMathJax(this.descriptionDiv); + this.setupBlanks(); + } + } + + setupBlanks() { + // Find and format the blanks. If a dynamic problem just changed the HTML, this will find the newly-created blanks. + const ba = $(this.descriptionDiv).find(":input"); + ba.attr("class", "form form-control selectwidthauto"); + ba.attr("aria-label", "input area"); + this.blankArray = ba.toArray(); + for (let blank of this.blankArray) { + $(blank).change(this.recordAnswered.bind(this)); + } + } + + // This tells timed questions that the fitb blanks received some interaction. + recordAnswered() { + this.isAnswered = true; + } + /*=================================== === Checking/loading from storage === ===================================*/ + // _`restoreAnswers`: update the problem with data from the server or from local storage. restoreAnswers(data) { + // Restore the seed first, since the dynamic render clears all the blanks. + this.seed = data.seed; + this.renderDynamicContent(); + var arr; // Restore answers from storage retrieval done in RunestoneBase. try { @@ -136,13 +380,23 @@ export default class FITB extends RunestoneBase { } } catch (err) { // The old format didn't. - arr = data.answer.split(","); + arr = (data.answer || "").split(","); } + let hasAnswer = false; for (var i = 0; i < this.blankArray.length; i++) { $(this.blankArray[i]).attr("value", arr[i]); + if (arr[i]) { + hasAnswer = true; + } } - // Use the feedback from the server, or recompute it locally. - if (!this.feedbackArray) { + // Is this client-side grading, or server-side grading? + if (this.feedbackArray) { + // For client-side grading, re-generate feedback if there's an answer. + if (hasAnswer) { + this.checkCurrentAnswer(); + } + } else { + // For server-side grading, use the provided feedback from the server or local storage. this.displayFeed = data.displayFeed; this.correct = data.correct; this.isCorrectArray = data.isCorrectArray; @@ -154,10 +408,16 @@ export default class FITB extends RunestoneBase { ) { this.renderFeedback(); } - } else { - this.checkCurrentAnswer(); + // For server-side dynamic problems, show the rendered problem text. + this.problemHtml = data.problemHtml; + if (this.problemHtml) { + this.descriptionDiv.innerHTML = this.problemHtml; + this.queueMathJax(this.descriptionDiv); + this.setupBlanks(); + } } } + checkLocalStorage() { // Loads previous answers from local storage if they exist var storedData; @@ -173,7 +433,7 @@ export default class FITB extends RunestoneBase { var arr = storedData.answer; } catch (err) { // error while parsing; likely due to bad value stored in storage - console.log(err.message); + console.assert(false, err.message); localStorage.removeItem(this.localStorageKey()); return; } @@ -181,6 +441,7 @@ export default class FITB extends RunestoneBase { } } } + setLocalStorage(data) { let key = this.localStorageKey(); localStorage.setItem(key, JSON.stringify(data)); @@ -190,129 +451,149 @@ export default class FITB extends RunestoneBase { // Start of the evaluation chain this.isCorrectArray = []; this.displayFeed = []; - this.given_arr = []; - for (var i = 0; i < this.blankArray.length; i++) - this.given_arr.push(this.blankArray[i].value); + const pca = this.prepareCheckAnswers(); + if (this.useRunestoneServices) { if (eBookConfig.enableCompareMe) { this.enableCompareButton(); } } + // Grade locally if we can't ask the server to grade. if (this.feedbackArray) { - this.evaluateAnswers(); + [ + // An array of HTML feedback. + this.displayFeed, + // true, false, or null (the question wasn't answered). + this.correct, + // An array of true, false, or null (the question wasn't answered). + this.isCorrectArray, + this.percent, + ] = checkAnswersCore(...pca); if (!this.isTimed) { this.renderFeedback(); } } } - async logCurrentAnswer(sid) { - let answer = JSON.stringify(this.given_arr); - // Save the answer locally. - let feedback = true; + // Inputs: + // + // - Strings entered by the student in ``this.blankArray[i].value``. + // - Feedback in ``this.feedbackArray``. + prepareCheckAnswers() { + this.given_arr = []; + for (var i = 0; i < this.blankArray.length; i++) + this.given_arr.push(this.blankArray[i].value); + return [ + this.blankNames, + this.given_arr, + this.feedbackArray, + this.dyn_vars_eval, + ]; + } + + // _`randomize`: This handles a click to the "Randomize" button. + async randomize() { + // Use the client-side case or the server-side case? + if (this.feedbackArray) { + // This is the client-side case. + // + this.seed = Math.floor(Math.random() * 2 ** 32); + this.renderDynamicContent(); + } else { + // This is the server-side case. Send a request to the `results ` endpoint with ``new_seed`` set to True. + const request = new Request("/assessment/results", { + method: "POST", + body: JSON.stringify({ + div_id: this.divid, + course: eBookConfig.course, + event: "fillb", + sid: this.sid, + new_seed: true, + }), + headers: this.jsonHeaders, + }); + const response = await fetch(request); + if (!response.ok) { + alert(`HTTP error getting results: ${response.statusText}`); + return; + } + const data = await response.json(); + const res = data.detail; + this.seed = res.seed; + this.descriptionDiv.innerHTML = res.problemHtml; + this.queueMathJax(this.descriptionDiv); + this.setupBlanks(); + } + // When getting a new seed, clear all the old answers and feedback. + this.given_arr = Array(this.blankArray.len).fill(""); + $(this.blankArray).attr("value", ""); + this.clearFeedbackDiv(); + this.saveAnswersLocallyOnly(); + } + + // Save the answers and associated data locally; don't save feedback provided by the server for this answer. It assumes that ``this.given_arr`` contains the current answers. + saveAnswersLocallyOnly() { this.setLocalStorage({ - answer: answer, + // The seed is used for client-side operation, but doesn't matter for server-side. + seed: this.seed, + answer: JSON.stringify(this.given_arr), timestamp: new Date(), + // This is only needed for server-side grading with dynamic problems. + problemHtml: this.descriptionDiv.innerHTML, }); - let data = { - event: "fillb", - act: answer || "", + } + + // _`logCurrentAnswer`: Save the current state of the problem to local storage and the server; display server feedback. + async logCurrentAnswer(sid) { + let answer = JSON.stringify(this.given_arr); + let feedback = true; + // Save the answer locally. + this.saveAnswersLocallyOnly(); + // Save the answer to the server. + const is_dynamic = this.dyn_vars !== undefined; + const data = { + event: is_dynamic ? "dyn-fillb" : "fillb", + div_id: this.divid, + act: (is_dynamic ? this.dyn_vars_eval : answer) || "", + seed: this.seed, answer: answer || "", correct: this.correct ? "T" : "F", - div_id: this.divid, + percent: this.percent, }; if (typeof sid !== "undefined") { data.sid = sid; feedback = false; } - // Per `logBookEvent `, the result is undefined if there's no server. Otherwise, the server provides the endpoint-specific results in ``data.details``; see `make_json_response`. - data = await this.logBookEvent(data); - let detail = data && data.detail; + const server_data = await this.logBookEvent(data); if (!feedback) return; - if (!this.feedbackArray) { - // On success, update the feedback from the server's grade. - this.setLocalStorage({ - answer: answer, - timestamp: detail.timestamp, - }); - this.correct = detail.correct; - this.displayFeed = detail.displayFeed; - this.isCorrectArray = detail.isCorrectArray; - if (!this.isTimed) { - this.renderFeedback(); - } + // Non-server side graded problems are done at this point; likewise, stop here if the server didn't respond. + if (this.feedbackArray || !server_data) { + return data; } - return detail; + // This is the server-side case. On success, update the feedback from the server's grade. + const res = server_data.detail; + this.timestamp = res.timestamp; + this.displayFeed = res.displayFeed; + this.correct = res.correct; + this.isCorrectArray = res.isCorrectArray; + this.setLocalStorage({ + seed: this.seed, + answer: answer, + timestamp: this.timestamp, + problemHtml: this.descriptionDiv.innerHTML, + displayFeed: this.displayFeed, + correct: this.correct, + isCorrectArray: this.isCorrectArray, + }); + this.renderFeedback(); + return server_data; } /*============================== === Evaluation of answer and === === display feedback === ==============================*/ - // Inputs: - // - // - Strings entered by the student in ``this.blankArray[i].value``. - // - Feedback in ``this.feedbackArray``. - // - // Outputs: - // - // - ``this.displayFeed`` is an array of HTML feedback. - // - ``this.isCorrectArray`` is an array of true, false, or null (the question wasn't answered). - // - ``this.correct`` is true, false, or null (the question wasn't answered). - evaluateAnswers() { - // Keep track if all answers are correct or not. - this.correct = true; - for (var i = 0; i < this.blankArray.length; i++) { - var given = this.blankArray[i].value; - // If this blank is empty, provide no feedback for it. - if (given === "") { - this.isCorrectArray.push(null); - this.displayFeed.push($.i18n("msg_no_answer")); - this.correct = false; - } else { - // Look through all feedback for this blank. The last element in the array always matches. If no feedback for this blank exists, use an empty list. - 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. A special case: if only one answer is provided, count it wrong; this is a misformed problem. - let is_correct = j === 0 && fbl.length > 1; - this.isCorrectArray.push(is_correct); - if (!is_correct) { - this.correct = false; - } - } - } - this.percent = - this.isCorrectArray.filter(Boolean).length / this.blankArray.length; - } - renderFeedback() { if (this.correct) { $(this.feedBackDiv).attr("class", "alert alert-info"); @@ -334,7 +615,22 @@ export default class FITB extends RunestoneBase { } var feedback_html = "
    "; for (var i = 0; i < this.displayFeed.length; i++) { - feedback_html += "
  • " + this.displayFeed[i] + "
  • "; + let df = this.displayFeed[i]; + // Render any dynamic feedback in the provided feedback, for client-side grading of dynamic problems. + if (typeof this.dyn_vars === "string") { + df = renderDynamicFeedback( + this.blankNames, + this.given_arr, + i, + df, + this.dyn_vars_eval + ); + // Convert the returned NodeList into a string of HTML. + df = df + ? df[0].parentElement.innerHTML + : "No feedback provided"; + } + feedback_html += `
  • ${df}
  • `; } feedback_html += "
"; // Remove the list if it's just one element. @@ -345,9 +641,7 @@ export default class FITB extends RunestoneBase { ); } this.feedBackDiv.innerHTML = feedback_html; - if (typeof MathJax !== "undefined") { - this.queueMathJax(document.body); - } + this.queueMathJax(this.feedBackDiv); } /*================================== @@ -421,7 +715,8 @@ $(document).on("runestone:login-complete", function () { try { FITBList[this.id] = new FITB(opts); } catch (err) { - console.log( + console.assert( + false, `Error rendering Fill in the Blank Problem ${this.id} Details: ${err}` ); diff --git a/runestone/fitb/js/libs/aleaPRNG-1.1.js b/runestone/fitb/js/libs/aleaPRNG-1.1.js new file mode 100644 index 000000000..d96dc48c2 --- /dev/null +++ b/runestone/fitb/js/libs/aleaPRNG-1.1.js @@ -0,0 +1,183 @@ +/*//////////////////////////////////////////////////////////////// +aleaPRNG 1.1 +////////////////////////////////////////////////////////////////// +https://github.com/macmcmeans/aleaPRNG/blob/master/aleaPRNG-1.1.js +////////////////////////////////////////////////////////////////// +Original work copyright © 2010 Johannes Baagøe, under MIT license +This is a derivative work copyright (c) 2017-2020, W. Mac" McMeans, under BSD license. +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +////////////////////////////////////////////////////////////////*/ +export function aleaPRNG() { + return( function( args ) { + "use strict"; + + const version = 'aleaPRNG 1.1.0'; + + var s0 + , s1 + , s2 + , c + , uinta = new Uint32Array( 3 ) + , initialArgs + , mashver = '' + ; + + /* private: initializes generator with specified seed */ + function _initState( _internalSeed ) { + var mash = Mash(); + + // internal state of generator + s0 = mash( ' ' ); + s1 = mash( ' ' ); + s2 = mash( ' ' ); + + c = 1; + + for( var i = 0; i < _internalSeed.length; i++ ) { + s0 -= mash( _internalSeed[ i ] ); + if( s0 < 0 ) { s0 += 1; } + + s1 -= mash( _internalSeed[ i ] ); + if( s1 < 0 ) { s1 += 1; } + + s2 -= mash( _internalSeed[ i ] ); + if( s2 < 0 ) { s2 += 1; } + } + + mashver = mash.version; + + mash = null; + }; + + /* private: dependent string hash function */ + function Mash() { + var n = 4022871197; // 0xefc8249d + + var mash = function( data ) { + data = data.toString(); + + // cache the length + for( var i = 0, l = data.length; i < l; i++ ) { + n += data.charCodeAt( i ); + + var h = 0.02519603282416938 * n; + + n = h >>> 0; + h -= n; + h *= n; + n = h >>> 0; + h -= n; + n += h * 4294967296; // 0x100000000 2^32 + } + return ( n >>> 0 ) * 2.3283064365386963e-10; // 2^-32 + }; + + mash.version = 'Mash 0.9'; + return mash; + }; + + + /* private: check if number is integer */ + function _isInteger( _int ) { + return parseInt( _int, 10 ) === _int; + }; + + /* public: return a 32-bit fraction in the range [0, 1] + This is the main function returned when aleaPRNG is instantiated + */ + var random = function() { + var t = 2091639 * s0 + c * 2.3283064365386963e-10; // 2^-32 + + s0 = s1; + s1 = s2; + + return s2 = t - ( c = t | 0 ); + }; + + /* public: return a 53-bit fraction in the range [0, 1] */ + random.fract53 = function() { + return random() + ( random() * 0x200000 | 0 ) * 1.1102230246251565e-16; // 2^-53 + }; + + /* public: return an unsigned integer in the range [0, 2^32] */ + random.int32 = function() { + return random() * 0x100000000; // 2^32 + }; + + /* public: advance the generator the specified amount of cycles */ + random.cycle = function( _run ) { + _run = typeof _run === 'undefined' ? 1 : +_run; + if( _run < 1 ) { _run = 1; } + for( var i = 0; i < _run; i++ ) { random(); } + }; + + /* public: return inclusive range */ + random.range = function() { + var loBound + , hiBound + ; + + if( arguments.length === 1 ) { + loBound = 0; + hiBound = arguments[ 0 ]; + + } else { + loBound = arguments[ 0 ]; + hiBound = arguments[ 1 ]; + } + + if( arguments[ 0 ] > arguments[ 1 ] ) { + loBound = arguments[ 1 ]; + hiBound = arguments[ 0 ]; + } + + // return integer + if( _isInteger( loBound ) && _isInteger( hiBound ) ) { + return Math.floor( random() * ( hiBound - loBound + 1 ) ) + loBound; + + // return float + } else { + return random() * ( hiBound - loBound ) + loBound; + } + }; + + /* public: initialize generator with the seed values used upon instantiation */ + random.restart = function() { + _initState( initialArgs ); + }; + + /* public: seeding function */ + random.seed = function() { + _initState( Array.prototype.slice.call( arguments ) ); + }; + + /* public: show the version of the RNG */ + random.version = function() { + return version; + }; + + /* public: show the version of the RNG and the Mash string hasher */ + random.versions = function() { + return version + ', ' + mashver; + }; + + // when no seed is specified, create a random one from Windows Crypto (Monte Carlo application) + if( args.length === 0 ) { + window.crypto.getRandomValues( uinta ); + args = [ uinta[ 0 ], uinta[ 1 ], uinta[ 2 ] ]; + }; + + // store the seed used when the RNG was instantiated, if any + initialArgs = args; + + // initialize the RNG + _initState( args ); + + return random; + + })( Array.prototype.slice.call( arguments ) ); +}; \ No newline at end of file diff --git a/runestone/fitb/js/timedfitb.js b/runestone/fitb/js/timedfitb.js index e60b5665b..8484775b4 100644 --- a/runestone/fitb/js/timedfitb.js +++ b/runestone/fitb/js/timedfitb.js @@ -43,9 +43,7 @@ export default class TimedFITB extends FITB { } reinitializeListeners() { - for (let blank of this.blankArray) { - $(blank).change(this.recordAnswered.bind(this)); - } + this.setupBlanks(); } } diff --git a/runestone/fitb/test/_sources/index.rst b/runestone/fitb/test/_sources/index.rst index 7f46d880d..058d00c11 100644 --- a/runestone/fitb/test/_sources/index.rst +++ b/runestone/fitb/test/_sources/index.rst @@ -24,14 +24,14 @@ Test 2 - test a numeric range. .. If this isn't treated as a comment, then it will cause a **syntax error, thus producing a test failure. - What is the solution to the following: + What is the solution to the following? :math:`2 * \pi =` |blank|. - - :6.28 0.005: Good job. - :3.27 3: Try higher. - :9.29 3: Try lower. - :.*: Incorrect. Try again. + - :6.28 0.005: Good job. + :3.27 3: Try higher. + :9.29 3: Try lower. + :.*: Incorrect. Try again. Error testing ------------- @@ -85,3 +85,214 @@ Regex testing - :\[\]: Correct. :x: Try again. + + +Dynamic problem testing +----------------------- +This problem demonstrates the basic syntax for a dynamic problem: + +- Define dynamic variables by placing JavaScript code in the ``:dyn_vars:`` option of a fill-in-the-blank problem. + + - Use only the ``rand()`` function to generate random numbers. This function produces values from a seeded RNG; this seed is saved on the client or server and restored so the problem doesn't change every time the page is refreshed. + - Any arbitrary JavaScript code can be included, such as defining functions, temporary variables, ``for`` loops, etc. + - **Blank lines are not allowed** due to the way reStructuredText parses options -- instead, use a comment with no content for additional visual space. See the quadratic roots problem for an example. + - To include additional JavaScript libraries for use in your problems, follow `these directions `_. (Note that the Runestone authoring system is built on Sphinx). + +- Include predefined, dynamically loaded libraries using the ``:dyn_imports:`` directive; currently, only ``BTM`` is available. You may also refer to local JavaScript files by providing a path to them, such as ``./my-lib.js``. +- To render dynamic probably statically (for use in a PDF, etc.), include ``:static_seed:`` followed by an arbitrary seed. +- Use ``v.``\ *variable_name* when creating variables inside the ``:dyn_vars:`` option for use in the problem. Everywhere else, use just *variable_name*. +- Use the syntax ``[%=`` *JavaScript_variable_name_or_expression* ``%]`` to display the value of a variable or expression in the problem description or in the feedback. Inside these tags, avoid the use of the `reserved HTML characters `_ ``&``, ``<``, ``>``, and ``"``. These will be automatically translated to HTML character entities ``&``, ``<``, ``>``, and ``"``, which will confuse the JavaScript interpreter. For example, ``[%= a < b %]`` becomes ``[%= a < b %]``, which produces a JavaScript error. Instead, put these expressions in the ``:dyn_vars:`` option, where no translation is done. For example, place ``v.c = a < b;`` in ``:dyn_vars:`` then use ``%[= c %]`` in the problem description instead. +- Create named blanks in the problem description using the syntax ``:blank:`blank_name_here```. You may also used unnamed blanks as usual via ``|blank|``. +- In the problem's feedback section, refer to a blank in any of three ways: the blank's name, ``ans`` (the student-provided answer for this blank), or the blank's index in ``ans_array`` (an array of all student-provided answers for this problem). +- Optionally (though strongly recommended) provide a type converter for blanks in either of the three following ways: + + - A dict of ``v.types = {blank0_name: converter0, blank1_name: converter1, ...}`` based on the blank's names. + - An array of ``v.types = [blank0_converter, blank1_converter, ...]`` based on the blank's index (order of appearance in the problem). + - A value of ``v.types = converter_for_all_blanks``. + + The converter is a function that takes a string (the raw value entered by a student) as input, returning the string converted to the appropriate type. If the converter isn't specified, then no conversion is performed. The standard JavaScript library provides the ``Number`` converter. [#converters]_ Converters bring a number of important advantages: + + - Using a converter helps avoid unexpected results for expressions: + + - Without conversion, the expression ``blank1 + blank2`` concatenates the two blanks as strings instead of adding them as numbers. + - Without conversion, The expression ``ans == 0`` is true if the answer was blank, since JavaScript converts an empty string to the value 0. Likewise, ``ans < 1`` is true for a blank answer. + - Converters allow `strict equality/inequality comparisons `_ in JavaScript (``===``/\ ``!==``). + - Converters provides a natural method to handle more complex types such as complex numbers, equations, matrices, etc. + +The problems below convert their inputs using ``Number``. + +.. fillintheblank:: test_fitb_dynamic_1 + :dyn_vars: + v.a = Math.floor(rand()*10); + v.b = Math.floor(rand()*10); + v.types = {c: Number}; + + What is [%= a %] + [%= b %]? :blank:`c` + + - :c === a + b: Correct; [%= a %] + [%= b %] is [%= c %]. Note that [%= ans %] or [%= ans_array[0] %] also works. + :c === a - b: That's subtraction. + :c === a * b: That's multiplication. + :x: I don't know what you're doing; [%= a %] + [%= b %] is [%= a + b %], not [%= c %]. + + +This problem demonstrates some of the possibilities and challenges in dynamic problems: + +- The solution gets computed on the client, which makes the problems vulnerable to students peeking at the JavaScript console to get the correct answer. Hence, the need for server-side grading. +- It's easy to include math. However, formatting math requires an optional plus sign -- negative numbers don't need it, while positive numbers do. Hence, use of the ``plus`` function below. +- Solution checking requires some careful thought. + +.. fillintheblank:: test_fitb_dynamic_2 + :dyn_vars: + // The solution. + v.ax1 = Math.floor(rand()*10); + v.ax2 = Math.floor(rand()*10); + // + // Values used in showing the problem. Don't allow a to be 0! + v.a = Math.floor(rand()*9) + 1; + v.b = v.a * -(v.ax1 + v.ax2); + v.c = v.a * v.ax1 * v.ax2; + // + // Formatting niceness: put a plus in front on non-negative values only. + v.plus = x => x < 0 ? x : `+${x}`; + // + v.types = Number; + + What are the solutions to :math:`[%= a %]x^2 [%= plus(b) %]x [%= plus(c) %] = 0`? For repeated roots, enter the same value in both blanks. + + :blank:`sx1`, :blank:`sx2` + + Notes: + + - ``ax1`` is short for "answer for x1"; ``sx1`` is "student's answer for x1". + - The first answer grades either root as correct. + - The second answer checks that the student isn't answering with the same value twice -- unless this happens to be a repeated root. + - The second hint has to be smart: if the first blank contained the second answer, then show the first answer as a hint. + + Writing dynamic problems is, fundamentally, hard. However, it produces an infinite stream of problems. + + - :ans === ax1 || ans === ax2: Correct! + :x: Try [%= ax1 %]. + - :(ans === ax1 || ans === ax2) && (sx1 !== sx2 || ax1 === ax2): Correct! + :x: Try [%= sx1 === ax2 ? ax1 : ax2 %]. + + +.. fillintheblank:: test_fitb_dynamic_3 + :dyn_vars: + // The correct answer, in percent. + v.correct = Math.round(rand()*100) + // Update the image. + v.beforeCheckAnswers = v => svg_rect.width.baseVal.valueAsString = v.a + "%" + v.types = Number + + This demonstrates drawing using an SVG as part of a dynamic problem. The percentage entered changes the image drawn. + + .. raw:: html + + + + + SVG + + + Guess a percentage! :blank:`a`\ % + + - :a === correct: Correct! + :x: Try [%= correct %]. + + +.. raw:: html + + + + +.. fillintheblank:: test_fitb_dynamic_4 + :dyn_vars: + // Initialize environment + v._menv = new BTM({'rand': rand}); + // Initialize problem parameters + v.a = v._menv.addParameter('a', { mode:'random', min:-4, max:3, by:0.1, prec:0.1 }); + v.b = v._menv.addParameter('b', { mode:'random', min:-4, max:5, by:0.1, prec:0.1 }); + v.dx = v._menv.addParameter('dx', { mode:'random', min:0.4, max:3, by:0.1, prec:0.1 }); + v.dy = v._menv.addParameter('dy', { mode:'random', min:-3, max:3, by:0.1, prec:0.1 }); + v.c = v._menv.addParameter('c', { mode:'calculate', formula:'a+dx', prec:0.1 }); + v.d = v._menv.addParameter('d', { mode:'calculate', formula:'b+dy', prec:0.1 }); + v.m = v.dy/v.dx; + v.bint = v.b-v.m*v.a; + // Equation of the line + v.pointSlope = v._menv.addExpression('pointSlope', '{{dy}}/{{dx}}*(x-{{a}})+{{b}}').reduce(); + // Declare answer parsers + v.types = v._menv.getParser(); + // Setup post-processing function + v.afterContentRender = v => { + // Create the graph + const board = JXG.JSXGraph.initBoard("test_fitb_dynamic_4-jsx", {boundingbox: [-6, 6, 6, -6], axis:true}); + const P1=board.create('point', [v.a, v.b], {name: 'P_1', fixed: true}); + const P2=board.create('point', [v.c, v.d], {name: 'P_2', fixed: true}); + board.create('line', [P1, P2]); + }; + :dyn_imports: BTM + :static_seed: 0 + + Can we include a randomly generated graph? This also tests dynamic imports and the use of a static seed. + + .. raw:: html + +
+ + Find the equation of the line passing through the points :math:`P_1=([%= a %], [%= b %])` and :math:`P_2=([%= c %], [%= d %])`. + + :math:`y =` :blank:`formula` + + - :_menv.compareExpressions(pointSlope, formula): Correct! + :x: Try again! The answer is [%= pointSlope %]. + +.. fillintheblank:: test_fitb_dynamic_5 + :dyn_vars: + v._menv = new BTM({'rand': rand}); + v.m = v._menv.addMathObject("m", "number", v._menv.generateRandom("discrete", { min:-4, max:5, by:1, nonzero:true}) + ); + v.b = v._menv.addMathObject("b", "number", v._menv.generateRandom("discrete", { min:-10, max:10, by:1, nonzero:false}) + ); + v.negB = v._menv.addMathObject("negB", "number", v._menv.parseExpression("-{{b}}", "number")); + v.theFunction = v._menv.addMathObject("theFunction", "formula", v._menv.parseExpression("{{m}}*x+{{b}}").reduce()); + v.theAnswer = v._menv.addMathObject("theAnswer", "formula", v._menv.parseExpression("-{{b}}\/{{m}}").reduce()); + v.types = [v._menv.getParser()]; + :dyn_imports: BTM + + Solve the equation + + .. raw:: html + + \begin{equation*} + [%= toTeX(theFunction) %]=0 + \end{equation*} + + to get the value of :math:`(x\text{.})` + + :math:`(x = )` :blank:`solution` + + Solution: We want to isolate the :math:`(x)` in the equation :math:`([%= toTeX(theFunction) %]=0\text{.})` Because addition of :math:`([%= toTeX(b) %])` is the last operation, we apply the inverse by adding :math:`([%= toTeX(negB) %])` to both sides. The new, but equivalent equation is now :math:`([%= toTeX(m) %]x = [%= toTeX(negB) %]\text{.})` Dividing both sides of the equation by :math:`([%= toTeX(m) %]\text{,})` we obtain the solution :math:`(x=[%= toTeX(theAnswer) %]\text{.})` + + - :function() { var testResults = new Array(); testResults[0] = _menv.compareExpressions(theAnswer, solution); return (testResults[0]); }(): Correct! + :function() { var testResults = new Array(); testResults[0] = _menv.compareExpressions(_menv.parseExpression("{{b}}/{{m}}").reduce(), solution); return (testResults[0]); }(): Error with signs while isolating x + :x: Incorrect; try again. + + +Footnotes +--------- +.. [#converters] + + While JavaScript provides ``Date`` and ``Date.parse`` converters, there's a lot of subtlety in time zones making this difficult to use for most cases. Likewise, ``Boolean`` makes little sense although it's available. It's possible to use ``Math.round``, but again this makes little sense for most cases (should a student answer of 3.4 correctly compare to a solution of 3?). + + It might be useful to write a ``CleanString`` converter to remove leading and trailing spaces in a blank and provide equality operators that ignore multiple spaces, capitalization, etc. However, what sort of dynamic problems would be able to correctly grade string answers? + + +qnum reset +---------- +Reset ``qnum`` values to prevent affecting other problems. + +.. qnum:: + :prefix: + :suffix: diff --git a/runestone/fitb/test/test_fitb.py b/runestone/fitb/test/test_fitb.py index 24a397c11..7b11d7050 100644 --- a/runestone/fitb/test/test_fitb.py +++ b/runestone/fitb/test/test_fitb.py @@ -1,3 +1,4 @@ +from selenium.common.exceptions import TimeoutException from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions as EC @@ -11,13 +12,15 @@ def test_1(selenium_module_fixture): ( 38, 'Content block expected for the "fillintheblank" directive; none found.', - "ERROR" + "ERROR", ), (37, "Not enough feedback for the number of blanks supplied.", "WARNING"), ) for error_line, error_string, mtype in directive_level_errors: - assert ":{}: {}: {}".format( - error_line, mtype, error_string) in mf.build_stderr_data + assert ( + ":{}: {}: {}".format(error_line, mtype, error_string) + in mf.build_stderr_data + ) # Check for the following error inside the directive. inside_directive_errors = ( @@ -33,10 +36,15 @@ def test_1(selenium_module_fixture): ), ) for error_line, error_string in inside_directive_errors: - assert ": ERROR: On line {}, {}".format( - error_line, error_string) in mf.build_stderr_data - - assert "WARNING: while setting up extension runestone.lp: role 'docname' is already registered, it will be overridden" in mf.build_stderr_data + assert ( + ": ERROR: On line {}, {}".format(error_line, error_string) + in mf.build_stderr_data + ) + + assert ( + "WARNING: while setting up extension runestone.lp: role 'docname' is already registered, it will be overridden" + in mf.build_stderr_data + ) # Make sure we saw all errors. assert len(inside_directive_errors) + 1 == mf.build_stderr_data.count("ERROR") @@ -65,15 +73,37 @@ def find_blank(fitb_element, index, clear=True): # Click the "Check me" button. -def click_checkme(fitb_element): +def click_checkme(selenium_utils, fitb_element): + selenium_utils.scroll_to_top() + # It's the first button in the component's div. fitb_element.find_element_by_tag_name("button").click() -# Find the question's feedback element. +# Click the "Randomize" button. +def click_randomize(fitb_element): + fitb_element.find_element_by_css_selector("button[name=randomize]").click() + + +# Require the expected text in the question's feedback element. def check_feedback(selenium_utils, fitb_element, expected_text): div_id = fitb_element.get_attribute("id") selenium_utils.wait.until( - EC.text_to_be_present_in_element((By.ID, div_id + "_feedback"), expected_text)) + EC.text_to_be_present_in_element((By.ID, div_id + "_feedback"), expected_text) + ) + + +# Require the expected text in the question's description. +def check_description(selenium_utils, fitb_element, expected_text): + div_id = fitb_element.get_attribute("id") + css_sel = f"#{div_id} > div:nth-child(1)" + try: + selenium_utils.wait.until( + EC.text_to_be_present_in_element((By.CSS_SELECTOR, css_sel), expected_text) + ) + except TimeoutException: + # Provide an error message that shows actual vs. expected text, instead of the more generic TimeoutException. + actual_text = fitb_element.find_element_by_css_selector(css_sel).text + assert expected_text == actual_text ## Tests @@ -86,10 +116,11 @@ def test_fitb1(selenium_utils_get): fitb = find_fitb(selenium_utils_get, "test_fitb_string") find_blank(fitb, 0) find_blank(fitb, 1) - click_checkme(fitb) + click_checkme(selenium_utils_get, fitb) # Get desired response from .i18n file loaded based on language attribute in the HTML tag initially set in conf.py msg_no_answer = selenium_utils_get.driver.execute_script( - "return $.i18n('msg_no_answer')") + "return $.i18n('msg_no_answer')" + ) check_feedback(selenium_utils_get, fitb, msg_no_answer) @@ -98,11 +129,12 @@ def test_fitb2(selenium_utils_get): fitb = find_fitb(selenium_utils_get, "test_fitb_string") find_blank(fitb, 0).send_keys("red") find_blank(fitb, 1) - click_checkme(fitb) + click_checkme(selenium_utils_get, fitb) check_feedback(selenium_utils_get, fitb, "Correct") # Get desired response from .i18n file loaded based on language attribute in the HTML tag initially set in conf.py msg_no_answer = selenium_utils_get.driver.execute_script( - "return $.i18n('msg_no_answer')") + "return $.i18n('msg_no_answer')" + ) check_feedback(selenium_utils_get, fitb, msg_no_answer) @@ -111,7 +143,7 @@ def test_fitb3(selenium_utils_get): fitb = find_fitb(selenium_utils_get, "test_fitb_string") find_blank(fitb, 0).send_keys("red") find_blank(fitb, 1).send_keys("away") - click_checkme(fitb) + click_checkme(selenium_utils_get, fitb) check_feedback(selenium_utils_get, fitb, "Correct") @@ -125,28 +157,28 @@ def test_fitb4(selenium_utils_get): # Type the correct answer. blank0.send_keys("red") find_blank(fitb, 1).send_keys("away") - click_checkme(fitb) + click_checkme(selenium_utils_get, fitb) check_feedback(selenium_utils_get, fitb, "Correct") def test_fitboneblank_too_low(selenium_utils_get): fitb = find_fitb(selenium_utils_get, "test_fitb_number") find_blank(fitb, 0).send_keys(" 6") - click_checkme(fitb) + click_checkme(selenium_utils_get, fitb) check_feedback(selenium_utils_get, fitb, "Try higher.") def test_fitboneblank_wildcard(selenium_utils_get): fitb = find_fitb(selenium_utils_get, "test_fitb_number") find_blank(fitb, 0).send_keys("I give up") - click_checkme(fitb) + click_checkme(selenium_utils_get, fitb) check_feedback(selenium_utils_get, fitb, "Incorrect. Try again.") def test_fitbfillrange(selenium_utils_get): fitb = find_fitb(selenium_utils_get, "test_fitb_number") find_blank(fitb, 0).send_keys(" 6.28 ") - click_checkme(fitb) + click_checkme(selenium_utils_get, fitb) check_feedback(selenium_utils_get, fitb, "Good job.") @@ -156,19 +188,68 @@ def test_fitbregex(selenium_utils_get): # find_blank(fitb, 0).send_keys(" mARy ") find_blank(fitb, 1).send_keys("LITTLE") find_blank(fitb, 2).send_keys("2") - click_checkme(fitb) + click_checkme(selenium_utils_get, fitb) check_feedback(selenium_utils_get, fitb, "Correct") def test_regexescapes1(selenium_utils_get): fitb = find_fitb(selenium_utils_get, "test_fitb_regex_2") find_blank(fitb, 0).send_keys(r"C:\windows\system") - click_checkme(fitb) + click_checkme(selenium_utils_get, fitb) check_feedback(selenium_utils_get, fitb, "Correct") def test_regexescapes2(selenium_utils_get): fitb = find_fitb(selenium_utils_get, "test_fitb_regex_3") find_blank(fitb, 0).send_keys("[]") - click_checkme(fitb) + click_checkme(selenium_utils_get, fitb) + check_feedback(selenium_utils_get, fitb, "Correct") + + +# _`dynamic problem value repetition`: define it like this so that the server can provide a different sequence of random values. On the server, it asks for random value each time the server grades a problem and when the "randomize" button is clicked, so it needs lots of repetition. The client only asks for random values when the "randomize" button is clicked. +def test_dynamic_1(selenium_utils_get): + _test_dynamic_1(selenium_utils_get, [0.2, 0.1, 0.3, 0.4]) + + +def _test_dynamic_1(selenium_utils_get, test_values): + fitb = find_fitb(selenium_utils_get, "test_fitb_dynamic_1") + + # Inject controlled values to the RNG for dynamic problems. + selenium_utils_get.inject_random_values(test_values) + click_randomize(fitb) + + # Try all the different answers. Include whitespace, various numeric formats, etc. + check_description(selenium_utils_get, fitb, "What is 2 + 1?") + # Dynamic problems re-create the blanks after receiving new HTML. Wait for this to be ready before typing in a blank to avoid errors. + import time + + time.sleep(0.1) + find_blank(fitb, 0).send_keys(" 3") + click_checkme(selenium_utils_get, fitb) + check_feedback(selenium_utils_get, fitb, "Correct") + find_blank(fitb, 0).send_keys("1.0 ") + click_checkme(selenium_utils_get, fitb) + check_feedback(selenium_utils_get, fitb, "subtraction") + find_blank(fitb, 0).send_keys(" 0x2 ") + click_checkme(selenium_utils_get, fitb) + check_feedback(selenium_utils_get, fitb, "multiplication") + find_blank(fitb, 0).send_keys(" 4e0") + click_checkme(selenium_utils_get, fitb) + check_feedback(selenium_utils_get, fitb, "know what") + + # Verify the feedback is removed. + click_randomize(fitb) + # Put this before the assertions, since it will wait until the text appears (implying the problem has been updated). + check_description(selenium_utils_get, fitb, "What is 3 + 4?") + assert ( + selenium_utils_get.driver.find_element_by_id( + "test_fitb_dynamic_1_feedback" + ).text + == "" + ) + assert fitb.find_element_by_tag_name("input").text == "" + + # Run another check to make sure a new problem appeared. + find_blank(fitb, 0).send_keys(" 0b111 ") + click_checkme(selenium_utils_get, fitb) check_feedback(selenium_utils_get, fitb, "Correct") diff --git a/runestone/fitb/toctree.rst b/runestone/fitb/toctree.rst index 0d97e0547..6980bf7f2 100644 --- a/runestone/fitb/toctree.rst +++ b/runestone/fitb/toctree.rst @@ -8,4 +8,6 @@ fitb: A Sphinx extension for fill-in-the-blank questions *.py js/*.js css/*.css + fitb_html_structure.html + dynamic_problems.rst test/test_*.py diff --git a/webpack.config.js b/webpack.config.js index 8bd7a2e29..31abc2647 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -5,7 +5,13 @@ // :caption: Related contents // // webpack.index.js - +// webpack.server-index.js +// +// Includes +// ======== +// +// Node +// ---- const path = require("path"); const CopyPlugin = require("copy-webpack-plugin"); @@ -13,100 +19,173 @@ const CompressionPlugin = require("compression-webpack-plugin"); const CssMinimizerPlugin = require("css-minimizer-webpack-plugin"); const HtmlWebpackPlugin = require("html-webpack-plugin"); const MiniCssExtractPlugin = require("mini-css-extract-plugin"); +const { DefinePlugin } = require("webpack"); + +// Globals +// ======= +function definePluginDict(env) { + return { + // _`RAND_FUNC`: for testing, use a random function supplied by the test framework if it exists. Otherwise, use the seedable RNG. + // + // Implementation: pass webpack the ``--env test`` option (see the `env docs `_). Using the `DefinePlugin `_, select the appropriate random function. + RAND_FUNC: env.test + ? "(typeof rs_test_rand === 'undefined') ? rand : rs_test_rand" + : "rand", + }; +} module.exports = (env, argv) => { const is_dev_mode = argv.mode === "development"; - return { - // Cache build results between builds in development mode, per the `docs `__. - cache: is_dev_mode - ? { - type: "filesystem", - } - : false, - entry: { - runestone: "./webpack.index.js", - }, - // See `mode `_ for the conditional statement below. - devtool: is_dev_mode ? "inline-source-map" : "source-map", - module: { - rules: [ - { - test: /\.css$/i, - use: [MiniCssExtractPlugin.loader, "css-loader"], - }, - { - test: /\.(png|jpe?g|gif|svg|eot|ttf|woff|woff2)$/i, - // For more information, see `Asset Modules `_. - type: "asset", + return [ + // Webpack configuration + // ===================== + { + // Cache build results between builds in development mode, per the `docs `__. + cache: is_dev_mode + ? { + type: "filesystem", + } + : false, + entry: { + runestone: "./webpack.index.js", + }, + // See `mode `_ for the conditional statement below. + devtool: is_dev_mode ? "inline-source-map" : "source-map", + module: { + rules: [ + { + test: /\.css$/i, + use: [MiniCssExtractPlugin.loader, "css-loader"], + }, + { + test: /\.(png|jpe?g|gif|svg|eot|ttf|woff|woff2)$/i, + // For more information, see `Asset Modules `_. + type: "asset", + }, + ], + }, + externals: { + // Use the jQuery that Sphinx provides for jQuery.ui. See `externals `_. + jquery: "jQuery", + }, + output: { + path: path.resolve(__dirname, "runestone/dist"), + // _`Output file naming`: see the `caching guide `_. This provides a hash for dynamic imports as well, avoiding caching out-of-date JS. Putting the hash in a query parameter (such as ``[name].js?v=[contenthash]``) causes the compression plugin to not update zipped files. + filename: is_dev_mode + ? "[name].bundle.js" + : "[name].[contenthash].bundle.js", + // Node 17.0 reports ``Error: error:0308010C:digital envelope routines::unsupported``. Per `SO `_, this error is produced by using an old, default hash that OpenSSL removed support for. The `webpack docs `__ say that ``xxhash64`` is a faster algorithm. + hashFunction: "xxhash64", + // Delete everything in the output directory on each build. + clean: true, + }, + // See the `SplitChunksPlugin docs `_. + optimization: { + moduleIds: "deterministic", + // Collect all the webpack import runtime into a single file, which is named ``runtime.bundle.js``. This must be statically imported by all pages containing Runestone components. + runtimeChunk: "single", + splitChunks: { + chunks: "all", }, + // CSS for production was copied from `Minimizing For Production `_. + minimizer: [ + // For webpack@5 you can use the ``...`` syntax to extend existing minimizers (i.e. ``terser-webpack-plugin``), uncomment the next line. + `...`, + new CssMinimizerPlugin(), + ], + }, + plugins: [ + // _`webpack_static_imports`: Instead of HTML, produce a list of static imports as JSON. Sphinx will then read this file and inject these imports when creating each page. + new HtmlWebpackPlugin({ + filename: "webpack_static_imports.json", + // Don't prepend the ```` tag and data to the output. + inject: false, + // The template to create JSON. + templateContent: ({ htmlWebpackPlugin }) => + JSON.stringify({ + js: htmlWebpackPlugin.files.js, + css: htmlWebpackPlugin.files.css, + }), + }), + new CopyPlugin({ + patterns: [ + { + // sql.js support: this wasm file will be fetched dynamically when we initialize sql.js. It is important that we do not change its name, and that it is in the same folder as the js. + from: "node_modules/sql.js/dist/sql-wasm.wasm", + to: ".", + }, + ], + }), + new DefinePlugin(definePluginDict(env)), + new MiniCssExtractPlugin({ + // See `output file naming`_. + filename: is_dev_mode + ? "[name].css" + : "[name].[contenthash].css", + chunkFilename: "[id].css", + }), + // Copied from the `webpack docs `_. This creates ``.gz`` versions of all files. The webserver in use needs to be configured to send this instead of the uncompressed versions. + new CompressionPlugin(), ], - }, - resolve: { - fallback: { - // ``sql.js`` wants these in case it's running under node.js. They're not needed by JS in the browser. - crypto: false, - fs: false, - path: false, + resolve: { + fallback: { + // ``sql.js`` wants these in case it's running under node.js. They're not needed by JS in the browser. + crypto: false, + fs: false, + path: false, + }, }, }, - externals: { - // Use the jQuery that Sphinx provides for jQuery.ui. See `externals `_. - jquery: "jQuery", - }, - output: { - path: path.resolve(__dirname, "runestone/dist"), - // _`Output file naming`: see the `caching guide `_. This provides a hash for dynamic imports as well, avoiding caching out-of-date JS. Putting the hash in a query parameter (such as ``[name].js?v=[contenthash]``) causes the compression plugin to not update zipped files. - filename: "[name].[contenthash].bundle.js", - // Node 17.0 reports ``Error: error:0308010C:digital envelope routines::unsupported``. Per `SO `_, this error is produced by using an old, default hash that OpenSSL removed support for. The `webpack docs `__ say that ``xxhash64`` is a faster algorithm. - hashFunction: "xxhash64", - // Delete everything in the output directory on each build. - clean: true, - }, - // See the `SplitChunksPlugin docs `_. - optimization: { - moduleIds: "deterministic", - // Collect all the webpack import runtime into a single file, which is named ``runtime.bundle.js``. This must be statically imported by all pages containing Runestone components. - runtimeChunk: "single", - splitChunks: { - chunks: "all", + + // Server-side + // ----------- + // Config for server-side code. + { + // See `mode `_ for the conditional statement below. + devtool: + argv.mode === "development" + ? "inline-source-map" + : "source-map", + entry: { + server_side: "./webpack.server-index.js", }, - // CSS for production was copied from `Minimizing For Production `_. - minimizer: [ - // For webpack@5 you can use the ``...`` syntax to extend existing minimizers (i.e. ``terser-webpack-plugin``), uncomment the next line. - `...`, - new CssMinimizerPlugin(), - ], - }, - plugins: [ - // _`webpack_static_imports`: Instead of HTML, produce a list of static imports as JSON. Sphinx will then read this file and inject these imports when creating each page. - new HtmlWebpackPlugin({ - filename: "webpack_static_imports.json", - // Don't prepend the ```` tag and data to the output. - inject: false, - // The template to create JSON. - templateContent: ({ htmlWebpackPlugin }) => - JSON.stringify({ - js: htmlWebpackPlugin.files.js, - css: htmlWebpackPlugin.files.css, - }), - }), - new CopyPlugin({ - patterns: [ + module: { + rules: [ { - // sql.js support: this wasm file will be fetched dynamically when we initialize sql.js. It is important that we do not change its name, and that it is in the same folder as the js. - from: "node_modules/sql.js/dist/sql-wasm.wasm", - to: ".", + // Use Babel to transpile to ECMAScript 5.1, since the server-side engine supports that. + // + // Only run ``.js`` files through Babel + test: /\.m?js$/, + use: { + loader: "babel-loader", + options: { + presets: ["@babel/preset-env"], + }, + }, }, ], - }), - new MiniCssExtractPlugin({ - // See `output file naming`_. - filename: "[name].[contenthash].css", - chunkFilename: "[id].css", - }), - // Copied from the `webpack docs `_. This creates ``.gz`` versions of all files. The webserver in use needs to be configured to send this instead of the uncompressed versions. - new CompressionPlugin(), - ], - }; + }, + output: { + // Expose the library as a variable. + library: { + name: "serverSide", + type: "var", + }, + path: path.resolve(__dirname, "runestone/dist"), + // Delete everything in the output directory on each build. Putting these here (in the server-side build) works, while putting it in the client-side build causes it to delete the output from the server-side build. + clean: true, + }, + plugins: [new DefinePlugin(definePluginDict(env))], + resolve: { + // EJS tries to import these. + fallback: { + fs: false, + path: false, + }, + }, + // The server-side JS engine supports ECMAScript 5.1. See `target `_. + target: ["es5", "web"], + }, + ]; }; diff --git a/webpack.server-index.js b/webpack.server-index.js new file mode 100644 index 000000000..b371804bf --- /dev/null +++ b/webpack.server-index.js @@ -0,0 +1,12 @@ +// *********************************************************************************** +// |docname| - Import files needed for server-side operation +// *********************************************************************************** +// This file simply imports all the modules that the server-side code needs. + +"use strict"; + +// Import from all server-side code. +import * as fitb from "./runestone/fitb/js/fitb-utils.js"; + +// Make it visible. +export { fitb };