From 69475daa4460ebbb2d001e2e78484af5e6dfab7b Mon Sep 17 00:00:00 2001 From: Brad Miller Date: Fri, 27 Aug 2021 21:08:34 +0000 Subject: [PATCH 01/21] Update skulpt, support MathJax2/3 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 53ff84ae9..3c7b36a58 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ with open("requirements.txt", "r", encoding="utf-8") as fh: dependencies = [l.strip() for l in fh] -VERSION = "5.10.0" +VERSION = "5.10.1" # These pre-install hooks are useful to make sure any pre-requisite # programs that are not pip installable are in place. From c41ac624250637aafa8aaaedd50e5dd8c31f4474 Mon Sep 17 00:00:00 2001 From: bjones1 Date: Wed, 25 Aug 2021 13:36:00 +0100 Subject: [PATCH 02/21] Add: Compress webpage output. --- package.json | 1 + webpack.config.js | 3 +++ 2 files changed, 4 insertions(+) diff --git a/package.json b/package.json index 01b941dd3..e487534ee 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "license": "ISC", "devDependencies": { "copy-webpack-plugin": "^8.0.0", + "compression-webpack-plugin": "^6.0.0", "css-loader": "^5.0.0", "css-minimizer-webpack-plugin": "^3.0.0", "eslint": "^7.0.0", diff --git a/webpack.config.js b/webpack.config.js index 0ed273600..ba43a74bf 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -10,6 +10,7 @@ const path = require("path"); const CssMinimizerPlugin = require('css-minimizer-webpack-plugin'); const CopyPlugin = require('copy-webpack-plugin'); +const CompressionPlugin = require("compression-webpack-plugin"); const HtmlWebpackPlugin = require('html-webpack-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); @@ -90,6 +91,8 @@ module.exports = (env, argv) => { filename: '[name].css?v=[contenthash]', 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(), ], }; }; From 470eebed6544a9004278f821f5c4de6538ba41e0 Mon Sep 17 00:00:00 2001 From: Brad Miller Date: Tue, 31 Aug 2021 12:26:37 -0500 Subject: [PATCH 03/21] Fix: counting toggle questions --- runestone/common/js/runestonebase.js | 49 ++++++++++++++++++---------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/runestone/common/js/runestonebase.js b/runestone/common/js/runestonebase.js index 0febbea94..d1840be97 100644 --- a/runestone/common/js/runestonebase.js +++ b/runestone/common/js/runestonebase.js @@ -93,27 +93,22 @@ export default class RunestoneBase { eventInfo.percent = this.percent; } if (eBookConfig.useRunestoneServices && eBookConfig.logLevel > 0) { - let request = new Request(eBookConfig.ajaxURL + "hsblog", { - method: "POST", - headers: this.jsonHeaders, - body: JSON.stringify(eventInfo), - }); - try { - let response = await fetch(request); - if (!response.ok) { - throw new Error("Failed to save the log entry"); - } - post_return = response.json(); - } catch (e) { - if (this.isTimed) { - alert(`Error: Your action was not saved! The error was ${e}`); - } - console.log(`Error: ${e}`); - } + post_return = this.postLogMessage(eventInfo) } if (!this.isTimed || eBookConfig.debug) { console.log("logging event " + JSON.stringify(eventInfo)); } + // When selectquestions are part of an assignment especially toggle questions + // we need to count using the selector_id of the select question. + // We also need to log an event for that selector so that we will know + // that interaction has taken place. This is **independent** of how the + // autograder will ultimately grade the question! + if (this.selector_id) { + eventInfo.div_id = this.selector_id; + eventInfo.event = "selectquestion"; + eventInfo.act = "interaction" + this.postLogMessage(eventInfo); + } if ( typeof pageProgressTracker.updateProgress === "function" && eventInfo.act != "edit" && @@ -124,6 +119,26 @@ export default class RunestoneBase { return post_return; } + async postLogMessage(eventInfo) { + let request = new Request(eBookConfig.ajaxURL + "hsblog", { + method: "POST", + headers: this.jsonHeaders, + body: JSON.stringify(eventInfo), + }); + try { + let response = await fetch(request); + if (!response.ok) { + throw new Error("Failed to save the log entry"); + } + post_return = response.json(); + } catch (e) { + if (this.isTimed) { + alert(`Error: Your action was not saved! The error was ${e}`); + } + console.log(`Error: ${e}`); + } + return post_return; + } // .. _logRunEvent: // // logRunEvent From 2cfbdad5e7fb21c3da30c59d9e4634e6f7da7d6a Mon Sep 17 00:00:00 2001 From: Brad Miller Date: Tue, 31 Aug 2021 13:57:41 -0500 Subject: [PATCH 04/21] fix logging selectqs --- runestone/common/js/runestonebase.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/runestone/common/js/runestonebase.js b/runestone/common/js/runestonebase.js index d1840be97..79bb5cb3f 100644 --- a/runestone/common/js/runestonebase.js +++ b/runestone/common/js/runestonebase.js @@ -104,7 +104,7 @@ export default class RunestoneBase { // that interaction has taken place. This is **independent** of how the // autograder will ultimately grade the question! if (this.selector_id) { - eventInfo.div_id = this.selector_id; + eventInfo.div_id = this.selector_id.replace("-toggleSelectedQuestion", ""); eventInfo.event = "selectquestion"; eventInfo.act = "interaction" this.postLogMessage(eventInfo); @@ -120,6 +120,7 @@ export default class RunestoneBase { } async postLogMessage(eventInfo) { + var post_return; let request = new Request(eBookConfig.ajaxURL + "hsblog", { method: "POST", headers: this.jsonHeaders, From 0205059805b3b7c5f2072508d52a911a0217236d Mon Sep 17 00:00:00 2001 From: Brad Miller Date: Thu, 2 Sep 2021 11:53:41 -0500 Subject: [PATCH 05/21] Add peer instruction links --- runestone/common/js/bookfuncs.js | 9 +++++---- .../plugin_layouts/sphinx_bootstrap/layout.html | 2 ++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/runestone/common/js/bookfuncs.js b/runestone/common/js/bookfuncs.js index c950c69ac..f61ab64ab 100644 --- a/runestone/common/js/bookfuncs.js +++ b/runestone/common/js/bookfuncs.js @@ -58,9 +58,8 @@ function addReadingList() { name: "link", class: "btn btn-lg ' + 'buttonConfirmCompletion'", href: nxt_link, - text: `Continue to page ${ - position + 2 - } of ${num_readings} in the reading assignment.`, + text: `Continue to page ${position + 2 + } of ${num_readings} in the reading assignment.`, }); } else { l = $("
", { @@ -154,7 +153,7 @@ class PageProgressBar { if ( val == 100.0 && $("#completionButton").text().toLowerCase() === - "mark as completed" + "mark as completed" ) { $("#completionButton").click(); } @@ -190,6 +189,7 @@ async function handlePageSetup() { mess = `username: ${eBookConfig.username}`; if (!eBookConfig.isInstructor) { $("#ip_dropdown_link").remove(); + $("#inst_peer_link").remove(); } $(document).trigger("runestone:login"); addReadingList(); @@ -221,6 +221,7 @@ function setupNavbarLoggedOut() { $("#profilelink").hide(); $("#passwordlink").hide(); $("#ip_dropdown_link").hide(); + $("#inst_peer_link").hide(); $("li.loginout").html( 'Login' ); diff --git a/runestone/common/project_template/_templates/plugin_layouts/sphinx_bootstrap/layout.html b/runestone/common/project_template/_templates/plugin_layouts/sphinx_bootstrap/layout.html index 20fa2f7ba..9ae1b3710 100644 --- a/runestone/common/project_template/_templates/plugin_layouts/sphinx_bootstrap/layout.html +++ b/runestone/common/project_template/_templates/plugin_layouts/sphinx_bootstrap/layout.html @@ -150,6 +150,8 @@
  • Assignments
  • Practice
  • + +
  • Peer Instruction (Student)
  • {% if minimal_outside_links != 'True' %}
  • Change Course
  • From 9d029c85d3ff7b6af3b8f9295edf9698f4c66a51 Mon Sep 17 00:00:00 2001 From: Brad Miller Date: Fri, 3 Sep 2021 08:09:25 -0500 Subject: [PATCH 06/21] Change: prevent refresh on grading page --- runestone/common/js/bookfuncs.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/runestone/common/js/bookfuncs.js b/runestone/common/js/bookfuncs.js index 32d758cbe..a12395c8c 100644 --- a/runestone/common/js/bookfuncs.js +++ b/runestone/common/js/bookfuncs.js @@ -180,7 +180,10 @@ function handlePageSetup() { } $(document).trigger("runestone:login"); addReadingList(); - timedRefresh(); + // Avoid the timedRefresh on the grading page. + if (window.location.pathname.indexOf("/admin/grading") == -1) { + timedRefresh(); + } } else { mess = "Not logged in"; $(document).trigger("runestone:logout"); From 2c21f28b0df7380711ed14fab7e54797e8b2da3a Mon Sep 17 00:00:00 2001 From: Brad Miller Date: Fri, 3 Sep 2021 13:24:06 -0500 Subject: [PATCH 07/21] log runs --- runestone/activecode/js/livecode.js | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/runestone/activecode/js/livecode.js b/runestone/activecode/js/livecode.js index efa26195c..18a8e84d9 100644 --- a/runestone/activecode/js/livecode.js +++ b/runestone/activecode/js/livecode.js @@ -29,7 +29,7 @@ export default class LiveCode extends ActiveCode { } this.createErrorOutput(); } - outputfun(a) {} + outputfun(a) { } createInputElement() { var label = document.createElement("label"); label.for = this.divid + "_stdin"; @@ -43,12 +43,20 @@ export default class LiveCode extends ActiveCode { this.outerDiv.appendChild(input); this.stdin_el = input; } - createErrorOutput() {} + createErrorOutput() { } /* Main runProg method for livecode * */ - async runProg() { + async runProg(noUI, logResults) { + if (typeof logResults === "undefined") { + this.logResults = true; + } else { + this.logResults = logResults; + } + if (typeof noUI !== "boolean") { + noUI = false; + } await this.runSetup(); try { let res = await this.submitToJobe(); @@ -197,9 +205,9 @@ export default class LiveCode extends ActiveCode { public static void main(String[] args) { CodeTestHelper.resetFinalResults(); Result result = JUnitCore.runClasses(${testdrivername.replace( - ".java", - ".class" - )}); + ".java", + ".class" + )}); System.out.println(CodeTestHelper.getFinalResults()); int total = result.getRunCount(); From 3e42c6641a5e694de36fbae12c26482841056ef5 Mon Sep 17 00:00:00 2001 From: Brad Miller Date: Fri, 3 Sep 2021 18:51:25 +0000 Subject: [PATCH 08/21] Fixes - esp counting toggles --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 3c7b36a58..7f82faf48 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ with open("requirements.txt", "r", encoding="utf-8") as fh: dependencies = [l.strip() for l in fh] -VERSION = "5.10.1" +VERSION = "5.10.2" # These pre-install hooks are useful to make sure any pre-requisite # programs that are not pip installable are in place. From b26c13a304a62cc57a2d021c57b411cecd6c262a Mon Sep 17 00:00:00 2001 From: Brad Miller Date: Fri, 3 Sep 2021 19:25:42 +0000 Subject: [PATCH 09/21] 5.10.2 was bad --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 7f82faf48..6e6954ee4 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ with open("requirements.txt", "r", encoding="utf-8") as fh: dependencies = [l.strip() for l in fh] -VERSION = "5.10.2" +VERSION = "5.10.3" # These pre-install hooks are useful to make sure any pre-requisite # programs that are not pip installable are in place. From 1d8100e426d6b3250ea080d1eb8a40e55054e8c2 Mon Sep 17 00:00:00 2001 From: Brad Miller Date: Mon, 13 Sep 2021 11:16:26 -0500 Subject: [PATCH 10/21] make answer more available --- runestone/mchoice/js/mchoice.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/runestone/mchoice/js/mchoice.js b/runestone/mchoice/js/mchoice.js index e4a291dc7..5e9cbaf25 100644 --- a/runestone/mchoice/js/mchoice.js +++ b/runestone/mchoice/js/mchoice.js @@ -223,6 +223,7 @@ export default class MultipleChoice extends RunestoneBase { this.processMCMFSubmission(true); if (eBookConfig.peer) { this.submitButton.disabled = true; + this.disableInteraction(); } }.bind(this), false @@ -522,6 +523,7 @@ export default class MultipleChoice extends RunestoneBase { scoreMCMFSubmission() { if (this.givenArray[0] == this.correctIndexList[0]) { this.correct = true; + this.answer = this.givenArray[0] this.percent = 1.0; } else if (this.givenArray[0] != null) { // if given is null then the question wasn"t answered and should be counted as skipped @@ -657,6 +659,13 @@ export default class MultipleChoice extends RunestoneBase { this.optionArray[i].input.disabled = true; } } + + enableInteraction() { + for (var i = 0; i < this.optionArray.length; i++) { + this.optionArray[i].input.disabled = false; + } + } + } /*================================= From 1b36b8275ee34db74f7fe3a7c803e332853fc5ee Mon Sep 17 00:00:00 2001 From: Brad Miller Date: Tue, 14 Sep 2021 18:36:13 -0500 Subject: [PATCH 11/21] always set answer --- runestone/mchoice/js/mchoice.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runestone/mchoice/js/mchoice.js b/runestone/mchoice/js/mchoice.js index 5e9cbaf25..e4fbec76e 100644 --- a/runestone/mchoice/js/mchoice.js +++ b/runestone/mchoice/js/mchoice.js @@ -521,9 +521,9 @@ export default class MultipleChoice extends RunestoneBase { } scoreMCMFSubmission() { + this.answer = this.givenArray[0] if (this.givenArray[0] == this.correctIndexList[0]) { this.correct = true; - this.answer = this.givenArray[0] this.percent = 1.0; } else if (this.givenArray[0] != null) { // if given is null then the question wasn"t answered and should be counted as skipped From 0bfabcaf86fc115655759b989395a1c0a302604f Mon Sep 17 00:00:00 2001 From: bjones1 Date: Mon, 20 Sep 2021 12:20:56 +0100 Subject: [PATCH 12/21] Add: Dynamic questions. --- .github/workflows/python-package.yml | 16 +- package.json | 3 + runestone/common/project_template/conf.tmpl | 2 +- runestone/conftest.py | 17 +- runestone/fitb/fitb.py | 209 +++-- runestone/fitb/fitb_html_structure.html | 268 ++++++ runestone/fitb/js/ejs/lib/ejs.js | 940 ++++++++++++++++++++ runestone/fitb/js/ejs/lib/utils.js | 179 ++++ runestone/fitb/js/ejs/package.json | 42 + runestone/fitb/js/fitb-i18n.en.js | 1 + runestone/fitb/js/fitb-utils.js | 242 +++++ runestone/fitb/js/fitb.js | 400 ++++++--- runestone/fitb/js/timedfitb.js | 4 +- runestone/fitb/test/_sources/index.rst | 120 ++- runestone/fitb/test/conf.py | 2 +- runestone/fitb/test/test_fitb.py | 87 +- runestone/fitb/toctree.rst | 1 + runestone/timed/js/timed.js | 4 +- runestone/timed/test/test_timed.py | 9 +- runestone/unittest_base.py | 2 +- webpack.config.js | 85 +- webpack.server-index.js | 12 + 22 files changed, 2393 insertions(+), 252 deletions(-) create mode 100644 runestone/fitb/fitb_html_structure.html create mode 100644 runestone/fitb/js/ejs/lib/ejs.js create mode 100644 runestone/fitb/js/ejs/lib/utils.js create mode 100644 runestone/fitb/js/ejs/package.json create mode 100644 runestone/fitb/js/fitb-utils.js create mode 100644 webpack.server-index.js diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index c7e58362c..8e16a9364 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -5,9 +5,9 @@ name: Python package on: push: - branches: [master] + branches: [master, bookserver] pull_request: - branches: [master] + branches: [master, bookserver] jobs: build: @@ -28,26 +28,22 @@ jobs: if: always() - name: Setup npm and build runestone.js - id: create-runestone-bundle run: | npm install - npm run build - name: Set up Python ${{ matrix.python-version }} - id: install-python uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Install dependencies - id: install-deps run: | python -m pip install --upgrade pip python -m pip install flake8 pytest - pip install . + # Install as editable so that tests run in cwd, instead of in wherever Python puts system lib. This is important because tests are run on the local (not system lib) files. Therefore, the npm run build produces its files locally, not in system libs; if installed in system libs, then the components won't be able to find these files. + pip install -e . if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi - - name: Test with pytest - id: pytest + - name: Tests run: | - pytest + pytest - uses: act10ns/slack@v1 with: diff --git a/package.json b/package.json index 01b941dd3..7cc26d3ed 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", "copy-webpack-plugin": "^8.0.0", "css-loader": "^5.0.0", "css-minimizer-webpack-plugin": "^3.0.0", diff --git a/runestone/common/project_template/conf.tmpl b/runestone/common/project_template/conf.tmpl index d38d3c9b3..6fe101343 100644 --- a/runestone/common/project_template/conf.tmpl +++ b/runestone/common/project_template/conf.tmpl @@ -103,7 +103,7 @@ rst_prolog = ( # For fill-in-the-blank questions, provide a convenient means to indicate a blank. """ -.. |blank| replace:: :blank:`x` +.. |blank| replace:: :blank:`-` """ # For literate programming files, provide a convenient way to refer to a source file's name. See `runestone.lp.lp._docname_role`. diff --git a/runestone/conftest.py b/runestone/conftest.py index 4c3b0b9e7..72cbf5930 100644 --- a/runestone/conftest.py +++ b/runestone/conftest.py @@ -32,8 +32,8 @@ # 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 @@ -57,10 +57,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/fitb.py b/runestone/fitb/fitb.py index 1112d00d3..8fc5a4086 100644 --- a/runestone/fitb/fitb.py +++ b/runestone/fitb/fitb.py @@ -68,55 +68,79 @@ def __init__(self, content, **kwargs): def visit_fitb_node(self, node): + # 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 = [] - node.delimiter = "_start__{}_".format(node.runestone_options["divid"]) - self.body.append(node.delimiter) - - res = node.template_start % node.runestone_options - self.body.append(res) def depart_fitb_node(self, node): - # If there were fewer blanks than feedback items, add blanks at the end of the question. - blankCount = 0 - for _ in node.traverse(BlankNode): - blankCount += 1 - while blankCount < len(node.feedbackArray): + # 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_node(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 ) + # Capture HTML generated inside this directive. + inner_html = self.body + self.body = self.context.pop() + # Generate the HTML. - json_feedback = json.dumps(node.feedbackArray) + server_json = json.dumps({ + "problemHtml": "".join(inner_html), + "dyn_vars": node.dynamic, + "blankNames": blank_names, + "feedbackArray": node.feedbackArray + }) # 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 client-side grading info if we're not grading on the server. - node.runestone_options["json"] = ( - "false" - if node_with_document.document.settings.env.config.runestone_server_side_grading - else json_feedback - ) - res = node.template_end % node.runestone_options - self.body.append(res) + 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 = server_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 :]), - json_feedback, + outer_html, + server_json, ) - - self.body.remove(node.delimiter) + self.body.append(outer_html) class FillInTheBlank(RunestoneIdDirective): @@ -141,7 +165,10 @@ 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, + # case insensitive matching + "casei": directives.flag, } ) @@ -155,39 +182,28 @@ def run(self): super(FillInTheBlank, self).run() - TEMPLATE_START = """ -
    - -
    - """ - addQuestionToDB(self) fitbNode = FITBNode(self.options, rawsource=self.block_text) 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 @@ -206,14 +222,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 @@ -221,13 +239,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 @@ -263,47 +282,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] @@ -334,7 +355,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, @@ -348,9 +369,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], [] @@ -359,7 +378,11 @@ class BlankNode(nodes.Inline, nodes.TextElement, RunestoneNode): def visit_blank_node(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 depart_blank_node(self, node): diff --git a/runestone/fitb/fitb_html_structure.html b/runestone/fitb/fitb_html_structure.html new file mode 100644 index 000000000..442dec952 --- /dev/null +++ b/runestone/fitb/fitb_html_structure.html @@ -0,0 +1,268 @@ + + +
    + +
    + + + + +
    + +
    + + + + +
    + +
    diff --git a/runestone/fitb/js/ejs/lib/ejs.js b/runestone/fitb/js/ejs/lib/ejs.js new file mode 100644 index 000000000..87bc7b535 --- /dev/null +++ b/runestone/fitb/js/ejs/lib/ejs.js @@ -0,0 +1,940 @@ +/* + * EJS Embedded JavaScript templates + * Copyright 2112 Matthew Eernisse (mde@fleegix.org) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * +*/ + +'use strict'; + +/** + * @file Embedded JavaScript templating engine. {@link http://ejs.co} + * @author Matthew Eernisse + * @author Tiancheng "Timothy" Gu + * @project EJS + * @license {@link http://www.apache.org/licenses/LICENSE-2.0 Apache License, Version 2.0} + */ + +/** + * EJS internal functions. + * + * Technically this "module" lies in the same file as {@link module:ejs}, for + * the sake of organization all the private functions re grouped into this + * module. + * + * @module ejs-internal + * @private + */ + +/** + * Embedded JavaScript templating engine. + * + * @module ejs + * @public + */ + +var fs = require('fs'); +var path = require('path'); +var utils = require('./utils'); + +var scopeOptionWarned = false; +/** @type {string} */ +var _VERSION_STRING = require('../package.json').version; +var _DEFAULT_OPEN_DELIMITER = '<'; +var _DEFAULT_CLOSE_DELIMITER = '>'; +var _DEFAULT_DELIMITER = '%'; +var _DEFAULT_LOCALS_NAME = 'locals'; +var _NAME = 'ejs'; +var _REGEX_STRING = '(<%%|%%>|<%=|<%-|<%_|<%#|<%|%>|-%>|_%>)'; +var _OPTS_PASSABLE_WITH_DATA = ['delimiter', 'scope', 'context', 'debug', 'compileDebug', + 'client', '_with', 'rmWhitespace', 'strict', 'filename', 'async']; +// We don't allow 'cache' option to be passed in the data obj for +// the normal `render` call, but this is where Express 2 & 3 put it +// so we make an exception for `renderFile` +var _OPTS_PASSABLE_WITH_DATA_EXPRESS = _OPTS_PASSABLE_WITH_DATA.concat('cache'); +var _BOM = /^\uFEFF/; + +/** + * EJS template function cache. This can be a LRU object from lru-cache NPM + * module. By default, it is {@link module:utils.cache}, a simple in-process + * cache that grows continuously. + * + * @type {Cache} + */ + +exports.cache = utils.cache; + +/** + * Custom file loader. Useful for template preprocessing or restricting access + * to a certain part of the filesystem. + * + * @type {fileLoader} + */ + +exports.fileLoader = fs.readFileSync; + +/** + * Name of the object containing the locals. + * + * This variable is overridden by {@link Options}`.localsName` if it is not + * `undefined`. + * + * @type {String} + * @public + */ + +exports.localsName = _DEFAULT_LOCALS_NAME; + +/** + * Promise implementation -- defaults to the native implementation if available + * This is mostly just for testability + * + * @type {PromiseConstructorLike} + * @public + */ + +exports.promiseImpl = (new Function('return this;'))().Promise; + +/** + * Get the path to the included file from the parent file path and the + * specified path. + * + * @param {String} name specified path + * @param {String} filename parent file path + * @param {Boolean} [isDir=false] whether the parent file path is a directory + * @return {String} + */ +exports.resolveInclude = function(name, filename, isDir) { + var dirname = path.dirname; + var extname = path.extname; + var resolve = path.resolve; + var includePath = resolve(isDir ? filename : dirname(filename), name); + var ext = extname(name); + if (!ext) { + includePath += '.ejs'; + } + return includePath; +}; + +/** + * Try to resolve file path on multiple directories + * + * @param {String} name specified path + * @param {Array} paths list of possible parent directory paths + * @return {String} + */ +function resolvePaths(name, paths) { + var filePath; + if (paths.some(function (v) { + filePath = exports.resolveInclude(name, v, true); + return fs.existsSync(filePath); + })) { + return filePath; + } +} + +/** + * Get the path to the included file by Options + * + * @param {String} path specified path + * @param {Options} options compilation options + * @return {String} + */ +function getIncludePath(path, options) { + var includePath; + var filePath; + var views = options.views; + var match = /^[A-Za-z]+:\\|^\//.exec(path); + + // Abs path + if (match && match.length) { + path = path.replace(/^\/*/, ''); + if (Array.isArray(options.root)) { + includePath = resolvePaths(path, options.root); + } else { + includePath = exports.resolveInclude(path, options.root || '/', true); + } + } + // Relative paths + else { + // Look relative to a passed filename first + if (options.filename) { + filePath = exports.resolveInclude(path, options.filename); + if (fs.existsSync(filePath)) { + includePath = filePath; + } + } + // Then look in any views directories + if (!includePath && Array.isArray(views)) { + includePath = resolvePaths(path, views); + } + if (!includePath && typeof options.includer !== 'function') { + throw new Error('Could not find the include file "' + + options.escapeFunction(path) + '"'); + } + } + return includePath; +} + +/** + * Get the template from a string or a file, either compiled on-the-fly or + * read from cache (if enabled), and cache the template if needed. + * + * If `template` is not set, the file specified in `options.filename` will be + * read. + * + * If `options.cache` is true, this function reads the file from + * `options.filename` so it must be set prior to calling this function. + * + * @memberof module:ejs-internal + * @param {Options} options compilation options + * @param {String} [template] template source + * @return {(TemplateFunction|ClientFunction)} + * Depending on the value of `options.client`, either type might be returned. + * @static + */ + +function handleCache(options, template) { + var func; + var filename = options.filename; + var hasTemplate = arguments.length > 1; + + if (options.cache) { + if (!filename) { + throw new Error('cache option requires a filename'); + } + func = exports.cache.get(filename); + if (func) { + return func; + } + if (!hasTemplate) { + template = fileLoader(filename).toString().replace(_BOM, ''); + } + } + else if (!hasTemplate) { + // istanbul ignore if: should not happen at all + if (!filename) { + throw new Error('Internal EJS error: no file name or template ' + + 'provided'); + } + template = fileLoader(filename).toString().replace(_BOM, ''); + } + func = exports.compile(template, options); + if (options.cache) { + exports.cache.set(filename, func); + } + return func; +} + +/** + * Try calling handleCache with the given options and data and call the + * callback with the result. If an error occurs, call the callback with + * the error. Used by renderFile(). + * + * @memberof module:ejs-internal + * @param {Options} options compilation options + * @param {Object} data template data + * @param {RenderFileCallback} cb callback + * @static + */ + +function tryHandleCache(options, data, cb) { + var result; + if (!cb) { + if (typeof exports.promiseImpl == 'function') { + return new exports.promiseImpl(function (resolve, reject) { + try { + result = handleCache(options)(data); + resolve(result); + } + catch (err) { + reject(err); + } + }); + } + else { + throw new Error('Please provide a callback function'); + } + } + else { + try { + result = handleCache(options)(data); + } + catch (err) { + return cb(err); + } + + cb(null, result); + } +} + +/** + * fileLoader is independent + * + * @param {String} filePath ejs file path. + * @return {String} The contents of the specified file. + * @static + */ + +function fileLoader(filePath){ + return exports.fileLoader(filePath); +} + +/** + * Get the template function. + * + * If `options.cache` is `true`, then the template is cached. + * + * @memberof module:ejs-internal + * @param {String} path path for the specified file + * @param {Options} options compilation options + * @return {(TemplateFunction|ClientFunction)} + * Depending on the value of `options.client`, either type might be returned + * @static + */ + +function includeFile(path, options) { + var opts = utils.shallowCopy({}, options); + opts.filename = getIncludePath(path, opts); + if (typeof options.includer === 'function') { + var includerResult = options.includer(path, opts.filename); + if (includerResult) { + if (includerResult.filename) { + opts.filename = includerResult.filename; + } + if (includerResult.template) { + return handleCache(opts, includerResult.template); + } + } + } + return handleCache(opts); +} + +/** + * Re-throw the given `err` in context to the `str` of ejs, `filename`, and + * `lineno`. + * + * @implements {RethrowCallback} + * @memberof module:ejs-internal + * @param {Error} err Error object + * @param {String} str EJS source + * @param {String} flnm file name of the EJS file + * @param {Number} lineno line number of the error + * @param {EscapeCallback} esc + * @static + */ + +function rethrow(err, str, flnm, lineno, esc) { + var lines = str.split('\n'); + var start = Math.max(lineno - 3, 0); + var end = Math.min(lines.length, lineno + 3); + var filename = esc(flnm); + // Error context + var context = lines.slice(start, end).map(function (line, i){ + var curr = i + start + 1; + return (curr == lineno ? ' >> ' : ' ') + + curr + + '| ' + + line; + }).join('\n'); + + // Alter exception message + err.path = filename; + err.message = (filename || 'ejs') + ':' + + lineno + '\n' + + context + '\n\n' + + err.message; + + throw err; +} + +function stripSemi(str){ + return str.replace(/;(\s*$)/, '$1'); +} + +/** + * Compile the given `str` of ejs into a template function. + * + * @param {String} template EJS template + * + * @param {Options} [opts] compilation options + * + * @return {(TemplateFunction|ClientFunction)} + * Depending on the value of `opts.client`, either type might be returned. + * Note that the return type of the function also depends on the value of `opts.async`. + * @public + */ + +exports.compile = function compile(template, opts) { + var templ; + + // v1 compat + // 'scope' is 'context' + // FIXME: Remove this in a future version + if (opts && opts.scope) { + if (!scopeOptionWarned){ + console.warn('`scope` option is deprecated and will be removed in EJS 3'); + scopeOptionWarned = true; + } + if (!opts.context) { + opts.context = opts.scope; + } + delete opts.scope; + } + templ = new Template(template, opts); + return templ.compile(); +}; + +/** + * Render the given `template` of ejs. + * + * If you would like to include options but not data, you need to explicitly + * call this function with `data` being an empty object or `null`. + * + * @param {String} template EJS template + * @param {Object} [data={}] template data + * @param {Options} [opts={}] compilation and rendering options + * @return {(String|Promise)} + * Return value type depends on `opts.async`. + * @public + */ + +exports.render = function (template, d, o) { + var data = d || {}; + var opts = o || {}; + + // No options object -- if there are optiony names + // in the data, copy them to options + if (arguments.length == 2) { + utils.shallowCopyFromList(opts, data, _OPTS_PASSABLE_WITH_DATA); + } + + return handleCache(opts, template)(data); +}; + +/** + * Render an EJS file at the given `path` and callback `cb(err, str)`. + * + * If you would like to include options but not data, you need to explicitly + * call this function with `data` being an empty object or `null`. + * + * @param {String} path path to the EJS file + * @param {Object} [data={}] template data + * @param {Options} [opts={}] compilation and rendering options + * @param {RenderFileCallback} cb callback + * @public + */ + +exports.renderFile = function () { + var args = Array.prototype.slice.call(arguments); + var filename = args.shift(); + var cb; + var opts = {filename: filename}; + var data; + var viewOpts; + + // Do we have a callback? + if (typeof arguments[arguments.length - 1] == 'function') { + cb = args.pop(); + } + // Do we have data/opts? + if (args.length) { + // Should always have data obj + data = args.shift(); + // Normal passed opts (data obj + opts obj) + if (args.length) { + // Use shallowCopy so we don't pollute passed in opts obj with new vals + utils.shallowCopy(opts, args.pop()); + } + // Special casing for Express (settings + opts-in-data) + else { + // Express 3 and 4 + if (data.settings) { + // Pull a few things from known locations + if (data.settings.views) { + opts.views = data.settings.views; + } + if (data.settings['view cache']) { + opts.cache = true; + } + // Undocumented after Express 2, but still usable, esp. for + // items that are unsafe to be passed along with data, like `root` + viewOpts = data.settings['view options']; + if (viewOpts) { + utils.shallowCopy(opts, viewOpts); + } + } + // Express 2 and lower, values set in app.locals, or people who just + // want to pass options in their data. NOTE: These values will override + // anything previously set in settings or settings['view options'] + utils.shallowCopyFromList(opts, data, _OPTS_PASSABLE_WITH_DATA_EXPRESS); + } + opts.filename = filename; + } + else { + data = {}; + } + + return tryHandleCache(opts, data, cb); +}; + +/** + * Clear intermediate JavaScript cache. Calls {@link Cache#reset}. + * @public + */ + +/** + * EJS template class + * @public + */ +exports.Template = Template; + +exports.clearCache = function () { + exports.cache.reset(); +}; + +function Template(text, opts) { + opts = opts || {}; + var options = {}; + this.templateText = text; + /** @type {string | null} */ + this.mode = null; + this.truncate = false; + this.currentLine = 1; + this.source = ''; + options.client = opts.client || false; + options.escapeFunction = opts.escape || opts.escapeFunction || utils.escapeXML; + options.compileDebug = opts.compileDebug !== false; + options.debug = !!opts.debug; + options.filename = opts.filename; + options.openDelimiter = opts.openDelimiter || exports.openDelimiter || _DEFAULT_OPEN_DELIMITER; + options.closeDelimiter = opts.closeDelimiter || exports.closeDelimiter || _DEFAULT_CLOSE_DELIMITER; + options.delimiter = opts.delimiter || exports.delimiter || _DEFAULT_DELIMITER; + options.strict = opts.strict || false; + options.context = opts.context; + options.cache = opts.cache || false; + options.rmWhitespace = opts.rmWhitespace; + options.root = opts.root; + options.includer = opts.includer; + options.outputFunctionName = opts.outputFunctionName; + options.localsName = opts.localsName || exports.localsName || _DEFAULT_LOCALS_NAME; + options.views = opts.views; + options.async = opts.async; + options.destructuredLocals = opts.destructuredLocals; + options.legacyInclude = typeof opts.legacyInclude != 'undefined' ? !!opts.legacyInclude : true; + + if (options.strict) { + options._with = false; + } + else { + options._with = typeof opts._with != 'undefined' ? opts._with : true; + } + + this.opts = options; + + this.regex = this.createRegex(); +} + +Template.modes = { + EVAL: 'eval', + ESCAPED: 'escaped', + RAW: 'raw', + COMMENT: 'comment', + LITERAL: 'literal' +}; + +Template.prototype = { + createRegex: function () { + var str = _REGEX_STRING; + var delim = utils.escapeRegExpChars(this.opts.delimiter); + var open = utils.escapeRegExpChars(this.opts.openDelimiter); + var close = utils.escapeRegExpChars(this.opts.closeDelimiter); + str = str.replace(/%/g, delim) + .replace(//g, close); + return new RegExp(str); + }, + + compile: function () { + /** @type {string} */ + var src; + /** @type {ClientFunction} */ + var fn; + var opts = this.opts; + var prepended = ''; + var appended = ''; + /** @type {EscapeCallback} */ + var escapeFn = opts.escapeFunction; + /** @type {FunctionConstructor} */ + var ctor; + /** @type {string} */ + var sanitizedFilename = opts.filename ? JSON.stringify(opts.filename) : 'undefined'; + + if (!this.source) { + this.generateSource(); + prepended += + ' var __output = "";\n' + + ' function __append(s) { if (s !== undefined && s !== null) __output += s }\n'; + if (opts.outputFunctionName) { + prepended += ' var ' + opts.outputFunctionName + ' = __append;' + '\n'; + } + if (opts.destructuredLocals && opts.destructuredLocals.length) { + var destructuring = ' var __locals = (' + opts.localsName + ' || {}),\n'; + for (var i = 0; i < opts.destructuredLocals.length; i++) { + var name = opts.destructuredLocals[i]; + if (i > 0) { + destructuring += ',\n '; + } + destructuring += name + ' = __locals.' + name; + } + prepended += destructuring + ';\n'; + } + if (opts._with === false) { + prepended += ' Function.apply(null, ["__append", "escapeFn"].concat(Object.keys(' + opts.localsName + ' || {}), [\n'; + appended += '])).apply(null, [__append, escapeFn].concat(Object.values(' + opts.localsName + ' || {})));\n'; + this.source = JSON.stringify(this.source); + } + appended += ' return __output;' + '\n'; + this.source = prepended + this.source + appended; + } + + if (opts.compileDebug) { + src = 'var __line = 1' + '\n' + + ' , __lines = ' + JSON.stringify(this.templateText) + '\n' + + ' , __filename = ' + sanitizedFilename + ';' + '\n' + + 'try {' + '\n' + + this.source + + '} catch (e) {' + '\n' + + ' rethrow(e, __lines, __filename, __line, escapeFn);' + '\n' + + '}' + '\n'; + } + else { + src = this.source; + } + + if (opts.client) { + src = 'escapeFn = escapeFn || ' + escapeFn.toString() + ';' + '\n' + src; + if (opts.compileDebug) { + src = 'rethrow = rethrow || ' + rethrow.toString() + ';' + '\n' + src; + } + } + + if (opts.strict) { + src = '"use strict";\n' + src; + } + if (opts.debug) { + console.log(src); + } + if (opts.compileDebug && opts.filename) { + src = src + '\n' + + '//# sourceURL=' + sanitizedFilename + '\n'; + } + + try { + if (opts.async) { + // Have to use generated function for this, since in envs without support, + // it breaks in parsing + try { + ctor = (new Function('return (async function(){}).constructor;'))(); + } + catch(e) { + if (e instanceof SyntaxError) { + throw new Error('This environment does not support async/await'); + } + else { + throw e; + } + } + } + else { + ctor = Function; + } + fn = new ctor(opts.localsName + ', escapeFn, include, rethrow', src); + } + catch(e) { + // istanbul ignore else + if (e instanceof SyntaxError) { + if (opts.filename) { + e.message += ' in ' + opts.filename; + } + e.message += ' while compiling ejs\n\n'; + e.message += 'If the above error is not helpful, you may want to try EJS-Lint:\n'; + e.message += 'https://github.com/RyanZim/EJS-Lint'; + if (!opts.async) { + e.message += '\n'; + e.message += 'Or, if you meant to create an async function, pass `async: true` as an option.'; + } + } + throw e; + } + + // Return a callable function which will execute the function + // created by the source-code, with the passed data as locals + // Adds a local `include` function which allows full recursive include + var returnedFn = opts.client ? fn : function anonymous(data) { + var include = function (path, includeData) { + var d = utils.shallowCopy({}, data); + if (includeData) { + d = utils.shallowCopy(d, includeData); + } + return includeFile(path, opts)(d); + }; + return fn.apply(opts.context, [data || {}, escapeFn, include, rethrow]); + }; + if (opts.filename && typeof Object.defineProperty === 'function') { + var filename = opts.filename; + var basename = path.basename(filename, path.extname(filename)); + try { + Object.defineProperty(returnedFn, 'name', { + value: basename, + writable: false, + enumerable: false, + configurable: true + }); + } catch (e) {/* ignore */} + } + return returnedFn; + }, + + generateSource: function () { + var opts = this.opts; + + if (opts.rmWhitespace) { + // Have to use two separate replace here as `^` and `$` operators don't + // work well with `\r` and empty lines don't work well with the `m` flag. + this.templateText = + this.templateText.replace(/[\r\n]+/g, '\n').replace(/^\s+|\s+$/gm, ''); + } + + // Slurp spaces and tabs before <%_ and after _%> + this.templateText = + this.templateText.replace(/[ \t]*<%_/gm, '<%_').replace(/_%>[ \t]*/gm, '_%>'); + + var self = this; + var matches = this.parseTemplateText(); + var d = this.opts.delimiter; + var o = this.opts.openDelimiter; + var c = this.opts.closeDelimiter; + + if (matches && matches.length) { + matches.forEach(function (line, index) { + var closing; + // If this is an opening tag, check for closing tags + // FIXME: May end up with some false positives here + // Better to store modes as k/v with openDelimiter + delimiter as key + // Then this can simply check against the map + if ( line.indexOf(o + d) === 0 // If it is a tag + && line.indexOf(o + d + d) !== 0) { // and is not escaped + closing = matches[index + 2]; + if (!(closing == d + c || closing == '-' + d + c || closing == '_' + d + c)) { + throw new Error('Could not find matching close tag for "' + line + '".'); + } + } + self.scanLine(line); + }); + } + + }, + + parseTemplateText: function () { + var str = this.templateText; + var pat = this.regex; + var result = pat.exec(str); + var arr = []; + var firstPos; + + while (result) { + firstPos = result.index; + + if (firstPos !== 0) { + arr.push(str.substring(0, firstPos)); + str = str.slice(firstPos); + } + + arr.push(result[0]); + str = str.slice(result[0].length); + result = pat.exec(str); + } + + if (str) { + arr.push(str); + } + + return arr; + }, + + _addOutput: function (line) { + if (this.truncate) { + // Only replace single leading linebreak in the line after + // -%> tag -- this is the single, trailing linebreak + // after the tag that the truncation mode replaces + // Handle Win / Unix / old Mac linebreaks -- do the \r\n + // combo first in the regex-or + line = line.replace(/^(?:\r\n|\r|\n)/, ''); + this.truncate = false; + } + if (!line) { + return line; + } + + // Preserve literal slashes + line = line.replace(/\\/g, '\\\\'); + + // Convert linebreaks + line = line.replace(/\n/g, '\\n'); + line = line.replace(/\r/g, '\\r'); + + // Escape double-quotes + // - this will be the delimiter during execution + line = line.replace(/"/g, '\\"'); + this.source += ' ; __append("' + line + '")' + '\n'; + }, + + scanLine: function (line) { + var self = this; + var d = this.opts.delimiter; + var o = this.opts.openDelimiter; + var c = this.opts.closeDelimiter; + var newLineCount = 0; + + newLineCount = (line.split('\n').length - 1); + + switch (line) { + case o + d: + case o + d + '_': + this.mode = Template.modes.EVAL; + break; + case o + d + '=': + this.mode = Template.modes.ESCAPED; + break; + case o + d + '-': + this.mode = Template.modes.RAW; + break; + case o + d + '#': + this.mode = Template.modes.COMMENT; + break; + case o + d + d: + this.mode = Template.modes.LITERAL; + this.source += ' ; __append("' + line.replace(o + d + d, o + d) + '")' + '\n'; + break; + case d + d + c: + this.mode = Template.modes.LITERAL; + this.source += ' ; __append("' + line.replace(d + d + c, d + c) + '")' + '\n'; + break; + case d + c: + case '-' + d + c: + case '_' + d + c: + if (this.mode == Template.modes.LITERAL) { + this._addOutput(line); + } + + this.mode = null; + this.truncate = line.indexOf('-') === 0 || line.indexOf('_') === 0; + break; + default: + // In script mode, depends on type of tag + if (this.mode) { + // If '//' is found without a line break, add a line break. + switch (this.mode) { + case Template.modes.EVAL: + case Template.modes.ESCAPED: + case Template.modes.RAW: + if (line.lastIndexOf('//') > line.lastIndexOf('\n')) { + line += '\n'; + } + } + switch (this.mode) { + // Just executing code + case Template.modes.EVAL: + this.source += ' ; ' + line + '\n'; + break; + // Exec, esc, and output + case Template.modes.ESCAPED: + this.source += ' ; __append(escapeFn(' + stripSemi(line) + '))' + '\n'; + break; + // Exec and output + case Template.modes.RAW: + this.source += ' ; __append(' + stripSemi(line) + ')' + '\n'; + break; + case Template.modes.COMMENT: + // Do nothing + break; + // Literal <%% mode, append as raw output + case Template.modes.LITERAL: + this._addOutput(line); + break; + } + } + // In string mode, just add the output + else { + this._addOutput(line); + } + } + + if (self.opts.compileDebug && newLineCount) { + this.currentLine += newLineCount; + this.source += ' ; __line = ' + this.currentLine + '\n'; + } + } +}; + +/** + * Escape characters reserved in XML. + * + * This is simply an export of {@link module:utils.escapeXML}. + * + * If `markup` is `undefined` or `null`, the empty string is returned. + * + * @param {String} markup Input string + * @return {String} Escaped string + * @public + * @func + * */ +exports.escapeXML = utils.escapeXML; + +/** + * Express.js support. + * + * This is an alias for {@link module:ejs.renderFile}, in order to support + * Express.js out-of-the-box. + * + * @func + */ + +exports.__express = exports.renderFile; + +/** + * Version of EJS. + * + * @readonly + * @type {String} + * @public + */ + +exports.VERSION = _VERSION_STRING; + +/** + * Name for detection of EJS. + * + * @readonly + * @type {String} + * @public + */ + +exports.name = _NAME; + +/* istanbul ignore if */ +if (typeof window != 'undefined') { + window.ejs = exports; +} diff --git a/runestone/fitb/js/ejs/lib/utils.js b/runestone/fitb/js/ejs/lib/utils.js new file mode 100644 index 000000000..4de799a66 --- /dev/null +++ b/runestone/fitb/js/ejs/lib/utils.js @@ -0,0 +1,179 @@ +/* + * EJS Embedded JavaScript templates + * Copyright 2112 Matthew Eernisse (mde@fleegix.org) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * +*/ + +/** + * Private utility functions + * @module utils + * @private + */ + +'use strict'; + +var regExpChars = /[|\\{}()[\]^$+*?.]/g; + +/** + * Escape characters reserved in regular expressions. + * + * If `string` is `undefined` or `null`, the empty string is returned. + * + * @param {String} string Input string + * @return {String} Escaped string + * @static + * @private + */ +exports.escapeRegExpChars = function (string) { + // istanbul ignore if + if (!string) { + return ''; + } + return String(string).replace(regExpChars, function(match) { return "\\" + match; }); +}; + +var _ENCODE_HTML_RULES = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' +}; +var _MATCH_HTML = /[&<>'"]/g; + +function encode_char(c) { + return _ENCODE_HTML_RULES[c] || c; +} + +/** + * Stringified version of constants used by {@link module:utils.escapeXML}. + * + * It is used in the process of generating {@link ClientFunction}s. + * + * @readonly + * @type {String} + */ + +var escapeFuncStr = + 'var _ENCODE_HTML_RULES = {\n' ++ ' "&": "&"\n' ++ ' , "<": "<"\n' ++ ' , ">": ">"\n' ++ ' , \'"\': """\n' ++ ' , "\'": "'"\n' ++ ' }\n' ++ ' , _MATCH_HTML = /[&<>\'"]/g;\n' ++ 'function encode_char(c) {\n' ++ ' return _ENCODE_HTML_RULES[c] || c;\n' ++ '};\n'; + +/** + * Escape characters reserved in XML. + * + * If `markup` is `undefined` or `null`, the empty string is returned. + * + * @implements {EscapeCallback} + * @param {String} markup Input string + * @return {String} Escaped string + * @static + * @private + */ + +exports.escapeXML = function (markup) { + return markup == undefined + ? '' + : String(markup) + .replace(_MATCH_HTML, encode_char); +}; +exports.escapeXML.toString = function () { + return Function.prototype.toString.call(this) + ';\n' + escapeFuncStr; +}; + +/** + * Naive copy of properties from one object to another. + * Does not recurse into non-scalar properties + * Does not check to see if the property has a value before copying + * + * @param {Object} to Destination object + * @param {Object} from Source object + * @return {Object} Destination object + * @static + * @private + */ +exports.shallowCopy = function (to, from) { + from = from || {}; + for (var p in from) { + to[p] = from[p]; + } + return to; +}; + +/** + * Naive copy of a list of key names, from one object to another. + * Only copies property if it is actually defined + * Does not recurse into non-scalar properties + * + * @param {Object} to Destination object + * @param {Object} from Source object + * @param {Array} list List of properties to copy + * @return {Object} Destination object + * @static + * @private + */ +exports.shallowCopyFromList = function (to, from, list) { + for (var i = 0; i < list.length; i++) { + var p = list[i]; + if (typeof from[p] != 'undefined') { + to[p] = from[p]; + } + } + return to; +}; + +/** + * Simple in-process cache implementation. Does not implement limits of any + * sort. + * + * @implements {Cache} + * @static + * @private + */ +exports.cache = { + _data: {}, + set: function (key, val) { + this._data[key] = val; + }, + get: function (key) { + return this._data[key]; + }, + remove: function (key) { + delete this._data[key]; + }, + reset: function () { + this._data = {}; + } +}; + +/** + * Transforms hyphen case variable into camel case. + * + * @param {String} string Hyphen case string + * @return {String} Camel case string + * @static + * @private + */ +exports.hyphenToCamel = function (str) { + return str.replace(/-[a-z]/g, function (match) { return match[1].toUpperCase(); }); +}; diff --git a/runestone/fitb/js/ejs/package.json b/runestone/fitb/js/ejs/package.json new file mode 100644 index 000000000..c2b036bea --- /dev/null +++ b/runestone/fitb/js/ejs/package.json @@ -0,0 +1,42 @@ +{ + "name": "ejs", + "description": "Embedded JavaScript templates", + "keywords": [ + "template", + "engine", + "ejs" + ], + "version": "3.1.6", + "author": "Matthew Eernisse (http://fleegix.org)", + "license": "Apache-2.0", + "bin": { + "ejs": "./bin/cli.js" + }, + "main": "./lib/ejs.js", + "jsdelivr": "ejs.min.js", + "unpkg": "ejs.min.js", + "repository": { + "type": "git", + "url": "git://github.com/mde/ejs.git" + }, + "bugs": "https://github.com/mde/ejs/issues", + "homepage": "https://github.com/mde/ejs", + "dependencies": { + "jake": "^10.6.1" + }, + "devDependencies": { + "browserify": "^16.5.1", + "eslint": "^6.8.0", + "git-directory-deploy": "^1.5.1", + "jsdoc": "^3.6.4", + "lru-cache": "^4.0.1", + "mocha": "^7.1.1", + "uglify-js": "^3.3.16" + }, + "engines": { + "node": ">=0.10.0" + }, + "scripts": { + "test": "mocha" + } +} diff --git a/runestone/fitb/js/fitb-i18n.en.js b/runestone/fitb/js/fitb-i18n.en.js index cc949cd7f..31d8e00f7 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..83d590ddb --- /dev/null +++ b/runestone/fitb/js/fitb-utils.js @@ -0,0 +1,242 @@ +// ******************************************************** +// |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"; + +// Includes +// ======== +// This is an edited copy of `EJS `_: +// +// - It contains the improvement mentioned in `this issue `_. +// - It also contains a workaround for a `js2py v0.71 bug `_. The fix is merged, but not yet released. +// +// If both issues are merged and released, then use EJS from NPM. +import { render as ejs_render } from "./ejs/lib/ejs.js"; + + +// Globals +// ======= +// Standard options to use for EJS templates. +const EJS_OPTIONS = { + strict: true, + // Not needed, but might reduce confusion -- you can access the variable ``a`` as ``a`` or ``v.a``. + localsName: "v", + // Avoid the default delimiters of ``<`` and ``>``, which get translated to HTML entities by Sphinx. + openDelimiter: "[", + closeDelimiter: "]" +}; + + +// Functions +// ========= +// Update the problem's description based on dynamically-generated content. +export function renderDynamicContent(seed, dyn_vars, html_in) { + // Initialize RNG with ``this.seed``. Taken from `SO `_. + const rand = function mulberry32(a) { + return function() { + var t = a += 0x6D2B79F5; + t = Math.imul(t ^ t >>> 15, t | 1); + t ^= t + Math.imul(t ^ t >>> 7, t | 61); + return ((t ^ t >>> 14) >>> 0) / 4294967296; + } + }(seed); + + // See `RAND_FUNC `_, which refers to ``rand`` above. + const dyn_vars_eval = window.Function("v", "rand", `"use strict";\n${dyn_vars};\nreturn v;`)({}, RAND_FUNC); + let html_out = ""; + try { + html_out = ejs_render(html_in, dyn_vars_eval, EJS_OPTIONS); + } catch (err) { + html_out += `
    ${err}
    `; + } + + 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 evaluateAnswersCore( + // _`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, + // True if this is running on the server, to work around a `js2py v0.71 bug `_ fixed in master. When a new version is released, remove this. + is_server=false, +) { + // Keep track if all answers are correct or not. + let correct = true; + let isCorrectArray = []; + let displayFeed = []; + for (var i = 0; i < given_arr.length; i++) { + var 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. + var fbl = feedbackArray[i] || []; + for (var 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) { + // Prepare the needed inputs for calling the grading function. + // + // Provide a dict of {blank_name, converter_answer_value}. + const blankValues = getBlankValues(given_arr, blankNamesDict, dyn_vars_eval); + // Compute an array of [blank_0_name, ...]. + let given_arr_names = []; + for (const [k, v] of Object.entries(blankNamesDict)) { + given_arr_names[v] = k; + } + // Compute an array of [converted_blank_0_val, ...]. + const given_arr_converted = given_arr.map((value, index) => type_convert(given_arr_names[index], value, index, dyn_vars_eval)); + // 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. + let is_correct = window.Function( + "ans", + "ans_array", + // Not necessary, but allows access of a variable such as ``a`` using ``v.a`` (or as ``a``). + "v", + ...Object.keys(dyn_vars_eval), + ...Object.keys(blankValues), + `"use strict;"\nreturn ${fbl[j]["solution_code"]};` + )( + given_arr_converted[j], + given_arr_converted, + Object.assign({}, dyn_vars_eval, blankValues), + ...Object.values(dyn_vars_eval), + ...Object.values(blankValues) + ); + if (is_correct) { + displayFeed.push(fbl[j]["feedback"]); + break; + } + } else + // If this is a regexp... + if ("regex" in fbl[j]) { + var 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]); + 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) { + displayFeed.push(fbl[j]["feedback"]); + break; + } + } + } + + // js2py seems to increment j in the for loop **after** encountering a break statement. Aargh. Work around this. + if (is_server) { + --j; + } + // 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; + isCorrectArray.push(is_correct); + if (!is_correct) { + correct = false; + } + } + } + + const percent = isCorrectArray.filter(Boolean).length / isCorrectArray.length; + return [displayFeed, correct, isCorrectArray, percent]; +} + + +// 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 blankValues = getBlankValues(given_arr, blankNamesDict, dyn_vars_eval); + const sol_vars_plus = Object.assign({ + ans: given_arr[index], + ans_array: given_arr + }, + dyn_vars_eval, + blankValues, + ); + try { + displayFeed_i = ejs_render(displayFeed_i, sol_vars_plus, EJS_OPTIONS); + } catch (err) { + displayFeed_i += `
    ${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 getBlankValues(given_arr, blankNamesDict, dyn_vars_eval) { + let blankValues = {}; + for (let [blank_name, blank_index] of Object.entries(blankNamesDict)) { + blankValues[blank_name] = type_convert(blank_name, given_arr[blank_index], blank_index, dyn_vars_eval); + } + return blankValues; +} + + +// 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; + let 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. + console.log([converter, Number, converter === Number, typeof Number_, name, value, index, dyn_vars_eval]); + if (converter === Number && typeof Number_ !== "undefined") { + converter = Number_; + } + try { + return converter(value); + } catch (err) { + console.log(`Error converting blank named "${name}" with value "${value}" at blank index ${index} using converter ${converter.name}: ${err}.`); + return value; + } +} + + +// A pass-through "converter". +function pass_through(val) { + return val; +} \ No newline at end of file diff --git a/runestone/fitb/js/fitb.js b/runestone/fitb/js/fitb.js index cea20fc42..9de714aac 100644 --- a/runestone/fitb/js/fitb.js +++ b/runestone/fitb/js/fitb.js @@ -1,15 +1,80 @@ -// ********* -// |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 +// ----------------------- +// Client restores the problem, server sends or local storage has: +// +// - seed (used only for dynamic problems) +// - answer +// - displayFeed (server-side grading only) +// - correct (SSG) +// - isCorrectArray (SSG) +// - problemHtml (SSG with dynamic problems only) +// +// If any of the answers are true, 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 +// - problemHtml +// - displayFeed (SSG only) +// - correct (SSG only) +// - isCorrectArray (SSG only) +// +// 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, evaluateAnswersCore, 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,14 +87,28 @@ 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); + this.checkServer("fillb", false).then(() => { + // If there's no seed for a client-side dynamic problem after this check, create one and render it. + if (typeof this.dyn_vars === "string" && this.seed === undefined) { + this.randomize(); + } + this.indicate_component_ready(); + }); } // Find the script tag containing JSON in a given root DOM node. scriptSelector(root_node) { @@ -50,31 +129,22 @@ export default class FITB extends RunestoneBase { this.containerDiv = document.createElement("div"); $(this.containerDiv).addClass("alert alert-warning"); 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({ @@ -84,15 +154,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({ @@ -111,6 +181,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() { @@ -119,10 +209,59 @@ 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") { + [this.descriptionDiv.innerHTML, this.dyn_vars_eval] = renderDynamicContent(this.seed, this.dyn_vars, this.descriptionDiv.origInnerHTML); + this.runMathJax(this.descriptionDiv); + this.setupBlanks(); + } + } + + runMathJax(element, count=10) { + if (typeof MathJax !== "undefined" && count > 0) { + // Sometimes, MathJax exists but hasn't finished loading yet. wait for it. + if (typeof MathJax.Hub === "undefined") { + setTimeout(this.runMathJax, 1000, element, count - 1); + } else { + MathJax.Hub.Queue(["Typeset", MathJax.Hub, element]); + } + } + } + + 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 { @@ -134,13 +273,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; @@ -152,8 +301,13 @@ 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.runMathJax(this.descriptionDiv); + this.setupBlanks(); + } } } checkLocalStorage() { @@ -203,40 +357,104 @@ export default class FITB extends RunestoneBase { } } - async logCurrentAnswer(sid) { - let answer = JSON.stringify(this.given_arr); - // Save the answer locally. - let feedback = true; + // _`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.runMathJax(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 = { + + } + + // _`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 data = { event: "fillb", + div_id: this.divid, act: 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; - }; - data = await this.logBookEvent(data); - 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: data.timestamp, - }); - this.correct = data.correct; - this.displayFeed = data.displayFeed; - this.isCorrectArray = data.isCorrectArray; - 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 data; + // This is the server-side case. On success, update the feedback from the server's grade. + const res = server_data.detail; + this.displayFeed = res.displayFeed; + this.correct = res.correct; + this.isCorrectArray = res.isCorrectArray; + this.setLocalStorage({ + seed: this.seed, + answer: answer, + // TODO: should this be updated with the date from the server instead? + timestamp: new Date(), + problemHtml: this.descriptionDiv.innerHTML, + displayFeed: this.displayFeed, + correct: this.correct, + isCorrectArray: this.isCorrectArray, + }); + this.renderFeedback(); + return server_data; } /*============================== @@ -254,56 +472,7 @@ export default class FITB extends RunestoneBase { // - ``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; + [this.displayFeed, this.correct, this.isCorrectArray, this.percent] = evaluateAnswersCore(this.blankNames, this.given_arr, this.feedbackArray, this.dyn_vars_eval); } renderFeedback() { @@ -327,7 +496,12 @@ 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); + } + feedback_html += `
    • ${df}
    • `; } feedback_html += "
    "; // Remove the list if it's just one element. @@ -338,9 +512,7 @@ export default class FITB extends RunestoneBase { ); } this.feedBackDiv.innerHTML = feedback_html; - if (typeof MathJax !== "undefined") { - this.queueMathJax(document.body) - } + this.runMathJax(this.feedBackDiv); } /*================================== 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..3da4f99f9 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,113 @@ Regex testing - :\[\]: Correct. :x: Try again. + + +Dynamic problem testing +----------------------- +TODO items: + +- Need a good basic math library. +- Need a `good approximate equality test `_. + +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). + +- 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 %]. + + +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/conf.py b/runestone/fitb/test/conf.py index 1ddd128be..12ba60e81 100644 --- a/runestone/fitb/test/conf.py +++ b/runestone/fitb/test/conf.py @@ -107,7 +107,7 @@ # For fill-in-the-blank questions, provide a convenient means to indicate a blank. """ -.. |blank| replace:: :blank:`x` +.. |blank| replace:: :blank:`-` """ # For literate programming files, provide a convenient way to refer to a source file's name. See `runestone.lp.lp._docname_role`. """.. |docname| replace:: :docname:`name` diff --git a/runestone/fitb/test/test_fitb.py b/runestone/fitb/test/test_fitb.py index cc8ad94fe..a8cdd15c1 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 @@ -62,17 +63,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)) +# 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 ## ===== # One of two correct answers @@ -83,7 +104,7 @@ 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')") check_feedback(selenium_utils_get, fitb, msg_no_answer) @@ -94,7 +115,7 @@ 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')") @@ -106,7 +127,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") @@ -120,28 +141,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.") @@ -151,19 +172,61 @@ 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 numberic 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..3e65120b3 100644 --- a/runestone/fitb/toctree.rst +++ b/runestone/fitb/toctree.rst @@ -8,4 +8,5 @@ fitb: A Sphinx extension for fill-in-the-blank questions *.py js/*.js css/*.css + fitb_html_structure.html test/test_*.py diff --git a/runestone/timed/js/timed.js b/runestone/timed/js/timed.js index b77370c22..e521a3cfd 100644 --- a/runestone/timed/js/timed.js +++ b/runestone/timed/js/timed.js @@ -354,7 +354,7 @@ export default class Timed extends RunestoneBase { this.currentQuestionIndex ].question.checkCurrentAnswer(); if (!this.done) { - this.renderedQuestionArray[ + await this.renderedQuestionArray[ this.currentQuestionIndex ].question.logCurrentAnswer(); } @@ -952,7 +952,7 @@ export default class Timed extends RunestoneBase { this.currentQuestionIndex ].question; await currentQuestion.checkCurrentAnswer(); - currentQuestion.logCurrentAnswer(); + await currentQuestion.logCurrentAnswer(); currentQuestion.renderFeedback(); currentQuestion.disableInteraction(); diff --git a/runestone/timed/test/test_timed.py b/runestone/timed/test/test_timed.py index 248a427f9..c8926c9f6 100644 --- a/runestone/timed/test/test_timed.py +++ b/runestone/timed/test/test_timed.py @@ -133,7 +133,12 @@ def _test_1(selenium_utils_mq, timed_divid): selenium_utils_mq.driver.find_element_by_id(f"{div_id}_solution").send_keys("ROYGBIV circle area") finish(selenium_utils_mq) - results = selenium_utils_mq.driver.find_element_by_id(f"{timed_divid}results") - assert "Num Correct: 6" in results.text + div_id = f"{timed_divid}results" + # Wait for the initial text to appear. + selenium_utils_mq.wait.until( + EC.text_to_be_present_in_element((By.ID, div_id), "Num Correct: 6") + ) + # We can check the rest of the text without waiting, since it's all assigned at the same time. + results = selenium_utils_mq.driver.find_element_by_id(div_id) assert "Num Wrong: 0" in results.text assert "Num Skipped: 1" in results.text diff --git a/runestone/unittest_base.py b/runestone/unittest_base.py index 7feb15963..d1cfb1f90 100644 --- a/runestone/unittest_base.py +++ b/runestone/unittest_base.py @@ -228,7 +228,7 @@ def setUp(self): self.wait = WebDriverWait(self.driver, 10) def tearDown(self): - # Clear as much as possible, to present an almost-fresh instance of a browser for the next test. (Shutting down then starting up a browswer is very slow.) + # Clear as much as possible, to present an almost-fresh instance of a browser for the next test. (Shutting down then starting up a browser is very slow.) self.driver.execute_script("window.localStorage.clear();") self.driver.execute_script("window.sessionStorage.clear();") self.driver.delete_all_cookies() diff --git a/webpack.config.js b/webpack.config.js index 0ed273600..dc03340ae 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -5,16 +5,43 @@ // :caption: Related contents // // webpack.index.js - +// webpack.server-index.js +// +// Includes +// ======== +// +// Node +// ---- const path = require("path"); +// NPM packages +// ------------ const CssMinimizerPlugin = require('css-minimizer-webpack-plugin'); const CopyPlugin = require('copy-webpack-plugin'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); +const { DefinePlugin } = require("webpack"); + -module.exports = (env, argv) => { +// 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') ? Math.random : rs_test_rand" : "rand", + } +} + + +// Webpack configuration +// ===================== +module.exports = (env, argv) => [ +// Client-side +// ----------- + // The primary config: client-side build. This config file contains `multiple targets `_. + { entry: { runestone: "./webpack.index.js", }, @@ -50,7 +77,7 @@ module.exports = (env, argv) => { // See https://webpack.js.org/guides/caching/. This provides a hash for dynamic imports as well, avoiding caching out-of-date JS. filename: "[name].bundle.js?v=[contenthash]", // Delete everything in the output directory on each build. - clean: true, + //clean: true, }, // See https://webpack.js.org/guides/code-splitting/#splitchunksplugin. optimization: { @@ -86,10 +113,58 @@ module.exports = (env, argv) => { to: '.' }], }), + new DefinePlugin(definePluginDict(env)), new MiniCssExtractPlugin({ filename: '[name].css?v=[contenthash]', chunkFilename: '[id].css', }), ], - }; -}; + }, + +// Server-side +// ----------- + // Config for server-side code. + { + entry: { + server_side: "./webpack.server-index.js", + }, + // See `mode `_ for the conditional statement below. + devtool: argv.mode === "development" ? "inline-source-map" : "source-map", + module: { + rules: [ + { + // 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'] + } + }, + }, + ], + }, + output: { + // Expose the library as a variable. + library: { + name: "serverSide", + type: "var", + }, + path: path.resolve(__dirname, "runestone/dist"), + }, + 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 }; From ae1f65bd100f55374e44a5cddcf4c52422e5408b Mon Sep 17 00:00:00 2001 From: bjones1 Date: Mon, 20 Sep 2021 12:23:34 +0100 Subject: [PATCH 13/21] Clean: Alphabetize webpack config. --- webpack.config.js | 46 +++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/webpack.config.js b/webpack.config.js index dc03340ae..b23b050df 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -42,11 +42,15 @@ module.exports = (env, argv) => [ // ----------- // The primary config: client-side build. This config file contains `multiple targets `_. { + // See `mode `_ for the conditional statement below. + devtool: argv.mode === "development" ? "inline-source-map" : "source-map", entry: { runestone: "./webpack.index.js", }, - // See `mode `_ for the conditional statement below. - devtool: argv.mode === "development" ? "inline-source-map" : "source-map", + externals: { + // Use the jQuery that Sphinx provides for jQuery.ui. See `externals `_. + jquery: "jQuery", + }, module: { rules: [ { @@ -60,25 +64,6 @@ module.exports = (env, argv) => [ }, ], }, - 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"), - // See https://webpack.js.org/guides/caching/. This provides a hash for dynamic imports as well, avoiding caching out-of-date JS. - filename: "[name].bundle.js?v=[contenthash]", - // Delete everything in the output directory on each build. - //clean: true, - }, // See https://webpack.js.org/guides/code-splitting/#splitchunksplugin. optimization: { moduleIds: 'deterministic', @@ -94,6 +79,13 @@ module.exports = (env, argv) => [ new CssMinimizerPlugin(), ], }, + output: { + path: path.resolve(__dirname, "runestone/dist"), + // See https://webpack.js.org/guides/caching/. This provides a hash for dynamic imports as well, avoiding caching out-of-date JS. + filename: "[name].bundle.js?v=[contenthash]", + // Delete everything in the output directory on each build. + //clean: true, + }, 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({ @@ -119,17 +111,25 @@ module.exports = (env, argv) => [ chunkFilename: '[id].css', }), ], + 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 + } + }, }, // 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", }, - // See `mode `_ for the conditional statement below. - devtool: argv.mode === "development" ? "inline-source-map" : "source-map", module: { rules: [ { From 6cca708249447ed0d5bd1e4a08027a49edad7c13 Mon Sep 17 00:00:00 2001 From: bjones1 Date: Mon, 20 Sep 2021 12:29:49 +0100 Subject: [PATCH 14/21] Fix: Clean on each webpack build. --- webpack.config.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webpack.config.js b/webpack.config.js index b23b050df..e0218db77 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -83,8 +83,6 @@ module.exports = (env, argv) => [ path: path.resolve(__dirname, "runestone/dist"), // See https://webpack.js.org/guides/caching/. This provides a hash for dynamic imports as well, avoiding caching out-of-date JS. filename: "[name].bundle.js?v=[contenthash]", - // Delete everything in the output directory on each build. - //clean: true, }, 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. @@ -153,6 +151,8 @@ module.exports = (env, argv) => [ 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)), From 9e454f0da939ab1522f447593cd6aefc60a636a6 Mon Sep 17 00:00:00 2001 From: Brad Miller Date: Fri, 24 Sep 2021 16:22:26 -0500 Subject: [PATCH 15/21] No refresh on peer pages --- runestone/common/js/bookfuncs.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/runestone/common/js/bookfuncs.js b/runestone/common/js/bookfuncs.js index 92863aefa..ccb0fe227 100644 --- a/runestone/common/js/bookfuncs.js +++ b/runestone/common/js/bookfuncs.js @@ -194,7 +194,8 @@ async function handlePageSetup() { $(document).trigger("runestone:login"); addReadingList(); // Avoid the timedRefresh on the grading page. - if (window.location.pathname.indexOf("/admin/grading") == -1) { + if ((window.location.pathname.indexOf("/admin/grading") == -1) + && (window.location.pathname.indexOf("/peer/") == -1)) { timedRefresh(); } } else { From f62a7682cf5afc2752ccf0f5178395c00ab53591 Mon Sep 17 00:00:00 2001 From: bjones1 Date: Sat, 16 Oct 2021 12:31:40 +0100 Subject: [PATCH 16/21] Clean: prettier. --- runestone/fitb/fitb_html_structure.html | 26 +++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/runestone/fitb/fitb_html_structure.html b/runestone/fitb/fitb_html_structure.html index 442dec952..a636146db 100644 --- a/runestone/fitb/fitb_html_structure.html +++ b/runestone/fitb/fitb_html_structure.html @@ -42,8 +42,12 @@ -->
    -