diff --git a/adminui/index.php b/adminui/index.php index d95d8fb1b102ca3ce0556c9a56b249d0e4060081..6a87bb0e284d9340ef343a6655e252742f2645a7 100644 --- a/adminui/index.php +++ b/adminui/index.php @@ -37,16 +37,16 @@ $links = array( array('link' => (string) new moodle_url('/question/type/stack/doc/doc.php/'))), get_string('chat_desc', 'qtype_stack', array('link' => (string) new moodle_url('/question/type/stack/adminui/caschat.php'))), - get_string('stackInstall_testsuite_title_desc', 'qtype_stack', - array('link' => (string) new moodle_url('/question/type/stack/adminui/answertests.php'))), - get_string('stackInstall_input_title_desc', 'qtype_stack', - array('link' => (string) new moodle_url('/question/type/stack/adminui/studentinputs.php'))), get_string('bulktestindexintro_desc', 'qtype_stack', - array('link' => (string) new moodle_url('/question/type/stack/adminui/bulktestindex.php'))), + array('link' => (string) new moodle_url('/question/type/stack/adminui/bulktestindex.php'))), get_string('dependenciesintro_desc', 'qtype_stack', array('link' => (string) new moodle_url('/question/type/stack/adminui/dependencies.php'))), get_string('stackInstall_replace_dollars_desc', 'qtype_stack', - array('link' => (string) new moodle_url('/question/type/stack/adminui/replacedollarsindex.php'))), + array('link' => (string) new moodle_url('/question/type/stack/adminui/replacedollarsindex.php'))), + get_string('stackInstall_testsuite_title_desc', 'qtype_stack', + array('link' => (string) new moodle_url('/question/type/stack/adminui/answertests.php'))), + get_string('stackInstall_input_title_desc', 'qtype_stack', + array('link' => (string) new moodle_url('/question/type/stack/adminui/studentinputs.php'))), ); // Set up the page object. diff --git a/amd/build/stackjsvle.min.js b/amd/build/stackjsvle.min.js index e632a33d9d932621a89ebd44ad56bd5a23d6260d..4c55d16d26e18550d3af27c82031c395fcef24e8 100644 --- a/amd/build/stackjsvle.min.js +++ b/amd/build/stackjsvle.min.js @@ -31,6 +31,6 @@ * @copyright 2023 Aalto University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -define("qtype_stack/stackjsvle",["core/event"],(function(CustomEvents){let IFRAMES={},INPUTS={},INPUTS_INPUT_EVENT={},DISABLE_CHANGES=!1;function vle_get_element(id){let candidate=document.getElementById(id),iter=candidate;for(;iter&&!iter.classList.contains("formulation");)iter=iter.parentElement;return iter&&iter.classList.contains("formulation")?candidate:null}function vle_get_input_element(name,srciframe){let iter=document.getElementById(srciframe);for(;iter&&!iter.classList.contains("formulation");)iter=iter.parentElement;if(iter&&iter.classList.contains("formulation")){let possible=iter.querySelector('input[id$="_'+name+'"]');if(null!==possible)return possible;if(possible=iter.querySelector('input[id$="_'+name+'_1"][type=radio]'),null!==possible)return possible;if(possible=iter.querySelector('select[id$="_'+name+'"]'),null!==possible)return possible}let possible=document.querySelector('.formulation input[id$="_'+name+'"]');return null!==possible?possible:(possible=document.querySelector('.formulation input[id$="_'+name+'_1"][type=radio]'),null!==possible||(possible=document.querySelector('.formulation select[id$="_'+name+'"]')),possible)}function vle_update_dom(modifiedsubtreerootelement){CustomEvents.notifyFilterContentUpdated(modifiedsubtreerootelement)}function is_evil_attribute(name,value){const lcname=name.toLowerCase();if(lcname.startsWith("on"))return!0;if("src"===lcname||lcname.endsWith("href")){const lcvalue=value.replace(/\s+/g,"").toLowerCase();if(lcvalue.includes("javascript:")||lcvalue.includes("data:text"))return!0}return!1}return window.addEventListener("message",(e=>{if(!("string"==typeof e.data||e.data instanceof String))return;let msg=null;try{msg=JSON.parse(e.data)}catch(e){return}if(!("version"in msg)||!msg.version.startsWith("STACK-JS"))return;if(!("src"in msg&&"type"in msg&&msg.src in IFRAMES))return;let element=null,input=null,response={version:"STACK-JS:1.1.0"};switch(msg.type){case"register-input-listener":if(input=vle_get_input_element(msg.name,msg.src),null===input)return response.type="error",response.msg='Failed to connect to input: "'+msg.name+'"',response.tgt=msg.src,void IFRAMES[msg.src].contentWindow.postMessage(JSON.stringify(response),"*");if(response.type="initial-input",response.name=msg.name,response.tgt=msg.src,"select"===input.nodeName.toLowerCase()?(response.value=input.value,response["input-type"]="select"):"checkbox"===input.type?(response.value=input.checked,response["input-type"]="checkbox"):(response.value=input.value,response["input-type"]=input.type),"radio"===input.type){response.value="";for(let inp of document.querySelectorAll("input[type=radio][name="+CSS.escape(input.name)+"]"))inp.checked&&(response.value=inp.value)}if(input.id in INPUTS){if(msg.src in INPUTS[input.id])return;if("radio"!==input.type)INPUTS[input.id].push(msg.src);else{let radgroup=document.querySelectorAll("input[type=radio][name="+CSS.escape(input.name)+"]");for(let inp of radgroup)INPUTS[inp.id].push(msg.src)}}else{if("radio"!==input.type)INPUTS[input.id]=[msg.src];else{let radgroup=document.querySelectorAll("input[type=radio][name="+CSS.escape(input.name)+"]");for(let inp of radgroup)INPUTS[inp.id]=[msg.src]}if("radio"!==input.type)input.addEventListener("change",(()=>{if(DISABLE_CHANGES)return;let resp={version:"STACK-JS:1.0.0",type:"changed-input",name:msg.name};"checkbox"===input.type?resp.value=input.checked:resp.value=input.value;for(let tgt of INPUTS[input.id])resp.tgt=tgt,IFRAMES[tgt].contentWindow.postMessage(JSON.stringify(resp),"*")}));else{document.querySelectorAll("input[type=radio][name="+CSS.escape(input.name)+"]").forEach((inp=>{inp.addEventListener("change",(()=>{if(DISABLE_CHANGES)return;let resp={version:"STACK-JS:1.0.0",type:"changed-input",name:msg.name};if(inp.checked){resp.value=inp.value;for(let tgt of INPUTS[inp.id])resp.tgt=tgt,IFRAMES[tgt].contentWindow.postMessage(JSON.stringify(resp),"*")}}))}))}}if("track-input"in msg&&msg["track-input"]&&"radio"!==input.type)if(input.id in INPUTS_INPUT_EVENT){if(msg.src in INPUTS_INPUT_EVENT[input.id])return;INPUTS_INPUT_EVENT[input.id].push(msg.src)}else INPUTS_INPUT_EVENT[input.id]=[msg.src],input.addEventListener("input",(()=>{if(DISABLE_CHANGES)return;let resp={version:"STACK-JS:1.0.0",type:"changed-input",name:msg.name};"checkbox"===input.type?resp.value=input.checked:resp.value=input.value;for(let tgt of INPUTS_INPUT_EVENT[input.id])resp.tgt=tgt,IFRAMES[tgt].contentWindow.postMessage(JSON.stringify(resp),"*")}));msg.src in INPUTS[input.id]||IFRAMES[msg.src].contentWindow.postMessage(JSON.stringify(response),"*");break;case"changed-input":if(input=vle_get_input_element(msg.name,msg.src),null===input){const ret={version:"STACK-JS:1.0.0",type:"error",msg:'Failed to modify input: "'+msg.name+'"',tgt:msg.src};return void IFRAMES[msg.src].contentWindow.postMessage(JSON.stringify(ret),"*")}DISABLE_CHANGES=!0,"checkbox"===input.type?input.checked=msg.value:input.value=msg.value,function(inputelement){const c=new Event("change");inputelement.dispatchEvent(c);const i=new Event("input");inputelement.dispatchEvent(i)}(input),DISABLE_CHANGES=!1,response.type="changed-input",response.name=msg.name,response.value=msg.value;for(let tgt of INPUTS[input.id])tgt!==msg.src&&(response.tgt=tgt,IFRAMES[tgt].contentWindow.postMessage(JSON.stringify(response),"*"));break;case"toggle-visibility":if(element=vle_get_element(msg.target),null===element){const ret={version:"STACK-JS:1.0.0",type:"error",msg:'Failed to find element: "'+msg.target+'"',tgt:msg.src};return void IFRAMES[msg.src].contentWindow.postMessage(JSON.stringify(ret),"*")}"show"===msg.set?(element.style.display="block",vle_update_dom(element)):"hide"===msg.set&&(element.style.display="none");break;case"change-content":if(element=vle_get_element(msg.target),null===element)return response.type="error",response.msg='Failed to find element: "'+msg.target+'"',response.tgt=msg.src,void IFRAMES[msg.src].contentWindow.postMessage(JSON.stringify(response),"*");element.replaceChildren(function(src){let doc=(new DOMParser).parseFromString(src,"text/html");for(let el of doc.querySelectorAll("script, style"))el.remove();for(let el of doc.querySelectorAll("*"))for(let{name:name,value:value}of el.attributes)is_evil_attribute(name,value)&&el.removeAttribute(name);return doc.body}(msg.content)),vle_update_dom(element);break;case"get-content":element=vle_get_element(msg.target),response.type="xfer-content",response.tgt=msg.src,response.target=msg.target,response.content=null,null!==element&&(response.content=element.innerHTML),IFRAMES[msg.src].contentWindow.postMessage(JSON.stringify(response),"*");break;case"resize-frame":element=IFRAMES[msg.src].parentElement,element.style.width=msg.width,element.style.height=msg.height,IFRAMES[msg.src].style.width="100%",IFRAMES[msg.src].style.height="100%",vle_update_dom(element);break;case"ping":return response.type="ping",response.tgt=msg.src,void IFRAMES[msg.src].contentWindow.postMessage(JSON.stringify(response),"*");case"initial-input":case"error":break;default:response.type="error",response.msg='Unknown message-type: "'+msg.type+'"',response.tgt=msg.src,IFRAMES[msg.src].contentWindow.postMessage(JSON.stringify(response),"*")}})),{create_iframe(iframeid,content,targetdivid,title,scrolling,evil){const frm=document.createElement("iframe");frm.id=iframeid,frm.style.width="100%",frm.style.height="100%",frm.style.border=0,!1===scrolling?(frm.scrolling="no",frm.style.overflow="hidden"):frm.scrolling="yes",frm.title=title,frm.referrerpolicy="no-referrer",evil||(frm.sandbox="allow-scripts allow-downloads"),frm.srcdoc=content,document.getElementById(targetdivid).replaceChildren(frm),IFRAMES[iframeid]=frm}}})); +define("qtype_stack/stackjsvle",["core/event"],(function(CustomEvents){let IFRAMES={},INPUTS={},INPUTS_INPUT_EVENT={},DISABLE_CHANGES=!1;function vle_get_element(id){let candidate=document.getElementById(id),iter=candidate;for(;iter&&!iter.classList.contains("formulation");)iter=iter.parentElement;return iter&&iter.classList.contains("formulation")?candidate:null}function vle_get_input_element(name,srciframe){let iter=document.getElementById(srciframe);for(;iter&&!iter.classList.contains("formulation");)iter=iter.parentElement;if(iter&&iter.classList.contains("formulation")){let possible=iter.querySelector('input[id$="_'+name+'"]');if(null!==possible)return possible;if(possible=iter.querySelector('input[id$="_'+name+'_1"][type=radio]'),null!==possible)return possible;if(possible=iter.querySelector('select[id$="_'+name+'"]'),null!==possible)return possible}let possible=document.querySelector('.formulation input[id$="_'+name+'"]');return null!==possible?possible:(possible=document.querySelector('.formulation input[id$="_'+name+'_1"][type=radio]'),null!==possible||(possible=document.querySelector('.formulation select[id$="_'+name+'"]')),possible)}function vle_update_dom(modifiedsubtreerootelement){CustomEvents.notifyFilterContentUpdated(modifiedsubtreerootelement)}function is_evil_attribute(name,value){const lcname=name.toLowerCase();if(lcname.startsWith("on"))return!0;if("src"===lcname||lcname.endsWith("href")){const lcvalue=value.replace(/\s+/g,"").toLowerCase();if(lcvalue.includes("javascript:")||lcvalue.includes("data:text"))return!0}return!1}return window.addEventListener("message",(e=>{if(!("string"==typeof e.data||e.data instanceof String))return;let msg=null;try{msg=JSON.parse(e.data)}catch(e){return}if(!("version"in msg)||!msg.version.startsWith("STACK-JS"))return;if(!("src"in msg&&"type"in msg&&msg.src in IFRAMES))return;let element=null,input=null,response={version:"STACK-JS:1.1.0"};switch(msg.type){case"register-input-listener":if(input=vle_get_input_element(msg.name,msg.src),null===input)return response.type="error",response.msg='Failed to connect to input: "'+msg.name+'"',response.tgt=msg.src,void IFRAMES[msg.src].contentWindow.postMessage(JSON.stringify(response),"*");if(response.type="initial-input",response.name=msg.name,response.tgt=msg.src,"select"===input.nodeName.toLowerCase()?(response.value=input.value,response["input-type"]="select",response["input-readonly"]=input.hasAttribute("disabled")):"checkbox"===input.type?(response.value=input.checked,response["input-type"]="checkbox",response["input-readonly"]=input.hasAttribute("disabled")):(response.value=input.value,response["input-type"]=input.type,response["input-readonly"]=input.hasAttribute("readonly")),"radio"===input.type){response["input-readonly"]=input.hasAttribute("disabled"),response.value="";for(let inp of document.querySelectorAll("input[type=radio][name="+CSS.escape(input.name)+"]"))inp.checked&&(response.value=inp.value)}if(input.id in INPUTS){if(msg.src in INPUTS[input.id])return;if("radio"!==input.type)INPUTS[input.id].push(msg.src);else{let radgroup=document.querySelectorAll("input[type=radio][name="+CSS.escape(input.name)+"]");for(let inp of radgroup)INPUTS[inp.id].push(msg.src)}}else{if("radio"!==input.type)INPUTS[input.id]=[msg.src];else{let radgroup=document.querySelectorAll("input[type=radio][name="+CSS.escape(input.name)+"]");for(let inp of radgroup)INPUTS[inp.id]=[msg.src]}if("radio"!==input.type)input.addEventListener("change",(()=>{if(DISABLE_CHANGES)return;let resp={version:"STACK-JS:1.0.0",type:"changed-input",name:msg.name};"checkbox"===input.type?resp.value=input.checked:resp.value=input.value;for(let tgt of INPUTS[input.id])resp.tgt=tgt,IFRAMES[tgt].contentWindow.postMessage(JSON.stringify(resp),"*")}));else{document.querySelectorAll("input[type=radio][name="+CSS.escape(input.name)+"]").forEach((inp=>{inp.addEventListener("change",(()=>{if(DISABLE_CHANGES)return;let resp={version:"STACK-JS:1.0.0",type:"changed-input",name:msg.name};if(inp.checked){resp.value=inp.value;for(let tgt of INPUTS[inp.id])resp.tgt=tgt,IFRAMES[tgt].contentWindow.postMessage(JSON.stringify(resp),"*")}}))}))}}if("track-input"in msg&&msg["track-input"]&&"radio"!==input.type)if(input.id in INPUTS_INPUT_EVENT){if(msg.src in INPUTS_INPUT_EVENT[input.id])return;INPUTS_INPUT_EVENT[input.id].push(msg.src)}else INPUTS_INPUT_EVENT[input.id]=[msg.src],input.addEventListener("input",(()=>{if(DISABLE_CHANGES)return;let resp={version:"STACK-JS:1.0.0",type:"changed-input",name:msg.name};"checkbox"===input.type?resp.value=input.checked:resp.value=input.value;for(let tgt of INPUTS_INPUT_EVENT[input.id])resp.tgt=tgt,IFRAMES[tgt].contentWindow.postMessage(JSON.stringify(resp),"*")}));msg.src in INPUTS[input.id]||IFRAMES[msg.src].contentWindow.postMessage(JSON.stringify(response),"*");break;case"changed-input":if(input=vle_get_input_element(msg.name,msg.src),null===input){const ret={version:"STACK-JS:1.0.0",type:"error",msg:'Failed to modify input: "'+msg.name+'"',tgt:msg.src};return void IFRAMES[msg.src].contentWindow.postMessage(JSON.stringify(ret),"*")}DISABLE_CHANGES=!0,"checkbox"===input.type?input.checked=msg.value:input.value=msg.value,function(inputelement){const c=new Event("change");inputelement.dispatchEvent(c);const i=new Event("input");inputelement.dispatchEvent(i)}(input),DISABLE_CHANGES=!1,response.type="changed-input",response.name=msg.name,response.value=msg.value;for(let tgt of INPUTS[input.id])tgt!==msg.src&&(response.tgt=tgt,IFRAMES[tgt].contentWindow.postMessage(JSON.stringify(response),"*"));break;case"toggle-visibility":if(element=vle_get_element(msg.target),null===element){const ret={version:"STACK-JS:1.0.0",type:"error",msg:'Failed to find element: "'+msg.target+'"',tgt:msg.src};return void IFRAMES[msg.src].contentWindow.postMessage(JSON.stringify(ret),"*")}"show"===msg.set?(element.style.display="block",vle_update_dom(element)):"hide"===msg.set&&(element.style.display="none");break;case"change-content":if(element=vle_get_element(msg.target),null===element)return response.type="error",response.msg='Failed to find element: "'+msg.target+'"',response.tgt=msg.src,void IFRAMES[msg.src].contentWindow.postMessage(JSON.stringify(response),"*");element.replaceChildren(function(src){let doc=(new DOMParser).parseFromString(src,"text/html");for(let el of doc.querySelectorAll("script, style"))el.remove();for(let el of doc.querySelectorAll("*"))for(let{name:name,value:value}of el.attributes)is_evil_attribute(name,value)&&el.removeAttribute(name);return doc.body}(msg.content)),vle_update_dom(element);break;case"get-content":element=vle_get_element(msg.target),response.type="xfer-content",response.tgt=msg.src,response.target=msg.target,response.content=null,null!==element&&(response.content=element.innerHTML),IFRAMES[msg.src].contentWindow.postMessage(JSON.stringify(response),"*");break;case"resize-frame":element=IFRAMES[msg.src].parentElement,element.style.width=msg.width,element.style.height=msg.height,IFRAMES[msg.src].style.width="100%",IFRAMES[msg.src].style.height="100%",vle_update_dom(element);break;case"ping":return response.type="ping",response.tgt=msg.src,void IFRAMES[msg.src].contentWindow.postMessage(JSON.stringify(response),"*");case"initial-input":case"error":break;default:response.type="error",response.msg='Unknown message-type: "'+msg.type+'"',response.tgt=msg.src,IFRAMES[msg.src].contentWindow.postMessage(JSON.stringify(response),"*")}})),{create_iframe(iframeid,content,targetdivid,title,scrolling,evil){const frm=document.createElement("iframe");frm.id=iframeid,frm.style.width="100%",frm.style.height="100%",frm.style.border=0,!1===scrolling?(frm.scrolling="no",frm.style.overflow="hidden"):frm.scrolling="yes",frm.title=title,frm.referrerpolicy="no-referrer",evil||(frm.sandbox="allow-scripts allow-downloads"),frm.srcdoc=content,document.getElementById(targetdivid).replaceChildren(frm),IFRAMES[iframeid]=frm}}})); //# sourceMappingURL=stackjsvle.min.js.map \ No newline at end of file diff --git a/amd/build/stackjsvle.min.js.map b/amd/build/stackjsvle.min.js.map index 41cc31a96a67efbd9c9301c4625f3dabfde679f6..16850f51a307bc018a0c808605ddb73ea131b6ea 100644 --- a/amd/build/stackjsvle.min.js.map +++ b/amd/build/stackjsvle.min.js.map @@ -1 +1 @@ -{"version":3,"file":"stackjsvle.min.js","sources":["../src/stackjsvle.js"],"sourcesContent":["/**\n * A javascript module to handle separation of author sourced scripts into\n * IFRAMES. All such scripts will have limited access to the actual document\n * on the VLE side and this script represents the VLE side endpoint for\n * message handling needed to give that access. When porting STACK onto VLEs\n * one needs to map this script to do the following:\n *\n * 1. Ensure that searches for target elements/inputs are limited to questions\n * and do not return any elements outside them.\n *\n * 2. Map any identifiers needed to identify inputs by name.\n *\n * 3. Any change handling related to input value modifications through this\n * logic gets connected to any such handling on the VLE side.\n *\n *\n * This script is intenttionally ordered so that the VLE specific bits should\n * be at the top.\n *\n *\n * This script assumes the following:\n *\n * 1. Each relevant IFRAME has an `id`-attribute that will be told to this\n * script.\n *\n * 2. Each such IFRAME exists within the question itself, so that one can\n * traverse up the DOM tree from that IFRAME to find the border of\n * the question.\n *\n * @module qtype_stack/stackjsvle\n * @copyright 2023 Aalto University\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine([\n 'core/event'\n], function(\n CustomEvents\n) {\n 'use strict';\n // Note the VLE specific include of logic.\n\n /* All the IFRAMES have unique identifiers that they give in their\n * messages. But we only work with those that have been created by\n * our logic and are found from this map.\n */\n let IFRAMES = {};\n\n /* For event handling, lists of IFRAMES listening particular inputs.\n */\n let INPUTS = {};\n\n /* For event handling, lists of IFRAMES listening particular inputs\n * and their input events. By default we only listen to changes.\n * We report input events as changes to the other side.\n */\n let INPUTS_INPUT_EVENT = {};\n\n /* A flag to disable certain things. */\n let DISABLE_CHANGES = false;\n\n\n /**\n * Returns an element with a given id, if an only if that element exists\n * inside a portion of DOM that represents a question.\n *\n * If not found or exists outside the restricted area then returns `null`.\n *\n * @param {String} id the identifier of the element we want.\n */\n function vle_get_element(id) {\n /* In the case of Moodle we are happy as long as the element is inside\n something with the `formulation`-class. */\n let candidate = document.getElementById(id);\n let iter = candidate;\n while (iter && !iter.classList.contains('formulation')) {\n iter = iter.parentElement;\n }\n if (iter && iter.classList.contains('formulation')) {\n return candidate;\n }\n\n return null;\n }\n\n /**\n * Returns an input element with a given name, if and only if that element\n * exists inside a portion of DOM that represents a question.\n *\n * Note that, the input element may have a name that multiple questions\n * use and to pick the preferred element one needs to pick the one\n * within the same question as the IFRAME.\n *\n * Note that the input can also be a select. In the case of radio buttons\n * returning one of the possible buttons is enough.\n *\n * If not found or exists outside the restricted area then returns `null`.\n *\n * @param {String} name the name of the input we want\n * @param {String} srciframe the identifier of the iframe wanting it\n */\n function vle_get_input_element(name, srciframe) {\n /* In the case of Moodle we are happy as long as the element is inside\n something with the `formulation`-class. */\n let initialcandidate = document.getElementById(srciframe);\n let iter = initialcandidate;\n while (iter && !iter.classList.contains('formulation')) {\n iter = iter.parentElement;\n }\n if (iter && iter.classList.contains('formulation')) {\n // iter now represents the borders of the question containing\n // this IFRAME.\n let possible = iter.querySelector('input[id$=\"_' + name + '\"]');\n if (possible !== null) {\n return possible;\n }\n // Radios have interesting ids, but the name makes sense\n possible = iter.querySelector('input[id$=\"_' + name + '_1\"][type=radio]');\n if (possible !== null) {\n return possible;\n }\n possible = iter.querySelector('select[id$=\"_' + name + '\"]');\n if (possible !== null) {\n return possible;\n }\n }\n // If none found within the question itself, search everywhere.\n let possible = document.querySelector('.formulation input[id$=\"_' + name + '\"]');\n if (possible !== null) {\n return possible;\n }\n // Radios have interesting ids, but the name makes sense\n possible = document.querySelector('.formulation input[id$=\"_' + name + '_1\"][type=radio]');\n if (possible !== null) {\n return possible;\n }\n possible = document.querySelector('.formulation select[id$=\"_' + name + '\"]');\n return possible;\n }\n\n /**\n * Triggers any VLE specific scripting related to updates of the given\n * input element.\n *\n * @param {HTMLElement} inputelement the input element that has changed\n */\n function vle_update_input(inputelement) {\n // Triggering a change event may be necessary.\n const c = new Event('change');\n inputelement.dispatchEvent(c);\n // Also there are those that listen to input events.\n const i = new Event('input');\n inputelement.dispatchEvent(i);\n }\n\n /**\n * Triggers any VLE specific scripting related to DOM updates.\n *\n * @param {HTMLElement} modifiedsubtreerootelement element under which changes may have happened.\n */\n function vle_update_dom(modifiedsubtreerootelement) {\n CustomEvents.notifyFilterContentUpdated(modifiedsubtreerootelement);\n }\n\n /**\n * Does HTML-string cleaning, i.e., removes any script payload. Returns\n * a DOM version of the given input string.\n *\n * This is used when receiving replacement content for a div.\n *\n * @param {String} src a raw string to sanitise\n */\n function vle_html_sanitize(src) {\n // This can be implemented with many libraries or by custom code\n // however as this is typically a thing that a VLE might already have\n // tools for we have it at this level so that the VLE can use its own\n // tools that do things that the VLE developpers consider safe.\n\n // As Moodle does not currently seem to have such a sanitizer in\n // the core libraries, here is one implementation that shows what we\n // are looking for.\n\n // TODO: look into replacing this with DOMPurify or some such.\n\n let parser = new DOMParser();\n let doc = parser.parseFromString(src, \"text/html\");\n\n // First remove all <script> tags. Also <style> as we do not want\n // to include too much style.\n for (let el of doc.querySelectorAll('script, style')) {\n el.remove();\n }\n\n // Check all elements for attributes.\n for (let el of doc.querySelectorAll('*')) {\n for (let {name, value} of el.attributes) {\n if (is_evil_attribute(name, value)) {\n el.removeAttribute(name);\n }\n }\n }\n\n return doc.body;\n }\n\n /**\n * Utility function trying to determine if a given attribute is evil\n * when sanitizing HTML-fragments.\n *\n * @param {String} name the name of an attribute.\n * @param {String} value the value of an attribute.\n */\n function is_evil_attribute(name, value) {\n const lcname = name.toLowerCase();\n if (lcname.startsWith('on')) {\n // We do not allow event listeners to be defined.\n return true;\n }\n if (lcname === 'src' || lcname.endsWith('href')) {\n // Do not allow certain things in the urls.\n const lcvalue = value.replace(/\\s+/g, '').toLowerCase();\n // Ignore es-lint false positive.\n /* eslint-disable no-script-url */\n if (lcvalue.includes('javascript:') || lcvalue.includes('data:text')) {\n return true;\n }\n }\n\n return false;\n }\n\n\n /*************************************************************************\n * Above this are the bits that one would probably tune when porting.\n *\n * Below is the actuall message handling and it should be left alone.\n */\n window.addEventListener(\"message\", (e) => {\n // NOTE! We do not check the source or origin of the message in\n // the normal way. All actions that can bypass our filters to trigger\n // something are largely irrelevant and all traffic will be kept\n // \"safe\" as anyone could be listening.\n\n // All messages we receive are strings, anything else is for someone\n // else and will be ignored.\n if (!(typeof e.data === 'string' || e.data instanceof String)) {\n return;\n }\n\n // That string is a JSON encoded dictionary.\n let msg = null;\n try {\n msg = JSON.parse(e.data);\n } catch (e) {\n // Only JSON objects that are parseable will work.\n return;\n }\n\n // All messages we handle contain a version field with a particular\n // value, for now we leave the possibility open for that value to have\n // an actual version number suffix...\n if (!(('version' in msg) && msg.version.startsWith('STACK-JS'))) {\n return;\n }\n\n // All messages we handle must have a source and a type,\n // and that source must be one of the registered ones.\n if (!(('src' in msg) && ('type' in msg) && (msg.src in IFRAMES))) {\n return;\n }\n let element = null;\n let input = null;\n\n let response = {\n version: 'STACK-JS:1.1.0'\n };\n\n switch (msg.type) {\n case 'register-input-listener':\n // 1. Find the input.\n input = vle_get_input_element(msg.name, msg.src);\n\n if (input === null) {\n // Requested something that is not available.\n response.type = 'error';\n response.msg = 'Failed to connect to input: \"' + msg.name + '\"';\n response.tgt = msg.src;\n IFRAMES[msg.src].contentWindow.postMessage(JSON.stringify(response), '*');\n return;\n }\n\n response.type = 'initial-input';\n response.name = msg.name;\n response.tgt = msg.src;\n\n // 2. What type of an input is this? Note that we do not\n // currently support all types in sensible ways. In particular,\n // anything with multiple values will be a problem.\n if (input.nodeName.toLowerCase() === 'select') {\n response.value = input.value;\n response['input-type'] = 'select';\n } else if (input.type === 'checkbox') {\n response.value = input.checked;\n response['input-type'] = 'checkbox';\n } else {\n response.value = input.value;\n response['input-type'] = input.type;\n }\n if (input.type === 'radio') {\n response.value = '';\n for (let inp of document.querySelectorAll('input[type=radio][name=' + CSS.escape(input.name) + ']')) {\n if (inp.checked) {\n response.value = inp.value;\n }\n }\n }\n\n // 3. Add listener for changes of this input.\n if (input.id in INPUTS) {\n if (msg.src in INPUTS[input.id]) {\n // DO NOT BIND TWICE!\n return;\n }\n if (input.type !== 'radio') {\n INPUTS[input.id].push(msg.src);\n } else {\n let radgroup = document.querySelectorAll('input[type=radio][name=' + CSS.escape(input.name) + ']');\n for (let inp of radgroup) {\n INPUTS[inp.id].push(msg.src);\n }\n }\n } else {\n if (input.type !== 'radio') {\n INPUTS[input.id] = [msg.src];\n } else {\n let radgroup = document.querySelectorAll('input[type=radio][name=' + CSS.escape(input.name) + ']');\n for (let inp of radgroup) {\n INPUTS[inp.id] = [msg.src];\n }\n }\n if (input.type !== 'radio') {\n input.addEventListener('change', () => {\n if (DISABLE_CHANGES) {\n return;\n }\n let resp = {\n version: 'STACK-JS:1.0.0',\n type: 'changed-input',\n name: msg.name\n };\n if (input.type === 'checkbox') {\n resp['value'] = input.checked;\n } else {\n resp['value'] = input.value;\n }\n for (let tgt of INPUTS[input.id]) {\n resp['tgt'] = tgt;\n IFRAMES[tgt].contentWindow.postMessage(JSON.stringify(resp), '*');\n }\n });\n } else {\n // Assume that if we received a radio button that is safe\n // then all its friends are also safe.\n let radgroup = document.querySelectorAll('input[type=radio][name=' + CSS.escape(input.name) + ']');\n radgroup.forEach((inp) => {\n inp.addEventListener('change', () => {\n if (DISABLE_CHANGES) {\n return;\n }\n let resp = {\n version: 'STACK-JS:1.0.0',\n type: 'changed-input',\n name: msg.name\n };\n if (inp.checked) {\n resp.value = inp.value;\n } else {\n // What about unsetting?\n return;\n }\n for (let tgt of INPUTS[inp.id]) {\n resp['tgt'] = tgt;\n IFRAMES[tgt].contentWindow.postMessage(JSON.stringify(resp), '*');\n }\n });\n });\n }\n }\n\n if (('track-input' in msg) && msg['track-input'] && input.type !== 'radio') {\n if (input.id in INPUTS_INPUT_EVENT) {\n if (msg.src in INPUTS_INPUT_EVENT[input.id]) {\n // DO NOT BIND TWICE!\n return;\n }\n INPUTS_INPUT_EVENT[input.id].push(msg.src);\n } else {\n INPUTS_INPUT_EVENT[input.id] = [msg.src];\n\n input.addEventListener('input', () => {\n if (DISABLE_CHANGES) {\n return;\n }\n let resp = {\n version: 'STACK-JS:1.0.0',\n type: 'changed-input',\n name: msg.name\n };\n if (input.type === 'checkbox') {\n resp['value'] = input.checked;\n } else {\n resp['value'] = input.value;\n }\n for (let tgt of INPUTS_INPUT_EVENT[input.id]) {\n resp['tgt'] = tgt;\n IFRAMES[tgt].contentWindow.postMessage(JSON.stringify(resp), '*');\n }\n });\n }\n }\n\n // 4. Let the requester know that we have bound things\n // and let it know the initial value.\n if (!(msg.src in INPUTS[input.id])) {\n IFRAMES[msg.src].contentWindow.postMessage(JSON.stringify(response), '*');\n }\n\n break;\n case 'changed-input':\n // 1. Find the input.\n input = vle_get_input_element(msg.name, msg.src);\n\n if (input === null) {\n // Requested something that is not available.\n const ret = {\n version: 'STACK-JS:1.0.0',\n type: 'error',\n msg: 'Failed to modify input: \"' + msg.name + '\"',\n tgt: msg.src\n };\n IFRAMES[msg.src].contentWindow.postMessage(JSON.stringify(ret), '*');\n return;\n }\n\n // Disable change events.\n DISABLE_CHANGES = true;\n\n // TODO: Radio buttons should we check that value is possible?\n if (input.type === 'checkbox') {\n input.checked = msg.value;\n } else {\n input.value = msg.value;\n }\n\n // Trigger VLE side actions.\n vle_update_input(input);\n\n // Enable change tracking.\n DISABLE_CHANGES = false;\n\n // Tell all other frames, that care, about this.\n response.type = 'changed-input';\n response.name = msg.name;\n response.value = msg.value;\n\n for (let tgt of INPUTS[input.id]) {\n if (tgt !== msg.src) {\n response.tgt = tgt;\n IFRAMES[tgt].contentWindow.postMessage(JSON.stringify(response), '*');\n }\n }\n\n break;\n case 'toggle-visibility':\n // 1. Find the element.\n element = vle_get_element(msg.target);\n\n if (element === null) {\n // Requested something that is not available.\n const ret = {\n version: 'STACK-JS:1.0.0',\n type: 'error',\n msg: 'Failed to find element: \"' + msg.target + '\"',\n tgt: msg.src\n };\n IFRAMES[msg.src].contentWindow.postMessage(JSON.stringify(ret), '*');\n return;\n }\n\n // 2. Toggle display setting.\n if (msg.set === 'show') {\n element.style.display = 'block';\n // If we make something visible we should let the VLE know about it.\n vle_update_dom(element);\n } else if (msg.set === 'hide') {\n element.style.display = 'none';\n }\n\n break;\n case 'change-content':\n // 1. Find the element.\n element = vle_get_element(msg.target);\n\n if (element === null) {\n // Requested something that is not available.\n response.type = 'error';\n response.msg = 'Failed to find element: \"' + msg.target + '\"';\n response.tgt = msg.src;\n IFRAMES[msg.src].contentWindow.postMessage(JSON.stringify(response), '*');\n return;\n }\n\n // 2. Secure content.\n // 3. Switch the content.\n element.replaceChildren(vle_html_sanitize(msg.content));\n // If we tune something we should let the VLE know about it.\n vle_update_dom(element);\n\n break;\n case 'get-content':\n // 1. Find the element.\n element = vle_get_element(msg.target);\n // 2. Build the message.\n response.type = 'xfer-content';\n response.tgt = msg.src;\n response.target = msg.target;\n response.content = null;\n if (element !== null) {\n // TODO: Should we sanitise the content? Probably not as using\n // this to interrogate neighbouring questions only allows\n // messing with the other questions and not anything outside\n // them. If we do not sanitise it we allow some interesting\n // question-analytics tooling, and if we do we really don't\n // gain anything sensible.\n // Matti's opinnion is to not sanitise at this point as\n // interraction between questions is not inherently evil\n // and could be of use even at the level of reading code from\n // from other questions.\n response.content = element.innerHTML;\n }\n IFRAMES[msg.src].contentWindow.postMessage(JSON.stringify(response), '*');\n break;\n case 'resize-frame':\n // 1. Find the frames wrapper div.\n element = IFRAMES[msg.src].parentElement;\n\n // 2. Set the wrapper size.\n element.style.width = msg.width;\n element.style.height = msg.height;\n\n // 3. Reset the frame size.\n IFRAMES[msg.src].style.width = '100%';\n IFRAMES[msg.src].style.height = '100%';\n\n // Only touching the size but still let the VLE know.\n vle_update_dom(element);\n break;\n case 'ping':\n // This is for testing the connection. The other end will\n // send these untill it receives a reply.\n // Part of the logic for startup.\n response.type = 'ping';\n response.tgt = msg.src;\n\n IFRAMES[msg.src].contentWindow.postMessage(JSON.stringify(response), '*');\n return;\n case 'initial-input':\n case 'error':\n // These message types are for the other end.\n break;\n\n default:\n // If we see something unexpected, lets let the other end know\n // and make sure that they know our version. Could be that this\n // end has not been upgraded.\n response.type = 'error';\n response.msg = 'Unknown message-type: \"' + msg.type + '\"';\n response.tgt = msg.src;\n\n IFRAMES[msg.src].contentWindow.postMessage(JSON.stringify(response), '*');\n }\n\n });\n\n\n return {\n /* To avoid any logic that forbids IFRAMEs in the VLE output one can\n also create and register that IFRAME through this logic. This\n also ensures that all relevant security settigns for that IFRAME\n have been correctly tuned.\n\n Here the IDs are for the secrect identifier that may be present\n inside the content of that IFRAME and for the question that contains\n it. One also identifies a DIV element that marks the position of\n the IFRAME and limits the size of the IFRAME (all IFRAMEs this\n creates will be 100% x 100%).\n\n @param {String} iframeid the id that the IFRAME has stored inside\n it and uses for communication.\n @param {String} the full HTML content of that IFRAME.\n @param {String} targetdivid the id of the element (div) that will\n hold the IFRAME.\n @param {String} title a descriptive name for the iframe.\n @param {bool} scrolling whether we have overflow:scroll or\n overflow:hidden.\n @param {bool} evil allows certain special cases to act without\n sandboxing, this is a feature that will be removed so do\n not rely on it only use it to test STACK-JS before you get your\n thing to run in a sandbox.\n */\n create_iframe(iframeid, content, targetdivid, title, scrolling, evil) {\n const frm = document.createElement('iframe');\n frm.id = iframeid;\n frm.style.width = '100%';\n frm.style.height = '100%';\n frm.style.border = 0;\n if (scrolling === false) {\n frm.scrolling = 'no';\n frm.style.overflow = 'hidden';\n } else {\n frm.scrolling = 'yes';\n }\n frm.title = title;\n // Somewhat random limitation.\n frm.referrerpolicy = 'no-referrer';\n // We include that allow-downloads as an example of XLS-\n // document building in JS has been seen.\n // UNDER NO CIRCUMSTANCES DO WE ALLOW-SAME-ORIGIN!\n // That would defeat the whole point of this.\n if (!evil) {\n frm.sandbox = 'allow-scripts allow-downloads';\n }\n\n // As the SOP is intentionally broken we need to allow\n // scripts from everywhere.\n\n // NOTE: this bit commented out as long as the csp-attribute\n // is not supported by more browsers.\n // frm.csp = \"script-src: 'unsafe-inline' 'self' '*';\";\n // frm.csp = \"script-src: 'unsafe-inline' 'self' '*';img-src: '*';\";\n\n // Plug the content into the frame.\n frm.srcdoc = content;\n\n // The target DIV will have its children removed.\n // This allows that div to contain some sort of loading\n // indicator until we plug in the frame.\n // Naturally the frame will then start to load itself.\n document.getElementById(targetdivid).replaceChildren(frm);\n IFRAMES[iframeid] = frm;\n }\n\n };\n});"],"names":["define","CustomEvents","IFRAMES","INPUTS","INPUTS_INPUT_EVENT","DISABLE_CHANGES","vle_get_element","id","candidate","document","getElementById","iter","classList","contains","parentElement","vle_get_input_element","name","srciframe","possible","querySelector","vle_update_dom","modifiedsubtreerootelement","notifyFilterContentUpdated","is_evil_attribute","value","lcname","toLowerCase","startsWith","endsWith","lcvalue","replace","includes","window","addEventListener","e","data","String","msg","JSON","parse","version","src","element","input","response","type","tgt","contentWindow","postMessage","stringify","nodeName","checked","inp","querySelectorAll","CSS","escape","push","radgroup","resp","forEach","ret","inputelement","c","Event","dispatchEvent","i","vle_update_input","target","set","style","display","replaceChildren","doc","DOMParser","parseFromString","el","remove","attributes","removeAttribute","body","vle_html_sanitize","content","innerHTML","width","height","create_iframe","iframeid","targetdivid","title","scrolling","evil","frm","createElement","border","overflow","referrerpolicy","sandbox","srcdoc"],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"stackjsvle.min.js","sources":["../src/stackjsvle.js"],"sourcesContent":["/**\n * A javascript module to handle separation of author sourced scripts into\n * IFRAMES. All such scripts will have limited access to the actual document\n * on the VLE side and this script represents the VLE side endpoint for\n * message handling needed to give that access. When porting STACK onto VLEs\n * one needs to map this script to do the following:\n *\n * 1. Ensure that searches for target elements/inputs are limited to questions\n * and do not return any elements outside them.\n *\n * 2. Map any identifiers needed to identify inputs by name.\n *\n * 3. Any change handling related to input value modifications through this\n * logic gets connected to any such handling on the VLE side.\n *\n *\n * This script is intenttionally ordered so that the VLE specific bits should\n * be at the top.\n *\n *\n * This script assumes the following:\n *\n * 1. Each relevant IFRAME has an `id`-attribute that will be told to this\n * script.\n *\n * 2. Each such IFRAME exists within the question itself, so that one can\n * traverse up the DOM tree from that IFRAME to find the border of\n * the question.\n *\n * @module qtype_stack/stackjsvle\n * @copyright 2023 Aalto University\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine([\n 'core/event'\n], function(\n CustomEvents\n) {\n 'use strict';\n // Note the VLE specific include of logic.\n\n /* All the IFRAMES have unique identifiers that they give in their\n * messages. But we only work with those that have been created by\n * our logic and are found from this map.\n */\n let IFRAMES = {};\n\n /* For event handling, lists of IFRAMES listening particular inputs.\n */\n let INPUTS = {};\n\n /* For event handling, lists of IFRAMES listening particular inputs\n * and their input events. By default we only listen to changes.\n * We report input events as changes to the other side.\n */\n let INPUTS_INPUT_EVENT = {};\n\n /* A flag to disable certain things. */\n let DISABLE_CHANGES = false;\n\n\n /**\n * Returns an element with a given id, if an only if that element exists\n * inside a portion of DOM that represents a question.\n *\n * If not found or exists outside the restricted area then returns `null`.\n *\n * @param {String} id the identifier of the element we want.\n */\n function vle_get_element(id) {\n /* In the case of Moodle we are happy as long as the element is inside\n something with the `formulation`-class. */\n let candidate = document.getElementById(id);\n let iter = candidate;\n while (iter && !iter.classList.contains('formulation')) {\n iter = iter.parentElement;\n }\n if (iter && iter.classList.contains('formulation')) {\n return candidate;\n }\n\n return null;\n }\n\n /**\n * Returns an input element with a given name, if and only if that element\n * exists inside a portion of DOM that represents a question.\n *\n * Note that, the input element may have a name that multiple questions\n * use and to pick the preferred element one needs to pick the one\n * within the same question as the IFRAME.\n *\n * Note that the input can also be a select. In the case of radio buttons\n * returning one of the possible buttons is enough.\n *\n * If not found or exists outside the restricted area then returns `null`.\n *\n * @param {String} name the name of the input we want\n * @param {String} srciframe the identifier of the iframe wanting it\n */\n function vle_get_input_element(name, srciframe) {\n /* In the case of Moodle we are happy as long as the element is inside\n something with the `formulation`-class. */\n let initialcandidate = document.getElementById(srciframe);\n let iter = initialcandidate;\n while (iter && !iter.classList.contains('formulation')) {\n iter = iter.parentElement;\n }\n if (iter && iter.classList.contains('formulation')) {\n // iter now represents the borders of the question containing\n // this IFRAME.\n let possible = iter.querySelector('input[id$=\"_' + name + '\"]');\n if (possible !== null) {\n return possible;\n }\n // Radios have interesting ids, but the name makes sense\n possible = iter.querySelector('input[id$=\"_' + name + '_1\"][type=radio]');\n if (possible !== null) {\n return possible;\n }\n possible = iter.querySelector('select[id$=\"_' + name + '\"]');\n if (possible !== null) {\n return possible;\n }\n }\n // If none found within the question itself, search everywhere.\n let possible = document.querySelector('.formulation input[id$=\"_' + name + '\"]');\n if (possible !== null) {\n return possible;\n }\n // Radios have interesting ids, but the name makes sense\n possible = document.querySelector('.formulation input[id$=\"_' + name + '_1\"][type=radio]');\n if (possible !== null) {\n return possible;\n }\n possible = document.querySelector('.formulation select[id$=\"_' + name + '\"]');\n return possible;\n }\n\n /**\n * Triggers any VLE specific scripting related to updates of the given\n * input element.\n *\n * @param {HTMLElement} inputelement the input element that has changed\n */\n function vle_update_input(inputelement) {\n // Triggering a change event may be necessary.\n const c = new Event('change');\n inputelement.dispatchEvent(c);\n // Also there are those that listen to input events.\n const i = new Event('input');\n inputelement.dispatchEvent(i);\n }\n\n /**\n * Triggers any VLE specific scripting related to DOM updates.\n *\n * @param {HTMLElement} modifiedsubtreerootelement element under which changes may have happened.\n */\n function vle_update_dom(modifiedsubtreerootelement) {\n CustomEvents.notifyFilterContentUpdated(modifiedsubtreerootelement);\n }\n\n /**\n * Does HTML-string cleaning, i.e., removes any script payload. Returns\n * a DOM version of the given input string.\n *\n * This is used when receiving replacement content for a div.\n *\n * @param {String} src a raw string to sanitise\n */\n function vle_html_sanitize(src) {\n // This can be implemented with many libraries or by custom code\n // however as this is typically a thing that a VLE might already have\n // tools for we have it at this level so that the VLE can use its own\n // tools that do things that the VLE developpers consider safe.\n\n // As Moodle does not currently seem to have such a sanitizer in\n // the core libraries, here is one implementation that shows what we\n // are looking for.\n\n // TODO: look into replacing this with DOMPurify or some such.\n\n let parser = new DOMParser();\n let doc = parser.parseFromString(src, \"text/html\");\n\n // First remove all <script> tags. Also <style> as we do not want\n // to include too much style.\n for (let el of doc.querySelectorAll('script, style')) {\n el.remove();\n }\n\n // Check all elements for attributes.\n for (let el of doc.querySelectorAll('*')) {\n for (let {name, value} of el.attributes) {\n if (is_evil_attribute(name, value)) {\n el.removeAttribute(name);\n }\n }\n }\n\n return doc.body;\n }\n\n /**\n * Utility function trying to determine if a given attribute is evil\n * when sanitizing HTML-fragments.\n *\n * @param {String} name the name of an attribute.\n * @param {String} value the value of an attribute.\n */\n function is_evil_attribute(name, value) {\n const lcname = name.toLowerCase();\n if (lcname.startsWith('on')) {\n // We do not allow event listeners to be defined.\n return true;\n }\n if (lcname === 'src' || lcname.endsWith('href')) {\n // Do not allow certain things in the urls.\n const lcvalue = value.replace(/\\s+/g, '').toLowerCase();\n // Ignore es-lint false positive.\n /* eslint-disable no-script-url */\n if (lcvalue.includes('javascript:') || lcvalue.includes('data:text')) {\n return true;\n }\n }\n\n return false;\n }\n\n\n /*************************************************************************\n * Above this are the bits that one would probably tune when porting.\n *\n * Below is the actuall message handling and it should be left alone.\n */\n window.addEventListener(\"message\", (e) => {\n // NOTE! We do not check the source or origin of the message in\n // the normal way. All actions that can bypass our filters to trigger\n // something are largely irrelevant and all traffic will be kept\n // \"safe\" as anyone could be listening.\n\n // All messages we receive are strings, anything else is for someone\n // else and will be ignored.\n if (!(typeof e.data === 'string' || e.data instanceof String)) {\n return;\n }\n\n // That string is a JSON encoded dictionary.\n let msg = null;\n try {\n msg = JSON.parse(e.data);\n } catch (e) {\n // Only JSON objects that are parseable will work.\n return;\n }\n\n // All messages we handle contain a version field with a particular\n // value, for now we leave the possibility open for that value to have\n // an actual version number suffix...\n if (!(('version' in msg) && msg.version.startsWith('STACK-JS'))) {\n return;\n }\n\n // All messages we handle must have a source and a type,\n // and that source must be one of the registered ones.\n if (!(('src' in msg) && ('type' in msg) && (msg.src in IFRAMES))) {\n return;\n }\n let element = null;\n let input = null;\n\n let response = {\n version: 'STACK-JS:1.1.0'\n };\n\n switch (msg.type) {\n case 'register-input-listener':\n // 1. Find the input.\n input = vle_get_input_element(msg.name, msg.src);\n\n if (input === null) {\n // Requested something that is not available.\n response.type = 'error';\n response.msg = 'Failed to connect to input: \"' + msg.name + '\"';\n response.tgt = msg.src;\n IFRAMES[msg.src].contentWindow.postMessage(JSON.stringify(response), '*');\n return;\n }\n\n response.type = 'initial-input';\n response.name = msg.name;\n response.tgt = msg.src;\n\n // 2. What type of an input is this? Note that we do not\n // currently support all types in sensible ways. In particular,\n // anything with multiple values will be a problem.\n if (input.nodeName.toLowerCase() === 'select') {\n response.value = input.value;\n response['input-type'] = 'select';\n response['input-readonly'] = input.hasAttribute('disabled');\n } else if (input.type === 'checkbox') {\n response.value = input.checked;\n response['input-type'] = 'checkbox';\n response['input-readonly'] = input.hasAttribute('disabled');\n } else {\n response.value = input.value;\n response['input-type'] = input.type;\n response['input-readonly'] = input.hasAttribute('readonly');\n }\n if (input.type === 'radio') {\n response['input-readonly'] = input.hasAttribute('disabled');\n response.value = '';\n for (let inp of document.querySelectorAll('input[type=radio][name=' + CSS.escape(input.name) + ']')) {\n if (inp.checked) {\n response.value = inp.value;\n }\n }\n }\n\n // 3. Add listener for changes of this input.\n if (input.id in INPUTS) {\n if (msg.src in INPUTS[input.id]) {\n // DO NOT BIND TWICE!\n return;\n }\n if (input.type !== 'radio') {\n INPUTS[input.id].push(msg.src);\n } else {\n let radgroup = document.querySelectorAll('input[type=radio][name=' + CSS.escape(input.name) + ']');\n for (let inp of radgroup) {\n INPUTS[inp.id].push(msg.src);\n }\n }\n } else {\n if (input.type !== 'radio') {\n INPUTS[input.id] = [msg.src];\n } else {\n let radgroup = document.querySelectorAll('input[type=radio][name=' + CSS.escape(input.name) + ']');\n for (let inp of radgroup) {\n INPUTS[inp.id] = [msg.src];\n }\n }\n if (input.type !== 'radio') {\n input.addEventListener('change', () => {\n if (DISABLE_CHANGES) {\n return;\n }\n let resp = {\n version: 'STACK-JS:1.0.0',\n type: 'changed-input',\n name: msg.name\n };\n if (input.type === 'checkbox') {\n resp['value'] = input.checked;\n } else {\n resp['value'] = input.value;\n }\n for (let tgt of INPUTS[input.id]) {\n resp['tgt'] = tgt;\n IFRAMES[tgt].contentWindow.postMessage(JSON.stringify(resp), '*');\n }\n });\n } else {\n // Assume that if we received a radio button that is safe\n // then all its friends are also safe.\n let radgroup = document.querySelectorAll('input[type=radio][name=' + CSS.escape(input.name) + ']');\n radgroup.forEach((inp) => {\n inp.addEventListener('change', () => {\n if (DISABLE_CHANGES) {\n return;\n }\n let resp = {\n version: 'STACK-JS:1.0.0',\n type: 'changed-input',\n name: msg.name\n };\n if (inp.checked) {\n resp.value = inp.value;\n } else {\n // What about unsetting?\n return;\n }\n for (let tgt of INPUTS[inp.id]) {\n resp['tgt'] = tgt;\n IFRAMES[tgt].contentWindow.postMessage(JSON.stringify(resp), '*');\n }\n });\n });\n }\n }\n\n if (('track-input' in msg) && msg['track-input'] && input.type !== 'radio') {\n if (input.id in INPUTS_INPUT_EVENT) {\n if (msg.src in INPUTS_INPUT_EVENT[input.id]) {\n // DO NOT BIND TWICE!\n return;\n }\n INPUTS_INPUT_EVENT[input.id].push(msg.src);\n } else {\n INPUTS_INPUT_EVENT[input.id] = [msg.src];\n\n input.addEventListener('input', () => {\n if (DISABLE_CHANGES) {\n return;\n }\n let resp = {\n version: 'STACK-JS:1.0.0',\n type: 'changed-input',\n name: msg.name\n };\n if (input.type === 'checkbox') {\n resp['value'] = input.checked;\n } else {\n resp['value'] = input.value;\n }\n for (let tgt of INPUTS_INPUT_EVENT[input.id]) {\n resp['tgt'] = tgt;\n IFRAMES[tgt].contentWindow.postMessage(JSON.stringify(resp), '*');\n }\n });\n }\n }\n\n // 4. Let the requester know that we have bound things\n // and let it know the initial value.\n if (!(msg.src in INPUTS[input.id])) {\n IFRAMES[msg.src].contentWindow.postMessage(JSON.stringify(response), '*');\n }\n\n break;\n case 'changed-input':\n // 1. Find the input.\n input = vle_get_input_element(msg.name, msg.src);\n\n if (input === null) {\n // Requested something that is not available.\n const ret = {\n version: 'STACK-JS:1.0.0',\n type: 'error',\n msg: 'Failed to modify input: \"' + msg.name + '\"',\n tgt: msg.src\n };\n IFRAMES[msg.src].contentWindow.postMessage(JSON.stringify(ret), '*');\n return;\n }\n\n // Disable change events.\n DISABLE_CHANGES = true;\n\n // TODO: Radio buttons should we check that value is possible?\n if (input.type === 'checkbox') {\n input.checked = msg.value;\n } else {\n input.value = msg.value;\n }\n\n // Trigger VLE side actions.\n vle_update_input(input);\n\n // Enable change tracking.\n DISABLE_CHANGES = false;\n\n // Tell all other frames, that care, about this.\n response.type = 'changed-input';\n response.name = msg.name;\n response.value = msg.value;\n\n for (let tgt of INPUTS[input.id]) {\n if (tgt !== msg.src) {\n response.tgt = tgt;\n IFRAMES[tgt].contentWindow.postMessage(JSON.stringify(response), '*');\n }\n }\n\n break;\n case 'toggle-visibility':\n // 1. Find the element.\n element = vle_get_element(msg.target);\n\n if (element === null) {\n // Requested something that is not available.\n const ret = {\n version: 'STACK-JS:1.0.0',\n type: 'error',\n msg: 'Failed to find element: \"' + msg.target + '\"',\n tgt: msg.src\n };\n IFRAMES[msg.src].contentWindow.postMessage(JSON.stringify(ret), '*');\n return;\n }\n\n // 2. Toggle display setting.\n if (msg.set === 'show') {\n element.style.display = 'block';\n // If we make something visible we should let the VLE know about it.\n vle_update_dom(element);\n } else if (msg.set === 'hide') {\n element.style.display = 'none';\n }\n\n break;\n case 'change-content':\n // 1. Find the element.\n element = vle_get_element(msg.target);\n\n if (element === null) {\n // Requested something that is not available.\n response.type = 'error';\n response.msg = 'Failed to find element: \"' + msg.target + '\"';\n response.tgt = msg.src;\n IFRAMES[msg.src].contentWindow.postMessage(JSON.stringify(response), '*');\n return;\n }\n\n // 2. Secure content.\n // 3. Switch the content.\n element.replaceChildren(vle_html_sanitize(msg.content));\n // If we tune something we should let the VLE know about it.\n vle_update_dom(element);\n\n break;\n case 'get-content':\n // 1. Find the element.\n element = vle_get_element(msg.target);\n // 2. Build the message.\n response.type = 'xfer-content';\n response.tgt = msg.src;\n response.target = msg.target;\n response.content = null;\n if (element !== null) {\n // TODO: Should we sanitise the content? Probably not as using\n // this to interrogate neighbouring questions only allows\n // messing with the other questions and not anything outside\n // them. If we do not sanitise it we allow some interesting\n // question-analytics tooling, and if we do we really don't\n // gain anything sensible.\n // Matti's opinnion is to not sanitise at this point as\n // interraction between questions is not inherently evil\n // and could be of use even at the level of reading code from\n // from other questions.\n response.content = element.innerHTML;\n }\n IFRAMES[msg.src].contentWindow.postMessage(JSON.stringify(response), '*');\n break;\n case 'resize-frame':\n // 1. Find the frames wrapper div.\n element = IFRAMES[msg.src].parentElement;\n\n // 2. Set the wrapper size.\n element.style.width = msg.width;\n element.style.height = msg.height;\n\n // 3. Reset the frame size.\n IFRAMES[msg.src].style.width = '100%';\n IFRAMES[msg.src].style.height = '100%';\n\n // Only touching the size but still let the VLE know.\n vle_update_dom(element);\n break;\n case 'ping':\n // This is for testing the connection. The other end will\n // send these untill it receives a reply.\n // Part of the logic for startup.\n response.type = 'ping';\n response.tgt = msg.src;\n\n IFRAMES[msg.src].contentWindow.postMessage(JSON.stringify(response), '*');\n return;\n case 'initial-input':\n case 'error':\n // These message types are for the other end.\n break;\n\n default:\n // If we see something unexpected, lets let the other end know\n // and make sure that they know our version. Could be that this\n // end has not been upgraded.\n response.type = 'error';\n response.msg = 'Unknown message-type: \"' + msg.type + '\"';\n response.tgt = msg.src;\n\n IFRAMES[msg.src].contentWindow.postMessage(JSON.stringify(response), '*');\n }\n\n });\n\n\n return {\n /* To avoid any logic that forbids IFRAMEs in the VLE output one can\n also create and register that IFRAME through this logic. This\n also ensures that all relevant security settigns for that IFRAME\n have been correctly tuned.\n\n Here the IDs are for the secrect identifier that may be present\n inside the content of that IFRAME and for the question that contains\n it. One also identifies a DIV element that marks the position of\n the IFRAME and limits the size of the IFRAME (all IFRAMEs this\n creates will be 100% x 100%).\n\n @param {String} iframeid the id that the IFRAME has stored inside\n it and uses for communication.\n @param {String} the full HTML content of that IFRAME.\n @param {String} targetdivid the id of the element (div) that will\n hold the IFRAME.\n @param {String} title a descriptive name for the iframe.\n @param {bool} scrolling whether we have overflow:scroll or\n overflow:hidden.\n @param {bool} evil allows certain special cases to act without\n sandboxing, this is a feature that will be removed so do\n not rely on it only use it to test STACK-JS before you get your\n thing to run in a sandbox.\n */\n create_iframe(iframeid, content, targetdivid, title, scrolling, evil) {\n const frm = document.createElement('iframe');\n frm.id = iframeid;\n frm.style.width = '100%';\n frm.style.height = '100%';\n frm.style.border = 0;\n if (scrolling === false) {\n frm.scrolling = 'no';\n frm.style.overflow = 'hidden';\n } else {\n frm.scrolling = 'yes';\n }\n frm.title = title;\n // Somewhat random limitation.\n frm.referrerpolicy = 'no-referrer';\n // We include that allow-downloads as an example of XLS-\n // document building in JS has been seen.\n // UNDER NO CIRCUMSTANCES DO WE ALLOW-SAME-ORIGIN!\n // That would defeat the whole point of this.\n if (!evil) {\n frm.sandbox = 'allow-scripts allow-downloads';\n }\n\n // As the SOP is intentionally broken we need to allow\n // scripts from everywhere.\n\n // NOTE: this bit commented out as long as the csp-attribute\n // is not supported by more browsers.\n // frm.csp = \"script-src: 'unsafe-inline' 'self' '*';\";\n // frm.csp = \"script-src: 'unsafe-inline' 'self' '*';img-src: '*';\";\n\n // Plug the content into the frame.\n frm.srcdoc = content;\n\n // The target DIV will have its children removed.\n // This allows that div to contain some sort of loading\n // indicator until we plug in the frame.\n // Naturally the frame will then start to load itself.\n document.getElementById(targetdivid).replaceChildren(frm);\n IFRAMES[iframeid] = frm;\n }\n\n };\n});"],"names":["define","CustomEvents","IFRAMES","INPUTS","INPUTS_INPUT_EVENT","DISABLE_CHANGES","vle_get_element","id","candidate","document","getElementById","iter","classList","contains","parentElement","vle_get_input_element","name","srciframe","possible","querySelector","vle_update_dom","modifiedsubtreerootelement","notifyFilterContentUpdated","is_evil_attribute","value","lcname","toLowerCase","startsWith","endsWith","lcvalue","replace","includes","window","addEventListener","e","data","String","msg","JSON","parse","version","src","element","input","response","type","tgt","contentWindow","postMessage","stringify","nodeName","hasAttribute","checked","inp","querySelectorAll","CSS","escape","push","radgroup","resp","forEach","ret","inputelement","c","Event","dispatchEvent","i","vle_update_input","target","set","style","display","replaceChildren","doc","DOMParser","parseFromString","el","remove","attributes","removeAttribute","body","vle_html_sanitize","content","innerHTML","width","height","create_iframe","iframeid","targetdivid","title","scrolling","evil","frm","createElement","border","overflow","referrerpolicy","sandbox","srcdoc"],"mappings":"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"} \ No newline at end of file diff --git a/amd/src/stackjsvle.js b/amd/src/stackjsvle.js index 5afeb4028c9777e0cf1c960c26d1a2fd8b810e35..386f08324a91a853f9e2aa28e4fc96d15bc4180c 100644 --- a/amd/src/stackjsvle.js +++ b/amd/src/stackjsvle.js @@ -298,14 +298,18 @@ define([ if (input.nodeName.toLowerCase() === 'select') { response.value = input.value; response['input-type'] = 'select'; + response['input-readonly'] = input.hasAttribute('disabled'); } else if (input.type === 'checkbox') { response.value = input.checked; response['input-type'] = 'checkbox'; + response['input-readonly'] = input.hasAttribute('disabled'); } else { response.value = input.value; response['input-type'] = input.type; + response['input-readonly'] = input.hasAttribute('readonly'); } if (input.type === 'radio') { + response['input-readonly'] = input.hasAttribute('disabled'); response.value = ''; for (let inp of document.querySelectorAll('input[type=radio][name=' + CSS.escape(input.name) + ']')) { if (inp.checked) { diff --git a/corsscripts/cors.php b/corsscripts/cors.php index e43012bb93f6566922e49cf03b31771e9cf38fe1..6ff0481b7323731eb6a1cbd8762924c3e927d5e8 100644 --- a/corsscripts/cors.php +++ b/corsscripts/cors.php @@ -29,15 +29,25 @@ if (strpos('..', $scriptname) !== false } if (file_exists($scriptname)) { - header('HTTP/1.0 200 OK'); - if (strrpos($scriptname, '.js') === strlen($scriptname) - 3) { - header('Content-Type: text/javascript;charset=UTF-8'); - } else if (strrpos($scriptname, '.css') === strlen($scriptname) - 4) { - header('Content-Type: text/css;charset=UTF-8'); + if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { + header('HTTP/1.0 204 OK'); + header('Access-Control-Allow-Origin: *'); + header('Access-Control-Allow-Methods: GET'); + header('Access-Control-Allow-Headers: *'); + header('Access-Control-Max-Age: 86400'); + header('Connection: keep-alive'); + } else if ($_SERVER['REQUEST_METHOD'] === 'GET') { + header('HTTP/1.0 200 OK'); + if (strrpos($scriptname, '.js') === strlen($scriptname) - 3) { + header('Content-Type: text/javascript;charset=UTF-8'); + } else if (strrpos($scriptname, '.css') === strlen($scriptname) - 4) { + header('Content-Type: text/css;charset=UTF-8'); + } + header('Cache-Control: public, max-age=31104000, immutable'); + header('Access-Control-Allow-Origin: *'); + header('Access-Control-Allow-Headers: *'); + echo(file_get_contents($scriptname)); } - header('Cache-Control: public, max-age=31104000, immutable'); - header('Access-Control-Allow-Origin: *'); - echo(file_get_contents($scriptname)); } else { // Give the same error to stop people from trying to figure out // whether a given file exists, even when placed in a bad place. diff --git a/corsscripts/stackjsiframe.js b/corsscripts/stackjsiframe.js index 2e952b024f23aeff04feeeb907e1ccbebb04703d..16948d17ca3c60e8a11d8b9079ee3464ccae288f 100644 --- a/corsscripts/stackjsiframe.js +++ b/corsscripts/stackjsiframe.js @@ -94,6 +94,9 @@ window.addEventListener("message", (e) => { // 2. Set its value. But don't trigger changes. DISABLE_CHANGES[msg.name] = true; element.value = msg.value; + if (msg['input-readonly']) { + element.setAttribute('readonly', 'readonly'); + } DISABLE_CHANGES[msg.name] = false; // 3. Resolve the promise so that things can move forward. @@ -171,6 +174,10 @@ export const stack_js = { * * You may declare that you want to also react to input events. * This might not be that efficient but matches the old JSXGraph binding. + * + * From 4.4.7 readonly/disabled inputs are cloned as readonly, at this point + * we do not automatically disable accessing or editing them but you can base your + * own logic on the input having that attribute. `.hasAttribute('readonly')`. */ request_access_to_input: function(inputname, inputevents) { const input = document.createElement('input'); @@ -319,8 +326,13 @@ export const stack_js = { CONNECTED.then(() => {window.parent.postMessage(JSON.stringify(msg), '*');}); }, - + /** + * Displays an error message on the question page. + * + * @param {*} errmesg + */ display_error(errmesg) { + // 1. Create the message. const p = document.createElement('p'); p.appendChild(document.createTextNode(errmesg)); diff --git a/db/install.php b/db/install.php index 4ca1457ba592203f8004f5cbad4c34735bdea607..27e8fa20b7889a17ff1a665228b16e8f9f516aa5 100644 --- a/db/install.php +++ b/db/install.php @@ -77,7 +77,8 @@ function xmldb_qtype_stack_install() { set_config('casdebugging', 1, 'qtype_stack'); set_config('mathsdisplay', 'mathjax', 'qtype_stack'); - if (!defined('QTYPE_STACK_TEST_CONFIG_PLATFORM') || !in_array(QTYPE_STACK_TEST_CONFIG_PLATFORM, ['server', 'none'])) { + if (!defined('QTYPE_STACK_TEST_CONFIG_PLATFORM') + || !in_array(QTYPE_STACK_TEST_CONFIG_PLATFORM, ['server', 'server-proxy', 'none'])) { list($ok, $message) = stack_cas_configuration::create_auto_maxima_image(); if (!$ok) { throw new coding_exception('maxima_opt_auto creation failed.', $message); diff --git a/db/upgrade.php b/db/upgrade.php index e2a6062f1d3faba835cb9be5e8412c90d5fad2c4..9afe4f3393e01a1b4ffd8c98d10215ffd1190c1f 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -968,7 +968,7 @@ function xmldb_qtype_stack_upgrade($oldversion) { // If appropriate, clear the CAS cache and re-generate the image. if ($latestversion != $currentlyusedversion) { stack_cas_connection_db_cache::clear_cache($DB); - if (get_config('qtype_stack', 'platform') !== 'server') { + if (!in_array(get_config('qtype_stack', 'platform'), ['server', 'server-proxy'])) { $pbar = new progress_bar('healthautomaxopt', 500, true); list($ok, $message) = stack_cas_configuration::create_auto_maxima_image(); $pbar->update(500, 500, get_string('healthautomaxopt', 'qtype_stack', array())); diff --git a/doc/en/AbInitio/Authoring_quick_start_1.md b/doc/en/AbInitio/Authoring_quick_start_1.md index bfef249b65a4bc077b2da3b545dd9a8ee668e5b2..dff41fcbd269b5a710e0fbcf6d7daf96ca550a53 100644 --- a/doc/en/AbInitio/Authoring_quick_start_1.md +++ b/doc/en/AbInitio/Authoring_quick_start_1.md @@ -39,7 +39,7 @@ Let's focus on the problem of differentiating \((x-1)^3\) with respect to \(x\). Notes: * Moodle has a wide choice for text editors, so the screenshots in this quick start guide might look slightly different to your variant of Moodle. Also, the cut and paste may or may not include some of the formatting. -* The text contains LaTeX mathematics environments. Do not use mathematics environments `$..$` and `$$..$$`. Instead you must use `\(..\)` and `\[..\]` for inline and displayed mathematics respectively. (There is an automatic bulk converter if you have a lot of legacy materials.) +* The text contains LaTeX mathematics environments. Do not use mathematics environments `$..$` and `$$..$$`. Instead you must use `\(..\)` and `\[..\]` for inline and displayed mathematics respectively. (There is an automatic bulk converter if you have a lot of legacy materials, found on the Moodle qtype_stack plugin page.) * Internally the student's answer will be assigned to a variable `ans1`. * The tag `[[input:ans1]]` denotes the position of the box into which the student puts their answer. * The tag `[[validation:ans1]]` will be replaced by any feedback related to the validity of the input `ans1`, e.g. syntax errors caused by missing brackets. diff --git a/doc/en/Authoring/JSXGraph.md b/doc/en/Authoring/JSXGraph.md index fa0536ecb4a13fda839c0fd7c9bd67422f5c6481..51ae240e101f05194cdf3aa08ada4d32449fe110 100644 --- a/doc/en/Authoring/JSXGraph.md +++ b/doc/en/Authoring/JSXGraph.md @@ -25,7 +25,7 @@ Then include the following question text, which includes a simple `[[jsxgraph]]` <p>Type in an algebraic expression which has the graph shown below.</p> [[jsxgraph]] - // boundingbox:[left, top, right, bottom] + /* boundingbox:[left, top, right, bottom] */ var board = JXG.JSXGraph.initBoard(divid, {boundingbox: [-10, 5, 10, -5], axis: true, showCopyright: false}); var f = board.jc.snippet('{#fx#}', true, 'x', true); board.create('functiongraph', [f,-10,10]); @@ -62,7 +62,7 @@ This question contains an interactive sliding element. <p>A graph, together with the tangent line and its slope, are shown below. Find an algebraic expression for the graph shown below.</p> [[jsxgraph]] - // boundingbox:[left, top, right, bottom] + /* boundingbox:[left, top, right, bottom] */ var board = JXG.JSXGraph.initBoard(divid, {boundingbox: [-5, 10, 5, -10], axis: true, showCopyright: false}); var f = board.jc.snippet('{#fx#}', true, 'x', true); var curve = board.create('functiongraph', [f,-10,10], {strokeWidth:2}); @@ -81,7 +81,7 @@ In this example the student can interact with a dynamic diagram to help them und In this example we provide a simple slider. Notice in this example we use the JavaScript notation `a**x` for \(a^x\) and not Maxima's `a^x`. [[jsxgraph]] - // boundingbox:[left, top, right, bottom] + /* boundingbox:[left, top, right, bottom] */ var board = JXG.JSXGraph.initBoard(divid, {boundingbox: [-5, 10, 5, -10], axis: true, showCopyright: false}); var a = board.create('slider',[[-3,6],[2,6],[0,2,6]],{name:'a'}); var curve = board.create('functiongraph', [function(x) {return a.Value()**x}], {strokeWidth:2}); @@ -115,35 +115,31 @@ You can use that input field to store the state of the graph as a string, for ex [[jsxgraph input-ref-stateStore="stateRef"]] - // Note that the input-ref-X attribute above will store the element identifier of the input X in - // a variable named in the attribute, you can have multiple references to multiple inputs. + /* Note that the input-ref-X attribute above will store the element identifier of the input X in + a variable named in the attribute, you can have multiple references to multiple inputs. */ - // Create a normal board. + /* Create a normal board. */ var board = JXG.JSXGraph.initBoard(divid, {axis: true, showCopyright: false}); - // State represented as a JS-object, first define default then try loading the stored values. + /* State represented as a JS-object, first define default then try loading the stored values. */ var state = {'x':4, 'y':3}; var stateInput = document.getElementById(stateRef); if (stateInput.value && stateInput.value != '') { state = JSON.parse(stateInput.value); } - // Then make the graph represent the state + /* Then make the graph represent the state */ var p = board.create('point',[state['x'],state['y']]); - // And finally the most important thing, update the stored state when things change. + /* And finally the most important thing, update the stored state when things change. */ p.on('drag', function() { var newState = {'x':p.X(), 'y':p.Y()}; - // Encode the state as JSON for storage and store it + /* Encode the state as JSON for storage and store it */ stateInput.value = JSON.stringify(newState); - // Since the STACK-JS system one needs to also remember to tell others - // about the changed value. Do this by dispatching an event. + /* Since the STACK-JS system one needs to also remember to tell others + about the changed value. Do this by dispatching an event. */ stateInput.dispatchEvent(new Event('change')); }); - - // As a side note, you typically do not want the state storing input to be directly visible to the user - // although it may be handy during development to see what happens in it. You might hide it like this: - stateInput.style.display = 'none'; [[/jsxgraph]] Note, in the above example in `[[jsxgraph input-ref-stateStore="stateRef"]]` the `stateStore` part of this tag directly relates to, and must match, the name of the input. @@ -175,19 +171,14 @@ The previous section covered the general case of storing the state of the graph The example in the previous section about moving the point around and storing the points position as an JSON object can be redone with the convenience functions in much simpler form. The only major differences being that the value is stored as a raw list of float values, and the input field should not be of the String type instead we can store it as Algebraic input and directly access the values, just make sure you allow float values in that input. [[jsxgraph input-ref-stateStore="stateRef"]] - // Create a board like normal. + /* Create a board like normal. */ var board = JXG.JSXGraph.initBoard(divid, {axis: true, showCopyright: false}); - // Create a point, its initial position will be the default position if no state is present. + /* Create a point, its initial position will be the default position if no state is present. */ var p = board.create('point', [4, 3]); - // Bind it to the input and state stored in it. + /* Bind it to the input and state stored in it. */ stack_jxg.bind_point(stateRef, p); - - // As a side note, you typically do not want the state storing input to be directly visible to the user - // although it may be handy during development to see what happens in it. You might hide it like this: - var stateInput = document.getElementById(stateRef); - stateInput.style.display = 'none'; [[/jsxgraph]] For sliders you use the function `stack_jxg.bind_slider(inputRef, slider)` and it stores the sliders value as a raw float. Sliders will however require that you call `board.update()` after binding to them, otherwise the graph may not display the stored state after reload. @@ -214,5 +205,124 @@ You can use this with mathematical input: `{@stack_disp_comma_separate([a,b,sin( ## Discrete mathematics and graph theory. +A graph can be displayed with JSXGraph, see [discrete mathematics](../Topics/Discrete_mathematics.md) for examples. + +## Custom binding + +In the event that you wish to bind a JSXGraph object that is *not* a point or a slider (or a group of these), you can build your own binding function using `stack_jxg.custom_bind(inputRef, serializer, deserializer, [object(s)])`. The `serializer` function is used to generate the value for the input. The `deserializer` is used to extract the value in the input and subsequently update the state of the JSXGraph. + +One use case of this could be tying together a STACK `[[input]]` with an Input object in JSXGraph. This is probably a rare use case, but one could imagine a scenario where it it useful to have an input box in the graph, such as labelling a probability tree diagram, or wanting a draggable input box for some reason. In any case, this is a particularly simple example of using the `custom_bind()` function, shown below. + + <p>Enter \(\sin(x)\)</p> + <span hidden="">[[input:ans1]]</span><span>[[validation:ans1]]</span><br> + [[jsxgraph width="200px" height="100px" input-ref-ans1="ans1Ref"]] + let board = JXG.JSXGraph.initBoard(divid, { + boundingbox: [-1,1,1,-1], axis: false + }); + let inputBox = board.create('input',[0,0,'',''],{}); /*Create input box we want to bind to the STACK input*/ + + /* We want to create our own binding function using custom_bind as a base. + Our function, inputBinder, will take the reference to the STACK input and the object we want to bind to it as inputs. + The serializer function doesn't take any inputs, but will refer to the object given to inputBinder directly. + The deserializer function takes exactly one input: the data with which it will update the graph. + Lastly, we run the custom_bind function. */ + + let inputBinder = function(inputRef, object) { + let serializer = function() {return object.Value()} /*Simply returns the value in the inputBox*/ + let deserializer = function(data) {object.set(data)} /*Given some data, put this data into the inputBox*/ + stack_jxg.custom_bind(inputRef, serializer, deserializer, [object]) + } + + /* Now we run the function */ + inputBinder(ans1Ref, inputBox) + [[/jsxgraph]] + +In most cases the `serializer` and `deserializer` functions will be a bit more complicated, and will probably need to use functions like `JSON.stringify` or `JSON.parse` as in the earlier examples on this page. + +Sometimes you may wish to bind a STACK input to something in the JSXGraph IFRAME that isn't an object, in which case the `stack_jxg.custom_bind` will not work. One example of this would be asking students to identify or shade in a certain region in a graph, such as part of a Venn diagram, identifying the region of integration for an iterated integral, or showing that (for example), two sixths is equal to one third. In this case, you will need to write the binding more explicitly, using the steps listed above as a framework. An example is given below, in which we ask the student to "shade" in one third of a circle divided into six equal segments. + +Let us first assume that we will hard-code this question to always ask students to shade in one third of a circle divided into sixths. This is not too difficult to generalise, and it keeps the code clean. Then let us define a model answer as the list: + + ta: [1,1,0,0,0,0]; + +We interpret this as two of the six sectors in our eventual graph being shaded, and four of them being unshaded, with 1 representing on and 0 representing off. Our student input, `ans1`, will then be a normal algebraic input. + +Now we can create the question text. Firstly, we state the instructions for the student and create the board and associated objects. + + <p>Shade some regions of the diagram below so that it represents the fraction \(\dfrac{1}{3}\). Click a region to shade it, and click a second time to un-shade it if needed.</p> + [[jsxgraph width="500px" height="500px" input-ref-ans1="ans1Ref"]] + var board = JXG.JSXGraph.initBoard(divid, { + boundingbox: [-1.2,1.2,1.2,-1.2], axis: false, + showNavigation: false, showCopyright: false}); + + var plotColours = ["#1f77b4", "#ff7f0e"]; + var numSectors = 6; + + var origin = board.create('point',[0,0],{visible:false}); /* This will be referenced multiple times as we create the sectors */ + + var points = []; + var sectors = []; + + /* Create 7 points (doubling up the start and end) and then between each pair of adjacent points, define a sector. */ + + var sectorAttr = {strokeColor:plotColours[0],strokeOpacity:0.5,strokeWidth: 2,fillColor:plotColours[1],fillOpacity:0, highlight: false} + for(let ii=0;ii<numSectors+1;ii++) { + points[ii] = board.create('point',[Math.cos(ii*2*Math.PI / numSectors),Math.sin(ii*2*Math.PI / numSectors)],{visible:false}); + if (ii>0) { + sectors[ii-1] = board.create('sector',[origin,points[ii-1],points[ii]],sectorAttr); + } + } + +Now that the graph has been drawn, we need to initialise the shading based on existing student input. This means that if a student has given an answer and then refreshed the page, the graph should show the correct sectors shaded or unshaded based on that answer. + + var shading = [0,0,0,0,0,0]; + var shadingInput = document.getElementById(ans1Ref); + if (shadingInput.value && shadingInput.value != '') { /* If the student has given an input and it is not an empty string: */ + shading = JSON.parse(shadingInput.value) /* Over-write the current shading array with the student input */ + for (var ii=0;ii<numSectors;ii++) { /* and then update the shading to match. */ + sectors[ii].setAttribute({fillOpacity:0.3*shading[ii]}) + } + } + +The graph should now have the appropriate shading applied. We have completed the first two out of three steps as outlined above. To accomplish the last step we will write three functions; one that will return the coordinates of a location that is clicked, andother that will update the graph given those coordinates, and a third that will listen to a click event and then run these functions. + + /* The below code is adapted from an example found at https://jsxgraph.org/wiki/index.php/Browser_event_and_coordinates */ + + var getMouseCoords = function(e, i) { + var cPos = board.getCoordsTopLeftCorner(e, i), + absPos = JXG.getPosition(e, i), + dx = absPos[0]-cPos[0], + dy = absPos[1]-cPos[1]; + + var coords = new JXG.Coords(JXG.COORDS_BY_SCREEN, [dx, dy], board); + return [coords.usrCoords[1], coords.usrCoords[2]] + }; + + var shadeSectors = function(x,y) { /* Given a coordinate pair x,y */ + var r = Math.sqrt(x**2 + y**2); /* convert to polar form r,angle */ + var angle = Math.atan2(y,x); + if (angle<0) {angle = angle + 2*Math.PI} /* Ensure argument is from 0 to 2π */ + + if (r<1) { /* If inside the unit circle */ + var whichSector = Math.floor(angle*numSectors/(2*Math.PI)); /* read which sextant the coordinates are in */ + shading[whichSector] = 1 - shading[whichSector] + sectors[whichSector].setAttribute({fillOpacity:0.3*shading[whichSector]}) + + shadingInput.value = JSON.stringify(shading); /* Update the input value */ + shadingInput.dispatchEvent(new Event('change')); /* Tell the STACK input outside the JSXGraph to look for this updated value */ + } + } + + var onClick = function(e) { + var [x, y] = getMouseCoords(e, 0); + shadeSectors(x,y) + } + + board.on('down',onClick); + [[/jsxgraph]] + +Finally, we finish the question by adding the appropriate answer box inside a hidden div (as well as setting "Student must verify" to No). + + <div hidden="">[[input:ans1]] [[validation:ans1]]</div> -A graph can be displayed with JSXGraph, see [discrete mathematics](../Topics/Discrete_mathematics.md) for examples. \ No newline at end of file +The student's input, `ans1`, is now exactly a Maxima list of ones and zeros, and to mark the students answer we could check that `apply("+",ans1)` is exactly equal to 2. diff --git a/doc/en/Authoring/Multiple_choice_questions.md b/doc/en/Authoring/Multiple_choice_questions.md index 0601d69b8ca24cd3d5222d19f719087d6483fb32..212f612bd537919d39f61fdcd693ced6dbd65c62 100644 --- a/doc/en/Authoring/Multiple_choice_questions.md +++ b/doc/en/Authoring/Multiple_choice_questions.md @@ -117,7 +117,7 @@ To enable a student to indicate "none of the others", the teacher must add this The radio and dropdown types always add a "not answered" option as the first option. This allows a student to retract their choice, otherwise they will be unable to "uncheck" a radio button, which will be stored, validated and possibly assessed (to their potential detriment). If you want to remove this then use the extra option `nonotanswered`, but keep in mind the possible effect when using the penalty scheme. -If one of the items in the teacher's answer list is is the special variable name `notanswered`, and then default mesage `(No answer given)` will be replaced by the `display` value. If no `display` value is given (and it is optional) then the original message will remain. `notanswered` will not appear in the list of valid choices for a user and `value` for this input is ingored. +If one of the items in the teacher's answer list is is the special variable name `notanswered`, and then default mesage `(Clear my choice)` will be replaced by the `display` value. If no `display` value is given (and it is optional) then the original message will remain. `notanswered` will not appear in the list of valid choices for a user and `value` for this input is ingored. ## Extra options ## diff --git a/doc/en/Authoring/Testing.md b/doc/en/Authoring/Testing.md index 8f9459687fae891fd24dc6d74b7f3afcb08ec666..bde887104ecb9eaed9bd7e216bce0d3431f07bce 100644 --- a/doc/en/Authoring/Testing.md +++ b/doc/en/Authoring/Testing.md @@ -94,10 +94,13 @@ For the checkbox type you will need the whole list. tc1:mcq_correct(ta); -### Test case construction and numerical precision. +### Test case construction and numerical precision You can construct test cases using the functions such as `dispdp` to create a test-case input with trailing zeros. This is neeeded if the input, or answer test, is testing for a minimum number of decimal places or significant figures. +### Test case construction and decimal separators + +The decimal separator option (e.g. `.` or `,`) is a very thin layer based on the student input. The teacher must always use a `.` (full stop) as the decimal separator in question variables. Consistent with this, all test-case construction must use a `.` (full stop) as the decimal separator. This means it's hard to test the functionality of the decimal separator option (sorry), but otherwise there is genuine confusion in the internal logic about _when_ to assume a `,` is a decimal separator or a list separator. Also, if you change this option in the question you do not need to change all your test cases. ## Testing values of variables diff --git a/doc/en/Authoring/Tidy_Tool.md b/doc/en/Authoring/Tidy_Tool.md index a30c3c2670f12f85a42c1203de13451abb2a839f..15cecffeed375be339f820f10d54c8a43a6c7552 100644 --- a/doc/en/Authoring/Tidy_Tool.md +++ b/doc/en/Authoring/Tidy_Tool.md @@ -1,4 +1,4 @@ -## Tidy Tool +g## Tidy Tool STACK potential response trees, and other parts of questions, can easily get messy. You may have one long question and delete some inputs, or duplicate a question and use it as a template but have to heavily edit it. Another example is to add nodes in an existing PRT and place these nodes between existing nodes. The numbering won't be in order and it will make it harder to follow the PRT logic. @@ -25,3 +25,4 @@ Go back into the question and you can see the change at the nodes of the PRT. The question has five inputs but because some inputs were removed, the names of the inputs are not in order. You need to type the new names of the inputs and PRTs.  + diff --git a/doc/en/CAS/Validator.md b/doc/en/CAS/Validator.md index a2eeb5046d8ccc2b88989ae73e7ccd13e4cfd51a..937f6ae0bcb39bc1910ee527732be5f576762423 100644 --- a/doc/en/CAS/Validator.md +++ b/doc/en/CAS/Validator.md @@ -160,3 +160,7 @@ Create a new question. 2. Use the extra option `validator:validate_underscore` in the input. +### Example: forbid user-defined functions and array entries + +As above, include the contributed validators. Use the extra option `validator:validate_nofunctions` in the input. + diff --git a/doc/en/Developer/Development_track.md b/doc/en/Developer/Development_track.md index b69beafc8167e330309d7c20488364a6b2b9d49c..73dd3aaef957eccd7dc971c3dbe9c79c1a2aa3da 100644 --- a/doc/en/Developer/Development_track.md +++ b/doc/en/Developer/Development_track.md @@ -78,4 +78,4 @@ Later * 1st version of API. * Enable individual questions to load Maxima libraries. (See issue #305) * Markdown support? - +* SBCL on the continuous integration does not seem to have support for unicode. There are examples in the inputs fixtures and walkthrough adapctive tests. Search for SBCL. diff --git a/doc/en/Maintaining/index.md b/doc/en/Maintaining/index.md index 9d448d5978bfcfc06367cbdb280c276c984b8d77..1df1bf288ae50103debb91946eafac282a0a9838 100644 --- a/doc/en/Maintaining/index.md +++ b/doc/en/Maintaining/index.md @@ -1,10 +1,16 @@ # Maintaining questions and question banks -This section of the documentation provides information on testing questions and maintaining question banks for the long term. Many functions related to maintaining STACK questions are available from the question setting page or from the "adminui" page +This section of the documentation provides information on testing questions and maintaining question banks for the long term. Access to functions related to maintaining STACK questions is through the "adminui" page [...]/question/type/stack/adminui/index.php -To make use of these tools (in Moodle) users require the capability `qtype/stack:usediagnostictools` via Moodle's capability system. If your institution restricts site admin status, then this capability will allow a subset of users to access these functions. If that is also not possible, you might be able to convince your site administrators to run the tests themselves and give you the results. +(or available from the qtype_stack plugin setting page). To make use of these tools (in Moodle) users require the capability `qtype/stack:usediagnostictools` via Moodle's capability system. We stronly recommend anyone who regularly writes STACK questions across more than one Moodle course be given this capability. It enables the following: + +* Bulk testing of questions. +* Identifying STACK questions using particular blocks, e.g. the "todo" block, or includes. +* Bulk change of the default settings. + +If your institution restricts site admin status, then this capability will allow a subset of users to access these functions. If it is not possible to get this capability, then Moodle site administrators will need to run the tests themselves and give you the results. When you upgrade, or before you upgrade, please check the [release notes](../Developer/Development_history.md) carefully. @@ -26,13 +32,13 @@ You can bulk test all question tests on all variants of all question by using th STACK questions store the version of the STACK plug-in _last used_ to edit the question. The bulk tester runs all question tests, and also checks for changes with the current STACK plug-in version. -It is possible to [bulk test materials on other sites](Running_question_tests_other_site.md). +It is possible to [bulk test materials on other sites](Running_question_tests_other_site.md). (Site admins will have the option to bulk test all materials, and there is also a command line bulk test option.) ## Identifying STACK questions using particular blocks -It is possible to identify questions for dependencies, such as use of JSXGraph or inclusion of external maxima code. +It is possible to identify questions for dependencies, such as use of JSXGraph, inclusion of external maxima code, or "todo" blocks. -This is available from the question setting page or from the "adminui" page +The dependency checker is available from the question setting page or from the "adminui" page [...]/question/type/stack/adminui/index.php @@ -42,4 +48,6 @@ See also the notes on [local usage](Local_Usage.md) of STACK questions on your s You may need to [upgrade question defaults](UpgradeDefaults.md) over a range of questions. +## Import and replace questions +The STACK community developed an [import question as new version](https://github.com/maths/moodle-qbank_importasversion) plugin for Moodle. This plugin allows you to import a question from a Moodle XML file as a new version of an existing question. This is useful when a question is fixed/updated on an external site. diff --git a/doc/en/Topics/Propositional_Logic.md b/doc/en/Topics/Propositional_Logic.md index e48e957570d78067d2b5356824cd204f544f347e..a26411a23670bcf2641425257cab76e6dfa9b5fb 100644 --- a/doc/en/Topics/Propositional_Logic.md +++ b/doc/en/Topics/Propositional_Logic.md @@ -45,7 +45,7 @@ To replace all `nounand` (etc) operators and replace them with the Maxima equiva Notes -* There is no support for symbolic logic symbol input currently and students cannot type `&`, `*` for `and`, and similarly students cannot type `+` for `or`. +* If you would like to accept `*` for `and` and `+` for `or` then you can use the feedback variables to replace operators. E.g. by using `sa:subst(["*"="nounand", "+"="nounor","!"="nounnot"], ans1);`. Note that students cannot type `&` or an apostrophe as part of their input. In the above example we use the post-fix factorial operator `!` is used for logical negation. * There is no existential operator (not that this is propositional logic, but for the record) or an interpretation of '?' as there exits, and there is no universal operator (which some people type in as `!`). * To change between language or symbols for logic, use the Logic symbols [option](../Authoring/Tables.md). The default behaviour is to use language. diff --git a/lang/en/qtype_stack.php b/lang/en/qtype_stack.php index 6d61fd34ab917122f6de2f252cf51bf6c47783bb..ff133a94e475b5044fbac92a34bb96efa6518412 100644 --- a/lang/en/qtype_stack.php +++ b/lang/en/qtype_stack.php @@ -83,7 +83,7 @@ $string['autosimplifyprt_link'] = '%%WWWROOT%%/question/type/stack/doc/doc.php/C $string['boxsize'] = 'Input box size'; $string['boxsize_help'] = 'Width of the html formfield.'; $string['boxsize_link'] = '%%WWWROOT%%/question/type/stack/doc/doc.php/Authoring/Inputs.md#Box_Size'; -$string['bulktestindexintro_desc'] = 'The <a href="{$a->link}">run the question tests in bulk script</a> lets you easily run all the STACK questions in a given context. Not only does this test the questions. It is also a good way to re-populate the CAS cache after it has been cleared.'; +$string['bulktestindexintro_desc'] = 'The <a href="{$a->link}">bulk test script</a> lets you easily run all the STACK question tests in a given context. Not only does this test the questions. It is also a good way to re-populate the CAS cache after it has been cleared.'; $string['dependenciesintro_desc'] = 'The <a href="{$a->link}">dependencies</a>, checker finds questions with dependencies such as JSXGraph or inclusion of external maxima code.'; $string['checkanswertype'] = 'Check the type of the response'; $string['checkanswertype_help'] = 'If yes, answers which are of a different "type" (e.g. expression, equation, matrix, list, set) are rejected as invalid.'; @@ -280,6 +280,8 @@ $string['prts'] = 'Potential response trees'; $string['prtwillbecomeactivewhen'] = 'This potential response tree will become active when the student has answered: {$a}'; $string['prtruntimeerror'] = '{$a->prt} generated the following runtime error: {$a->error}'; $string['prtwillberemoved'] = 'This potential response tree is no longer referred to in the question text or specific feedback. If you save the question now, the data about this potential response tree will be lost. Please confirm that you want to do this. Alternatively edit the question text or specific feedback to put back the \'[[feedback:{$a}]]\' placeholder.'; +$string['prtruntimescore'] = 'The score was not fully evaluated to a numerical value (check variable names).'; +$string['prtruntimepenalty'] = 'The penalty was not fully evaluated to a numerical value (check variable names).'; $string['feedbackstyle'] = 'PRT feedback style'; $string['feedbackstyle_help'] = 'Controls how PRT feedback is displayed.'; $string['feedbackstyle_link'] = '%%WWWROOT%%/question/type/stack/doc/doc.php/Authoring/Potential_response_trees.md'; @@ -396,7 +398,7 @@ $string['singlechargotmorethanone'] = 'You can only enter a single character her $string['true'] = 'True'; $string['false'] = 'False'; -$string['notanswered'] = '(No answer given)'; +$string['notanswered'] = '(Clear my choice)'; $string['ddl_runtime'] = 'The input has generated the following runtime error which prevents you from answering. Please contact your teacher.'; $string['ddl_empty'] = 'No choices were provided for this drop-down.'; $string['ddl_nocorrectanswersupplied'] = 'The teacher did not indicate at least one correct answer. '; @@ -455,6 +457,7 @@ $string['settingplatformtypelinux'] = 'Linux'; $string['settingplatformtypelinuxoptimised'] = 'Linux (optimised)'; $string['settingplatformtypewin'] = 'Windows'; $string['settingplatformtypeserver'] = 'Server'; +$string['settingplatformtypeserverproxy'] = 'Server (via proxy)'; $string['settingplatformmaximacommand'] = 'Maxima command'; $string['settingplatformmaximacommand_desc'] = 'If this is blank, STACK will make an educated guess as to where to find Maxima. If that fails, this should be set to the full path of the maxima or maxima-optimised executable. Use for development and debugging only. Do not use on a production system: use optimised, or better, the Maxima Pool option.'; $string['settingplatformmaximacommandopt'] = 'Optimised Maxima command'; @@ -627,6 +630,7 @@ $string['healthchecklatexmathjax'] = 'STACK relies on the Moodle MathJax filter. $string['healthcheckmathsdisplaymethod'] = 'Maths display method being used: {$a}.'; $string['healthcheckmaximabat'] = 'The maxima.bat file is missing'; $string['healthcheckmaximabatinfo'] = 'This script tried to automatically copy the maxima.bat script from inside "C:\Program files\Maxima-1.xx.y\bin" into "{$a}\stack". However, this seems not to have worked. Please copy this file manually.'; +$string['healthcheckproxysettings'] = '<strong>Warning:</strong> Moodle is set to use a proxy server but calls to maxima are bypassing this. Switch platform from "server" to "server (via proxy)" to route calls via the proxy server or add the maxima server to $CFG->proxybypass to make the bypass explicit. STACK should still function for now even if you do not make a change but Moodle proxy settings will be enforced in a later version.'; $string['healthchecksamplecas'] = 'The derivative of {@ x^4/(1+x^4) @} is \[ \frac{d}{dx} \frac{x^4}{1+x^4} = {@ diff(x^4/(1+x^4),x) @}. \]'; $string['healthcheckconnectunicode'] = 'Trying to send unicode to the CAS'; $string['healthchecksamplecasunicode'] = 'Confirm if unicode is supported: \(\forall\) should be displayed {@unicode(8704)@}.'; @@ -1120,7 +1124,7 @@ $string['Interval_illegal_entries'] = 'The following should not appe // Documentation strings. $string['stackDoc_404'] = 'Error 404'; $string['stackDoc_docs'] = 'STACK Documentation'; -$string['stackDoc_docs_desc'] = '<a href="{$a->link}">Documentation for STACK</a>: a local static wiki documenting the code you actually have running on your server.'; +$string['stackDoc_docs_desc'] = 'The <a href="{$a->link}">documentation for STACK</a>: a local static wiki documenting the code you actually have running on your server.'; $string['stackDoc_home'] = 'Documentation home'; $string['stackDoc_index'] = 'Category index'; $string['stackDoc_siteMap'] = 'Site map'; diff --git a/question.php b/question.php index 08c8bf9c885daef66289def966953ff614685591..b17da1f6e7dd7209b49b072d36beb17704e51004 100644 --- a/question.php +++ b/question.php @@ -344,9 +344,6 @@ class qtype_stack_question extends question_graded_automatically_with_countback // 1. question variables. $session = new stack_cas_session2([], $this->options, $this->seed); - // Construct the security object. But first units declaration into the session. - $units = (boolean) $this->get_cached('units'); - // If we are using localisation we should tell the CAS side logic about it. // For castext rendering and other tasks. if (count($this->get_cached('langs')) > 0) { @@ -356,6 +353,9 @@ class qtype_stack_question extends question_graded_automatically_with_countback stack_utils::php_string_to_maxima_string($selected), 'language setting'), false); } + // Construct the security object. But first units declaration into the session. + $units = (boolean) $this->get_cached('units'); + // If we have units we might as well include the units declaration in the session. // To simplify authors work and remove the need to call that long function. // TODO: Maybe add this to the preable to save lines, but for now documented here. @@ -560,6 +560,12 @@ class qtype_stack_question extends question_graded_automatically_with_countback } else { $session = new stack_cas_session2($this->session->get_session(), $this->options, $this->seed); } + if (count($this->get_cached('langs')) > 0) { + $ml = new stack_multilang(); + $selected = $ml->pick_lang($this->get_cached('langs')); + $session->add_statement(new stack_secure_loader('%_STACK_LANG:' . + stack_utils::php_string_to_maxima_string($selected), 'language setting'), false); + } $session->add_statement($hinttext); $session->instantiate(); @@ -782,6 +788,12 @@ class qtype_stack_question extends question_graded_automatically_with_countback return $this->inputstates[$name]; } + $lang = null; + if ($this->get_cached('langs') !== null && count($this->get_cached('langs')) > 0) { + $ml = new stack_multilang(); + $lang = $ml->pick_lang($this->get_cached('langs')); + } + // TODO: we should probably give the whole ast_container to the input. // Direct access to LaTeX and the AST might be handy. $teacheranswer = ''; @@ -798,7 +810,7 @@ class qtype_stack_question extends question_graded_automatically_with_countback $this->inputstates[$name] = $this->inputs[$name]->validate_student_response( $response, $this->options, $teacheranswer, $this->security, $rawinput, - $this->castextprocessor, $qv); + $this->castextprocessor, $qv, $lang); return $this->inputstates[$name]; } return ''; @@ -1143,6 +1155,12 @@ class qtype_stack_question extends question_graded_automatically_with_countback // So now we build a session to evaluate all the PRTs. $session = new stack_cas_session2([], $this->options, $this->seed); + if (count($this->get_cached('langs')) > 0) { + $ml = new stack_multilang(); + $selected = $ml->pick_lang($this->get_cached('langs')); + $session->add_statement(new stack_secure_loader('%_STACK_LANG:' . + stack_utils::php_string_to_maxima_string($selected), 'language setting'), false); + } // Construct the security object. But first units declaration into the session. $units = (boolean) $this->get_cached('units'); @@ -1762,7 +1780,7 @@ class qtype_stack_question extends question_graded_automatically_with_countback * Currently the cache contains the following keys: * 'units' for declaring the units-mode. * 'forbiddenkeys' for the lsit of those. - * 'contextvariable-qv' the pre-validated question-variables which are context variables. + * 'contextvariables-qv' the pre-validated question-variables which are context variables. * 'statement-qv' the pre-validated question-variables. * 'preamble-qv' the matching blockexternals. * 'required' the lists of inputs required by given PRTs an array by PRT-name. @@ -1827,7 +1845,7 @@ class qtype_stack_question extends question_graded_automatically_with_countback if ($questionvariables === null || trim($questionvariables) === '') { $cc['statement-qv'] = null; $cc['preamble-qv'] = null; - $cc['contextvariable-qv'] = null; + $cc['contextvariables-qv'] = null; $cc['security-context'] = []; } else { $kv = new stack_cas_keyval($questionvariables, $options); diff --git a/questiontestedit.php b/questiontestedit.php index 45333c8a5e26c062f1926f10cdf2dace014b1338..c47b9a97c439c1bce49a1240479a48cecafbffa3 100644 --- a/questiontestedit.php +++ b/questiontestedit.php @@ -39,6 +39,8 @@ $confirmthistestcase = optional_param('confirmthistestcase', null, PARAM_INT); // Load the necessary data. $questiondata = $DB->get_record('question', array('id' => $questionid), '*', MUST_EXIST); $question = question_bank::load_question($questionid); +// We hard-wire decimals to be a full stop when testing questions. +$question->options->set_option('decimals', '.'); if ($testcase || $confirmthistestcase) { $qtest = question_bank::get_qtype('stack')->load_question_test($questionid, $testcase); } @@ -71,7 +73,6 @@ if (!is_null($seed)) { $slot = $quba->add_question($question, $question->defaultmark); $quba->start_question($slot); - // Initialise $PAGE. $backurl = new moodle_url('/question/type/stack/questiontestrun.php', $urlparams); if (!is_null($testcase)) { @@ -125,7 +126,6 @@ if ($mform->is_cancelled()) { $inputs[$name] = $value; } $qtest = new stack_question_test($qtest->description, $inputs); - $response = stack_question_test::compute_response($question, $inputs); foreach ($question->prts as $prtname => $prt) { diff --git a/questiontestrun.php b/questiontestrun.php index 9f194608df3f97ace6a76cb165cad94012d09a59..29927cb188e6711bc3d46d7a5f7fe0754c90d442 100644 --- a/questiontestrun.php +++ b/questiontestrun.php @@ -68,6 +68,8 @@ if (!$questiondata) { throw new stack_exception('questiondoesnotexist'); } $question = question_bank::load_question($questionid); +// We hard-wire decimals to be a full stop when testing questions. +$question->options->set_option('decimals', '.'); // Process any other URL parameters, and do require_login. list($context, $seed, $urlparams) = qtype_stack_setup_question_test_page($question); diff --git a/settings.php b/settings.php index 50a9c4402316516f0629771fc51fce675e16d533..b9fd2331abab17db24c3a792a2d3b18d1e704d10 100644 --- a/settings.php +++ b/settings.php @@ -31,22 +31,23 @@ require_once(__DIR__ . '/stack/prt.class.php'); // Useful links. $links = array( get_string('stackDoc_docs_desc', 'qtype_stack', - array('link' => (string) new moodle_url('/question/type/stack/doc/doc.php/'))), + array('link' => (string) new moodle_url('/question/type/stack/doc/doc.php/'))), get_string('healthcheck_desc', 'qtype_stack', - array('link' => (string) new moodle_url('/question/type/stack/adminui/healthcheck.php'))), + array('link' => (string) new moodle_url('/question/type/stack/adminui/healthcheck.php'))), get_string('chat_desc', 'qtype_stack', - array('link' => (string) new moodle_url('/question/type/stack/adminui/caschat.php'))), - get_string('stackInstall_testsuite_title_desc', 'qtype_stack', - array('link' => (string) new moodle_url('/question/type/stack/adminui/answertests.php'))), - get_string('stackInstall_input_title_desc', 'qtype_stack', - array('link' => (string) new moodle_url('/question/type/stack/adminui/studentinputs.php'))), + array('link' => (string) new moodle_url('/question/type/stack/adminui/caschat.php'))), get_string('bulktestindexintro_desc', 'qtype_stack', - array('link' => (string) new moodle_url('/question/type/stack/adminui/bulktestindex.php'))), + array('link' => (string) new moodle_url('/question/type/stack/adminui/bulktestindex.php'))), get_string('dependenciesintro_desc', 'qtype_stack', array('link' => (string) new moodle_url('/question/type/stack/adminui/dependencies.php'))), get_string('stackInstall_replace_dollars_desc', 'qtype_stack', - array('link' => (string) new moodle_url('/question/type/stack/adminui/replacedollarsindex.php'))), + array('link' => (string) new moodle_url('/question/type/stack/adminui/replacedollarsindex.php'))), + get_string('stackInstall_testsuite_title_desc', 'qtype_stack', + array('link' => (string) new moodle_url('/question/type/stack/adminui/answertests.php'))), + get_string('stackInstall_input_title_desc', 'qtype_stack', + array('link' => (string) new moodle_url('/question/type/stack/adminui/studentinputs.php'))), ); + $settings->add(new admin_setting_heading('docs', get_string('settingusefullinks', 'qtype_stack'), '* ' . implode("\n* ", $links))); @@ -61,11 +62,13 @@ $settings->add(new admin_setting_heading('maixmasettingsheading', $settings->add(new admin_setting_configselect('qtype_stack/platform', get_string('settingplatformtype', 'qtype_stack'), // Note, install.php tries to auto-detect Windows installs, and set the default appropriately. - get_string('settingplatformtype_desc', 'qtype_stack'), null, array( + get_string('settingplatformtype_desc', 'qtype_stack'), null, [ 'linux' => get_string('settingplatformtypelinux', 'qtype_stack'), 'linux-optimised' => get_string('settingplatformtypelinuxoptimised', 'qtype_stack'), 'win' => get_string('settingplatformtypewin', 'qtype_stack'), - 'server' => get_string('settingplatformtypeserver', 'qtype_stack')))); + 'server' => get_string('settingplatformtypeserver', 'qtype_stack'), + 'server-proxy' => get_string('settingplatformtypeserverproxy', 'qtype_stack'), + ])); $settings->add(new admin_setting_configselect('qtype_stack/maximaversion', get_string('settingcasmaximaversion', 'qtype_stack'), diff --git a/stack/cas/connector.class.php b/stack/cas/connector.class.php index 8f9be8fb8b81546dfe46791662f38f27488cac7f..617dd132883822679ec18363c3288ce9489918cf 100644 --- a/stack/cas/connector.class.php +++ b/stack/cas/connector.class.php @@ -177,7 +177,7 @@ abstract class stack_cas_connection_base implements stack_cas_connection { $cmd = $settings->maximacommand; if ($settings->platform == 'linux-optimised') { $cmd = $settings->maximacommandopt; - } else if ($settings->platform == 'server') { + } else if (in_array($settings->platform, ['server', 'server-proxy'])) { $cmd = $settings->maximacommandserver; } if ('' === trim($cmd)) { diff --git a/stack/cas/connector.healthcheck.class.php b/stack/cas/connector.healthcheck.class.php index 3b6b28445e20e5c4c3b08bee0cce4a93ea3f6b3f..8b1ea03617e8e0b58a1d39ba3748fb4d798844ff 100644 --- a/stack/cas/connector.healthcheck.class.php +++ b/stack/cas/connector.healthcheck.class.php @@ -117,8 +117,17 @@ class stack_cas_healthcheck { $test['details'] = html_writer::tag('pre', $connection->get_maxima_available()); $this->tests[] = $test; break; + case 'server': + if (!empty($CFG->proxyhost) && !is_proxybypass(get_config('qtype_stack', 'maximacommandserver'))) { + $test = []; + $test['tag'] = 'healthcheckproxysettings'; + $test['result'] = null; + $test['summary'] = stack_string('healthcheckproxysettings'); + $this->tests[] = $test; + break; + } default: - // Server/optimised. + // Server-proxy/optimised. // TODO: add in any specific tests for these setups? break; } diff --git a/stack/cas/connector.server_proxy.class.php b/stack/cas/connector.server_proxy.class.php new file mode 100644 index 0000000000000000000000000000000000000000..e83de1d1e411c4fcb1cc313720b31869e7ea78b6 --- /dev/null +++ b/stack/cas/connector.server_proxy.class.php @@ -0,0 +1,134 @@ +<?php +// This file is part of Stack - http://stack.maths.ed.ac.uk/ +// +// Stack is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Stack is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Stack. If not, see <http://www.gnu.org/licenses/>. + +/** + * Connection via proxy to Maxima running in a tomcat-server using the MaximaPool-servlet. + * This version handles transfer of the plots generated on possibly remote servlet. + * For details of this see https://github.com/maths/stack_util_maximapool/ + * + * @copyright 2012 The University of Birmingham + * @copyright 2012 Aalto University - Matti Harjula + * @copyright 2014 Loughborough University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class stack_cas_connection_server_proxy extends stack_cas_connection_base { + + protected function guess_maxima_command($path) { + return 'http://localhost:8080/MaximaPool/MaximaPool'; + } + + protected function call_maxima($command) { + global $CFG; + $err = ''; + + $starttime = microtime(true); + + $request = curl_init($this->command); + + $postdata = 'input=' . urlencode($command) . + '&timeout=' . ($this->timeout * 1000) . + '&ploturlbase=!ploturl!' . + '&version=' . stack_connection_helper::get_required_stackmaxima_version(); + + curl_setopt($request, CURLOPT_POST, true); + curl_setopt($request, CURLOPT_POSTFIELDS, $postdata); + curl_setopt($request, CURLOPT_RETURNTRANSFER, true); + curl_setopt($request, CURLOPT_HTTPAUTH, CURLAUTH_ANY); + if (!empty($this->serveruserpass)) { + curl_setopt($request, CURLOPT_USERPWD, $this->serveruserpass); + } + + // Set extra curl options to deal with using proxy server. + // If Moodle proxy settings are not set or maxima is in the + // proxy bypass then, this will just + // carry on as if we're using the server platform. + // Based on auth/cas/auth.php/auth_plugin_cas->connectCAS() checks. + if (!empty($CFG->proxyhost) && !is_proxybypass($this->command)) { + curl_setopt($request, CURLOPT_PROXY, $CFG->proxyhost); + if (!empty($CFG->proxyport)) { + curl_setopt($request, CURLOPT_PROXYPORT, $CFG->proxyport); + } + if (!empty($CFG->proxytype)) { + // Only set CURLOPT_PROXYTYPE if it's something other than the curl-default http. + if ($CFG->proxytype == 'SOCKS5') { + curl_setopt($request, CURLOPT_PROXYTYPE, CURLPROXY_SOCKS5); + } + } + if (!empty($CFG->proxyuser) && !empty($CFG->proxypassword)) { + curl_setopt($request, CURLOPT_PROXYUSERPWD, $CFG->proxyuser.':'.$CFG->proxypassword); + if (defined('CURLOPT_PROXYAUTH')) { + // Use any proxy authentication if required - PHP 5.1+. + curl_setopt($request, CURLOPT_PROXYAUTH, CURLAUTH_BASIC | CURLAUTH_NTLM); + } + } + } + + $ret = curl_exec($request); + + $timedout = false; + + // The servlet will return 416 if the evaluation hits the timelimit. + if (curl_getinfo($request, CURLINFO_HTTP_CODE) != '200') { + if (curl_getinfo($request, CURLINFO_HTTP_CODE) != '416') { + throw new Exception('stack_cas_connection: MaximaPool error: '.curl_getinfo($request, CURLINFO_HTTP_CODE)); + } else { + $timedout = true; + } + } + + // Did we get files? + if (strpos(curl_getinfo($request, CURLINFO_CONTENT_TYPE), "text/plain") === false) { + // We have to save the zip file on local disk before opening. + $ziptemp = tempnam($CFG->dataroot . '/stack/tmp/', 'zip'); + file_put_contents($ziptemp, $ret); + + // Loop over the contents of the zip. + $zip = new ZipArchive(); + $zip->open($ziptemp); + for ($i = 0; $i < $zip->numFiles; $i++) { + $filenameinzip = $zip->getNameIndex($i); + + if ($filenameinzip === 'OUTPUT') { + // This one contains the output from maxima. + $ret = $zip->getFromIndex($i); + + } else { + // Otherwise this is a plot. + $filename = $CFG->dataroot . "/stack/plots/" . $filenameinzip; + file_put_contents($filename, $zip->getFromIndex($i)); + } + } + + // Clean up. + $zip->close(); + unlink($ziptemp); + } + + curl_close($request); + + $now = microtime(true); + + $this->debug->log('Timings', "Start: {$starttime}, End: {$now}, Taken = ".($now - $starttime)); + + // Add sufficient closing ]'s to allow something to be un-parsed from the CAS. + // WARNING: the string 'The CAS timed out' is used by the cache to serach for a timout occurance. + if ($timedout) { + $ret .= ' The CAS timed out. ] ] ] ]'; + } + + return $ret; + } +} diff --git a/stack/cas/connectorhelper.class.php b/stack/cas/connectorhelper.class.php index e5ccffcdfe4bd36eb0569a8ddb9b372497b8b93e..3b90b5a2e0c8b74d971672c558228035352bd6a3 100644 --- a/stack/cas/connectorhelper.class.php +++ b/stack/cas/connectorhelper.class.php @@ -67,6 +67,10 @@ abstract class stack_connection_helper { require_once(__DIR__ . '/connector.server.class.php'); $connection = new stack_cas_connection_server(self::$config, $debuglog); break; + case 'server-proxy': + require_once(__DIR__ . '/connector.server_proxy.class.php'); + $connection = new stack_cas_connection_server_proxy(self::$config, $debuglog); + break; case 'tomcat': case 'tomcat-optimised': throw new stack_exception('stack_connection_helper: ' . @@ -261,6 +265,7 @@ abstract class stack_connection_helper { break; case 'server': + case 'server-proxy': $fix = stack_string('healthchecksstackmaximaversionfixserver'); break; diff --git a/stack/input/inputbase.class.php b/stack/input/inputbase.class.php index 6402309f11ac75976d919624e9336334f1e5aacd..3ece65eefd1de48084779c301faa562f88b227fb 100644 --- a/stack/input/inputbase.class.php +++ b/stack/input/inputbase.class.php @@ -636,7 +636,7 @@ abstract class stack_input { * @return stack_input_state represents the current state of the input. */ public function validate_student_response($response, $options, $teacheranswer, stack_cas_security $basesecurity, - $ajaxinput = false, $castextprocessor = null, $questionvariables = null) { + $ajaxinput = false, $castextprocessor = null, $questionvariables = null, $lang = null) { if (!is_a($options, 'stack_options')) { throw new stack_exception('stack_input: validate_student_response: options not of class stack_options'); } @@ -703,7 +703,13 @@ abstract class stack_input { } $lvarsdisp = ''; $note = ''; - $sessionvars = $this->contextsession; + $sessionvars = []; + // We might need languages in bespoke validation functions defined by the user. + if ($lang !== null) { + $sessionvars[] = new stack_secure_loader('%_STACK_LANG:' . + stack_utils::php_string_to_maxima_string($lang), 'language setting'); + } + $sessionvars = array_merge($sessionvars, $this->contextsession); // Clone answer so we can get the displayed form without the set validation context function, which simplifies. $answerd = clone $answer; diff --git a/stack/maxima/assessment.mac b/stack/maxima/assessment.mac index 7cce978abba3db0e37116775d24c7c455351d726..c9b414e3a44a75ac6cb18e7a1bb5600457552c8a 100644 --- a/stack/maxima/assessment.mac +++ b/stack/maxima/assessment.mac @@ -980,7 +980,8 @@ algebraic_equivalence(SA, SB) := /* This test establishes if two expressions appear NOT to be equivalent. It does so by evaluating the expressions numerically. */ -numerical_not_alg_equiv(p1, p2):= block([pvars, pval, lv, sz, pnum, stack_mtell_quiet,listdummyvars], +numerical_not_alg_equiv(p1, p2):= block([pvars, pval, lv, sz, pnum, stack_mtell_quiet,listdummyvars,trigexpand], + trigexpand:false, stack_mtell_quiet:true, listdummyvars:false, /* We take the *union* of the two lists of variables, this way we @@ -1026,7 +1027,7 @@ numerical_not_alg_equiv(p1, p2):= block([pvars, pval, lv, sz, pnum, stack_mtell_ if first(sz) > 0.0001 then true else false )$ -/* Are there any user-defined function? */ +/* Are there any user-defined functions? */ recurse_userfunctionp(ex):= block([op1], if atom(ex) then return(false), op1:ev(op(ex)), diff --git a/stack/maxima/contrib/validators.mac b/stack/maxima/contrib/validators.mac index 80e9e8d0cc15d779bada1c477f0f41e3d3cf0077..9d82e9d9e8d810b9254377f68a7cdee77a883f43 100644 --- a/stack/maxima/contrib/validators.mac +++ b/stack/maxima/contrib/validators.mac @@ -31,3 +31,21 @@ validate_underscore(ex) := if is(sposition("_", string(ex)) = false) then "" /* Add in unit-test cases using STACK's s_test_case function. At least two please! */ s_test_case(validate_underscore(1+a1), ""); s_test_case(validate_underscore(1+a_1), "Underscore characters are not permitted in this input."); + +/* The student may not use a user-defined function, or arrays, anywhere in their input. */ + +validate_nofunctions(ex):= block([op1], + if atom(ex) then return(""), + op1:ev(op(ex)), + op1:apply(properties, [op1]), + if ev(emptyp(op1) or is(op1=[noun]),simp) then return("User-defined functions are not permitted in this input."), + apply(sconcat, map(validate_nofunctions, args(ex))) +); + +s_test_case(validate_nofunctions(1+a1), ""); +s_test_case(validate_nofunctions(sin(n*x)), ""); +s_test_case(validate_nofunctions(-b#pm#sqrt(b^2-4*a*c)), ""); +s_test_case(validate_nofunctions(x(2)), "User-defined functions are not permitted in this input."); +s_test_case(validate_nofunctions(x(t)), "User-defined functions are not permitted in this input."); +s_test_case(validate_nofunctions(1+f(x+1)), "User-defined functions are not permitted in this input."); + diff --git a/stack/maxima/stackmaxima.mac b/stack/maxima/stackmaxima.mac index 2ca770c6a5c66b258e1f281b90e69f3f903202a4..caf359189350eaf7d04abbbff35f037d1f8f7213 100644 --- a/stack/maxima/stackmaxima.mac +++ b/stack/maxima/stackmaxima.mac @@ -226,14 +226,16 @@ texput(multsgnonlyfornumberssym, "\\times")$ multsgnonlyfornumbers(e) := block([arglist, resstr, a, lastisnum, isnum, multsgn, str], arglist: args(e), a: pop(arglist), - resstr: if (atom(a) or is(length(args(a))=1) or safe_op(a) = "^") then tex1(a) - else sconcat("\\left(", tex1(a), "\\right)"), + resstr: if (safe_op(a) = "-" or (real_numberp(a) and is(a<0))) then sconcat("\\left(", tex1(a), "\\right)") + else if (atom(a) or is(length(args(a))=1) or safe_op(a) = "^") then tex1(a) + else sconcat("\\left(", tex1(a), "\\right)"), lastisnum: numberp(a), for a in arglist do ( isnum: numberp(a), multsgn: if (lastisnum and isnum) then tex1(multsgnonlyfornumberssym) else "\\,", - str: if (atom(a) or is(length(args(a))=1) or safe_op(a) = "^") then tex1(a) - else sconcat("\\left(", tex1(a), "\\right)"), + str: if (safe_op(a) = "-" or (real_numberp(a) and is(a<0))) then sconcat("\\left(", tex1(a), "\\right)") + else if (atom(a) or is(length(args(a))=1) or safe_op(a) = "^") then tex1(a) + else sconcat("\\left(", tex1(a), "\\right)"), resstr: sconcat(resstr, multsgn, " ", str), lastisnum: isnum ), diff --git a/stack/maximaparser/MP_classes.php b/stack/maximaparser/MP_classes.php index a1e9a898f9f85a1ef191c0a930b53d02d8911cc3..ab3b67ca97ad89e953ca311f51ce6c18b686bb77 100644 --- a/stack/maximaparser/MP_classes.php +++ b/stack/maximaparser/MP_classes.php @@ -1488,7 +1488,7 @@ class MP_List extends MP_Node { return $indent . '[' . implode(', ', $ar) . ']'; } - return '[' . implode(',', $ar) . ']'; + return '[' . implode($sep, $ar) . ']'; } public function replace($node, $with) { diff --git a/stack/prt.evaluatable.class.php b/stack/prt.evaluatable.class.php index bd13b74473ac70b6908840cfb84a0051c50265f0..029ddfae3de82fe03271d2206992de2ab4b371e4 100644 --- a/stack/prt.evaluatable.class.php +++ b/stack/prt.evaluatable.class.php @@ -119,8 +119,18 @@ class prt_evaluatable implements cas_raw_value_extractor { return; } $this->path = $value[0]; - $this->score = stack_utils::fix_to_continued_fraction($value[1], 4); - $this->penalty = stack_utils::fix_to_continued_fraction($value[2], 4); + $this->score = 0; + if (is_numeric($value[1])) { + $this->score = stack_utils::fix_to_continued_fraction($value[1], 4); + } else { + $this->errors[] = new stack_cas_error(stack_string('prtruntimescore'), ''); + } + $this->penalty = 0; + if (is_numeric($value[2])) { + $this->penalty = stack_utils::fix_to_continued_fraction($value[2], 4); + } else { + $this->errors[] = new stack_cas_error(stack_string('prtruntimepenalty'), ''); + } $this->feedback = $value[3]; $this->notes = $value[4]; } diff --git a/tests/ast_container_test.php b/tests/ast_container_test.php index 7114a5c623226d45ab31c30062be910cb6d47ef3..841c99f319db609299155c65c30e207830f79df7 100644 --- a/tests/ast_container_test.php +++ b/tests/ast_container_test.php @@ -1027,4 +1027,21 @@ class ast_container_test extends qtype_stack_testcase { $this->assertEquals($t3['last-seen'], false); } + + public function test_teacher_answer_decimals() { + // This tests the functions which generate "The teacher's answer is". + $s = '{4.4,4}'; + $at1 = stack_ast_container::make_from_teacher_source($s, '', new stack_cas_security()); + $at1->set_nounify(0); + $this->assertTrue($at1->get_valid()); + $this->assertEquals('', $at1->get_errors()); + $this->assertEquals($at1->get_inputform(true, 0, true, ','), '{4,4;4}'); + + $s = '[4.4,4]'; + $at1 = stack_ast_container::make_from_teacher_source($s, '', new stack_cas_security()); + $at1->set_nounify(0); + $this->assertTrue($at1->get_valid()); + $this->assertEquals('', $at1->get_errors()); + $this->assertEquals($at1->get_inputform(true, 0, true, ','), '[4,4;4]'); + } } diff --git a/tests/behat/documentation.feature b/tests/behat/documentation.feature index b8b0bbe4cc266e188fee68e8539c04cc2381336c..1d6a07d6034f6767daf3e99fd66e200c7af0a664 100644 --- a/tests/behat/documentation.feature +++ b/tests/behat/documentation.feature @@ -11,7 +11,7 @@ Feature: STACK has built-in documentation. @javascript Scenario: Navigate to the documentation - When I follow "Documentation for STACK" + When I follow "documentation for STACK" Then I should see "STACK is the world-leading open-source (GPL) automatic assessment system for mathematics, science and related disciplines." When I follow "Site map" Then I should see "Directory structure" diff --git a/tests/behat/restore_demo.feature b/tests/behat/restore_demo.feature index 03a80e8df32581c43224c34249dae31b78d0aed6..462d00c928752126512797270fd8658c72cd39be 100644 --- a/tests/behat/restore_demo.feature +++ b/tests/behat/restore_demo.feature @@ -10,13 +10,26 @@ Feature: Test restoring a backup including STACK questions | Demonstrating STACK | STACK | And I log in as "admin" And I navigate to "Courses > Restore course" in site administration - And I click on "Manage backup files" "button" in the "//h2[contains(., 'User private backup area')]/following-sibling::div[1]" "xpath_element" + + @javascript @_file_upload + Scenario: Restore the STACK demo course in Moodle ≤ 4.3. + Given the site is running Moodle version 4.3 or lower + When I click on "Manage backup files" "button" in the "//h2[contains(., 'User private backup area')]/following-sibling::div[1]" "xpath_element" And I upload "question/type/stack/samplequestions/STACK-syntax-quiz.mbz" file to "Files" filemanager And I press "Save changes" + And I restore "STACK-syntax-quiz" backup into "Demonstrating STACK" course using this options: + And I am on "Demonstrating STACK" course homepage + Then I should see "Stack Syntax Quiz" + And I am on the "Stack Syntax Quiz" "mod_quiz > edit" page + And I should see "Syntax-21-Numbers-Greek" @javascript @_file_upload - Scenario: Restore the STACK demo course. - When I restore "STACK-syntax-quiz" backup into "Demonstrating STACK" course using this options: + Scenario: Restore the STACK demo course in Moodle ≥ 4.4. + Given the site is running Moodle version 4.4 or higher + When I press "Manage course backups" + And I upload "question/type/stack/samplequestions/STACK-syntax-quiz.mbz" file to "Files" filemanager + And I press "Save changes" + And I restore "STACK-syntax-quiz" backup into "Demonstrating STACK" course using this options: And I am on "Demonstrating STACK" course homepage Then I should see "Stack Syntax Quiz" And I am on the "Stack Syntax Quiz" "mod_quiz > edit" page diff --git a/tests/behat/restore_reveal_question.feature b/tests/behat/restore_reveal_question.feature index 07901038918b8db3ee374a1b18743159deb28fe9..852db18a7548a1a326b3506bbc1ebc2876bb2267 100644 --- a/tests/behat/restore_reveal_question.feature +++ b/tests/behat/restore_reveal_question.feature @@ -10,14 +10,14 @@ Feature: Test restoring and testing an individual STACK question from the sample | Demonstrating STACK | STACK | And I log in as "admin" And I navigate to "Courses > Restore course" in site administration - And I click on "Manage backup files" "button" in the "//h2[contains(., 'User private backup area')]/following-sibling::div[1]" "xpath_element" - And I upload "question/type/stack/samplequestions/STACK-reveal-test.mbz" file to "Files" filemanager - And I press "Save changes" @javascript @_file_upload Scenario: Restore the STACK demo course on a Moodle ≤ 3.11 Given the site is running Moodle version 3.11 or lower - When I restore "STACK-reveal-test" backup into "Demonstrating STACK" course using this options: + When I click on "Manage backup files" "button" in the "//h2[contains(., 'User private backup area')]/following-sibling::div[1]" "xpath_element" + And I upload "question/type/stack/samplequestions/STACK-reveal-test.mbz" file to "Files" filemanager + And I press "Save changes" + And I restore "STACK-reveal-test" backup into "Demonstrating STACK" course using this options: And I am on "Demonstrating STACK" course homepage Then I should see "Reveal block test" When I follow "Reveal block test" @@ -38,9 +38,36 @@ Feature: Test restoring and testing an individual STACK question from the sample Then I should see "Correct answer, well done." @javascript @_file_upload - Scenario: Restore the STACK demo course on a Moodle ≥ 4.0 + Scenario: Restore the STACK demo course on a Moodle 4.3 ≥ 4.0 Given the site is running Moodle version 4.0 or higher - When I restore "STACK-reveal-test" backup into "Demonstrating STACK" course using this options: + Given the site is running Moodle version 4.3 or lower + When I click on "Manage backup files" "button" in the "//h2[contains(., 'User private backup area')]/following-sibling::div[1]" "xpath_element" + And I upload "question/type/stack/samplequestions/STACK-reveal-test.mbz" file to "Files" filemanager + And I press "Save changes" + And I restore "STACK-reveal-test" backup into "Demonstrating STACK" course using this options: + And I am on "Demonstrating STACK" course homepage + Then I should see "Reveal block test" + When I follow "Reveal block test" + And I click on "Preview quiz" "button" + Then I should see "made from the straight line through the origin" + When I set the input "ans1" to "true" in the STACK question + And I wait "2" seconds + Then I should see "If true write the subspace in parametric form" + When I set the input "ans2_sub_0_0" to "-t" in the STACK question + When I set the input "ans2_sub_1_0" to "3*t" in the STACK question + When I set the input "ans2_sub_2_0" to "2*t" in the STACK question + When I set the input "ans3" to "[t]" in the STACK question + And I wait "2" seconds + When I press "Check" + Then I should see "Correct answer, well done." + + @javascript @_file_upload + Scenario: Restore the STACK demo course on a Moodle ≥ 4.4 + Given the site is running Moodle version 4.4 or higher + When I press "Manage course backups" + And I upload "question/type/stack/samplequestions/STACK-reveal-test.mbz" file to "Files" filemanager + And I press "Save changes" + And I restore "STACK-reveal-test" backup into "Demonstrating STACK" course using this options: And I am on "Demonstrating STACK" course homepage Then I should see "Reveal block test" When I follow "Reveal block test" diff --git a/tests/cassession2_test.php b/tests/cassession2_test.php index 76bb22ed86dd0b64a4e1c3b24a4f7cf9199d34a8..bdb7ad26093f57e2b0c148ce10f96abfcf59ba3a 100644 --- a/tests/cassession2_test.php +++ b/tests/cassession2_test.php @@ -430,7 +430,7 @@ class cassession2_test extends qtype_stack_testcase { public function test_multiplication_option_onum() { $s1 = []; - $cs = array('a:2*x', 'b:2*3*x', 'c:3*5^2', 'd:3*x^2'); + $cs = array('a:2*x', 'b:2*3*x', 'c:3*5^2', 'd:3*x^2', 's1:x*(-y)', 's2:3*(-4)*x*(-y)'); foreach ($cs as $s) { $s1[] = stack_ast_container::make_from_student_source($s, '', new stack_cas_security(), array()); } @@ -445,6 +445,8 @@ class cassession2_test extends qtype_stack_testcase { $this->assertEquals('2\times 3\, x', $s1[1]->get_display()); $this->assertEquals('3\, 5^2', $s1[2]->get_display()); $this->assertEquals('3\, x^2', $s1[3]->get_display()); + $this->assertEquals('x\, \left(-y\right)', $s1[4]->get_display()); + $this->assertEquals('3\times \left(-4\right)\, x\, \left(-y\right)', $s1[5]->get_display()); $s1 = []; $cs = array('texput(multsgnonlyfornumberssym, "\\\\cdot")', diff --git a/tests/fixtures/answertestfixtures.class.php b/tests/fixtures/answertestfixtures.class.php index 351f4c291216c339c5dc8955fbbe24ae23d88726..210d55c028788b0dd1e4af9f0c2dfc0975eb94ef 100644 --- a/tests/fixtures/answertestfixtures.class.php +++ b/tests/fixtures/answertestfixtures.class.php @@ -174,6 +174,8 @@ class stack_answertest_test_data { array('AlgEquiv', '', 'diff(tan(10*x)^2,x)', 'cos(6*x)', 0, '', ''), array('AlgEquiv', '', 'exp(%i*%pi)', '-1', 1, '', ''), array('AlgEquiv', '', '2*cos(2*x)+x+1', '-sin(x)^2+3*cos(x)^2+x', 1, '', ''), + // This caused a trigexpand (for some reason), which led to timeouts in issue #1073. + array('AlgEquiv', '', '4*x*cos(x^12/%pi)', 'x*cos(x^12/%pi)', 0, '', ''), array('AlgEquiv', '', '(2*sec(2*t)^2-2)/2', '-(sin(4*t)^2-2*sin(4*t)+cos(4*t)^2-1)*(sin(4*t)^2+2*sin(4*t)+cos(4*t)^2-1)/(sin(4*t)^2+cos(4*t)^2+2*cos(4*t)+1)^2', @@ -536,6 +538,8 @@ class stack_answertest_test_data { array('AlgEquiv', '', 'binomial(n,k)', 'binomial(n,n-k)', 1, '', ''), array('AlgEquiv', '', '175!*56!/(55!*176!)', '17556/55176', 1, '', ''), array('AlgEquiv', '', '3*s*diff(q(s),s)', '3*s*diff(q(s),s)', 1, '', 'Unevaluated derviatives'), + array('AlgEquiv', '', '3*t*diff(q(s),s)', '3*diff(t*q(s),s)', 1, '', ''), + array('AlgEquiv', '', 'diff(diff(q(s),s),s)', 'diff(q(s),s,2)', 1, '', ''), array('AlgEquiv', '', 'sum(k^n,n,0,3)', 'sum(k^n,n,0,3)', 1, '', 'Sums and products'), array('AlgEquiv', '', '1+k+k^2+k^3', 'sum(k^n,n,0,3)', 1, '', ''), array('AlgEquiv', '', '1+k+k^2', 'sum(k^n,n,0,3)', 0, '', ''), @@ -747,6 +751,7 @@ class stack_answertest_test_data { 'Expressions with subscripts'), array('EqualComAss', '', 'rho*z*V/(4*pi*epsilon[1]*(R^2+z^2)^(3/2))', 'rho*z*V/(4*pi*epsilon[0]*(R^2+z^2)^(3/2))', 0, 'ATEqualComAss (AlgEquiv-false).', ''), + array('EqualComAss', '', '+1-2', '1-2', 1, '', 'Unary plus'), array('EqualComAss', '', '-1+2', '2-1', 1, '', 'Unary minus'), array('EqualComAss', '', '-1*2+3*4', '3*4-1*2', 1, '', ''), array('EqualComAss', '', '(-1*2)+3*4', '10', 0, 'ATEqualComAss (AlgEquiv-true).', ''), @@ -850,6 +855,10 @@ class stack_answertest_test_data { array('EqualComAss', '', 'rationalized(1/(1+i))', '[i]', 1, '', ''), array('EqualComAss', '', 'rationalized(1/(1+1/root(3,2)))', '[root(3,2)]', 1, '', ''), + array('EqualComAss', '', 'B nounand A', 'A nounand B', 1, '', 'Logic'), + array('EqualComAss', '', 'A nounand A', 'A', 0, 'ATEqualComAss ATAlgEquiv_SA_not_expression.', ''), + array('EqualComAss', '', 'subst(["*"="nounand", "+"="nounor","!"="nounnot"], A*B)', 'A nounand B', 1, '', ''), + // Differential equations. // Functions are evaluated with simp:false. array('EqualComAss', '', 'diff(y,x)', '0', 1, '', 'Differential Equations'), @@ -998,6 +1007,8 @@ class stack_answertest_test_data { array('CasEqual', '', '4^(-1/2)', '1/2', 0, 'ATCASEqual (AlgEquiv-true).', 'Numbers'), array('CasEqual', '', 'ev(4^(-1/2),simp)', 'ev(1/2,simp)', 1, 'ATCASEqual_true.', ''), array('CasEqual', '', '2^2', '4', 0, 'ATCASEqual (AlgEquiv-true).', ''), + // Below is the intended behaviour: these trees are not equal. + array('CasEqual', '', '+1-2', '1-2', 0, 'ATCASEqual (AlgEquiv-true).', 'Unary plus'), array('CasEqual', '', 'a^2/b^3', 'a^2*b^(-3)', 0, 'ATCASEqual (AlgEquiv-true).', 'Powers'), array('CasEqual', '', 'rho*z*V/(4*pi*epsilon[0]*(R^2+z^2)^(3/2))', 'rho*z*V/(4*pi*epsilon[0]*(R^2+z^2)^(3/2))', 1, 'ATCASEqual_true.', 'Expressions with subscripts'), @@ -1558,6 +1569,8 @@ class stack_answertest_test_data { array('Int', 't', '(tan(2*t)-2*t)/2+c', '-(t*sin(4*t)^2-sin(4*t)+t*cos(4*t)^2+2*t*cos(4*t)+t)/(sin(4*t)^2+cos(4*t)^2+2*cos(4*t)+1)', 1, 'ATInt_true.', ''), array('Int', 'x', 'tan(x)-x+c', 'tan(x)-x', 1, 'ATInt_true.', ''), + array('Int', 'x', '4*x*cos(x^12/%pi)+c', 'x*cos(x^12/%pi)+c', 0, 'ATInt_generic.', ''), + array('Int', 'x', '4*x*cos(x^50/%pi)+c', 'x*cos(x^12/%pi)+c', 0, 'ATInt_generic.', ''), array('Int', 'x', '((5*%e^7*x-%e^7)*%e^(5*x))', '((5*%e^7*x-%e^7)*%e^(5*x))/25+c', 0, 'ATInt_generic.', 'Note the difference in feedback here, generated by the options.'), array('Int', '[x,x*%e^(5*x+7)]', '((5*%e^7*x-%e^7)*%e^(5*x))', '((5*%e^7*x-%e^7)*%e^(5*x))/25+c', 0, 'ATInt_generic.', ''), diff --git a/tests/fixtures/equivfixtures.class.php b/tests/fixtures/equivfixtures.class.php index 68387ef58c6b31a5c150504cf273d9a37d9d5ba2..c7f41c28c155bbb8e1ea62ca9ddb5cb870e5d495 100644 --- a/tests/fixtures/equivfixtures.class.php +++ b/tests/fixtures/equivfixtures.class.php @@ -1387,8 +1387,6 @@ class stack_equiv_test_data { $newarg['outcome'] = true; $samplearguments[] = $newarg; - /* ....................................... */ - $newarg = array(); $newarg['section'] = 'Induction steps'; $samplearguments[] = $newarg; diff --git a/tests/fixtures/inputfixtures.class.php b/tests/fixtures/inputfixtures.class.php index ea9e0a766eb1e1796138a50978b7379830a7e468..aa1847a04a186e9679c5811ae061af95a39a9ab1 100644 --- a/tests/fixtures/inputfixtures.class.php +++ b/tests/fixtures/inputfixtures.class.php @@ -98,8 +98,7 @@ class stack_inputvalidation_test_data { array('"1+1"', 'php_true', '"1+1"', 'cas_true', '\mbox{1+1}', '', "Strings - generally discouraged. Note, this is a string within a mathematical expression, not literally 1+1."), array('"Hello world"', 'php_true', '"Hello world"', 'cas_true', '\mbox{Hello world}', '', ''), - // In the continuous integration, this works with GCL but not with SBCL. - // array("\"We \u{1F497} STACK!\"", 'php_true', "\"We \u{1F497} STACK!\"", 'cas_true', "\mbox{We \u{1F497} STACK!}", '', ''), + // In the continuous integration, "\"We \u{1F497} STACK!\" works with GCL but not with SBCL. array('x', 'php_true', 'x', 'cas_true', 'x', '', "Names for variables etc."), array('a1', 'php_true', 'a*1', 'cas_true', 'a\cdot 1', 'missing_stars', ""), array('a12', 'php_true', 'a*12', 'cas_true', 'a\cdot 12', 'missing_stars', ""), diff --git a/tests/fixtures/test_base.php b/tests/fixtures/test_base.php index 9d17527d73696894adad008db51b2e2f49311010..ec49828344e5a6a84f383865c140741e425e9019 100644 --- a/tests/fixtures/test_base.php +++ b/tests/fixtures/test_base.php @@ -437,6 +437,11 @@ abstract class qtype_stack_walkthrough_test_base extends \qbehaviour_walkthrough 'The string ' . $string . ' should not be present in ' . $this->currentoutput); } + protected function check_output_does_not_contain_text($str) { + $this->assertStringNotContainsString($str, $this->currentoutput, + 'The string ' . $str . ' should not be present in ' . $this->currentoutput); + } + /** * Verify that some content, containing maths, that is due to be output, is as expected. * diff --git a/tests/helper.php b/tests/helper.php index 568bebef2a8a041f41dd914bba06d8c723d13c0b..e23d36d8e9b63ee409e48decc2f1ba13e4807496 100644 --- a/tests/helper.php +++ b/tests/helper.php @@ -66,8 +66,9 @@ class qtype_stack_test_helper extends question_test_helper { 'sregexp', // Uses the SRegExp answer test, and string input. 'feedbackstyle', // Test the various feedbackstyle options. 'multilang', // Check for mismatching languages. + 'lang_blocks', // Check for mismatching languages using STACK's [[lang...]] block mechanism. 'block_locals', // Make sure local variables within a block are still permitted student input. - 'validator' // Test teacher-defined input validators. + 'validator' // Test teacher-defined input validators and language. ); } @@ -3391,6 +3392,72 @@ class qtype_stack_test_helper extends question_test_helper { return $q; } + /** + * @return qtype_stack_question a question which tests language blocks. + */ + public static function make_stack_question_lang_blocks() { + $q = self::make_a_stack_question(); + + $q->stackversion = '2020112300'; + $q->name = 'langblocks'; + $q->questionvariables = "pt:5;ta2:(x-pt)^2"; + + $q->questiontext = '[[lang code="en,other"]] Give an example of a function \(f(x)\) with a stationary point ' . + 'at \(x={@pt@}\).[[/lang]][[lang code="da"]] Giv et eksempel på en funktion \(f(x)\) med et stationært ' . + 'punkt ved \(x={@pt@}\). [[/lang]] [[input:ans1]][[validation:ans1]][[feedback:prt1]]'; + + $q->specificfeedback = ''; + $q->penalty = 0.35; // Non-zero and not the default. + + $q->inputs['ans1'] = stack_input_factory::make( + 'algebraic', 'ans1', 'ta2', new stack_options(), + array('boxWidth' => 5, 'allowWords' => '')); + + $q->options->set_option('simplify', true); + + $prt = new stdClass; + $prt->name = 'prt1'; + $prt->id = 0; + $prt->value = 1; + $prt->feedbackstyle = 1; + $prt->feedbackvariables = ''; + $prt->firstnodename = '0'; + $prt->nodes = []; + $prt->autosimplify = true; + + $newnode = new stdClass; + $newnode->id = '0'; + $newnode->nodename = '0'; + $newnode->description = ''; + $newnode->sans = 'subst(x=pt,diff(ans1,x))'; + $newnode->tans = '0'; + $newnode->answertest = 'AlgEquiv'; + $newnode->testoptions = ''; + $newnode->quiet = false; + $newnode->falsescore = '0'; + $newnode->falsescoremode = '='; + $newnode->falsepenalty = $q->penalty; + $newnode->falsefeedback = '[[lang code="en,other"]]At a stationary point, \\(f\'(x)\\) ' . + 'should be zero. However, in your answer, \\(f\'({@pt@})={@subst(x=pt,diff(ans1,x))@}\\).[[/lang]]' . + '[[lang code="da"]]Ved et stationært punkt skal \\(f\'(x)\\) være nul. Men i dit svar er ' . + '\\(f\'({@pt@})={@subst(x=pt,diff(ans2,x))@}\\).[[/lang]]'; + $newnode->falsefeedbackformat = '1'; + $newnode->falseanswernote = 'prt1-1-F'; + $newnode->falsenextnode = '1'; + $newnode->truescore = '1'; + $newnode->truescoremode = '='; + $newnode->truepenalty = $q->penalty; + $newnode->truefeedback = ''; + $newnode->truefeedbackformat = '1'; + $newnode->trueanswernote = 'prt1-1-T'; + $newnode->truenextnode = '-1'; + $prt->nodes[] = $newnode; + + $q->prts[$prt->name] = new stack_potentialresponse_tree_lite($prt, $prt->value, $q); + + return $q; + } + /** * @return qtype_stack_question. */ @@ -3462,57 +3529,62 @@ class qtype_stack_test_helper extends question_test_helper { // We need to check that local variable names within the block are not invalid for student's input. $q->questionvariables = 'ta:phi^2-1;myvalidityidea(ex):=block(if ev(subsetp(setify(listofvars(ex)),' . 'setify(listofvars(ta))), simp) then return(""),castext("[[lang code=\'fi\']]Vastauksesi sisältää ' . - 'vääriä muuttujia.[[/lang]][[lang code=\'en\']]Your answer contains the wrong variables.[[/lang]]"));'; - $q->questiontext = 'Type in the input {@ta@}.' - . '<p>[[input:ans1]]</p><div>[[validation:ans1]]</div>'; - $q->generalfeedback = ''; - $q->questionnote = ''; - - $q->specificfeedback = '[[feedback:firsttree]]'; - $q->penalty = 0.25; // Non-zero and not the default. - - $q->inputs['ans1'] = stack_input_factory::make( - 'algebraic', 'ans1', 'ta', null, - array('boxWidth' => 20, 'forbidWords' => '', 'allowWords' => '', - 'options' => 'validator:myvalidityidea')); - - $prt = new stdClass; - $prt->name = 'firsttree'; - $prt->id = 0; - $prt->value = 1; - $prt->feedbackstyle = 1; - $prt->feedbackvariables = ''; - $prt->firstnodename = '0'; - $prt->nodes = []; - $prt->autosimplify = true; - - $newnode = new stdClass; - $newnode->id = '0'; - $newnode->nodename = '0'; - $newnode->description = ''; - $newnode->sans = 'ans1'; - $newnode->tans = 'ta'; - $newnode->answertest = 'AlgEquiv'; - $newnode->testoptions = ''; - $newnode->quiet = false; - $newnode->falsescore = '0'; - $newnode->falsescoremode = '='; - $newnode->falsepenalty = $q->penalty; - $newnode->falsefeedback = ''; - $newnode->falsefeedbackformat = '1'; - $newnode->falseanswernote = 'firsttree-0-0'; - $newnode->falsenextnode = '-1'; - $newnode->truescore = '1'; - $newnode->truescoremode = '='; - $newnode->truepenalty = $q->penalty; - $newnode->truefeedback = ''; - $newnode->truefeedbackformat = '1'; - $newnode->trueanswernote = 'firsttree-0-1'; - $newnode->truenextnode = '-1'; - $prt->nodes[] = $newnode; - - $q->prts[$prt->name] = new stack_potentialresponse_tree_lite($prt, $prt->value, $q); - - return $q; + 'vääriä muuttujia.[[/lang]][[lang code=\'en,other\']]Your answer contains the wrong variables.[[/lang]]"));'; + // This question is also used to test the lang blocks at the top level. + $q->questiontext = "[[lang code='en,other']] What is {@ta@}? [[/lang]]<br>" . + "[[lang code='de']] Was ist {@ta@}? [[/lang]]<br>" . + "[[lang code='fi']] Mikä on {@ta@}? [[/lang]]<br>" . + "[[input:ans1]] [[validation:ans1]]"; + $q->generalfeedback = ''; + $q->questionnote = ''; + + $q->specificfeedback = '[[feedback:firsttree]]'; + $q->penalty = 0.25; // Non-zero and not the default. + + $q->inputs['ans1'] = stack_input_factory::make( + 'algebraic', 'ans1', 'ta', null, + array('boxWidth' => 20, 'forbidWords' => '', 'allowWords' => '', + 'options' => 'validator:myvalidityidea')); + + $prt = new stdClass; + $prt->name = 'firsttree'; + $prt->id = 0; + $prt->value = 1; + $prt->feedbackstyle = 1; + $prt->feedbackvariables = ''; + $prt->firstnodename = '0'; + $prt->nodes = []; + $prt->autosimplify = true; + + $newnode = new stdClass; + $newnode->id = '0'; + $newnode->nodename = '0'; + $newnode->description = ''; + $newnode->sans = 'ans1'; + $newnode->tans = 'ta'; + $newnode->answertest = 'AlgEquiv'; + $newnode->testoptions = ''; + $newnode->quiet = false; + $newnode->falsescore = '0'; + $newnode->falsescoremode = '='; + $newnode->falsepenalty = $q->penalty; + $newnode->falsefeedback = "[[lang code='en,other']] wrong [[/lang]]<br> [[lang code='de']] falsch [[/lang]]" . + "<br> [[lang code='fi']] väärä [[/lang]]"; + $newnode->falsefeedbackformat = '1'; + $newnode->falseanswernote = 'firsttree-0-0'; + $newnode->falsenextnode = '-1'; + $newnode->truescore = '1'; + $newnode->truescoremode = '='; + $newnode->truepenalty = $q->penalty; + $newnode->truefeedback = "[[lang code='en,other']] true answer [[/lang]]<br> [[lang code='de']] richtig [[/lang]]" . + "<br> [[lang code='fi']] oikea [[/lang]]"; + $newnode->truefeedbackformat = '1'; + $newnode->trueanswernote = 'firsttree-0-1'; + $newnode->truenextnode = '-1'; + $prt->nodes[] = $newnode; + + $q->prts[$prt->name] = new stack_potentialresponse_tree_lite($prt, $prt->value, $q); + + return $q; } } diff --git a/tests/input_dropdown_test.php b/tests/input_dropdown_test.php index ac16aaefc8679be477ea68941c00f99c76ea8416..321f1bf92d4a27a86db19b54053db7d7d93fe0db 100644 --- a/tests/input_dropdown_test.php +++ b/tests/input_dropdown_test.php @@ -72,7 +72,7 @@ class input_dropdown_test extends qtype_stack_walkthrough_test_base { $el = stack_input_factory::make('dropdown', 'ans1', '[[1+x,true],[2+y,false]]', null, array()); // @codingStandardsIgnoreEnd $expected = '<select id="menustack1__ans1" class="select menustack1__ans1" name="stack1__ans1">' - .'<option value="">(No answer given)</option><option value="1"><code>1+x</code></option>' + .'<option value="">(Clear my choice)</option><option value="1"><code>1+x</code></option>' .'<option selected="selected" value="2"><code>2+y</code></option></select>'; $this->assert_same_select_html($expected, $el->render(new stack_input_state( stack_input::SCORE, array('2'), '', '', '', '', ''), 'stack1__ans1', false, null)); @@ -127,7 +127,7 @@ class input_dropdown_test extends qtype_stack_walkthrough_test_base { $el->adapt_to_model_answer('[[1,true],[2,false,1]]'); // @codingStandardsIgnoreEnd $expected = '<select id="menustack1__ans1" class="select menustack1__ans1" name="stack1__ans1">' - . '<option value="">(No answer given)</option><option value="1"><code>1</code></option>' + . '<option value="">(Clear my choice)</option><option value="1"><code>1</code></option>' . '<option selected="selected" value="2"><code>1</code></option></select>'; $this->assert_same_select_html($expected, $el->render(new stack_input_state( stack_input::SCORE, array('2'), '', '', '', '', ''), 'stack1__ans1', false, null)); @@ -153,7 +153,7 @@ class input_dropdown_test extends qtype_stack_walkthrough_test_base { $el = $this->make_dropdown(); $el->adapt_to_model_answer($this->make_ta()); $expected = '<select id="menustack1__ans1" class="select menustack1__ans1" name="stack1__ans1">' - .'<option value="">(No answer given)</option>' + .'<option value="">(Clear my choice)</option>' .'<option value="1"><code>x+1</code></option><option value="2"><code>x+2</code></option>' .'<option selected="selected" value="3"><code>sin(pi*n)</code></option></select>'; $this->assert_same_select_html($expected, $el->render(new stack_input_state( @@ -173,7 +173,7 @@ class input_dropdown_test extends qtype_stack_walkthrough_test_base { public function test_render_latex() { $el = $this->make_dropdown(array('options' => 'LaTeX')); $expected = '<select id="menustack1__ans1" class="select menustack1__ans1" name="stack1__ans1">' - .'<option value="">(No answer given)</option><option value="1">\(x+1\)</option>' + .'<option value="">(Clear my choice)</option><option value="1">\(x+1\)</option>' .'<option value="2">\(x+2\)</option>' .'<option selected="selected" value="3">\(\sin \left( \pi\cdot n \right)\)</option></select>'; $this->assert_same_select_html($expected, $el->render(new stack_input_state( @@ -183,7 +183,7 @@ class input_dropdown_test extends qtype_stack_walkthrough_test_base { public function test_render_latexdisplay() { $el = $this->make_dropdown(array('options' => 'LaTeXdisplay')); $expected = '<select id="menustack1__ans1" class="select menustack1__ans1" name="stack1__ans1">' - .'<option value="">(No answer given)</option><option value="1">\[x+1\]</option>' + .'<option value="">(Clear my choice)</option><option value="1">\[x+1\]</option>' .'<option value="2">\[x+2\]</option>' .'<option selected="selected" value="3">\[\sin \left( \pi\cdot n \right)\]</option></select>'; $this->assert_same_select_html($expected, $el->render(new stack_input_state( @@ -232,7 +232,7 @@ class input_dropdown_test extends qtype_stack_walkthrough_test_base { $el = stack_input_factory::make('dropdown', 'ans1', '[[1+x,false],[2+x^2,false],[{},true,"None of these"]]', null, array()); $el->adapt_to_model_answer('[[1+x,true],[2+x^2,false],[{},false,"None of these"]]'); $expected = '<select id="menustack1__ans1" class="select menustack1__ans1" name="stack1__ans1">' - . '<option value="">(No answer given)</option><option value="1"><code>1+x</code></option>' + . '<option value="">(Clear my choice)</option><option value="1"><code>1+x</code></option>' . '<option selected="selected" value="2"><code>2+x^2</code></option>' . '<option value="3">None of these</option></select>'; $this->assert_same_select_html($expected, $el->render(new stack_input_state( @@ -288,7 +288,7 @@ class input_dropdown_test extends qtype_stack_walkthrough_test_base { $el->adapt_to_model_answer($ta); $expected = '<select id="menustack1__ans1" class="select menustack1__ans1" name="stack1__ans1">' . - '<option value="">(No answer given)</option><option selected="selected" value="1">n/a</option>' . + '<option value="">(Clear my choice)</option><option selected="selected" value="1">n/a</option>' . '<option value="2">≥</option><option value="3">≤</option><option value="4">=</option>' . '<option value="5">?</option></select>'; $this->assert_same_select_html($expected, $el->render(new stack_input_state( diff --git a/tests/input_radio_test.php b/tests/input_radio_test.php index cafd6ea65ec53e020e7afec7f5d436f679652c8a..463f9c4170abcfea0ab3179083a903fbcf9ae4da 100644 --- a/tests/input_radio_test.php +++ b/tests/input_radio_test.php @@ -72,7 +72,7 @@ class input_radio_test extends qtype_stack_walkthrough_test_base { $el = stack_input_factory::make('radio', 'ans1', '[[1+x,true],[2+y,false]]', null, array()); // @codingStandardsIgnoreEnd $expected = '<div class="answer"><div class="option"><input type="radio" name="stack1__ans1" value="" ' . - 'id="stack1__ans1_" /><label for="stack1__ans1_">(No answer given)</label></div><div class="option">' . + 'id="stack1__ans1_" /><label for="stack1__ans1_">(Clear my choice)</label></div><div class="option">' . '<br /></div><div class="option"><input type="radio" name="stack1__ans1" value="1" ' . 'id="stack1__ans1_1" /><label for="stack1__ans1_1"><span class="filter_mathjaxloader_equation">' . '<span class="nolink">\(1+x\)</span></span></label></div><div class="option">' . @@ -93,7 +93,7 @@ class input_radio_test extends qtype_stack_walkthrough_test_base { // @codingStandardsIgnoreEnd $expected = '<div class="answer">' . '<div class="option"><input type="radio" name="stack1__ans1" value="" id="stack1__ans1_" />' - . '<label for="stack1__ans1_">(No answer given)</label></div>' + . '<label for="stack1__ans1_">(Clear my choice)</label></div>' . '<div class="option"><br /></div><div class="option">' . '<input type="radio" name="stack1__ans1" value="1" id="stack1__ans1_1" />' . '<label for="stack1__ans1_1"><code>1+x</code></label></div>' @@ -154,7 +154,7 @@ class input_radio_test extends qtype_stack_walkthrough_test_base { $el->adapt_to_model_answer('[[1,true],[2,false,1]]'); // @codingStandardsIgnoreStart $expected = '<div class="answer"><div class="option"><input type="radio" name="stack1__ans1" value="" ' . - 'id="stack1__ans1_" /><label for="stack1__ans1_">(No answer given)</label></div><div class="option">' . + 'id="stack1__ans1_" /><label for="stack1__ans1_">(Clear my choice)</label></div><div class="option">' . '<br /></div><div class="option">' . '<input type="radio" name="stack1__ans1" value="1" id="stack1__ans1_1" />' . '<label for="stack1__ans1_1"><span class="filter_mathjaxloader_equation">' . @@ -169,7 +169,7 @@ class input_radio_test extends qtype_stack_walkthrough_test_base { public function test_render_not_answered() { $el = $this->make_radio(); $expected = '<div class="answer"><div class="option"><input type="radio" name="stack1__ans1" value="" ' . - 'id="stack1__ans1_" /><label for="stack1__ans1_">(No answer given)</label></div><div class="option">' . + 'id="stack1__ans1_" /><label for="stack1__ans1_">(Clear my choice)</label></div><div class="option">' . '<br /></div><div class="option">' . '<input type="radio" name="stack1__ans1" value="1" id="stack1__ans1_1" />' . '<label for="stack1__ans1_1"><span class="filter_mathjaxloader_equation">' . @@ -188,7 +188,7 @@ class input_radio_test extends qtype_stack_walkthrough_test_base { public function test_render_default() { $el = $this->make_radio(); $expected = '<div class="answer"><div class="option"><input type="radio" name="stack1__ans1" value="" ' . - 'id="stack1__ans1_" /><label for="stack1__ans1_">(No answer given)</label></div>' . + 'id="stack1__ans1_" /><label for="stack1__ans1_">(Clear my choice)</label></div>' . '<div class="option"><br /></div><div class="option">' . '<input type="radio" name="stack1__ans1" value="1" id="stack1__ans1_1" />' . '<label for="stack1__ans1_1"><span class="filter_mathjaxloader_equation">' . @@ -210,7 +210,7 @@ class input_radio_test extends qtype_stack_walkthrough_test_base { $el = $this->make_radio(array('options' => 'casstring')); $el->adapt_to_model_answer($this->make_ta()); $expected = '<div class="answer">' - . '<div class="option"><input type="radio" name="stack1__ans1" value="" id="stack1__ans1_" /><label for="stack1__ans1_">(No answer given)</label></div>' + . '<div class="option"><input type="radio" name="stack1__ans1" value="" id="stack1__ans1_" /><label for="stack1__ans1_">(Clear my choice)</label></div>' . '<div class="option"><br /></div><div class="option"><input type="radio" name="stack1__ans1" value="1" id="stack1__ans1_1" />' . '<label for="stack1__ans1_1"><code>x+1</code></label></div>' . '<div class="option"><input type="radio" name="stack1__ans1" value="2" id="stack1__ans1_2" /><label for="stack1__ans1_2"><code>x+2</code></label></div>' @@ -226,7 +226,7 @@ class input_radio_test extends qtype_stack_walkthrough_test_base { public function test_render_latex() { $el = $this->make_radio(array('options' => 'LaTeX')); $expected = '<div class="answer"><div class="option"><input type="radio" name="stack1__ans1" value="" ' . - 'id="stack1__ans1_" /><label for="stack1__ans1_">(No answer given)</label></div>' . + 'id="stack1__ans1_" /><label for="stack1__ans1_">(Clear my choice)</label></div>' . '<div class="option"><br /></div><div class="option">' . '<input type="radio" name="stack1__ans1" value="1" id="stack1__ans1_1" />' . '<label for="stack1__ans1_1"><span class="filter_mathjaxloader_equation">' . @@ -247,7 +247,7 @@ class input_radio_test extends qtype_stack_walkthrough_test_base { public function test_render_latexdisplay() { $el = $this->make_radio(array('options' => 'LaTeXdisplay')); $expected = '<div class="answer"><div class="option"><input type="radio" name="stack1__ans1" value="" ' . - 'id="stack1__ans1_" /><label for="stack1__ans1_">(No answer given)</label></div><div class="option">' . + 'id="stack1__ans1_" /><label for="stack1__ans1_">(Clear my choice)</label></div><div class="option">' . '<br /></div><div class="option">' . '<input type="radio" name="stack1__ans1" value="1" id="stack1__ans1_1" />' . '<label for="stack1__ans1_1"><span class="filter_mathjaxloader_equation">' . @@ -268,7 +268,7 @@ class input_radio_test extends qtype_stack_walkthrough_test_base { public function test_render_latexdisplaystyle() { $el = $this->make_radio(array('options' => 'LaTeXdisplaystyle')); $expected = '<div class="answer"><div class="option"><input type="radio" name="stack1__ans1" value="" ' . - 'id="stack1__ans1_" /><label for="stack1__ans1_">(No answer given)</label></div>' . + 'id="stack1__ans1_" /><label for="stack1__ans1_">(Clear my choice)</label></div>' . '<div class="option"><br /></div><div class="option">' . '<input type="radio" name="stack1__ans1" value="1" id="stack1__ans1_1" />' . '<label for="stack1__ans1_1"><span class="filter_mathjaxloader_equation">' . @@ -334,7 +334,7 @@ class input_radio_test extends qtype_stack_walkthrough_test_base { $el->adapt_to_model_answer('[[1+2,true],[2*x,false]]'); // @codingStandardsIgnoreStart $expected = '<div class="answer"><div class="option"><input type="radio" name="stack1__ans1" value="" ' . - 'id="stack1__ans1_" /><label for="stack1__ans1_">(No answer given)</label></div><div class="option">' . + 'id="stack1__ans1_" /><label for="stack1__ans1_">(Clear my choice)</label></div><div class="option">' . '<br /></div><div class="option"><input type="radio" name="stack1__ans1" value="1" id="stack1__ans1_1" />' . '<label for="stack1__ans1_1"><span class="filter_mathjaxloader_equation">' . '<span class="nolink">\(1+2\)</span></span></label></div><div class="option">' . diff --git a/tests/parsons_block_test.php b/tests/parsons_block_test.php index f99d994e2c4967868702a94fa4a621ba1341325c..b6c851270efc3a0d3a3d04900ae9c8966117d2d4 100644 --- a/tests/parsons_block_test.php +++ b/tests/parsons_block_test.php @@ -267,7 +267,7 @@ class parsons_block_test extends qtype_stack_testcase { $session = new stack_cas_session2([$at1]); $this->assertFalse($at1->get_valid()); $this->assertEquals( - stack_string('stackBlock_parsons_unknown_named_version', ['version' => implode(', ', $validversions)]), + stack_string('stackBlock_parsons_unknown_named_version', ['version' => implode(', ', $validversions)]), $at1->get_errors()); } } @@ -329,7 +329,7 @@ class parsons_block_test extends qtype_stack_testcase { $session = new stack_cas_session2([$at1]); $this->assertFalse($at1->get_valid()); $this->assertEquals( - $err . ', ' . stack_string('stackBlock_parsons_param', ['param' => implode(', ', $validparameters)]), + $err . ', ' . stack_string('stackBlock_parsons_param', ['param' => implode(', ', $validparameters)]), $at1->get_errors()); } } diff --git a/tests/prt_test.php b/tests/prt_test.php index 9be606a385079dbfa62af959c85dc104f13c2681..1f5af4efb926049d0440700afed10cf15ecd387d 100644 --- a/tests/prt_test.php +++ b/tests/prt_test.php @@ -147,6 +147,7 @@ class prt_test extends qtype_stack_testcase { $session->add_statement($prtev); $session->instantiate(); + $this->assertEquals(array(), $prtev->get_errors('')); $this->assertEquals(1, $prtev->get_score()); $expected = 'Yeah!'; $this->assertEquals($expected, $prtev->get_feedback()); @@ -284,6 +285,7 @@ class prt_test extends qtype_stack_testcase { $session->add_statement($prtev); $session->instantiate(); + $this->assertEquals(array(), $prtev->get_errors('')); $this->assertEquals(0.7, $prtev->get_score()); $expected = 'Wait for it... Yeah good!'; $this->assertEquals($expected, $prtev->get_feedback()); @@ -292,4 +294,88 @@ class prt_test extends qtype_stack_testcase { 'ATAlgEquiv(1/(1+ans1),1/3);', '/* ------------------- */', 'prt_multiprt(ans1);'); $this->assertEquals($expected, $prtev->get_trace()); } + + public function test_runtime_score_error() { + + $newprt = new stdClass; + $newprt->name = 'testprt'; + $newprt->id = '0'; + $newprt->value = 5; + $newprt->feedbackstyle = 1; + $newprt->feedbackvariables = null; + $newprt->firstnodename = '0'; + $newprt->nodes = []; + $newprt->autosimplify = true; + + $node = $this->create_default_node(); + $node->id = '0'; + $node->sans = 'sans'; + $node->tans = '(x+1)^3/3+c'; + $node->answertest = 'Int'; + $node->testoptions = 'x'; + // Add in an un-evaluated variable name. + $node->truescore = 'score1'; + $node->truefeedback = 'Yeah!'; + $node->trueanswernote = '1-0-1'; + $node->falsefeedback = 'Boo!'; + $node->falseanswernote = '1-0-0'; + $newprt->nodes[] = $node; + + $prt = new stack_potentialresponse_tree_lite($newprt, 5); + + $this->assertFalse($prt->is_formative()); + $this->assertEquals(array('Int' => true), $prt->get_answertests()); + $expected = array('NULL' => 'NULL', '1-0-1' => '1-0-1', '1-0-0' => '1-0-0'); + $this->assertEquals($expected, $prt->get_all_answer_notes()); + + // For $inputs we only need the names of the inputs, not the full inputs. + $inputs = array('sans' => true); + $boundvars = array(); + $defaultpenalty = 0.1; + $security = new stack_cas_security(); + $pathprefix = '/p/' . '0'; + $sig = $prt->compile($inputs, $boundvars, $defaultpenalty, $security, $pathprefix, null); + + // A correct answer should generate a runtime error. + $inputs = array('sans' => '(x+1)^3/3+c'); + + $session = new stack_cas_session2([], new stack_options(), 123); + // Add preamble from PRTs as well. + if ($sig['be'] != '') { + $session->add_statement(new stack_secure_loader($sig['be'], 'preamble PRT: ' . $prt->get_name())); + } + if ($sig['cv'] != '') { + $session->add_statement(new stack_secure_loader($sig['cv'], 'contextvariables PRT: ' . $prt->get_name())); + } + // The prt definition itself. + $session->add_statement(new stack_secure_loader($sig['def'], 'definition PRT: ' . $prt->get_name())); + // Suppress simplification of raw inputs. + $session->add_statement(new stack_secure_loader('simp:false', 'input-simplification')); + $is = '_INPUT_STRING:["stack_map"'; + foreach ($inputs as $key => $value) { + $session->add_statement(new stack_secure_loader($key . ':' . $value, 'input ' . $key)); + $is .= ',[' . stack_utils::php_string_to_maxima_string($key) . ','; + if (strpos($value, 'ev(') === 0) { // Unpack the value if we have simp... + $is .= stack_utils::php_string_to_maxima_string(mb_substr($value, 3, -6)) . ']'; + } else { + $is .= stack_utils::php_string_to_maxima_string($value) . ']'; + } + } + $is .= ']'; + $session->add_statement(new stack_secure_loader($is, 'input-strings')); + $prtev = new prt_evaluatable($sig['sig'], 1, new castext2_static_replacer([]), $prt->get_trace()); + $session->add_statement(new stack_secure_loader('simp:false', 'prt-simplification')); + $session->add_statement($prtev); + $session->instantiate(); + + $this->assertEquals(0, $prtev->get_score()); + $expected = array('The score was not fully evaluated to a numerical value (check variable names).'); + $this->assertEquals($expected, $prtev->get_errors()); + $expected = 'Yeah!'; + $this->assertEquals($expected, $prtev->get_feedback()); + $this->assertEquals(array('ATInt_true.', '1-0-1'), $prtev->get_answernotes()); + $expected = array('ATInt(sans,(x+1)^3/3+c,ev(x,simp));', '/* ------------------- */', + 'prt_testprt(sans);'); + $this->assertEquals($expected, $prtev->get_trace()); + } } diff --git a/tests/walkthrough_adaptive_test.php b/tests/walkthrough_adaptive_test.php index 8e303495cb9f636f811b5153025f4232cf57b9c8..cc659a56934fcef1450ee79d1fc9e02fad1c34c4 100644 --- a/tests/walkthrough_adaptive_test.php +++ b/tests/walkthrough_adaptive_test.php @@ -3984,6 +3984,56 @@ class walkthrough_adaptive_test extends qtype_stack_walkthrough_test_base { $this->assertEquals($expected, $warnings); } + public function test_lang_blocks_en() { + + // TODO: how do we explicitly set the user's preferences, i.e. language? + $q = \test_question_maker::make_question('stack', 'lang_blocks'); + $this->start_attempt_at_question($q, 'adaptive', 1); + // Check the initial state. + $this->check_current_state(question_state::$todo); + $this->assertEquals('adaptivemultipart', + $this->quba->get_question_attempt($this->slot)->get_behaviour_name()); + $this->render(); + $this->check_output_does_not_contain_input_validation(); + $this->check_output_does_not_contain_prt_feedback(); + $this->check_output_does_not_contain_stray_placeholders(); + $this->check_current_output( + new question_pattern_expectation('/Give an example of a function/'), + $this->get_does_not_contain_feedback_expectation(), + $this->get_does_not_contain_num_parts_correct(), + $this->get_no_hint_visible_expectation() + ); + $this->check_output_does_not_contain_text('Giv et eksempel'); + + // Process a validate request. + $this->process_submission(array('ans1' => 'x^3', '-submit' => 1)); + + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_prt_score('prt1', null, null); + $this->render(); + $this->check_output_contains_text_input('ans1', 'x^3'); + $this->check_output_contains_input_validation('ans1'); + $this->check_output_does_not_contain_prt_feedback(); + $this->check_output_does_not_contain_stray_placeholders(); + + // Process a submition of an answer which is only partially correct. + $this->process_submission(array('ans1' => 'x^3', 'ans1_val' => 'x^3', '-submit' => 1)); + $this->check_current_state(question_state::$todo); + $this->check_current_mark(0); + $this->check_prt_score('prt1', 0.0, 0.35); + $this->check_response_summary('Seed: 1; ans1: x^3 [score]; prt1: # = 0 | prt1-1-F'); + $this->check_answer_note('prt1', 'prt1-1-F'); + $this->render(); + $this->check_current_output( + new question_pattern_expectation('/Give an example of a function/'), + new question_pattern_expectation('/However, in your answer/'), + $this->get_no_hint_visible_expectation() + ); + // Danish should not be seen in the output. + $this->check_output_does_not_contain_text('Men i dit svar er'); + } + public function test_block_locals() { $q = \test_question_maker::make_question('stack', 'block_locals'); @@ -4083,6 +4133,89 @@ class walkthrough_adaptive_test extends qtype_stack_walkthrough_test_base { public function test_input_validator() { + $this->resetAfterTest(); + set_config('lang', 'en'); + + $q = test_question_maker::make_question('stack', 'validator'); + $this->start_attempt_at_question($q, 'adaptive', 1); + + // Check the initial state. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_prt_score('firsttree', null, null); + $this->render(); + $this->check_output_contains_text_input('ans1'); + $this->check_output_does_not_contain_input_validation(); + $this->check_output_does_not_contain_prt_feedback(); + $this->check_output_does_not_contain_stray_placeholders(); + $this->check_current_output( + new question_pattern_expectation('/What is/'), + new question_no_pattern_expectation('/Was ist/'), + new question_no_pattern_expectation('/Mikä on/'), + $this->get_does_not_contain_feedback_expectation(), + $this->get_does_not_contain_num_parts_correct(), + $this->get_no_hint_visible_expectation() + ); + + // Process an invalidate request. + $ia = 'x^2-1'; + $this->process_submission(array('ans1' => $ia, '-submit' => 1)); + + $this->check_current_mark(null); + $this->check_prt_score('firsttree', null, null); + $this->render(); + + $expected = 'Seed: 1; ans1: x^2-1 [invalid]; firsttree: !'; + $this->check_response_summary($expected); + $this->check_output_contains_text_input('ans1', $ia); + $this->check_output_contains_input_validation('ans1'); + $this->check_output_does_not_contain_prt_feedback(); + $this->check_output_does_not_contain_stray_placeholders(); + $this->check_current_output( + new question_pattern_expectation('/Your answer contains the wrong variables/'), + new question_no_pattern_expectation('/Vastauksesi sisältää/') + ); + + // Process a validate request. + $ia = 'phi^2-1'; + $this->process_submission(array('ans1' => $ia, '-submit' => 1)); + + $this->check_current_mark(null); + $this->check_prt_score('firsttree', null, null); + $this->render(); + + $expected = 'Seed: 1; ans1: phi^2-1 [valid]; firsttree: !'; + $this->check_response_summary($expected); + $this->check_output_contains_text_input('ans1', $ia); + $this->check_output_contains_input_validation('ans1'); + $this->check_output_does_not_contain_prt_feedback(); + $this->check_output_does_not_contain_stray_placeholders(); + + // Process a score request. + $ia = 'phi^2-1'; + $this->process_submission(array('ans1' => $ia, 'ans1_val' => $ia, '-submit' => 1)); + + $this->check_current_mark(1); + $this->check_prt_score('firsttree', 1, 0); + $this->render(); + + $expected = 'Seed: 1; ans1: phi^2-1 [score]; firsttree: # = 1 | firsttree-0-1'; + $this->check_response_summary($expected); + $this->check_output_contains_text_input('ans1', $ia); + $this->check_output_contains_input_validation('ans1'); + $this->check_output_does_not_contain_stray_placeholders(); + $this->check_current_output( + new question_pattern_expectation('/true answer/'), + new question_no_pattern_expectation('/richtig/'), + new question_no_pattern_expectation('/oikea/') + ); + } + + public function test_input_validator_jp() { + // This language is not in the question, so should default back to English. + $this->resetAfterTest(); + set_config('lang', 'jp'); + $q = test_question_maker::make_question('stack', 'validator'); $this->start_attempt_at_question($q, 'adaptive', 1); @@ -4096,7 +4229,9 @@ class walkthrough_adaptive_test extends qtype_stack_walkthrough_test_base { $this->check_output_does_not_contain_prt_feedback(); $this->check_output_does_not_contain_stray_placeholders(); $this->check_current_output( - new question_pattern_expectation('/Type in the/'), + new question_pattern_expectation('/What is/'), + new question_no_pattern_expectation('/Was ist/'), + new question_no_pattern_expectation('/Mikä on/'), $this->get_does_not_contain_feedback_expectation(), $this->get_does_not_contain_num_parts_correct(), $this->get_no_hint_visible_expectation() @@ -4117,7 +4252,92 @@ class walkthrough_adaptive_test extends qtype_stack_walkthrough_test_base { $this->check_output_does_not_contain_prt_feedback(); $this->check_output_does_not_contain_stray_placeholders(); $this->check_current_output( - new question_pattern_expectation('/Your answer contains the wrong variables/') + new question_pattern_expectation('/Your answer contains the wrong variables/'), + new question_no_pattern_expectation('/Vastauksesi sisältää/') + ); + + // Process a validate request. + $ia = 'phi^2-1'; + $this->process_submission(array('ans1' => $ia, '-submit' => 1)); + + $this->check_current_mark(null); + $this->check_prt_score('firsttree', null, null); + $this->render(); + + $expected = 'Seed: 1; ans1: phi^2-1 [valid]; firsttree: !'; + $this->check_response_summary($expected); + $this->check_output_contains_text_input('ans1', $ia); + $this->check_output_contains_input_validation('ans1'); + $this->check_output_does_not_contain_prt_feedback(); + $this->check_output_does_not_contain_stray_placeholders(); + + // Process a score request. + $ia = 'phi^2-1'; + $this->process_submission(array('ans1' => $ia, 'ans1_val' => $ia, '-submit' => 1)); + + $this->check_current_mark(1); + $this->check_prt_score('firsttree', 1, 0); + $this->render(); + + $expected = 'Seed: 1; ans1: phi^2-1 [score]; firsttree: # = 1 | firsttree-0-1'; + $this->check_response_summary($expected); + $this->check_output_contains_text_input('ans1', $ia); + $this->check_output_contains_input_validation('ans1'); + $this->check_output_does_not_contain_stray_placeholders(); + $this->check_current_output( + new question_pattern_expectation('/true answer/'), + new question_no_pattern_expectation('/richtig/'), + new question_no_pattern_expectation('/oikea/') + ); + } + + public function test_input_validator_fi() { + // This language is in the question. + $this->resetAfterTest(); + set_config('lang', 'fi'); + + $q = test_question_maker::make_question('stack', 'validator'); + $this->start_attempt_at_question($q, 'adaptive', 1); + + // Check the initial state. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $this->check_prt_score('firsttree', null, null); + $this->render(); + $this->check_output_contains_text_input('ans1'); + $this->check_output_does_not_contain_input_validation(); + $this->check_output_does_not_contain_prt_feedback(); + $this->check_output_does_not_contain_stray_placeholders(); + $this->check_current_output( + new question_no_pattern_expectation('/What is/'), + new question_no_pattern_expectation('/Was ist/'), + // The full string expectation is Mikä on. + // However, SBCL on github actions does not support unicode, so the accents do not show. + new question_pattern_expectation('/Mik/'), + $this->get_does_not_contain_feedback_expectation(), + $this->get_does_not_contain_num_parts_correct(), + $this->get_no_hint_visible_expectation() + ); + + // Process an invalidate request. + $ia = 'x^2-1'; + $this->process_submission(array('ans1' => $ia, '-submit' => 1)); + + $this->check_current_mark(null); + $this->check_prt_score('firsttree', null, null); + $this->render(); + + $expected = 'Seed: 1; ans1: x^2-1 [invalid]; firsttree: !'; + $this->check_response_summary($expected); + $this->check_output_contains_text_input('ans1', $ia); + $this->check_output_contains_input_validation('ans1'); + $this->check_output_does_not_contain_prt_feedback(); + $this->check_output_does_not_contain_stray_placeholders(); + $this->check_current_output( + new question_no_pattern_expectation('/Your answer contains the wrong variables/'), + // The full string expectation is Vastauksesi sisältää. + // However, SBCL on github actions does not support unicode, so the accents do not show. + new question_pattern_expectation('/Vastauksesi/') ); // Process a validate request. @@ -4134,5 +4354,24 @@ class walkthrough_adaptive_test extends qtype_stack_walkthrough_test_base { $this->check_output_contains_input_validation('ans1'); $this->check_output_does_not_contain_prt_feedback(); $this->check_output_does_not_contain_stray_placeholders(); + + // Process a score request. + $ia = 'phi^2-1'; + $this->process_submission(array('ans1' => $ia, 'ans1_val' => $ia, '-submit' => 1)); + + $this->check_current_mark(1); + $this->check_prt_score('firsttree', 1, 0); + $this->render(); + + $expected = 'Seed: 1; ans1: phi^2-1 [score]; firsttree: # = 1 | firsttree-0-1'; + $this->check_response_summary($expected); + $this->check_output_contains_text_input('ans1', $ia); + $this->check_output_contains_input_validation('ans1'); + $this->check_output_does_not_contain_stray_placeholders(); + $this->check_current_output( + new question_no_pattern_expectation('/true answer/'), + new question_no_pattern_expectation('/richtig/'), + new question_pattern_expectation('/oikea/') + ); } } diff --git a/vle_specific.php b/vle_specific.php index 7eb832cb09c4ed59c6fc80be26ce75f48e6f7a4e..24953878dcb83ae44ba4349aff05b73dac526fec 100644 --- a/vle_specific.php +++ b/vle_specific.php @@ -190,7 +190,7 @@ function stack_cors_link(string $filename): string { */ function stack_get_mathjax_url(): string { // TODO: figure out how to support VLE local with CORS. - return 'https://cdn.mathjax.org/mathjax/latest/MathJax.js?config=TeX-AMS-MML_HTMLorMML'; + return 'https://cdn.jsdelivr.net/npm/mathjax@2.7.9/MathJax.js?config=TeX-AMS-MML_HTMLorMML'; } /*