Commit ab8b5776 by Peter Baratta

Moved js file and added improvments

- converted underscores to camelcase
- rearranged code in JS file to actually make sense
- Jasmine tests!
parent 261b5c45
...@@ -1096,7 +1096,7 @@ class FormulaEquationInput(InputTypeBase): ...@@ -1096,7 +1096,7 @@ class FormulaEquationInput(InputTypeBase):
reported_status = self.status reported_status = self.status
return { return {
'previewer': '/static/js/capa/formula_equation_preview.js', 'previewer': '/static/js/capa/src/formula_equation_preview.js',
'reported_status': reported_status 'reported_status': reported_status
} }
......
...@@ -800,13 +800,15 @@ class FormulaEquationTest(unittest.TestCase): ...@@ -800,13 +800,15 @@ class FormulaEquationTest(unittest.TestCase):
""" """
context = self.the_input._get_render_context() # pylint: disable=W0212 context = self.the_input._get_render_context() # pylint: disable=W0212
expected = {'id': 'prob_1_2', expected = {
'value': 'x^2+1/2', 'id': 'prob_1_2',
'status': 'unanswered', 'value': 'x^2+1/2',
'msg': '', 'status': 'unanswered',
'size': self.size, 'reported_status': '',
'previewer': '/static/js/capa/formula_equation_preview.js', 'msg': '',
} 'size': self.size,
'previewer': '/static/js/capa/src/formula_equation_preview.js',
}
self.assertEqual(context, expected) self.assertEqual(context, expected)
def test_rendering_reported_status(self): def test_rendering_reported_status(self):
......
(function () {
var min_delay = 300; // milliseconds between AJAX requests
// Dictionary holding information indexed by the IDs of the problems
var preview_data = {};
function update() {
/**
Given a user input, either send a request or enqueue one to be sent.
Don't call `send_request` if it's been less than `min_delay` ms.
Also, indicate that it is loading (using the loading icon).
*/
var data = preview_data[this.id];
var time_since_last = Date.now() - data.last_sent;
var bound_send_request = send_request.bind(this);
if (time_since_last >= min_delay) {
// If it's been long enough, just send the request
bound_send_request();
}
else {
// Otherwise, enqueue.
if (data.timeout_id !== null) {
// Clear any other queued requests.
window.clearTimeout(data.timeout_id);
}
// Wait for the rest of the `min_delay`.
// Store `timeout_id`
var wait_time = min_delay - time_since_last;
data.timeout_id = window.setTimeout(bound_send_request, wait_time);
}
// Show the loading icon.
data.$loading.css('visibility', 'visible');
}
function send_request() {
/**
Fire off a request for a preview of the current value.
Also send along the time it was sent, and store that locally.
*/
var data = preview_data[this.id];
data.timeout_id = null;
var $this = $(this); // cache the jQuery object
// Save the time.
var now = Date.now();
data.last_sent = now;
// Find the closest parent problems-wrapper and use that url.
var url = $this.closest('.problems-wrapper').data('url');
// Grab the input id from the input.
var input_id = $this.data('input-id')
Problem.inputAjax(url, input_id, 'preview_formcalc', {
"formula" : this.value,
"request_start" : now
}, create_handler(data));
// TODO what happens when an AJAX call is lost?
}
function create_handler(data) {
/** Create a closure for `data` */
return (function (response) {
/**
Respond to the preview request
Optionally, stop if it is outdated (a later request arrived
back earlier)
Otherwise:
-Refresh the MathJax
-Stop the loading icon if need be
-Save which request this is
*/
if (response.request_start == data.last_sent &&
data.timeout_id === null) {
data.$loading.css('visibility', 'hidden'); // Disable icon
}
if (response.request_start <= data.request_visible) {
return; // This is an old request.
}
// Save the value of the last response displayed.
data.request_visible = response.request_start;
var jax = MathJax.Hub.getAllJax(data.$preview[0])[0];
var math_code;
if (response.error) {
// TODO: wait for a bit to display error
math_code = "\text{" + response.error + "}";
} else {
math_code = response.preview;
}
// Set the text as the latex code, and then update the MathJax.
MathJax.Hub.Queue(
['Text', jax, math_code],
['Reprocess', jax]
);
});
}
inputs = $('.formulaequationinput input');
// Store information for each input and cache the jQuery objects
inputs.each(function () {
var prev_id = "#" + this.id + "_preview";
preview_data[this.id] = {
$preview: $(prev_id),
$loading: $(prev_id + " img.loading"),
last_sent: 0, // The time of the one that was last sent
request_visible: 0, // The original time of the visible request
timeout_id: null // If there is a timeout, store its ID here
};
});
// update on load
inputs.each(update);
// and on every change
inputs.bind("input", update);
}).call(this);
function callPeriodicallyUntil(block, delay, condition, i) { // i is optional
i = i || 0;
block(i);
waits(delay);
runs(function () {
if (!condition()) {
callPeriodicallyUntil(block, delay, condition, i + 1);
}
});
}
describe("Formula Equation Preview", function () {
beforeEach(function () {
// Simulate an environment conducive to a FormulaEquationInput
var $fixture = this.$fixture = $('\
<section class="problems-wrapper" data-url="THE_URL">\
<section class="formulaequationinput">\
<input type="text" id="input_THE_ID" data-input-id="THE_ID"\
value="prefilled_value"/>\
<div id="input_THE_ID_preview" class="equation">\
\[\]\
<img class="loading" style="visibility:hidden"/>\
</div>\
</section>\
</section>');
// Modify $ for the test to search the fixture.
var old$find = this.old$find = $.find;
$.find = function () {
// Given the default context, swap it out for the fixture.
if (arguments[1] == document) {
arguments[1] = $fixture[0];
}
// Call old function.
return old$find.apply(this, arguments);
}
$.find.matchesSelector = old$find.matchesSelector;
this.oldDGEBI = document.getElementById;
document.getElementById = function (id) {
return $("*#" + id)[0] || null;
};
// Catch the AJAX requests
var ajaxTimes = this.ajaxTimes = [];
this.oldProblem = window.Problem;
window.Problem = {};
Problem.inputAjax = jasmine.createSpy('Problem.inputAjax')
.andCallFake(function () {
ajaxTimes.push(Date.now());
});
// Spy on MathJax
this.jax = 'OUTPUT_JAX';
this.oldMathJax = window.MathJax;
window.MathJax = {Hub: {}};
MathJax.Hub.getAllJax = jasmine.createSpy('MathJax.Hub.getAllJax')
.andReturn([this.jax]);
MathJax.Hub.Queue = jasmine.createSpy('MathJax.Hub.Queue');
});
it('(the test) should be able to swap out the behavior of $', function () {
// This was a pain to write, make sure it doesn't get screwed up.
// Find the DOM element using DOM methods.
var legitInput = this.$fixture[0].getElementsByTagName("input")[0];
// Use the (modified) jQuery.
var jqueryInput = $('.formulaequationinput input');
var byIdInput = $("#input_THE_ID");
expect(jqueryInput[0]).toEqual(legitInput);
expect(byIdInput[0]).toEqual(legitInput);
});
describe('Ajax requests', function () {
beforeEach(function () {
// This is common to all tests on ajax requests.
formulaEquationPreview.enable();
// This part may be asynchronous, so wait.
waitsFor(function () {
return Problem.inputAjax.wasCalled;
}, "AJAX never called initially", 1000);
});
it('has an initial request with the correct parameters', function () {
expect(Problem.inputAjax.callCount).toEqual(1);
// Use `.toEqual` rather than `.toHaveBeenCalledWith`
// since it supports `jasmine.any`.
expect(Problem.inputAjax.mostRecentCall.args).toEqual([
"THE_URL",
"THE_ID",
"preview_formcalc",
{formula: "prefilled_value",
request_start: jasmine.any(Number)},
jasmine.any(Function)
]);
});
it('makes a request on user input', function () {
Problem.inputAjax.reset();
$('#input_THE_ID').val('user_input').trigger('input');
// This part is probably asynchronous
waitsFor(function () {
return Problem.inputAjax.wasCalled;
}, "AJAX never called on user input", 1000);
runs(function () {
expect(Problem.inputAjax.mostRecentCall.args[3].formula
).toEqual('user_input');
});
});
it("shouldn't be requested for empty input", function () {
Problem.inputAjax.reset();
// When we make an input of '',
$('#input_THE_ID').val('').trigger('input');
// Either it makes a request or jumps straight into displaying ''.
waitsFor(function () {
// (Short circuit if `inputAjax` is indeed called)
return Problem.inputAjax.wasCalled ||
MathJax.Hub.Queue.wasCalled;
}, "AJAX never called on user input", 1000);
runs(function () {
// Expect the request not to have been called.
expect(Problem.inputAjax).not.toHaveBeenCalled();
});
});
it('should limit the number of requests per second', function () {
var minDelay = formulaEquationPreview.minDelay;
var end = Date.now() + minDelay * 1.1;
var step = 10; // ms
var $input = $('#input_THE_ID');
var value;
function inputAnother(iter) {
value = "math input " + iter;
$input.val(value).trigger('input');
}
callPeriodicallyUntil(inputAnother, step, function () {
return Date.now() > end; // Stop when we get to `end`.
});
waitsFor(function () {
return Problem.inputAjax.wasCalled &&
Problem.inputAjax.mostRecentCall.args[3].formula == value;
}, "AJAX never called with final value from input", 1000);
runs(function () {
// There should be 2 or 3 calls (depending on leading edge).
expect(Problem.inputAjax.callCount).not.toBeGreaterThan(3);
// The calls should happen approximately `minDelay` apart.
for (var i =1; i < this.ajaxTimes.length; i ++) {
var diff = this.ajaxTimes[i] - this.ajaxTimes[i - 1];
expect(diff).toBeGreaterThan(minDelay - 10);
}
});
});
});
describe("Visible results (icon and mathjax)", function () {
it('should display a loading icon when requests are open', function () {
var $img = $("img.loading");
expect($img.css('visibility')).toEqual('hidden');
formulaEquationPreview.enable();
expect($img.css('visibility')).toEqual('visible');
// This part could be asynchronous
waitsFor(function () {
return Problem.inputAjax.wasCalled;
}, "AJAX never called initially", 1000);
runs(function () {
expect($img.css('visibility')).toEqual('visible');
// Reset and send another request.
$img.css('visibility', 'hidden');
$("#input_THE_ID").val("different").trigger('input');
expect($img.css('visibility')).toEqual('visible');
});
// Don't let it fail later.
waitsFor(function () {
var args = Problem.inputAjax.mostRecentCall.args;
return args[3].formula == "different";
});
});
it('should update MathJax and loading icon on callback', function () {
formulaEquationPreview.enable();
waitsFor(function () {
return Problem.inputAjax.wasCalled;
}, "AJAX never called initially", 1000);
runs(function () {
var args = Problem.inputAjax.mostRecentCall.args;
var callback = args[4];
callback({
preview: 'THE_FORMULA',
request_start: args[3].request_start
});
// The only request returned--it should hide the loading icon.
expect($("img.loading").css('visibility')).toEqual('hidden');
// We should look in the preview div for the MathJax.
var previewDiv = $("div")[0];
expect(MathJax.Hub.getAllJax).toHaveBeenCalledWith(previewDiv);
// Refresh the MathJax.
expect(MathJax.Hub.Queue).toHaveBeenCalledWith(
['Text', this.jax, 'THE_FORMULA'],
['Reprocess', this.jax]
);
});
});
it('should display errors from the server well', function () {
formulaEquationPreview.enable();
waitsFor(function () {
return Problem.inputAjax.wasCalled;
}, "AJAX never called initially", 1000);
runs(function () {
var args = Problem.inputAjax.mostRecentCall.args;
var callback = args[4];
callback({
error: 'OOPSIE',
request_start: args[3].request_start
});
expect(MathJax.Hub.Queue).not.toHaveBeenCalled();
});
var errorDelay = formulaEquationPreview.errorDelay * 1.1;
waitsFor(function () {
return MathJax.Hub.Queue.wasCalled;
}, "Error message never displayed", 2000);
runs(function () {
// Refresh the MathJax.
expect(MathJax.Hub.Queue).toHaveBeenCalledWith(
['Text', this.jax, '\\text{OOPSIE}'],
['Reprocess', this.jax]
);
});
});
});
describe('Multiple callbacks', function () {
beforeEach(function () {
formulaEquationPreview.enable();
waitsFor(function () {
return Problem.inputAjax.wasCalled;
});
runs(function () {
$('#input_THE_ID').val('different').trigger('input');
});
waitsFor(function () {
return Problem.inputAjax.callCount > 1;
});
runs(function () {
var args = Problem.inputAjax.argsForCall;
var response0 = {
preview: 'THE_FORMULA_0',
request_start: args[0][3].request_start
};
var response1 = {
preview: 'THE_FORMULA_1',
request_start: args[1][3].request_start
};
this.callbacks = [args[0][4], args[1][4]];
this.responses = [response0, response1];
});
});
it('should update requests sequentially', function () {
var $img = $("img.loading");
expect($img.css('visibility')).toEqual('visible');
this.callbacks[0](this.responses[0]);
expect(MathJax.Hub.Queue).toHaveBeenCalledWith(
['Text', this.jax, 'THE_FORMULA_0'],
['Reprocess', this.jax]
);
expect($img.css('visibility')).toEqual('visible');
this.callbacks[1](this.responses[1]);
expect(MathJax.Hub.Queue).toHaveBeenCalledWith(
['Text', this.jax, 'THE_FORMULA_1'],
['Reprocess', this.jax]
);
expect($img.css('visibility')).toEqual('hidden')
});
it("shouldn't display outdated information", function () {
var $img = $("img.loading");
expect($img.css('visibility')).toEqual('visible');
// Switch the order (1 returns before 0)
this.callbacks[1](this.responses[1]);
expect(MathJax.Hub.Queue).toHaveBeenCalledWith(
['Text', this.jax, 'THE_FORMULA_1'],
['Reprocess', this.jax]
);
expect($img.css('visibility')).toEqual('hidden')
MathJax.Hub.Queue.reset();
this.callbacks[0](this.responses[0]);
expect(MathJax.Hub.Queue).not.toHaveBeenCalled();
expect($img.css('visibility')).toEqual('hidden')
});
it("shouldn't show an error if the responses are close together",
function () {
this.callbacks[0]({
error: 'OOPSIE',
request_start: this.responses[0].request_start
});
expect(MathJax.Hub.Queue).not.toHaveBeenCalled();
// Error message waiting to be displayed
this.callbacks[1](this.responses[1]);
expect(MathJax.Hub.Queue).toHaveBeenCalledWith(
['Text', this.jax, 'THE_FORMULA_1'],
['Reprocess', this.jax]
);
// Make sure that it doesn't indeed show up later
MathJax.Hub.Queue.reset();
var errorDelay = formulaEquationPreview.errorDelay * 1.1;
waits(errorDelay);
runs(function () {
expect(MathJax.Hub.Queue).not.toHaveBeenCalled();
})
});
});
afterEach(function () {
// Return jQuery
$.find = this.old$find;
document.getElementById = this.oldDGEBI;
// Return Problem
Problem = this.oldProblem;
if (Problem === undefined) {
delete Problem;
}
// Return MathJax
MathJax = this.oldMathJax;
if (MathJax === undefined) {
delete MathJax;
}
});
});
var formulaEquationPreview = {
minDelay: 300, // Minimum time between requests sent out.
errorDelay: 1500 // Wait time before showing error (prevent frustration).
};
/** Setup the FormulaEquationInputs and associated javascript code. */
formulaEquationPreview.enable = function () {
/**
* Accumulate all the variables and attach event handlers.
* This includes rate-limiting `sendRequest` and creating a closure for
* its callback.
*/
function setupInput() {
var inputData = {
// These are the mutable values
lastSent: 0,
isWaitingForRequest: false,
requestVisible: 0,
errorDelayTimeout: null
};
// Other elements of `inputData` serve to hold pointers to variables.
var $this = $(this); // cache the jQuery object
// Find the closest parent problems-wrapper and use that url for Ajax.
inputData.url = $this.closest('.problems-wrapper').data('url');
// Grab the input id from the input.
inputData.inputId = $this.data('input-id')
// Store the DOM/MathJax elements in which visible output occurs.
inputData.$preview = $("#" + this.id + "_preview");
// Note: sometimes MathJax hasn't finished loading yet.
inputData.jax = MathJax.Hub.getAllJax(inputData.$preview[0])[0];
inputData.$img = inputData.$preview.find("img.loading");
// Give the callback access to `inputData` (fill in first parameter).
inputData.requestCallback = _.partial(updatePage, inputData);
// Limit `sendRequest` and have it show the loading icon.
var throttledRequest = _.throttle(
sendRequest,
formulaEquationPreview.minDelay,
{leading: false}
);
// The following acts as a closure of `inputData`.
var initializeRequest = function () {
// Show the loading icon.
inputData.$img.css('visibility', 'visible');
inputData.isWaitingForRequest = true;
throttledRequest(inputData, this.value);
};
$this.bind("input", initializeRequest);
// send an initial
initializeRequest.call(this);
}
/**
* Fire off a request for a preview of the current value.
* Also send along the time it was sent, and store that locally.
*/
function sendRequest(inputData, formula) {
// Save the time.
var now = Date.now();
inputData.lastSent = now;
// We're sending it.
inputData.isWaitingForRequest = false;
if (formula) {
// Send the request.
Problem.inputAjax(
inputData.url,
inputData.inputId,
'preview_formcalc',
{"formula" : formula, "request_start" : now},
inputData.requestCallback
);
// ).fail(function () {
// // This is run when ajax call fails.
// // Have an error message and other stuff here?
// inputData.$img.css('visibility', 'hidden');
// }); */
}
else {
inputData.requestCallback({
preview: '',
request_start: now
});
}
}
/**
* Respond to the preview request if need be.
* Stop if it is outdated (i.e. a later request arrived back earlier)
* Otherwise:
* -Refresh the MathJax
* -Stop the loading icon if this is the most recent request
* -Save which request is visible
*/
function updatePage(inputData, response) {
var requestStart = response['request_start'];
if (requestStart == inputData.lastSent &&
!inputData.isWaitingForRequest) {
// Disable icon.
inputData.$img.css('visibility', 'hidden');
}
if (requestStart <= inputData.requestVisible) {
// This is an old request.
return;
}
// Save the value of the last response displayed.
inputData.requestVisible = requestStart;
// Prevent an old error message from showing.
if (inputData.errorWaitTimeout != null) {
window.clearTimeout(inputData.errorWaitTimeout);
}
function display(latex) {
// Load jax if it failed before.
if (!inputData.jax) {
inputData.jax = MathJax.Hub.getAllJax(inputData.$preview[0])[0];
}
// Set the text as the latex code, and then update the MathJax.
MathJax.Hub.Queue(
['Text', inputData.jax, latex],
['Reprocess', inputData.jax]
);
}
if (response.error) {
inputData.errorWaitTimeout = window.setTimeout(function () {
display("\\text{" + response.error + "}");
}, formulaEquationPreview.errorDelay);
} else {
display(response.preview);
}
}
// Invoke the setup method.
$('.formulaequationinput input').each(setupInput);
};
formulaEquationPreview.enable();
...@@ -121,6 +121,7 @@ end ...@@ -121,6 +121,7 @@ end
static_js_dirs = Dir["common/lib/*"].select{|lib| File.directory?(lib)} static_js_dirs = Dir["common/lib/*"].select{|lib| File.directory?(lib)}
static_js_dirs << 'common/static/coffee' static_js_dirs << 'common/static/coffee'
static_js_dirs << 'common/static/js'
static_js_dirs.select!{|lib| !Dir["#{lib}/**/spec"].empty?} static_js_dirs.select!{|lib| !Dir["#{lib}/**/spec"].empty?}
static_js_dirs.each do |dir| static_js_dirs.each do |dir|
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment