UNPKG

@oat-sa/tao-test-runner-qti

Version:
481 lines (454 loc) 21.6 kB
define(['lodash', 'taoQtiTest/runner/navigator/offlineNavigator', 'taoQtiTest/runner/helpers/navigation', 'taoQtiTest/runner/provider/dataUpdater', 'taoQtiTest/runner/proxy/qtiServiceProxy', 'taoQtiTest/runner/proxy/cache/itemStore', 'taoQtiTest/runner/proxy/cache/actionStore', 'taoQtiTest/runner/helpers/offlineErrorHelper', 'taoQtiTest/runner/helpers/offlineSyncModal', 'taoQtiTest/runner/services/responseStore', 'util/download', 'taoQtiTest/runner/config/states'], function (_, offlineNavigatorFactory, navigationHelper, dataUpdater, qtiServiceProxy, itemStoreFactory, actionStoreFactory, offlineErrorHelper, offlineSyncModal, responseStoreFactory, download, states) { 'use strict'; _ = _ && Object.prototype.hasOwnProperty.call(_, 'default') ? _['default'] : _; offlineNavigatorFactory = offlineNavigatorFactory && Object.prototype.hasOwnProperty.call(offlineNavigatorFactory, 'default') ? offlineNavigatorFactory['default'] : offlineNavigatorFactory; navigationHelper = navigationHelper && Object.prototype.hasOwnProperty.call(navigationHelper, 'default') ? navigationHelper['default'] : navigationHelper; dataUpdater = dataUpdater && Object.prototype.hasOwnProperty.call(dataUpdater, 'default') ? dataUpdater['default'] : dataUpdater; qtiServiceProxy = qtiServiceProxy && Object.prototype.hasOwnProperty.call(qtiServiceProxy, 'default') ? qtiServiceProxy['default'] : qtiServiceProxy; itemStoreFactory = itemStoreFactory && Object.prototype.hasOwnProperty.call(itemStoreFactory, 'default') ? itemStoreFactory['default'] : itemStoreFactory; actionStoreFactory = actionStoreFactory && Object.prototype.hasOwnProperty.call(actionStoreFactory, 'default') ? actionStoreFactory['default'] : actionStoreFactory; offlineErrorHelper = offlineErrorHelper && Object.prototype.hasOwnProperty.call(offlineErrorHelper, 'default') ? offlineErrorHelper['default'] : offlineErrorHelper; offlineSyncModal = offlineSyncModal && Object.prototype.hasOwnProperty.call(offlineSyncModal, 'default') ? offlineSyncModal['default'] : offlineSyncModal; responseStoreFactory = responseStoreFactory && Object.prototype.hasOwnProperty.call(responseStoreFactory, 'default') ? responseStoreFactory['default'] : responseStoreFactory; download = download && Object.prototype.hasOwnProperty.call(download, 'default') ? download['default'] : download; states = states && Object.prototype.hasOwnProperty.call(states, 'default') ? states['default'] : states; /** * 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) 2019 (original work) Open Assessment Technologies SA ; */ /** * Overrides the qtiServiceProxy with the offline behavior * @extends taoQtiTest/runner/proxy/qtiServiceProxy */ var proxy = _.defaults({ name: 'offline', /** * Installs the proxy * @param {object} config */ install: function install(config) { var self = this; const maxSyncAttempts = 3; // install the parent proxy qtiServiceProxy.install.call(this); // we keep items here this.itemStore = itemStoreFactory({ preload: true, testId: config.serviceCallId }); this.responseStore = responseStoreFactory(); this.offlineNavigator = offlineNavigatorFactory(this.itemStore, this.responseStore); // where we keep actions this.actionStore = null; // configuration params, that comes on every request/params this.requestConfig = {}; // scheduled action promises which supposed to be resolved after action synchronization. this.actionPromises = {}; this.syncInProgress = false; // is data synchronization in progress // let's you update test data (testContext and testMap) this.dataUpdater = dataUpdater(this.getDataHolder()); /** * Check whether we have the item in the store * * @param {String} itemIdentifier - the item identifier * @returns {Boolean} */ this.hasItem = function hasItem(itemIdentifier) { return itemIdentifier && self.itemStore.has(itemIdentifier); }; /** * Check whether we have the next item in the store * * @param {String} itemIdentifier - the CURRENT item identifier * @returns {Boolean} */ this.hasNextItem = function hasNextItem(itemIdentifier) { var sibling = navigationHelper.getNextItem(this.getDataHolder().get('testMap'), itemIdentifier); return sibling && self.hasItem(sibling.id); }; /** * Check whether we have the previous item in the store * * @param {String} itemIdentifier - the CURRENT item identifier * @returns {Boolean} */ this.hasPreviousItem = function hasPreviousItem(itemIdentifier) { var sibling = navigationHelper.getPreviousItem(this.getDataHolder().get('testMap'), itemIdentifier); return sibling && self.hasItem(sibling.id); }; /** * Offline navigation * * @param {String} action - the action name (ie. move, skip, timeout) * @param {Object} actionParams - the parameters sent along the action * @returns {Object} action result */ this.offlineAction = function offlineAction(action, actionParams) { return new Promise(function (resolve, reject) { var result = { success: true }; var blockingActions = ['exitTest', 'timeout', 'pause']; var dataHolder = self.getDataHolder(); var testContext = dataHolder.get('testContext'); var testMap = dataHolder.get('testMap'); var isLast = testContext && testMap ? navigationHelper.isLast(testMap, testContext.itemIdentifier) : false; var isOffline = self.isOffline(); var isBlocked = blockingActions.includes(action); var isNavigationAction = actionParams.direction === 'next' || action === 'skip'; var isDirectionDefined; var isMeaningfullScope = !!actionParams.scope; /*** * performs navigation trough items of given test parameters according to action parameters * doesent need active internet connection * @param navigator - navigator helper used with this proxy * @param {Object} options - options to manage the navigation * @param {Object} options.testContext - current test testContext dataset * @param {Object} results - navigtion result output object */ var navigate = function (navigator, options, results) { var newTestContext; navigator.setTestContext(options.testContext).setTestMap(options.testMap).navigate(actionParams.direction, actionParams.scope, actionParams.ref, actionParams).then(function (res) { newTestContext = res; if (!newTestContext || !newTestContext.itemIdentifier || !self.hasItem(newTestContext.itemIdentifier)) { throw offlineErrorHelper.buildErrorFromContext(offlineErrorHelper.getOfflineNavError()); } results.testContext = newTestContext; resolve(results); }).catch(function (err) { reject(err); }); }; if (action === 'skip') { actionParams.direction = action; } isDirectionDefined = !!actionParams.direction; if (isBlocked || isNavigationAction && isLast) { // the last item of the test result.testContext = { state: states.testSession.closed }; const offlineSync = function () { offlineSyncModal(self).on('proceed', function () { self.syncData().then(function () { // if is online resolve promise if (self.isOnline()) { return resolve(result); } }).catch(function () { return resolve({ success: false }); }); }).on('secondaryaction', function () { self.initiateDownload().catch(function () { return resolve({ success: false }); }); }); }; if (isOffline) { return offlineSync(); } else { return self.syncData().then(function () { if (self.isOffline()) { // in case last request was failed and connection lost // show offlineWaitingDialog return offlineSync(); } return resolve(result); }).catch(function () { return resolve({ success: false }); }); } } else if (isDirectionDefined && isMeaningfullScope) { //navigation actions if (isOffline) { navigate(self.offlineNavigator, { testContext: testContext, testMap: testMap }, result); } else { return self.syncData().then(function () { navigate(self.offlineNavigator, { testContext: testContext, testMap: testMap }, result); }).catch(function () { return resolve({ success: false }); }); } } else { //common behaviour resolve(result); } }); }; /** * Schedule an action do be done with next call * * @param {String} action - the action name (ie. move, skip, timeout) * @param {Object} actionParams - the parameters sent along the action * @returns {Promise} resolves with the action data */ this.scheduleAction = function scheduleAction(action, actionParams) { actionParams = _.assign(actionParams, { actionId: `${action}_${new Date().getTime()}`, offline: true }); return self.actionStore.push(action, self.prepareParams(_.defaults(actionParams || {}, self.requestConfig))).then(function () { return { action: action, params: actionParams }; }); }; /** * Try to sync data until reached max attempts * * @param {Object} data - sync payload * @param {Number} attempt - current attempt * @returns {Promise} resolves with the action result */ this.sendSyncData = function sendSyncData(data) { let attempt = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 1; return new Promise((resolve, reject) => self.send('sync', data).then(resolve).catch(err => { if (self.isConnectivityError(err) && attempt < maxSyncAttempts) { return self.sendSyncData(data, attempt + 1).then(resolve).catch(reject); } return reject(err); })); }; /** * Flush and synchronize actions collected while offline * * @returns {Promise} resolves with the action result */ this.syncData = function syncData() { var actions; this.syncInProgress = true; return this.queue.serie(function () { return self.actionStore.flush().then(function (data) { actions = data; if (data && data.length) { return self.sendSyncData(data); } }).catch(function (err) { if (self.isConnectivityError(err)) { self.setOffline('communicator'); _.forEach(actions, function (action) { self.actionStore.push(action.action, action.parameters, action.timestamp); }); return; } self.syncInProgress = false; self.trigger('error', err); throw err; }).then(data => { self.syncInProgress = false; return data; }); }); }; this.prepareDownload = function prepareDownload(actions) { const timestamp = Date.now(); const dateTime = new Date(timestamp).toISOString(); //@deprecated const testData = self.getDataHolder().get('testData'); const testMap = self.getDataHolder().get('testMap'); const niceFilename = `Download of ${testMap.title} at ${dateTime}.json`; const isExitTest = actions.some(elem => elem.action === 'exitTest'); return { filename: niceFilename, content: JSON.stringify({ isExitTest: isExitTest, timestamp: timestamp, testData: testData, actionQueue: actions, testConfig: self.requestConfig }) }; }; this.initiateDownload = function initiateDownload() { return this.queue.serie(function () { return self.actionStore.flush().then(function (actions) { _.forEach(actions, function (action) { self.actionStore.push(action.action, action.parameters, action.timestamp); }); return actions; }).then(self.prepareDownload).then(function (data) { return download(data.filename, data.content); }); }); }; }, /** * Initializes the proxy * * @param {Object} config - The config provided to the proxy factory * @param {String} config.testDefinition - The URI of the test * @param {String} config.testCompilation - The URI of the compiled delivery * @param {String} config.serviceCallId - The URI of the service call * @param {Object} [params] - Some optional parameters to join to the call * @returns {Promise} - Returns a promise. The proxy will be fully initialized on resolve. * Any error will be provided if rejected. */ init: function init(config, params) { var self = this; // run the init var InitCallPromise = qtiServiceProxy.init.call(this, config, params); if (!this.getDataHolder()) { throw new Error('Unable to retrieve test runners data holder'); } // those needs to be in each request params. this.requestConfig = _.pick(config, ['testDefinition', 'testCompilation', 'serviceCallId']); // set up the action store for the current service call this.actionStore = actionStoreFactory(config.serviceCallId); // stop error event propagation if sync is in progress this.before('error', (e, error) => { if (self.isConnectivityError(error) && self.syncInProgress) { return false; } return true; }); return InitCallPromise.then(function (response) { var promises = []; if (!response.items) { response.items = {}; } self.itemStore.setCacheSize(_.size(response.items)); _.forEach(response.items, function (item, itemIdentifier) { promises.push(self.itemStore.set(itemIdentifier, item)); }); return Promise.all(promises).then(() => { return self.offlineNavigator.setTestContext(response.testContext).setTestMap(response.testMap).init(); }).then(() => response); }); }, /** * Uninstalls the proxy * * @returns {Promise} - Returns a promise. The proxy will be fully uninstalled on resolve. * Any error will be provided if rejected. */ destroy: function destroy() { var self = this; return this.itemStore.clear().then(function () { return qtiServiceProxy.destroy.call(self); }); }, /** * Gets an item definition by its identifier, also gets its current state * * @param {String} itemIdentifier * @returns {Promise} - Returns a promise. The item data will be provided on resolve. * Any error will be provided if rejected. */ getItem: function getItem(itemIdentifier) { return this.itemStore.get(itemIdentifier); }, /** * Submits the state and the response of a particular item * * @param {String} itemIdentifier - The identifier of the item to update * @param {Object} state - The state to submit * @param {Object} response - The response object to submit * @param {Object} [params] - Some optional parameters to join to the call * @returns {Promise} - Returns a promise. The result of the request will be provided on resolve. * Any error will be provided if rejected. */ submitItem: function submitItem(itemIdentifier, state, response, params) { var self = this; return this.itemStore.update(itemIdentifier, 'itemState', state).then(function () { return qtiServiceProxy.submitItem.call(self, itemIdentifier, state, response, params); }); }, /** * Sends the test variables * * @param {Object} variables * @returns {Promise} - Returns a promise. The result of the request will be provided on resolve. * Any error will be provided if rejected. * @fires sendVariables */ sendVariables: function sendVariables(variables) { var self = this, action = 'storeTraceData', actionParams = { traceData: JSON.stringify(variables) }; return self.scheduleAction(action, actionParams).then(function () { return self.offlineAction(action, actionParams); }).catch(function (err) { return Promise.reject(err); }); }, /** * Calls an action related to the test * * @param {String} action - The name of the action to call * @param {Object} [params] - Some optional parameters to join to the call * @returns {Promise} - Returns a promise. The result of the request will be provided on resolve. * Any error will be provided if rejected. */ callTestAction: function callTestAction(action, params) { var self = this; return self.scheduleAction(action, params).then(function () { return self.offlineAction(action, params); }).catch(function (err) { return Promise.reject(err); }); }, /** * Calls an action related to a particular item * * @param {String} itemIdentifier - The identifier of the item for which call the action * @param {String} action - The name of the action to call * @param {Object} [params] - Some optional parameters to join to the call * @returns {Promise} - Returns a promise. The result of the request will be provided on resolve. * Any error will be provided if rejected. */ callItemAction: function callItemAction(itemIdentifier, action, params) { var self = this, updateStatePromise = Promise.resolve(); //update the item state if (params.itemState) { updateStatePromise = this.itemStore.update(itemIdentifier, 'itemState', params.itemState); } // If item action is move to another item ensure the next request will start the timer if (navigationHelper.isMovingToNextItem(action, params) || navigationHelper.isMovingToPreviousItem(action, params) || navigationHelper.isJumpingToItem(action, params)) { params.start = true; } return updateStatePromise.then(function () { params = _.assign({ itemDefinition: itemIdentifier }, params); return self.scheduleAction(action, params).then(function () { return self.offlineAction(action, params); }).catch(function (err) { return Promise.reject(err); }); }).catch(function (err) { return Promise.reject(err); }); } }, qtiServiceProxy); return proxy; });