diff --git a/Gemfile b/Gemfile index 42ca2e315..5ac7ef4aa 100644 --- a/Gemfile +++ b/Gemfile @@ -5,7 +5,8 @@ ruby '~> 2.3' gem 'puma' -gem 'mumuki-domain', github: 'mumuki/mumuki-domain' +gem 'mumuki-domain', github: 'mumuki/mumuki-domain', branch: 'feature-cumparsita' +gem 'mulangjs', path: '../mulang/ghcjslib/gem' gem 'execjs' gem 'therubyracer', platforms: :ruby diff --git a/README.md b/README.md index ca9d4146e..c0dce6130 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,8 @@ which are granted to be safe and stable. * `setUpDeleteFiles` * `setUpDeleteFile` * `updateButtonsVisibility` +* `mumuki.registerLocalTestRunner` +* `mumuki.registerLocalExpectationsRunner` * `mumuki.version` ### Bridge Response Format @@ -204,9 +206,7 @@ which are granted to be safe and stable. { "status": "failed", "guide_finished_by_solution": false, - "class_for_progress_list_item":"progress-list-item text-center danger active", "html":"...", - "title_html":"...", "button_html":"...", "expectations_html":"...", "remaining_attempts_html":null, diff --git a/app/assets/javascripts/mumuki_laboratory/application.js b/app/assets/javascripts/mumuki_laboratory/application.js index 98007c637..3f825b32b 100644 --- a/app/assets/javascripts/mumuki_laboratory/application.js +++ b/app/assets/javascripts/mumuki_laboratory/application.js @@ -14,24 +14,15 @@ //= require moment/es.js //= require moment/pt.js //= require webcomponents-lite +//= require mulang //= require rails-ujs -//= require turbolinks //= require mumuki-styles -//= require nprogress -//= require nprogress-turbolinks -//= require nprogress-ajax //= require jquery-console //= require codemirror.min //= require codemirror-simple-mode.js //= require codemirror-autorefresh //= require codemirror-modes -//= require analytics -//= require hotjar //= require muvment //= require_tree ./application - -NProgress.configure({ - showSpinner: false -}); diff --git a/app/assets/javascripts/mumuki_laboratory/application/bridge.js b/app/assets/javascripts/mumuki_laboratory/application/bridge.js index 87fb7bdd5..a7e93ae20 100644 --- a/app/assets/javascripts/mumuki_laboratory/application/bridge.js +++ b/app/assets/javascripts/mumuki_laboratory/application/bridge.js @@ -1,68 +1,69 @@ -var mumuki = mumuki || {}; - -(function (mumuki) { - var lastSubmission = {}; - - function Laboratory(exerciseId){ - this.exerciseId = exerciseId; - } - - function asString(json){ - return JSON.stringify(json); - } - - function sameAsLastSolution(newSolution){ - return asString(lastSubmission.content) === asString(newSolution); - } - - function lastSubmissionFinishedSuccessfully(){ - return lastSubmission.result && lastSubmission.result.status !== 'aborted'; - } - - function sendNewSolution(solution){ - var token = new mumuki.CsrfToken(); - var request = token.newRequest({ - type: 'POST', - url: window.location.origin + window.location.pathname + '/solutions' + window.location.search, - data: solution - }); - - return $.ajax(request).done(function (result) { - lastSubmission = { content: solution, result: result }; - }); - } - - mumuki.load(function () { - lastSubmission = {}; - }); - - Laboratory.prototype = { - +(() => { + class Laboratory { // ========== // Public API // ========== // Runs tests for the current exercise using the given submission // content. - runTests: function(content) { - return this._submitSolution({ solution: content }); - }, + runTests(content) { + return this.runCurrentExerciseSolution({ solution: content }); + } + + // Runs the current exercise solution, trying to get the response from cache first + runCurrentExerciseSolution(solution) { + const cachedSolution = mumuki.SubmissionsStore.getCachedResultFor(mumuki.currentExerciseId, solution); + if(cachedSolution) { + return $.Deferred().resolve(cachedSolution); + } + return this._runNewSolution(mumuki.currentExerciseId, solution); + } + + // Actually sends the solution to server + submitCurrentExerciseSolution(_exerciseId, solution) { // TODO use exerciseId instead of window.location + const token = new mumuki.CsrfToken(); + const request = token.newRequest({ + type: 'POST', + url: window.location.origin + window.location.pathname + '/solutions' + window.location.search, + data: solution + }); + return $.ajax(request); + } // =========== // Private API // =========== - _submitSolution: function (solution) { - if(lastSubmissionFinishedSuccessfully() && sameAsLastSolution(solution)){ - return $.Deferred().resolve(lastSubmission.result); - } else { - return sendNewSolution(solution); + _runNewSolution(exerciseId, solution){ + const responsePromise = mumuki.Connection.runNewSolution(exerciseId, solution, this); + return responsePromise.then((result) => { + this._preRenderResult(exerciseId, result); + const lastSubmission = { content: solution, result: result }; + mumuki.SubmissionsStore.setLastSubmission(exerciseId, lastSubmission); + return result; + }); + } + + // pre-renders some html parts of submission that are normally + // generated by server, but will be empty in offline mode + _preRenderResult(exerciseId, result) { + // TODO defer rendering calculation. + // Caching them may introduce bugs with the next-button + try { + const status = result.status; + const exercise = mumuki.ExercisesStore.get(exerciseId); + + result.button_html = result.button_html || mumuki.renderButtonHtml(status); + result.html = result.html || mumuki.renderCorollaryHtml(status, exercise); + } catch (e) { + console.log(`[Mumuki::Laboratory::Bridge] pre-rendering failed ${e}`); + throw e; } - }, - }; + } + + } mumuki.bridge = { Laboratory: Laboratory }; - -}(mumuki)); +})(); diff --git a/app/assets/javascripts/mumuki_laboratory/application/codemirror.js b/app/assets/javascripts/mumuki_laboratory/application/codemirror.js index 5429369c0..78e89614a 100644 --- a/app/assets/javascripts/mumuki_laboratory/application/codemirror.js +++ b/app/assets/javascripts/mumuki_laboratory/application/codemirror.js @@ -60,10 +60,17 @@ var mumuki = mumuki || {}; }); } + function setContent(content) { + mumuki.page.editors.each(function (_, editor) { + editor.getDoc().setValue(content); + }); + } + mumuki.editor = mumuki.editor || {}; mumuki.editor.toggleFullscreen = toggleFullscreen; mumuki.editor.indentWithSpaces = indentWithSpaces; mumuki.editor.syncContent = syncContent; + mumuki.editor.setContent = setContent; mumuki.page = mumuki.page || {}; mumuki.page.dynamicEditors = []; diff --git a/app/assets/javascripts/mumuki_laboratory/application/confirmation.js b/app/assets/javascripts/mumuki_laboratory/application/confirmation.js index 42eec4525..c6dc31571 100644 --- a/app/assets/javascripts/mumuki_laboratory/application/confirmation.js +++ b/app/assets/javascripts/mumuki_laboratory/application/confirmation.js @@ -7,7 +7,7 @@ mumuki.load(function () { url: $(this).data('confirmation-url'), xhrFields: {withCredentials: true}, success: function(data){ - mumuki.updateProgressBarAndShowModal(data); + mumuki.updateCurrentExerciseProgressBarAndShowModal(data); } })); diff --git a/app/assets/javascripts/mumuki_laboratory/application/connection.js b/app/assets/javascripts/mumuki_laboratory/application/connection.js new file mode 100644 index 000000000..b67dc123b --- /dev/null +++ b/app/assets/javascripts/mumuki_laboratory/application/connection.js @@ -0,0 +1,58 @@ +(() => { + + const OfflineMode = new class { + // Runs solution by evaluating it locally + runNewSolution(exerciseId, solution, _bridge) { + return mumuki.runSolutionLocally(exerciseId, solution); + } + + // Renders progress from SubmissionsStore + renderExercisesProgressBar() { + $('.progress-list-item').each((_, it) => this._updateProgressListItemClass($(it))); + } + + configureExerciseEditorValue() { + const lastSubmission = mumuki.SubmissionsStore.getLastSubmission(mumuki.currentExerciseId); + if (lastSubmission) { + // TODO extract + if (lastSubmission.content.solution) { + $('#mu-custom-editor-value').val(lastSubmission.content.solution.content); + } else { + mumuki.editor.setContent(lastSubmission.content['solution[content]']); + } + } + } + + _updateProgressListItemClass(a) { + const exerciseId = a.data('mu-exercise-id'); + const status = mumuki.SubmissionsStore.getLastSubmissionStatus(exerciseId); + a.attr('class', mumuki.progressListItemClassForStatus(status, exerciseId == mumuki.currentExerciseId)); + } + } + + const OnlineMode = new class { + // Runs solution by sending it to server + runNewSolution(exerciseId, solution, bridge) { + return bridge.submitCurrentExerciseSolution(exerciseId, solution); + } + + // Does nothing. Progress is rendered by server + renderExercisesProgressBar() { + } + + // Does nothing. Editor value is configured by server + configureExerciseEditorValue() { + } + + } + + mumuki.goOnline = function () { + mumuki.Connection = OnlineMode; + }; + + mumuki.goOffline = function () { + mumuki.Connection = OfflineMode; + }; + + mumuki.goOnline(); +})(); diff --git a/app/assets/javascripts/mumuki_laboratory/application/console.js b/app/assets/javascripts/mumuki_laboratory/application/console.js index c8fa97758..31b8d9b2a 100644 --- a/app/assets/javascripts/mumuki_laboratory/application/console.js +++ b/app/assets/javascripts/mumuki_laboratory/application/console.js @@ -116,7 +116,7 @@ var mumuki = mumuki || {}; $('.submission-results').show(); $('.submission-results').html(response.html); mumuki.pin.scroll(); - mumuki.updateProgressBarAndShowModal(response); + mumuki.updateCurrentExerciseProgressBarAndShowModal(response); } else { $('.submission-results').hide(); $('.progress-list-item.active').attr('class', "progress-list-item text-center danger active"); diff --git a/app/assets/javascripts/mumuki_laboratory/application/current-exercise.js b/app/assets/javascripts/mumuki_laboratory/application/current-exercise.js new file mode 100644 index 000000000..f33686f6f --- /dev/null +++ b/app/assets/javascripts/mumuki_laboratory/application/current-exercise.js @@ -0,0 +1,13 @@ +(() => { + mumuki.load(() => { + // Set global currentExerciseId + const $muExerciseId = $('#mu-exercise-id')[0]; + const $muExerciseResource = $('#mu-exercise-resource')[0]; + if ($muExerciseId) { + mumuki.currentExerciseId = Number($muExerciseId.value); + mumuki.ExercisesStore.saveJson(mumuki.currentExerciseId, $muExerciseResource.value); + } else { + mumuki.currentExerciseId = null; + } + }) +})(); diff --git a/app/assets/javascripts/mumuki_laboratory/application/exercisess-store.js b/app/assets/javascripts/mumuki_laboratory/application/exercisess-store.js new file mode 100644 index 000000000..b2f0334a9 --- /dev/null +++ b/app/assets/javascripts/mumuki_laboratory/application/exercisess-store.js @@ -0,0 +1,32 @@ +(() => { + const ExercisesStore = new class { + find(exerciseId) { + const exercise = window.localStorage.getItem(this._keyFor(exerciseId)); + if (!exercise) return null; + return JSON.parse(exercise); + } + + // Saves an exercise object + save(exerciseId, exercise) { + this.saveJson(exerciseId, JSON.stringify(exercise)); + } + + // Saves an exercise json string + saveJson(exerciseId, exerciseJson) { + window.localStorage.setItem(this._keyFor(exerciseId), exerciseJson); + } + + // Finds and existent exercise + // Fails if exercise is missing + get(exerciseId) { + const exercise = this.find(exerciseId); + if (!exercise) throw new Error(`Missing exercise ${exerciseId}`); + return exercise; + } + + _keyFor(exerciseId) { + return `/exercise/${exerciseId}`; + } + }; + mumuki.ExercisesStore = ExercisesStore; +})(); diff --git a/app/assets/javascripts/mumuki_laboratory/application/kids.js b/app/assets/javascripts/mumuki_laboratory/application/kids.js index 9c712bbd6..16bdbe532 100644 --- a/app/assets/javascripts/mumuki_laboratory/application/kids.js +++ b/app/assets/javascripts/mumuki_laboratory/application/kids.js @@ -110,7 +110,7 @@ mumuki.load(function () { // This method needs to be called by the runner's editor.html extension // in order to finish an exercise showResult: function (data) { - mumuki.updateProgressBarAndShowModal(data); + mumuki.updateCurrentExerciseProgressBarAndShowModal(data); if (data.guide_finished_by_solution) return; mumuki.kids.resultAction[data.status](data); }, @@ -172,11 +172,15 @@ mumuki.load(function () { mumuki.kids._getOverlay().hide(); }, + _titleHtml: function (data) { + return mumuki.renderTitleHtml(data.status); + }, + _showMessageOnCharacterBubble: function (data) { var $bubble = mumuki.kids._getCharacterBubble(); $bubble.find('.mu-kids-character-speech-bubble-tabs').hide(); $bubble.find('.mu-kids-character-speech-bubble-normal').hide(); - $bubble.find('.mu-kids-character-speech-bubble-failed').show().html(data.title_html); + $bubble.find('.mu-kids-character-speech-bubble-failed').show().html(mumuki.kids._titleHtml(data)); $bubble.addClass(data.status); if (data.status === 'passed_with_warnings') { $bubble.find('.mu-kids-character-speech-bubble-failed').append(data.expectations_html); @@ -203,7 +207,7 @@ mumuki.load(function () { backdrop: 'static', keyboard: false }); - $resultsKidsModal.find('.modal-header').first().html(data.title_html); + $resultsKidsModal.find('.modal-header').first().html(mumuki.kids._titleHtml(data)); $resultsKidsModal.find('.modal-footer').first().html(data.button_html); mumuki.kids._showCorollaryCharacter(); $('.mu-close-modal').click(() => mumuki.kids._getResultsModal().modal('hide')); @@ -228,7 +232,7 @@ mumuki.load(function () { _stateScaler: function ($state, fullMargin, preferredWidth, preferredHeight) { var $table = $state.find('gs-board > table'); - if (!$table.length) return setTimeout(() => this.scaleState($state, fullMargin)); + if (!$table.length) return setTimeout(() => mumuki.kids.scaleState($state, fullMargin)); console.warn("You are using the default states scaler, which is gobstones-specific. Please register your own scaler in the future"); diff --git a/app/assets/javascripts/mumuki_laboratory/application/load-analytics.js b/app/assets/javascripts/mumuki_laboratory/application/load-analytics.js deleted file mode 100644 index 51a9af449..000000000 --- a/app/assets/javascripts/mumuki_laboratory/application/load-analytics.js +++ /dev/null @@ -1,4 +0,0 @@ -mumuki.load(function(){ - ga('create', 'UA-58353823-1', 'auto'); - ga('send', 'pageview'); -}); diff --git a/app/assets/javascripts/mumuki_laboratory/application/offline-runner.js b/app/assets/javascripts/mumuki_laboratory/application/offline-runner.js new file mode 100644 index 000000000..047dc8e7f --- /dev/null +++ b/app/assets/javascripts/mumuki_laboratory/application/offline-runner.js @@ -0,0 +1,74 @@ +(() => { + const BasicLocalTestRunner = new class { + // This basic runner does not perform any test evaluation, but sets a default + // passed state, with no test results. This could be improved in the future. + runTests(solution, exercise, result) { + result.status = "passed"; + result.test_results = [] + } + } + + const MulangLocalExpectationsRunner = new class { + _getMulangCode(solution, exercise, result) { + return result.mulangAst ? mulang.astCode(result.mulangAst) : mulang.nativeCode(exercise.language, solution); + } + + _expectationsFailed(analysisResult) { + return analysisResult.expectationResults.some((it) => !it.result); + } + + runExpectations(solution, exercise, result) { + try { + const analysisResult = this + ._getMulangCode(solution, exercise, result) + .analyse({ expectations: exercise.expectations }); + if (this._expectationsFailed(analysisResult) && result.status == 'passed') { + result.status = 'passed_with_warnings'; + } + result.expectations_html = ''; + } catch (e) { + console.warn(`[Mumuki::Laboratory::OfflineRunner] Mulang crashed with ${JSON.stringify(e)}`); + } + } + } + + function initialLocalResult() { + return { + // FIXME use a roadmap + guide_finished_by_solution: false, + // Attemps will be not considered when offline + remaining_attempts_html: null + }; + } + + mumuki.runSolutionLocally = function (exerciseId, solution) { + console.log('[Mumuki::Laboratory::OfflineRunner] Running solution...'); + + const exercise = mumuki.ExercisesStore.find(exerciseId); + + let result = initialLocalResult(); + + mumuki.localTestRunner.runTests(solution, exercise, result); + mumuki.localExpectationsRunner.runExpectations(solution, exercise, result); + + console.log(`[Mumuki::Laboratory::OfflineRunner] Done. Status is ${result.status}...`) + return Promise.resolve(result); + }; + + // Runners may call this function to set local test runners + mumuki.registerLocalTestRunner = function (runner) { + mumuki.localTestRunner = runner; + }; + + // Runners may call this function to set local expectation runners + mumuki.registerLocalExpectationsRunner = function (runner) { + mumuki.localExpectationsRunner = runner; + }; + + mumuki.load(() => { + mumuki.registerLocalTestRunner(BasicLocalTestRunner) + mumuki.registerLocalExpectationsRunner(MulangLocalExpectationsRunner) + }); +})(); + + diff --git a/app/assets/javascripts/mumuki_laboratory/application/polyfill.js b/app/assets/javascripts/mumuki_laboratory/application/polyfill.js new file mode 100644 index 000000000..950b0e779 --- /dev/null +++ b/app/assets/javascripts/mumuki_laboratory/application/polyfill.js @@ -0,0 +1,5 @@ +(() => { + // Allow polimorphism between standard promise and jquery promise + Promise.prototype.always = Promise.prototype.finally; + Promise.prototype.fail = Promise.prototype.catch; +})(); diff --git a/app/assets/javascripts/mumuki_laboratory/application/progress.js b/app/assets/javascripts/mumuki_laboratory/application/progress.js index 0a0dce364..bcdc3ff26 100644 --- a/app/assets/javascripts/mumuki_laboratory/application/progress.js +++ b/app/assets/javascripts/mumuki_laboratory/application/progress.js @@ -1,10 +1,19 @@ -var mumuki = mumuki || {}; - -(function (mumuki) { +(() => { + // Updates the current exercise progress indicator + mumuki.updateCurrentExerciseProgressBarAndShowModal = function (submission) { + $('.progress-list-item.active').attr('class', mumuki.progressListItemClassForStatus(submission.status, true)); + if(submission.guide_finished_by_solution) $('#guide-done').modal(); + }; - mumuki.updateProgressBarAndShowModal = function (data) { - $('.progress-list-item.active').attr('class', data.class_for_progress_list_item); - if(data.guide_finished_by_solution) $('#guide-done').modal(); + mumuki.progressListItemClassForStatus = function (status, active = false) { + return `progress-list-item text-center ${mumuki.classForStatus(status)} ${active ? 'active' : ''}`; }; -})(mumuki); + mumuki.load(() => { + // Update all exercises progress indicators + mumuki.Connection.renderExercisesProgressBar(); + + // Set the editor's current value + mumuki.Connection.configureExerciseEditorValue(); + }) +})(); diff --git a/app/assets/javascripts/mumuki_laboratory/application/results-renderer.js b/app/assets/javascripts/mumuki_laboratory/application/results-renderer.js new file mode 100644 index 000000000..09c8cb9ee --- /dev/null +++ b/app/assets/javascripts/mumuki_laboratory/application/results-renderer.js @@ -0,0 +1,88 @@ +(() => { + + // ========================== + // View function for building + // the results UI + // ========================== + + function _messageForStatus(status) { + switch (status) { // FIXME i18n + case "errored": return "¡Ups! Tu solución no se puede ejecutar"; + case "failed": return "Tu solución no pasó las pruebas"; + case "passed_with_warnings": return "Tu solución funcionó, pero hay cosas que mejorar"; + case "passed": return "¡Muy bien! Tu solución pasó todas las pruebas"; + } + } + + function _iconForStatus(status) { + switch (status) { + case "errored": return "fa-minus-circle"; + case "failed": return "fa-times-circle"; + case "passed_with_warnings": return "fa-exclamation-circle"; + case "passed": return "fa-check-circle"; + } + } + + function _closeModalButtonHtml() { + const keepLearning = "¡Seguí aprendiendo!"; // FIXME i18n + return ``; + } + + function _retryButtonHtml() { + const retryMessage = "Reintentar"; // FIXME i18n + return ``; + } + + function _nextExerciseButton() { + return `Siguiente Ejercicio `; // TODO missing exercise title + } + + mumuki.classForStatus = function (status) { + switch (status) { + case "passed": return "success"; + case "failed": return "danger"; + case "passed_with_warnings": return "warning"; + case "errored": return "broken"; + case "pending": return "muted"; + } + }; + + mumuki.renderTitleHtml = function (status) { + return `