@oat-sa/tao-test-runner-qti
Version:
TAO Test Runner QTI implementation
325 lines (290 loc) • 11.5 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) 2016 (original work) Open Assessment Technologies SA ;
*/
/**
* This helper provides information about the current item
*
* @author Jean-Sébastien Conan <jean-sebastien.conan@vesperiagroup.com>
*/
import _ from 'lodash';
/**
* List of QTI model cardinalities
* @type {Object}
*/
var responseCardinalities = {
single: 'base',
multiple: 'list',
ordered: 'list',
record: 'record'
};
/**
* List of QTI interaction minConstraint properties
* @type {Object}
*/
var interactionMinConstraintProperties = {
matchInteraction: 'minAssociations',
choiceInteraction: 'minChoices',
orderInteraction: 'minChoices',
associateInteraction: 'minAssociations',
hottextInteraction: 'minChoices',
hotspotInteraction: 'minChoices',
graphicOrderInteraction: 'minChoices',
graphicAssociateInteraction: 'minAssociations',
selectPointInteraction: 'minChoices'
};
/**
* @typedef {currentItemHelper}
*/
var currentItemHelper = {
/**
* Gets the responses declarations of the current item.
* @param {Object} runner - testRunner instance
* @returns {Object}
*/
getDeclarations: function getDeclarations(runner) {
var itemRunner = runner.itemRunner;
return itemRunner._item && itemRunner._item.responses;
},
/**
* Gets a response declaration by the identifier of the response
* @param {Object} runner - testRunner instance
* @param {String} identifier - The identifier of the response
* @returns {Object|null}
*/
getResponseDeclaration: function getResponseDeclaration(runner, identifier) {
var found = null;
_.forEach(currentItemHelper.getDeclarations(runner), function (declaration) {
var attributes = declaration.attributes || {};
if (attributes.identifier === identifier) {
found = declaration;
return false;
}
});
return found;
},
/**
* Convert a value to a response object
* @param {Array|String} value
* @param {String} baseType
* @param {String} cardinality
* @returns {Object}
*/
toResponse: function toResponse(value, baseType, cardinality) {
var mappedCardinality = responseCardinalities[cardinality];
var response = {};
if (_.isString(value) || _.isNumber(value)) {
value = [value];
}
let transform = v => v;
if (baseType === 'boolean') {
transform = v => v === true || v === 'true';
} else if (baseType === 'integer') {
transform = v => (typeof v === 'number' ? v : parseInt(v));
} else if (baseType === 'float') {
transform = v => (typeof v === 'number' ? v : parseFloat(v));
} else if (baseType === 'directedPair' || baseType === 'pair') {
transform = v => {
if (_.isString(v)) {
return v.split(' ');
}
return v;
};
}
value = _.map(value || [], transform);
if (mappedCardinality) {
if (mappedCardinality === 'base') {
if (value.length === 0) {
//return empty response:
response.base = null;
} else {
response.base = {};
response.base[baseType] = value[0];
}
} else {
response[mappedCardinality] = {};
response[mappedCardinality][baseType] = value;
}
}
return response;
},
/**
* Checks if the provided value can be considered as null
* @param {Object} value
* @param {String} baseType
* @param {String} cardinality
* @returns {boolean}
*/
isQtiValueNull: function isQtiValueNull(value, baseType, cardinality) {
var mappedCardinality = responseCardinalities[cardinality];
if (_.isObject(value) && value[mappedCardinality] === null) {
value = null;
}
if (
_.isObject(value) &&
value[mappedCardinality] &&
'undefined' !== typeof value[mappedCardinality][baseType]
) {
value = value[mappedCardinality][baseType];
}
const stringyValue = 'string' === baseType || 'integer' === baseType || 'float' === baseType;
return null === value || (stringyValue && value === '') || (cardinality !== 'single' && _.isEmpty(value));
},
/**
* Tells if an item question has been answered or not
* @param {Object} response
* @param {String} baseType
* @param {String} cardinality
* @param {Object} [defaultValue]
* @param {Object} constraintValue
* @returns {*}
*/
isQuestionAnswered: function isQuestionAnswered(response, baseType, cardinality, defaultValue, constraintValue) {
var answered, currentCardinality, responses;
var fullyAnswered = true;
defaultValue = _.isUndefined(defaultValue) ? null : defaultValue;
constraintValue = constraintValue || 0;
if (currentItemHelper.isQtiValueNull(response, baseType, cardinality)) {
answered = false;
} else {
answered = !_.isEqual(response, currentItemHelper.toResponse(defaultValue, baseType, cardinality));
if (constraintValue !== 0) {
currentCardinality = responseCardinalities[cardinality];
responses = response[currentCardinality][baseType] || [];
fullyAnswered = responses && responses.length >= constraintValue;
}
answered = answered && fullyAnswered;
}
return answered;
},
guessInteractionConstraintValues: function guessInteractionConstraintValues(runner) {
var itemRunner = runner.itemRunner;
var itemBody = (itemRunner._item && itemRunner._item.bdy) || {};
var interactions = itemBody.elements || {};
var constraintValues = {};
_.forEach(interactions, function (interaction) {
var attributes = interaction.attributes || {};
var qtiClass = interaction.__proto__.qtiClass;
var constraintProperty;
if (Object.prototype.hasOwnProperty.call(interactionMinConstraintProperties, qtiClass)) {
constraintProperty = interactionMinConstraintProperties[qtiClass];
constraintValues[attributes.responseIdentifier] = attributes[constraintProperty];
}
});
return constraintValues;
},
/**
* 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}
* @param {Object} runner - testRunner instance
* @param {Boolean} [partially=true] - if false all questions must have been answered
* @returns {Boolean}
*/
isAnswered: function isAnswered(runner, partially) {
var itemRunner = runner.itemRunner;
var responses = itemRunner && itemRunner.getResponses();
var count = 0;
var empty = 0;
var declarations, constraintValues;
if (itemRunner) {
declarations = currentItemHelper.getDeclarations(runner);
constraintValues = currentItemHelper.guessInteractionConstraintValues(runner);
_.forEach(declarations, function (declaration) {
var attributes = declaration.attributes || {};
var response = responses[attributes.identifier];
var baseType = attributes.baseType;
var cardinality = attributes.cardinality;
count++;
if (
!currentItemHelper.isQuestionAnswered(
response,
baseType,
cardinality,
declaration.defaultValue,
constraintValues[attributes.identifier]
)
) {
empty++;
}
});
}
if (partially === false) {
return count > 0 && empty === 0;
}
return count > 0 && empty < count;
},
/**
* Tells is the current item is valid or not.
* Interaction should put `{ validity: { isValid: false } }` object to `itemState` if it's invalid.
* - note: min/max constraints are handled by `isAnswered` method
* - note: doesn't check if is answered or not.
* @param {Object} runner - testRunner instance
* @returns {Boolean}
*/
isValid: function isValid(runner) {
const itemRunner = runner.itemRunner;
if (itemRunner) {
const itemState = itemRunner && itemRunner.getState();
const declarations = currentItemHelper.getDeclarations(runner);
return !Object.values(declarations).some(function (declaration) {
const attributes = declaration.attributes || {};
const interactionState = itemState[attributes.identifier];
return interactionState && interactionState.validity && interactionState.validity.isValid === false;
});
}
return true;
},
/**
* Gets list of shared stimuli hrefs in the current item
*
* @param {Object} runner - testRunner instance
* @returns {Array}
*/
getStimuliHrefs: function getStimuliHrefs(runner) {
var itemRunner = runner.itemRunner;
var itemBody = (itemRunner._item && itemRunner._item.bdy) || {};
var interactions = itemBody.elements || {};
return _(interactions)
.values()
.filter(function (element) {
return element.qtiClass === 'include';
})
.value()
.map(val => (val.attributes ? val.attributes.href : null));
},
/**
* Find the list of text stimulus ids in the current item
* Depends on the DOM already being loaded
* @param {Object} runner - testRunner instance
* @returns {Array}
*/
getTextStimuliHrefs: function getTextStimuliHrefs(runner) {
var stimuli = this.getStimuliHrefs(runner);
var textStimuli;
if (stimuli.length > 0) {
// Filter the ones containing text:
textStimuli = stimuli.filter(function (stimulusHref) {
var domNode = document.querySelector(`.qti-include[data-href="${stimulusHref}"]`);
return _(domNode.childNodes).some(function (child) {
return child.nodeType === child.TEXT_NODE;
});
});
return textStimuli;
}
return [];
}
};
export default currentItemHelper;