/* * This program 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; under version 2 * of the License (non-upgradable). * * This program 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 this program; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * * Copyright (c) 2015 (original work) Open Assessment Technologies SA; * */ define([ 'jquery', 'lodash', 'i18n', 'module', 'taoQtiTest/testRunner/actionBarTools', 'taoQtiTest/testRunner/testReview', 'taoQtiTest/testRunner/progressUpdater', 'taoQtiTest/testRunner/testMetaData', 'serviceApi/ServiceApi', 'serviceApi/UserInfoService', 'serviceApi/StateStorage', 'iframeNotifier', 'mathJax', 'ui/feedback', 'ui/deleter', 'moment', 'ui/modal', 'ui/progressbar' ], function ( $, _, __, module, actionBarTools, testReview, progressUpdater, testMetaDataFactory, ServiceApi, UserInfoService, StateStorage, iframeNotifier, MathJax, feedback, deleter, moment, modal ) { 'use strict'; var timerIds = [], currentTimes = [], lastDates = [], timeDiffs = [], waitingTime = 0, $timers, $controls, timerIndex, testMetaData, sessionStateService, $doc = $(document), optionNextSection = 'x-tao-option-nextSection', optionNextSectionWarning = 'x-tao-option-nextSectionWarning', optionReviewScreen = 'x-tao-option-reviewScreen', optionEndTestWarning = 'x-tao-option-endTestWarning', optionNoExitTimedSectionWarning = 'x-tao-option-noExitTimedSectionWarning', TestRunner = { // Constants 'TEST_STATE_INITIAL': 0, 'TEST_STATE_INTERACTING': 1, 'TEST_STATE_MODAL_FEEDBACK': 2, 'TEST_STATE_SUSPENDED': 3, 'TEST_STATE_CLOSED': 4, 'TEST_NAVIGATION_LINEAR': 0, 'TEST_NAVIGATION_NONLINEAR': 1, 'TEST_ITEM_STATE_INTERACTING': 1, /** * Prepare a transition to another item * @param {Function} [callback] */ beforeTransition: function (callback) { // Ask the top window to start the loader. iframeNotifier.parent('loading'); // Disable buttons. this.disableGui(); $controls.$itemFrame.hide(); $controls.$rubricBlocks.hide(); $controls.$timerWrapper.hide(); // Wait at least waitingTime ms for a better user experience. if (typeof callback === 'function') { setTimeout(callback, waitingTime); } }, /** * Complete a transition to another item */ afterTransition: function () { this.enableGui(); //ask the top window to stop the loader iframeNotifier.parent('unloading'); testMetaData.addData({ 'ITEM' : {'ITEM_START_TIME_CLIENT' : Date.now() / 1000} }); }, /** * Jumps to a particular item in the test * @param {Number} position The position of the item within the test */ jump: function(position) { var self = this, action = 'jump', params = {position: position}; this.disableGui(); if( this.isJumpOutOfSection(position) && this.isCurrentItemActive() && this.isTimedSection() ){ this.exitTimedSection(action, params); } else { this.killItemSession(function() { self.actionCall(action, params); }); } }, /** * Push to server how long user seen that item before to track duration * @param {Number} duration */ keepItemTimed: function(duration){ if (duration) { var self = this, action = 'keepItemTimed', params = {duration: duration}; self.actionCall(action, params); } }, /** * Marks an item for later review * @param {Boolean} flag The state of the flag * @param {Number} position The position of the item within the test */ markForReview: function(flag, position) { var self = this; // Ask the top window to start the loader. iframeNotifier.parent('loading'); // Disable buttons. this.disableGui(); $.ajax({ url: self.testContext.markForReviewUrl, cache: false, async: true, type: 'POST', dataType: 'json', data: { flag: flag, position: position }, success: function(data) { // update the item flagged state if (self.testReview) { self.testReview.setItemFlag(position, flag); self.testReview.updateNumberFlagged(self.testContext, position, flag); if (self.testContext.itemPosition === position) { self.testContext.itemFlagged = flag; } self.updateTools(self.testContext); } // Enable buttons. self.enableGui(); //ask the top window to stop the loader iframeNotifier.parent('unloading'); } }); }, /** * Move to the next available item */ moveForward: function () { var self = this, action = 'moveForward'; function doExitSection() { if( self.isTimedSection() && !self.testContext.isTimeout){ self.exitTimedSection(action); } else { self.exitSection(action); } } this.disableGui(); if( (( this.testContext.numberItemsSection - this.testContext.itemPositionSection - 1) == 0) && this.isCurrentItemActive()){ if (this.shouldDisplayEndTestWarning()) { this.displayEndTestWarning(doExitSection); this.enableGui(); } else { doExitSection(); } } else { this.killItemSession(function () { self.actionCall(action); }); } }, /** * Check if necessary to display an end test warning */ shouldDisplayEndTestWarning: function(){ return (this.testContext.isLast === true && this.hasOption(optionEndTestWarning)); }, /** * Warns upon exiting test */ displayEndTestWarning: function(nextAction){ var options = { confirmLabel: __('OK'), cancelLabel: __('Cancel'), showItemCount: false }; this.displayExitMessage( __('You are about to submit the test. You will not be able to access this test once submitted. Click OK to continue and submit the test.'), nextAction, options ); }, /** * Move to the previous available item */ moveBackward: function () { var self = this, action = 'moveBackward'; this.disableGui(); if( (this.testContext.itemPositionSection == 0) && this.isCurrentItemActive() && this.isTimedSection() ){ this.exitTimedSection(action); } else { this.killItemSession(function () { self.actionCall(action); }); } }, /** * Checks if a position is out of the current section * @param {Number} jumpPosition * @returns {Boolean} */ isJumpOutOfSection: function(jumpPosition){ var items = this.getCurrentSectionItems(), isJumpToOtherSection = true, isValidPosition = (jumpPosition >= 0) && ( jumpPosition < this.testContext.numberItems ); if( isValidPosition){ for(var i in items ) { if (!items.hasOwnProperty(i)) { continue; } if( items[i].position == jumpPosition ){ isJumpToOtherSection = false; break; } } } else { isJumpToOtherSection = false; } return isJumpToOtherSection; }, /** * Exit from the current section. Set the exit code.de * @param {String} action * @param {Object} params * @param {Number} [exitCode] */ exitSection: function(action, params, exitCode){ var self = this; testMetaData.addData({"SECTION" : {"SECTION_EXIT_CODE" : exitCode || testMetaData.SECTION_EXIT_CODE.COMPLETED_NORMALLY}}); self.killItemSession(function () { self.actionCall(action, params); }); }, /** * Tries to exit a timed section. Display a confirm message. * @param {String} action * @param {Object} params */ exitTimedSection: function(action, params){ var self = this, qtiRunner = this.getQtiRunner(), doExitTimedSection = function() { if (qtiRunner) { qtiRunner.updateItemApi(); } self.exitSection(action, params); }; if ((action === 'moveForward' && this.shouldDisplayEndTestWarning()) // prevent duplicate warning || this.hasOption(optionNoExitTimedSectionWarning) // check if warning is disabled || this.testContext.keepTimerUpToTimeout // no need to display the message as we may be able to go back ) { doExitTimedSection(); } else { this.displayExitMessage( __('After you complete the section it would be impossible to return to this section to make changes. Are you sure you want to end the section?'), doExitTimedSection, { scope: 'testSection' } ); } this.enableGui(); }, /** * Tries to leave the current section and go to the next */ nextSection: function(){ var self = this; var qtiRunner = this.getQtiRunner(); var doNextSection = function() { self.exitSection('nextSection', null, testMetaData.SECTION_EXIT_CODE.QUIT); }; if (qtiRunner) { qtiRunner.updateItemApi(); } if (this.hasOption(optionNextSectionWarning)) { this.displayExitMessage( __('After you complete the section it would be impossible to return to this section to make changes. Are you sure you want to end the section?'), doNextSection, { scope: 'testSection' } ); } else { doNextSection(); } this.enableGui(); }, /** * Gets the current progression within a particular scope * @param {String} [scope] * @returns {Object} */ getProgression: function(scope) { var scopeSuffixMap = { test : '', testPart : 'Part', testSection : 'Section' }; var scopeSuffix = scope && scopeSuffixMap[scope] || ''; return { total : this.testContext['numberItems' + scopeSuffix] || 0, answered : this.testContext['numberCompleted' + scopeSuffix] || 0, viewed : this.testContext['numberPresented' + scopeSuffix] || 0, flagged : this.testContext['numberFlagged' + scopeSuffix] || 0 }; }, /** * Displays an exit message for a particular scope * @param {String} message * @param {Function} [action] * @param {Object} [options] * @param {String} [options.scope] * @param {String} [options.confirmLabel] - label of confirm button * @param {String} [options.cancelLabel] - label of cancel button * @param {Boolean} [options.showItemCount] - display the number of unanswered / flagged items in modal * @returns {jQuery} Returns the message box */ displayExitMessage: function(message, action, options) { var self = this, options = options || {}, scope = options.scope, confirmLabel = options.confirmLabel || __('Yes'), cancelLabel = options.cancelLabel || __('No'), showItemCount = typeof options.showItemCount !== 'undefined' ? options.showItemCount : true; var $confirmBox = $('.exit-modal-feedback'); var progression = this.getProgression(scope); var unansweredCount = (progression.total - progression.answered); var flaggedCount = progression.flagged; if (showItemCount) { if (unansweredCount && this.isCurrentItemAnswered()) { unansweredCount--; } if (flaggedCount && unansweredCount) { message = __('You have %s unanswered question(s) and have %s item(s) marked for review.', unansweredCount.toString(), flaggedCount.toString() ) + ' ' + message; } else { if (flaggedCount) { message = __('You have %s item(s) marked for review.', flaggedCount.toString()) + ' ' + message; } if (unansweredCount) { message = __('You have %s unanswered question(s).', unansweredCount.toString()) + ' ' + message; } } } $confirmBox.find('.message').html(message); $confirmBox.find('.js-exit-confirm').html(confirmLabel); $confirmBox.find('.js-exit-cancel').html(cancelLabel); $confirmBox.modal({ width: 500 }); $confirmBox.find('.js-exit-cancel, .modal-close').off('click').on('click', function () { $confirmBox.modal('close'); }); $confirmBox.find('.js-exit-confirm').off('click').on('click', function () { $confirmBox.modal('close'); if (_.isFunction(action)) { action.call(self); } }); return $confirmBox; }, /** * Kill current item section and execute callback function given as first parameter. * Item end execution time will be stored in metadata object to be sent to the server. * @param {function} callback */ killItemSession : function (callback) { testMetaData.addData({ 'ITEM' : { 'ITEM_END_TIME_CLIENT' : Date.now() / 1000, 'ITEM_TIMEZONE' : moment().utcOffset(moment().utcOffset()).format('Z') } }); if (typeof callback !== 'function') { callback = _.noop; } this.itemServiceApi.kill(callback); }, /** * Checks if the current item is active * @returns {Boolean} */ isCurrentItemActive: function(){ return (this.testContext.itemSessionState !=4); }, /** * Tells is the current item has been answered or not * The item is considered answered when at least one response has been set to not empty {base : null} * * @returns {Boolean} */ isCurrentItemAnswered: function(){ var answered = false; _.each(this.getCurrentItemState(), function(state){ if(state && _.isObject(state.response) && state.response.base !== null){ answered = true;//at least one response is not null so consider the item answered return false; } }); return answered; }, /** * Checks if a particular option is enabled for the current item * @param {String} option * @returns {Boolean} */ hasOption: function(option) { return _.indexOf(this.testContext.categories, option) >= 0; }, /** * Gets access to the qtiRunner instance * @returns {Object} */ getQtiRunner: function(){ var itemFrame = document.getElementById('qti-item'); var itemWindow = itemFrame && itemFrame.contentWindow; var itemContainerFrame = itemWindow && itemWindow.document.getElementById('item-container'); var itemContainerWindow = itemContainerFrame && itemContainerFrame.contentWindow; return itemContainerWindow && itemContainerWindow.qtiRunner; }, /** * Checks if the current section is timed * @returns {Boolean} */ isTimedSection: function(){ var timeConstraints = this.testContext.timeConstraints, isTimedSection = false; for( var index in timeConstraints ){ if(timeConstraints.hasOwnProperty(index) && timeConstraints[index].qtiClassName === 'assessmentSection' ){ isTimedSection = true; } } return isTimedSection; }, /** * Gets the list of items owned by the current section * @returns {Array} */ getCurrentSectionItems: function(){ var partId = this.testContext.testPartId, navMap = this.testContext.navigatorMap, sectionItems; for( var partIndex in navMap ){ if( !navMap.hasOwnProperty(partIndex)){ continue; } if( navMap[partIndex].id !== partId ){ continue; } for(var sectionIndex in navMap[partIndex].sections){ if( !navMap[partIndex].sections.hasOwnProperty(sectionIndex)){ continue; } if( navMap[partIndex].sections[sectionIndex].active === true ){ sectionItems = navMap[partIndex].sections[sectionIndex].items; break; } } } return sectionItems; }, /** * Skips the current item */ skip: function () { var self = this, doSkip = function() { self.disableGui(); self.actionCall('skip'); }; if (this.shouldDisplayEndTestWarning()) { this.displayEndTestWarning(doSkip); } else { doSkip(); } }, /** * Handles the timeout state */ timeout: function () { var self = this; this.disableGui(); this.testContext.isTimeout = true; this.updateTimer(); this.killItemSession(function () { var confirmBox = $('.timeout-modal-feedback'), testContext = self.testContext, confirmBtn = confirmBox.find('.js-timeout-confirm, .modal-close'); if (testContext.numberCompletedSection === testContext.numberItemsSection) { testMetaData.addData({"SECTION" : {"SECTION_EXIT_CODE" : testMetaData.SECTION_EXIT_CODE.COMPLETE_TIMEOUT}}); } else { testMetaData.addData({"SECTION" : {"SECTION_EXIT_CODE" : testMetaData.SECTION_EXIT_CODE.TIMEOUT}}); } self.enableGui(); confirmBox.modal({width: 500}); confirmBtn.off('click').on('click', function () { confirmBox.modal('close'); self.actionCall('timeout'); }); }); }, /** * Sets the assessment test context object * @param {Object} testContext */ setTestContext: function(testContext) { this.testContext = testContext; this.itemServiceApi = eval(testContext.itemServiceApiCall); this.itemServiceApi.setHasBeenPaused(testContext.hasBeenPaused); }, /** * Handles Metadata initialization */ initMetadata: function (){ testMetaData = testMetaDataFactory({ testServiceCallId: this.itemServiceApi.serviceCallId }); }, /** * Retrieve service responsible for broken session tracking * @returns {*} */ getSessionStateService: function () { if (!sessionStateService) { sessionStateService = this.testContext.sessionStateService({accuracy: 1000}); } return sessionStateService; }, /** * Updates the GUI * @param {Object} testContext */ update: function (testContext) { var self = this; $controls.$itemFrame.remove(); var $runner = $('#runner'); $runner.css('height', 'auto'); this.getSessionStateService().restart(); this.setTestContext(testContext); this.updateContext(); this.updateProgress(); this.updateNavigation(); this.updateTestReview(); this.updateInformation(); this.updateRubrics(); this.updateTools(testContext); this.updateTimer(); this.updateExitButton(); this.resetCurrentItemState(); this.initMetadata(); $controls.$itemFrame = $(''); $controls.$itemFrame.appendTo($controls.$contentBox); if (this.testContext.itemSessionState === this.TEST_ITEM_STATE_INTERACTING && self.testContext.isTimeout === false) { $doc.off('.testRunner').on('serviceloaded.testRunner', function () { self.afterTransition(); self.adjustFrame(); $controls.$itemFrame.css({visibility: 'visible'}); }); // Inject API into the frame. this.itemServiceApi.loadInto($controls.$itemFrame[0], function () { // We now rely on the 'serviceloaded' event. }); } else { // e.g. no more attempts or timeout! Simply consider the transition is finished, // but do not load the item. self.afterTransition(); } }, /** * Displays feedback on the current state of the test */ updateInformation: function () { if (this.testContext.isTimeout === true) { feedback().error(__('Time limit reached for item "%s".', this.testContext.itemIdentifier)); } else if (this.testContext.itemSessionState !== this.TEST_ITEM_STATE_INTERACTING) { feedback().error(__('No more attempts allowed for item "%s".', this.testContext.itemIdentifier)); } }, /** * Updates the displayed tools * @param {Object} testContext */ updateTools: function updateTools(testContext) { var showSkip = false; var showSkipEnd = false; var showNextSection = !!testContext.nextSection && (this.hasOption(optionNextSection) || this.hasOption(optionNextSectionWarning)); if (this.testContext.allowSkipping === true) { if (this.testContext.isLast === false) { showSkip = true; } else { showSkipEnd = true; } } $controls.$skip.toggle(showSkip); $controls.$skipEnd.toggle(showSkipEnd); $controls.$nextSection.toggle(showNextSection); actionBarTools.render('.tools-box-list', testContext, TestRunner); }, /** * Displays a timer * @param {Object} cst * @returns {*|jQuery|HTMLElement} */ createTimer: function(cst) { var $timer = $('