Files
codeocean/app/assets/javascripts/editor/evaluation.js
kiragrammel 175c8933f3 Automatically submit LTI grade on each score run
With this commit, we refactor the overall score handling of CodeOcean. Previously, "Score" and "Submit" were two distinct actions, requiring users to confirm the LTI transmission of their score (after assessing their submission). This yielded many questions and was unnecessary, since LTI parameters are no longer expiring after each use. Therefore, we can now transmit the current grade on each score run with the very same LTI parameters. As a consequence, the LTI consumer gets a more detailed history of the scores, enabling further analytical insights.

For users, the previous "Submit" button got replaced with a notification that is shown as soon as the full score got reached. Then, learners can decide to "finalize" their work on the given exercise, which will initiate a redirect to a follow-up action (as defined in the RedirectBehavior). This RedirectBehavior has also been unified and simplified for better readability.

As part of this refactoring, we rephrased the notifications and UX workflow of a) the LTI transmission, b) the finalization of an exercise (measured by reaching the full score) and c) the deadline handling (on time, within grace period, too late). Those information are now separately shown, potentially resulting in multiple notifications. As a side effect, they are much better maintainable, and the LTI transmission is more decoupled from this notification handling.
2023-11-23 14:42:10 +01:00

285 lines
9.7 KiB
JavaScript

CodeOceanEditorEvaluation = {
// A list of non-printable characters that are not allowed in the code output.
// Taken from https://stackoverflow.com/a/69024306
nonPrintableRegEx: /[\u0000-\u0008\u000B\u000C\u000F-\u001F\u007F-\u009F\u2000-\u200F\u2028-\u202F\u205F-\u206F\u3000\uFEFF]/g,
sentryTransaction: null,
/**
* Scoring-Functions
*/
scoreCode: function (event) {
const cause = $('#assess');
this.startSentryTransaction(cause);
event.preventDefault();
this.stopCode(event);
this.clearScoringOutput();
$('#submit').addClass("d-none");
this.createSubmission(cause, null, function (response) {
this.showSpinner($('#assess'));
$('#score_div').removeClass('d-none');
var url = response.score_url;
this.initializeSocketForScoring(url);
}.bind(this));
},
handleScoringResponse: function (results) {
this.printScoringResults(results);
var score = _.reduce(results, function (sum, result) {
return sum + result.score * result.weight;
}, 0).toFixed(2);
$('#score').data('score', score);
this.renderScore();
},
printScoringResult: function (result, index) {
if (result === undefined || result === null) {
return;
}
$('#results').show();
let card;
if (result.file_role === 'teacher_defined_linter') {
card = $('#linter-dummies').children().first().clone();
} else {
card = $('#test-dummies').children().first().clone();
}
if (card.isPresent()) {
// the card won't be present if @embed_options[:hide_test_results] == true
this.populateCard(card, result, index);
$('#results ul').first().append(card);
}
},
printScoringResults: function (response) {
response = (Array.isArray(response)) ? response : [response]
const test_results = response.filter(function(x) {
if (x === undefined || x === null) {
return false;
}
switch (x.file_role) {
case 'teacher_defined_test':
return true;
case 'teacher_defined_linter':
return true;
default:
return false;
}
});
$('#results ul').first().html('');
$('.test-count .number').html(test_results.length);
this.clearOutput();
_.each(test_results, function (result, index) {
// based on https://stackoverflow.com/questions/8511281/check-if-a-value-is-an-object-in-javascript
if (result === Object(result)) {
this.printOutput(result, false, index);
this.printScoringResult(result, index);
}
}.bind(this));
if (_.some(response, function (result) {
return result.status === 'timeout';
})) {
this.showTimeoutMessage();
}
if (_.some(response, function (result) {
return result.status === 'out_of_memory';
})) {
this.showOutOfMemoryMessage();
}
if (_.some(response, function (result) {
return result.status === 'runner_in_use';
})) {
this.showRunnerInUseMessage();
}
if (_.some(response, function (result) {
return result.status === 'container_depleted';
})) {
this.showContainerDepletedMessage();
}
},
renderScore: function () {
var score = parseFloat($('#score').data('score'));
var maximum_score = parseFloat($('#score').data('maximum-score'));
if (score >= 0 && score <= maximum_score && maximum_score > 0) {
var percentage_score = (score / maximum_score * 100).toFixed(0);
$('.score').html(percentage_score + '%');
} else {
$('.score').html(0 + '%');
}
this.renderProgressBar(score, maximum_score);
},
/**
* Testing-Logic
*/
handleTestResponse: function (result) {
this.clearOutput();
this.printOutput(result, false, 0);
this.showStatus(result);
this.showOutputBar();
},
/**
* Stop-Logic
*/
stopCode: function (event) {
event.preventDefault();
if (this.isActiveFileStoppable() && this.websocket) {
this.websocket.send(JSON.stringify({'cmd': 'client_kill'}));
this.killWebsocket();
this.cleanUpUI();
}
},
killWebsocket: function () {
if (this.websocket != null && this.websocket.getReadyState() !== WebSocket.OPEN) {
return;
}
this.websocket.killWebSocket();
this.websocket.onError(_.noop);
this.running = false;
},
cleanUpUI: function () {
this.hideSpinner();
this.toggleButtonStates();
this.hidePrompt();
},
/**
* Output-Logic
*/
printWebsocketOutput: function (msg) {
if (!msg.data || msg.data === "\r") {
return;
}
var stream = {};
stream[msg.stream] = msg.data;
this.printOutput(stream, true, 0);
},
clearOutput: function () {
$('#output > .output-element').remove();
CodeOceanEditorTurtle.hideCanvas();
},
clearScoringOutput: function () {
$('#results ul').first().html('');
$('.test-count .number').html(0);
$('#score').data('score', 0);
this.renderScore();
this.clearOutput();
},
printOutput: function (output, colorize, index) {
if (output === undefined || output === null || output.stderr === undefined && output.stdout === undefined) {
// Prevent empty element with no text at all
return;
}
const sanitizedStdout = this.sanitizeOutput(output.stdout);
const sanitizedStderr = this.sanitizeOutput(output.stderr);
const element = this.findOrCreateOutputElement(index);
const pre = $('<span>');
if (sanitizedStdout !== '') {
if (colorize) {
pre.addClass('text-success');
}
pre.append(sanitizedStdout)
}
if (sanitizedStderr !== '') {
if (colorize) {
pre.addClass('text-warning');
} else {
pre.append('StdErr: ');
}
pre.append(sanitizedStderr);
}
if (sanitizedStdout === '' && sanitizedStderr === '') {
if (colorize) {
pre.addClass('text-body-secondary');
}
pre.text($('#output').data('message-no-output'))
}
element.append(pre);
},
sanitizeOutput: function (rawContent) {
let sanitizedContent = _.escape(rawContent).replace(this.nonPrintableRegEx, "");
if (rawContent !== undefined && rawContent.trim().startsWith("<img")) {
const doc = new DOMParser().parseFromString(rawContent, "text/html");
// Get the parsed element, it is automatically wrapped in a <html><body> document
const parsedElement = doc.firstChild.lastChild.firstChild;
if (parsedElement.src.startsWith("data:image")) {
const sanitizedImg = document.createElement('img');
sanitizedImg.src = parsedElement.src;
sanitizedContent = sanitizedImg.outerHTML;
}
}
return sanitizedContent;
},
getDeadlineInformation: function(deadline, translation_key, otherwise) {
if (deadline !== undefined) {
let li = document.createElement("li");
this.submission_deadline = new Date(deadline);
let deadline_text = I18n.l("time.formats.long", this.submission_deadline);
deadline_text += ` (${this.getUTCTime(this.submission_deadline, I18n.locale === 'en')})`;
const bullet_point = I18n.t('exercises.editor.hints.' + translation_key,
{ deadline: deadline_text, otherwise: otherwise })
let text = $.parseHTML(bullet_point);
$(li).append(text);
return li;
}
},
getUTCTime: function(d, use_am_pm) {
let hour = d.getUTCHours();
const pm = hour >= 12;
let hour12 = hour % 12;
if (!hour12) {
hour12 += 12;
}
hour = hour.toLocaleString('en-US', {minimumIntegerDigits: 2, useGrouping: false})
const minute = d.getUTCMinutes().toLocaleString('en-US', {minimumIntegerDigits: 2, useGrouping: false})
const second = d.getUTCSeconds().toLocaleString('en-US', {minimumIntegerDigits: 2, useGrouping: false})
if (use_am_pm) {
return `${hour12}:${minute}:${second} ${pm ? 'pm' : 'am'} UTC`;
} else {
return `${hour}:${minute}:${second} UTC`;
}
},
initializeDeadlines: function () {
const deadline = $('#deadline');
if (deadline) {
const submission_deadline = deadline.data('submission-deadline');
const late_submission_deadline = deadline.data('late-submission-deadline');
const ul = document.createElement("ul");
if (submission_deadline && late_submission_deadline) {
ul.append(this.getDeadlineInformation(submission_deadline, 'submission_deadline', ''));
ul.append(this.getDeadlineInformation(late_submission_deadline, 'late_submission_deadline', ''));
} else {
const otherwise_no_points = I18n.t('exercises.editor.hints.otherwise');
ul.append(this.getDeadlineInformation(submission_deadline, 'submission_deadline', otherwise_no_points));
}
$(ul).insertAfter($(deadline).children()[0]);
}
}
};