tao-test/app/taoQtiTest/views/js/controller/runtime/testRunner.js

1391 lines
54 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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 = $('<iframe id="qti-item" frameborder="0" scrollbars="no"/>');
$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 = $('<div>', {'class': 'qti-timer qti-timer__type-' + cst.qtiClassName }),
$label = $('<div>', {'class': 'qti-timer_label truncate', text: cst.label }),
$time = $('<div>', {'class': 'qti-timer_time', text: this.formatTime(cst.seconds) });
$timer.append($label);
$timer.append($time);
return $timer;
},
/**
* Updates the timers
*/
updateTimer: function () {
var self = this;
var hasTimers;
$controls.$timerWrapper.empty();
for (var i = 0; i < timerIds.length; i++) {
clearTimeout(timerIds[i]);
}
timerIds = [];
currentTimes = [];
lastDates = [];
timeDiffs = [];
if (self.testContext.isTimeout === false &&
self.testContext.itemSessionState === self.TEST_ITEM_STATE_INTERACTING) {
hasTimers = !!this.testContext.timeConstraints.length;
$controls.$topActionBar.toggleClass('has-timers', hasTimers);
if (hasTimers) {
// Insert QTI Timers container.
// self.formatTime(cst.seconds)
for (i = 0; i < this.testContext.timeConstraints.length; i++) {
var cst = this.testContext.timeConstraints[i];
if (cst.allowLateSubmission === false) {
// Set up a timer for this constraint
$controls.$timerWrapper.append(self.createTimer(cst));
// Set up a timer and update it with setInterval.
currentTimes[i] = cst.seconds;
lastDates[i] = new Date();
timeDiffs[i] = 0;
timerIndex = i;
if (self.testContext.timerWarning && self.testContext.timerWarning[cst.qtiClassName]) {
cst.warnings = {};
_(self.testContext.timerWarning[cst.qtiClassName]).forEach(function (value, key) {
if (_.contains(['info', 'warning', 'danger'], value)) {
cst.warnings[key] = {
type: value,
showed: cst.seconds <= key,
point: parseInt(key, 10)
};
}
});
var closestPreviousWarning = _.find(cst.warnings, { showed: true });
if (!_.isEmpty(closestPreviousWarning) && closestPreviousWarning.point) {
cst.warnings[closestPreviousWarning.point].showed = false;
}
}
(function (timerIndex, cst) {
timerIds[timerIndex] = setInterval(function () {
timeDiffs[timerIndex] += (new Date()).getTime() - lastDates[timerIndex].getTime();
if (timeDiffs[timerIndex] >= 1000) {
var seconds = timeDiffs[timerIndex] / 1000;
currentTimes[timerIndex] -= seconds;
timeDiffs[timerIndex] = 0;
}
$timers.eq(timerIndex)
.html(self.formatTime(Math.round(currentTimes[timerIndex])));
if (currentTimes[timerIndex] <= 0) {
// The timer expired...
currentTimes[timerIndex] = 0;
clearInterval(timerIds[timerIndex]);
// Hide item to prevent any further interaction with the candidate.
$controls.$itemFrame.hide();
self.timeout();
} else {
lastDates[timerIndex] = new Date();
}
var warning = _.findLast(cst.warnings, { showed: false });
if (!_.isEmpty(warning) && _.isFinite(warning.point) && currentTimes[timerIndex] <= warning.point) {
self.timeWarning(cst, warning);
}
}, 1000);
}(timerIndex, cst));
}
}
$timers = $controls.$timerWrapper.find('.qti-timer .qti-timer_time');
$controls.$timerWrapper.show();
}
}
},
/**
* Mark appropriate timer by warning colors and show feedback message
*
* @param {object} cst - Time constraint
* @param {integer} cst.warningTime - Warning time in seconds.
* @param {integer} cst.qtiClassName - Class name of qti instance for which the timer is set (assessmentItemRef | assessmentSection | testPart | assessmentTest).
* @param {integer} cst.seconds - Initial timer value.
* @param {object} warning - Current actual warning
* @param {integer} warning.point - Warning time point in seconds, when show message
* @param {boolean} warning.showed - boolean flag for mark already showed warnings
* @param {string} warning.type - type of warning (from config), can be info, warning or error
*
* @returns {undefined}
*/
timeWarning: function (cst, warning) {
var message = '',
remaining,
$timer = $controls.$timerWrapper.find('.qti-timer__type-' + cst.qtiClassName),
$time = $timer.find('.qti-timer_time');
$time.removeClass('txt-info txt-warning txt-danger').addClass('txt-' + warning.type);
remaining = moment.duration(warning.point, "seconds").humanize();
switch (cst.qtiClassName) {
case 'assessmentItemRef':
message = __("Warning You have %s remaining to complete this item.", remaining);
break;
case 'assessmentSection':
message = __("Warning You have %s remaining to complete this section.", remaining);
break;
case 'testPart':
message = __("Warning You have %s remaining to complete this test part.", remaining);
break;
case 'assessmentTest':
message = __("Warning You have %s remaining to complete the test.", remaining);
break;
}
feedback()[warning.type](message);
cst.warnings[warning.point].showed = true;
},
/**
* Displays or hides the rubric block
*/
updateRubrics: function () {
$controls.$rubricBlocks.remove();
if (this.testContext.rubrics.length > 0) {
$controls.$rubricBlocks = $('<div id="qti-rubrics"/>');
for (var i = 0; i < this.testContext.rubrics.length; i++) {
$controls.$rubricBlocks.append(this.testContext.rubrics[i]);
}
// modify the <a> tags in order to be sure it
// opens in another window.
$controls.$rubricBlocks.find('a').bind('click keypress', function () {
window.open(this.href);
return false;
});
$controls.$rubricBlocks.prependTo($controls.$contentBox);
if (MathJax) {
MathJax.Hub.Queue(["Typeset", MathJax.Hub], $controls.$rubricBlocks[0]);
}
}
},
/**
* Updates the list of navigation buttons (previous, next, skip, etc.)
*/
updateNavigation: function () {
$controls.$exit.show();
if(this.testContext.isLast === true) {
$controls.$moveForward.hide();
$controls.$moveEnd.show();
}
else {
$controls.$moveForward.show();
$controls.$moveEnd.hide();
}
if (this.testContext.navigationMode === this.TEST_NAVIGATION_LINEAR) {
// LINEAR
$controls.$moveBackward.hide();
}
else {
// NONLINEAR
$controls.$controls.show();
if(this.testContext.canMoveBackward === true) {
$controls.$moveBackward.show();
}
else {
$controls.$moveBackward.hide();
}
}
},
/**
* Updates the test taker review screen
*/
updateTestReview: function() {
var considerProgress = this.testContext.considerProgress === true;
if (this.testReview) {
this.testReview.toggle(considerProgress && this.hasOption(optionReviewScreen));
this.testReview.update(this.testContext);
}
},
/**
* Updates the progress bar
*/
updateProgress: function () {
var considerProgress = this.testContext.considerProgress === true;
$controls.$progressBox.css('visibility', considerProgress ? 'visible' : 'hidden');
if (considerProgress) {
this.progressUpdater.update(this.testContext);
}
},
/**
* Updates the test informations
*/
updateContext: function () {
$controls.$title.text(this.testContext.testTitle);
// Visibility of section?
var sectionText = (this.testContext.isDeepestSectionVisible === true) ? (' - ' + this.testContext.sectionTitle) : '';
$controls.$position.text(sectionText);
$controls.$titleGroup.show();
},
/**
* Displays the right exit button
*/
updateExitButton : function(){
$controls.$logout.toggleClass('hidden', !this.testContext.logoutButton);
$controls.$exit.toggleClass('hidden', !this.testContext.exitButton);
},
/**
* Ensures the frame has the right size
*/
adjustFrame: function () {
var rubricHeight = $controls.$rubricBlocks.outerHeight(true) || 0;
var frameContentHeight;
var finalHeight = $(window).innerHeight() - $controls.$topActionBar.outerHeight() - $controls.$bottomActionBar.outerHeight();
var itemFrame = $controls.$itemFrame.get(0);
$controls.$contentBox.height(finalHeight);
if($controls.$sideBars.length){
$controls.$sideBars.each(function() {
var $sideBar = $(this);
$sideBar.height(finalHeight - $sideBar.outerHeight() + $sideBar.height());
});
}
if(itemFrame && itemFrame.contentWindow){
frameContentHeight = $controls.$itemFrame.contents().outerHeight(true);
if (frameContentHeight < finalHeight) {
if (rubricHeight) {
frameContentHeight = Math.max(frameContentHeight, finalHeight - rubricHeight);
} else {
frameContentHeight = finalHeight;
}
}
if (itemFrame.contentWindow.$) {
itemFrame.contentWindow.$('body').trigger('setheight', [frameContentHeight]);
}
$controls.$itemFrame.height(frameContentHeight);
}
},
/**
* Locks the GUI
*/
disableGui: function () {
$controls.$naviButtons.addClass('disabled');
if (this.testReview) {
this.testReview.disable();
}
},
/**
* Unlocks the GUI
*/
enableGui: function () {
$controls.$naviButtons.removeClass('disabled');
if (this.testReview) {
this.testReview.enable();
}
},
/**
* Hides the GUI
*/
hideGui: function () {
$controls.$naviButtons.addClass('hidden');
if (this.testReview) {
this.testReview.hide();
}
},
/**
* Shows the GUI
*/
showGui: function () {
$controls.$naviButtons.removeClass('hidden');
if (this.testReview) {
this.testReview.show();
}
},
/**
* Formats a timer
* @param {Number} totalSeconds
* @returns {String}
*/
formatTime: function (totalSeconds) {
var sec_num = totalSeconds;
var hours = Math.floor(sec_num / 3600);
var minutes = Math.floor((sec_num - (hours * 3600)) / 60);
var seconds = Math.floor(sec_num - (hours * 3600) - (minutes * 60));
if (hours < 10) {
hours = "0" + hours;
}
if (minutes < 10) {
minutes = "0" + minutes;
}
if (seconds < 10) {
seconds = "0" + seconds;
}
var time = hours + ':' + minutes + ':' + seconds;
return time;
},
/**
* Processes an error
* @param {Object} error
*/
processError : function processError(error) {
var self = this;
// keep disabled
this.hideGui();
this.beforeTransition();
// ask the parent to display a message
iframeNotifier.parent('messagealert', {
message : error.message,
action : function() {
if (testMetaData) {
testMetaData.clearData();
}
if (error.state === self.TEST_STATE_CLOSED) {
// test is closed, finish it
self.serviceApi.finish();
} else {
// test is still open, just exit to the index
self.serviceApi.exit();
}
}
});
},
/**
* Call action specified in testContext. A postfix <i>Url</i> will be added to the action name.
* To specify actions see {@link https://github.com/oat-sa/extension-tao-testqti/blob/master/helpers/class.TestRunnerUtils.php}
* @param {String} action - Action name
* @param {Object} [extraParams] - Additional parameters to be sent to the server
* @returns {undefined}
*/
actionCall: function (action, extraParams) {
var self = this,
params = {metaData: testMetaData ? testMetaData.getData() : {}};
if (extraParams) {
params = _.assign(params, extraParams);
}
this.beforeTransition(function () {
$.ajax({
url: self.testContext[action + 'Url'],
cache: false,
data: params,
async: true,
dataType: 'json',
success: function (testContext) {
testMetaData.clearData();
if (!testContext.success) {
self.processError(testContext);
}
else if (testContext.state === self.TEST_STATE_CLOSED) {
self.serviceApi.finish();
}
else {
self.update(testContext);
}
}
});
});
},
/**
* Exit from test (after confirmation). All answered questions will be submitted.
*
* @returns {undefined}
*/
exit: function () {
var self = this;
testMetaData.addData({
"TEST" : {"TEST_EXIT_CODE" : testMetaData.TEST_EXIT_CODE.INCOMPLETE},
"SECTION" : {"SECTION_EXIT_CODE" : testMetaData.SECTION_EXIT_CODE.QUIT}
});
this.displayExitMessage(
__('Are you sure you want to end the test?'),
function() {
self.killItemSession(function () {
self.actionCall('endTestSession');
testMetaData.clearData();
});
},
{ scope: this.testReview ? this.testContext.reviewScope : null }
);
},
/**
* Set the state of the current item in the test runner
*
* @param {string} id
* @param {object} state
*/
setCurrentItemState : function(id, state){
if(id){
this.currentItemState[id] = state;
}
},
/**
* Reset the state of the current item in the test runner
*/
resetCurrentItemState : function(){
this.currentItemState = {};
},
/**
* Get the state of the current item as stored in the test runner
* @returns {Object}
*/
getCurrentItemState : function(){
return this.currentItemState;
}
};
var config = module.config();
if (config) {
actionBarTools.register(config.qtiTools);
}
return {
start: function (testContext) {
$controls = {
// navigation
$moveForward: $('[data-control="move-forward"]'),
$moveEnd: $('[data-control="move-end"]'),
$moveBackward: $('[data-control="move-backward"]'),
$nextSection: $('[data-control="next-section"]'),
$skip: $('[data-control="skip"]'),
$skipEnd: $('[data-control="skip-end"]'),
$exit: $(window.parent.document).find('[data-control="exit"]'),
$logout: $(window.parent.document).find('[data-control="logout"]'),
$naviButtons: $('.bottom-action-bar .action'),
$skipButtons: $('.navi-box .skip'),
$forwardButtons: $('.navi-box .forward'),
// progress bar
$progressBar: $('[data-control="progress-bar"]'),
$progressLabel: $('[data-control="progress-label"]'),
$progressBox: $('.progress-box'),
// title
$title: $('[data-control="qti-test-title"]'),
$position: $('[data-control="qti-test-position"]'),
// timers
$timerWrapper: $('[data-control="qti-timers"]'),
// other zones
$contentPanel: $('.content-panel'),
$controls: $('.qti-controls'),
$itemFrame: $('#qti-item'),
$rubricBlocks: $('#qti-rubrics'),
$contentBox: $('#qti-content'),
$sideBars: $('.test-sidebar'),
$topActionBar: $('.horizontal-action-bar.top-action-bar'),
$bottomActionBar: $('.horizontal-action-bar.bottom-action-bar')
};
// title
$controls.$titleGroup = $controls.$title.add($controls.$position);
$doc.ajaxError(function (event, jqxhr) {
if (jqxhr.status === 403) {
iframeNotifier.parent('serviceforbidden');
}
});
window.onServiceApiReady = function onServiceApiReady(serviceApi) {
TestRunner.serviceApi = serviceApi;
if (!testContext.success) {
TestRunner.processError(testContext);
}
// If the assessment test session is in CLOSED state,
// we give the control to the delivery engine by calling finish.
else if (testContext.state === TestRunner.TEST_STATE_CLOSED) {
serviceApi.finish();
testMetaData.clearData();
}
else {
if (TestRunner.getSessionStateService().getDuration()) {
TestRunner.setTestContext(testContext);
TestRunner.initMetadata();
TestRunner.keepItemTimed(TestRunner.getSessionStateService().getDuration());
TestRunner.getSessionStateService().restart();
} else {
TestRunner.update(testContext);
}
}
};
TestRunner.beforeTransition();
TestRunner.testContext = testContext;
$controls.$skipButtons.click(function () {
if (!$(this).hasClass('disabled')) {
TestRunner.skip();
}
});
$controls.$forwardButtons.click(function () {
if (!$(this).hasClass('disabled')) {
TestRunner.moveForward();
}
});
$controls.$moveBackward.click(function () {
if (!$(this).hasClass('disabled')) {
TestRunner.moveBackward();
}
});
$controls.$nextSection.click(function () {
if (!$(this).hasClass('disabled')) {
TestRunner.nextSection();
}
});
$controls.$exit.click(function (e) {
e.preventDefault();
TestRunner.exit();
});
$(window).on('resize', _.throttle(function () {
TestRunner.adjustFrame();
$controls.$titleGroup.show();
}, 250));
$doc.on('loading', function () {
iframeNotifier.parent('loading');
});
$doc.on('unloading', function () {
iframeNotifier.parent('unloading');
});
TestRunner.progressUpdater = progressUpdater($controls.$progressBar, $controls.$progressLabel);
if (testContext.reviewScreen) {
TestRunner.testReview = testReview($controls.$contentPanel, {
region: testContext.reviewRegion || 'left',
hidden: !TestRunner.hasOption(optionReviewScreen),
reviewScope: testContext.reviewScope,
preventsUnseen: !!testContext.reviewPreventsUnseen,
canCollapse: !!testContext.reviewCanCollapse
}).on('jump', function(event, position) {
TestRunner.jump(position);
}).on('mark', function(event, flag, position) {
TestRunner.markForReview(flag, position);
});
$controls.$sideBars = $('.test-sidebar');
}
TestRunner.updateProgress();
TestRunner.updateTestReview();
iframeNotifier.parent('serviceready');
TestRunner.adjustFrame();
$controls.$topActionBar.add($controls.$bottomActionBar).animate({ opacity: 1 }, 600);
deleter($('#feedback-box'));
modal($('body'));
//listen to state change in the current item
$(document).on('responsechange', function(e, responseId, response){
if(responseId && response){
TestRunner.setCurrentItemState(responseId, {response:response});
}
}).on('stateready', function(e, id, state){
if(id && state){
TestRunner.setCurrentItemState(id, state);
}
}).on('heightchange', function(e, height) {
$controls.$itemFrame.height(height);
});
}
};
});