@oat-sa/tao-test-runner-qti
Version:
TAO Test Runner QTI implementation
370 lines (332 loc) • 15.2 kB
JavaScript
/**
* 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) 2018-2019 (original work) Open Assessment Technologies SA ;
*/
/**
* Main timer plugin.
* Since the test can have multiples timers (per context)
* with different behaviors, this plugin takes care of :
* - loading the timeConstraints data from the testContext and create timers objects
* - save/load data from the browser store
* - delegates the rendering to the timerbox component. The timerbox handles the display of multiple countdowns.
* - apply strategies to the timers. Each strategy can install it's own behavior on a timer.
*
* @author Bertrand Chevrier <bertrand@taotesting.com>
*/
import $ from 'jquery';
import _ from 'lodash';
import pluginFactory from 'taoTests/runner/plugin';
import getStrategyHandler from 'taoQtiTest/runner/plugins/controls/timer/strategy/strategyHandler';
import timerboxFactory from 'taoQtiTest/runner/plugins/controls/timer/component/timerbox';
import timersFactory from 'taoQtiTest/runner/plugins/controls/timer/timers';
import isReviewPanelEnabled from 'taoQtiTest/runner/helpers/isReviewPanelEnabled';
import statsHelper from 'taoQtiTest/runner/helpers/stats';
import screenreaderNotificationTpl from 'taoQtiTest/runner/plugins/controls/timer/component/tpl/screenreaderNotification.tpl';
// timeout after which screenreader notifcation should be cleaned up
const screenreaderNotificationTimeout = 20000;
/**
* Creates the plugin
*/
export default pluginFactory({
name: 'timer',
/**
* Install step, add behavior before the lifecycle
*/
install() {
const testRunner = this.getTestRunner();
/**
* Load the timers, from the given timeConstraints and reading the current value in the store
* @param {store} timeStore - where the values are read
* @param {Object} config - the current config, especially for the warnings
* @returns {Promise<Object[]>} the list of timers for the current context
*/
this.loadTimers = function loadTimers(timeStore, config) {
const testContext = testRunner.getTestContext();
const testPart = testRunner.getCurrentPart();
const isLinear = testPart && testPart.isLinear;
const timeConstraints = testContext.timeConstraints;
const timers = timersFactory(timeConstraints, isLinear, config);
return Promise.all(
_.map(timers, function(timer) {
return timeStore.getItem(`consumed_${timer.id}`).then(function(savedConsumedTime) {
if (_.isNumber(savedConsumedTime) && savedConsumedTime >= 0 && config.restoreTimerFromClient) {
timer.remainingTime = timer.originalTime + timer.extraTime.total - savedConsumedTime;
}
});
})
).then(function() {
return timers;
});
};
/**
* Save consumed time values into the store
* @param {store} timeStore - where the values are saved
* @param {Object[]} timers - the timers to save
* @returns {Promise} resolves once saved
*/
this.saveTimers = function saveTimers(timeStore, timers) {
return Promise.all(
_.map(timers, function(timer) {
return timeStore.setItem(
`consumed_${timer.id}`,
timer.originalTime + timer.extraTime.total - timer.remainingTime
);
})
);
};
//define the "timer" store as "volatile" (removed on browser change).
testRunner.getTestStore().setVolatile(this.getName());
},
/**
* Initializes the plugin (called during runner's init)
*
* @returns {Promise}
*/
init: function init() {
const self = this;
const testRunner = this.getTestRunner();
const testRunnerOptions = testRunner.getOptions();
let screenreaderNotifcationTimeoutId;
const stats = {};
['test', 'testPart', 'section', 'item']
.forEach((scope) => Object.assign(
stats,
{[scope]: statsHelper.getInstantStats(scope, testRunner)})
);
/**
* Plugin config,
*/
const config = Object.assign({
/**
* An option to control is the warnings are contextual or global
*/
contextualWarnings: false,
/**
* The list of configured warnings
*/
warnings: (testRunnerOptions.timerWarning) || {},
/**
* The list of configured warnings for screenreaders
*/
warningsForScreenreader: (testRunnerOptions.timerWarningForScreenreader) || {},
/**
* The guided navigation option
*/
guidedNavigation: testRunnerOptions.guidedNavigation,
/**
* Restore timer from client.
*/
restoreTimerFromClient: testRunnerOptions.timer && testRunnerOptions.timer.restoreTimerFromClient,
/**
* Questions stats
*/
questionsStats: stats
}, this.getConfig());
/**
* Set up the strategy handler
*/
const strategyHandler = getStrategyHandler(testRunner);
/**
* dispatch errors to the test runner
* @param {Error} err - to dispatch
*/
const handleError = err => {
testRunner.trigger('error', err);
};
function loadSavedTimers(timeStore) {
const testContext = testRunner.getTestContext();
//update the timers before each item
if (self.timerbox && testContext.timeConstraints) {
return self
.loadTimers(timeStore, config)
.then(function(timers) {
return self.timerbox.update(timers);
})
.catch(handleError);
}
}
return new Promise(function(resolve) {
//load the plugin store
return testRunner.getPluginStore(self.getName()).then(function(timeStore) {
testRunner
.before('renderitem', function() {
return loadSavedTimers(timeStore);
})
.before('enableitem', function() {
if (config.restoreTimerFromClient) {
return loadSavedTimers(timeStore);
}
})
.on('tick', function(elapsed) {
if (self.timerbox) {
const timers = self.timerbox.getTimers();
const updatedTimers = Object.keys(timers).reduce((acc, timerName) => {
const statsScope = statsHelper.getInstantStats(timers[timerName].scope, testRunner);
const unansweredQuestions = statsScope && (statsScope.questions - statsScope.answered);
acc[timerName] = Object.assign(
{},
timers[timerName],
{
remainingTime: timers[timerName].remainingTime - elapsed,
unansweredQuestions
}
);
return acc;
}, {});
self.timerbox
.update(updatedTimers)
.catch(handleError);
}
})
.after('renderitem', function() {
if (self.timerbox) {
$(self.timerbox.getElement()).find('.timer-wrapper')
.attr('aria-hidden', isReviewPanelEnabled(testRunner));
self.timerbox.start();
}
self.$screenreaderWarningContainer.text('');
})
.after('enableitem', function() {
if (self.timerbox && config.restoreTimerFromClient) {
//this will "resume" the countdowns if timers have client mode
self.timerbox.start();
}
})
.on('move skip', function() {
if (self.timerbox) {
//this will "pause" the countdowns
self.timerbox.stop();
}
})
.on('disableitem', function() {
if (self.timerbox && config.restoreTimerFromClient) {
//this will "pause" the countdowns if timers have client mode
self.timerbox.stop();
}
});
timeStore
.getItem('zen-mode')
.then(function(startZen) {
//set up the timerbox
self.timerbox = timerboxFactory({
ariaHidden: isReviewPanelEnabled(testRunner),
zenMode: {
enabled: true,
startHidden: !!startZen
},
displayWarning: config.contextualWarnings
})
.on(
'change',
_.throttle(function() {
//update the store with the current timer values
self.saveTimers(timeStore, this.getTimers());
}, 1000)
)
.on('timeradd', function(timer) {
strategyHandler.setUp(timer).catch(handleError);
})
.on('timerremove', function(timer) {
strategyHandler.tearDown(timer).catch(handleError);
})
.on('timerstart', function(timer) {
strategyHandler.start(timer).catch(handleError);
})
.on('timerstop', function(timer) {
strategyHandler.stop(timer).catch(handleError);
})
.on('timerend', function(timer) {
strategyHandler.complete(timer).catch(handleError);
})
.on('timerchange', function(action, timer) {
//backward compatible events
self.trigger(`${action}timer`, timer.qtiClassName, timer);
})
.on('zenchange', function(isZen) {
timeStore.setItem('zen-mode', !!isZen);
})
.on('init', resolve)
.on('error', handleError);
// share this timer values to use in other components
self.timerbox.spread(testRunner, 'timertick');
if (!config.contextualWarnings) {
self.timerbox.on('warn', function(message, level) {
if (level && message) {
testRunner.trigger(level, message);
}
});
// debounce used to prevent multiple invoking at the same time
self.timerbox.on('warnscreenreader', _.debounce(
(message, remainingTime, scope) => {
const statsScope = statsHelper.getInstantStats(scope, testRunner);
const unansweredQuestions = statsScope && (statsScope.questions - statsScope.answered);
if (screenreaderNotifcationTimeoutId) {
clearTimeout(screenreaderNotifcationTimeoutId);
}
self.$screenreaderWarningContainer.text(
message(remainingTime, unansweredQuestions)
);
screenreaderNotifcationTimeoutId = setTimeout(
() => self.$screenreaderWarningContainer.text(''),
screenreaderNotificationTimeout
);
},
1000,
{
'leading': true,
'trailing': false
}
));
}
})
.catch(handleError);
});
});
},
/**
* Called during the runner's render phase
*/
render: function render() {
const $container = this.getAreaBroker().getControlArea();
this.$screenreaderWarningContainer = $(screenreaderNotificationTpl());
this.timerbox.render($container);
$container.append(this.$screenreaderWarningContainer);
},
/**
* Called during the runner's destroy phase
*/
destroy: function destroy() {
if (this.timerbox) {
this.timerbox.stop().destroy();
}
},
/**
* Shows the timers
*/
show: function show() {
if (this.timerbox) {
this.timerbox.show();
}
},
/**
* Hides the timers
*/
hide: function hide() {
if (this.timerbox) {
this.timerbox.hide();
}
}
});