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

1391 lines
54 KiB
JavaScript
Raw Normal View History

2022-08-29 20:14:13 +02:00
/*
* 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);
});
}
};
});