UNPKG

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

Version:
678 lines (654 loc) 24.6 kB
define(['lodash'], function (_) { 'use strict'; _ = _ && Object.prototype.hasOwnProperty.call(_, 'default') ? _['default'] : _; /** * 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 ; */ /** * @typedef {Object} itemStats * @property {Number} questions - the number of questions items * @property {Number} answered - the number of answered questions * @property {Number} flagged - the number of items flagged for review * @property {Number} viewed - the number of viewed items * @property {Number} total - the total number of items * @property {Number} questionsViewed - the number of viewed questions */ /** * Gets an empty stats record * @returns {itemStats} */ function getEmptyStats() { return { questions: 0, answered: 0, flagged: 0, viewed: 0, total: 0, questionsViewed: 0 }; } /** * Defines a helper that provides extractors for an assessment test map */ var map = { /** * Gets the jumps table * @param {Object} map - The assessment test map * @returns {Object} */ getJumps: function getJumps(map) { return map && map.jumps; }, /** * Gets the parts table * @param {Object} map - The assessment test map * @returns {Object} */ getParts: function getParts(map) { return map && map.parts; }, /** * Get sections table * @param {Object} map - The assessment test map * @returns {Object} the sections */ getSections: function getSections(map) { var parts = this.getParts(map), result = {}; _.forEach(parts, function (part) { var sections = part.sections; if (sections) { _.forEach(sections, function (section) { result[section.id] = section; }); } }); return result; }, /** * Get active item from the test map * @param {Object} mapWithActive - The assessment test map which has active part/section/item marked * @returns {Object} the active item */ getActiveItem: function getActiveItem(mapWithActive) { var parts = this.getParts(mapWithActive), result = {}; _.forEach(parts, function (part) { var sections = part.sections; if (sections) { _.forEach(sections, function (section) { if (section.active) { const items = section.items; _.forEach(items, function (item) { if (item.active) { result = item; } }); } }); } }); return result; }, /** * Return the list of remaining sections. * @param {Object} map - The assessment test map * @param {String} sectionId - The next sections will be gathered once this sectionId has been reached * @returns {Object} the next sections */ getNextSections: function getNextSections(map, sectionId) { var sections = this.getSections(map), result = {}, canList = false; _.forEach(sections, function (section) { if (canList) { result[section.id] = section; } if (section.id === sectionId) { canList = true; } }); return result; }, /** * Gets the jump at a particular position * @param {Object} map - The assessment test map * @param {Number} position - The position of the item * @returns {Object} */ getJump: function getJump(map, position) { var jumps = this.getJumps(map); return jumps && jumps[position]; }, /** * Gets a test part by its identifier * @param {Object} map - The assessment test map * @param {String} partName - The identifier of the test part * @returns {Object} */ getPart: function getPart(map, partName) { var parts = this.getParts(map); return parts && parts[partName]; }, /** * Gets a test section by its identifier * @param {Object} map - The assessment test map * @param {String} sectionName - The identifier of the test section * @returns {Object} */ getSection: function getSection(map, sectionName) { var parts = this.getParts(map); var section = null; _.forEach(parts, function (part) { var sections = part.sections; if (sections && sections[sectionName]) { section = sections[sectionName]; return false; } }); return section; }, /** * Gets a test item by its identifier * @param {Object} map - The assessment test map * @param {String} itemIdentifier - The identifier of the test item * @returns {Object} */ getItem(map, itemIdentifier) { const jump = _.find(this.getJumps(map), { identifier: itemIdentifier }); return this.getItemAt(map, jump && jump.position); }, /** * Gets a test item by its identifier * @param {Object} map - The assessment test map * @param {String} itemIdentifier - The identifier of the test item * @returns {String[]} the raw list of categories */ getItemCategories(map, itemIdentifier) { const item = this.getItem(map, itemIdentifier); if (item && Array.isArray(item.categories)) { return item.categories; } return []; }, /** * Check if an item has a category * @param {Object} map - The assessment test map * @param {String} itemIdentifier - The identifier of the test item * @param {String} category - the category to check * @param {Boolean} [fuzzyMatch=false] - if true the prefix or the case doesn't matter * @returns {String[]} the raw list of categories */ hasItemCategory(map, itemIdentifier, category) { let fuzzyMatch = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : false; const taoPrefix = 'x-tao-option-'; const categories = this.getItemCategories(map, itemIdentifier); if (!category || !category.length) { return false; } const exactMatch = categories.includes(category); if (exactMatch) { return true; } if (fuzzyMatch) { //check by adding the prefix first if (!category.startsWith(taoPrefix) && category.includes(`${taoPrefix}${category}`)) { return true; } //compare without the prefix and any case system const normalize = elt => elt.replace(taoPrefix, '').replace(/[-_\s]/g, '').toLowerCase(); return categories.some(itemCategory => normalize(itemCategory) === normalize(category)); } return false; }, /** * Gets the global stats of the assessment test * @param {Object} map - The assessment test map * @returns {itemStats} */ getTestStats: function getTestStats(map) { return map && map.stats; }, /** * Gets the stats of the test part containing a particular position * @param {Object} map - The assessment test map * @param {String} partName - The identifier of the test part * @returns {itemStats} */ getPartStats: function getPartStats(map, partName) { var part = this.getPart(map, partName); return part && part.stats; }, /** * Gets the stats of the test section containing a particular position * @param {Object} map - The assessment test map * @param {String} sectionName - The identifier of the test section * @returns {itemStats} */ getSectionStats: function getSectionStats(map, sectionName) { var section = this.getSection(map, sectionName); return section && section.stats; }, /** * Gets the stats related to a particular scope * @param {Object} map - The assessment test map * @param {Number} position - The current position * @param {String} [scope] - The name of the scope. Can be: test, part, section (default: test) * @returns {itemStats} */ getScopeStats: function getScopeStats(map, position, scope) { var jump = this.getJump(map, position); switch (scope) { case 'section': case 'testSection': return this.getSectionStats(map, jump && jump.section); case 'part': case 'testPart': return this.getPartStats(map, jump && jump.part); // During calculation stats for this case, // we are considiring all unanswered inaccessible items as answered case 'testWithoutInaccessibleItems': { const testStats = this.getTestStats(map); const { position: currentPartPosition, isLinear: isCurrentPartLinear } = this.getPart(map, jump && jump.part); const parts = Object.values(this.getParts(map)).filter(_ref => { let { position: partPosition } = _ref; return partPosition < currentPartPosition; }).sort((a, b) => b - a); // Find the neirest part to which test taker can not navigate const linearPartIndex = isCurrentPartLinear ? 0 : parts.findIndex(_ref2 => { let { isLinear } = _ref2; return isLinear; }); if (linearPartIndex === -1) { return testStats; } // Calculate all unanswered and flagged questions in inaccessible parts const inaccessibleParts = parts.slice(linearPartIndex); const countOfInaccessibleUnasweredQestions = inaccessibleParts.reduce((acc, _ref3) => { let { stats: { questions, answered } } = _ref3; return acc + (questions - answered); }, 0); const countOfInaccessibleFlaggedQestions = inaccessibleParts.reduce((acc, _ref4) => { let { stats: { flagged } } = _ref4; return acc + flagged; }, 0); return Object.assign({}, testStats, { answered: testStats.answered + countOfInaccessibleUnasweredQestions, flagged: testStats.flagged - countOfInaccessibleFlaggedQestions }); } default: case 'test': return this.getTestStats(map); } }, /** * Gets the map of a particular scope from a particular position * @param {Object} map - The assessment test map * @param {Number} position - The current position * @param {String} [scope] - The name of the scope. Can be: test, part, section (default: test) * @returns {object} The scoped map */ getScopeMap: function getScopeMap(map, position, scope) { // need a clone of the map as we will change some properties var scopeMap = _.cloneDeep(map || {}); // gets the current part and section var jump = this.getJump(scopeMap, position); var part = this.getPart(scopeMap, jump && jump.part); var section = this.getSection(scopeMap, jump && jump.section); // reduce the map to the scope part if (scope && scope !== 'test') { scopeMap.parts = {}; if (part) { scopeMap.parts[jump.part] = part; } } // reduce the map to the scope section if (part && (scope === 'section' || scope === 'testSection')) { part.sections = {}; if (section) { part.sections[jump.section] = section; } } // update the stats to reflect the scope if (section) { section.stats = this.computeItemStats(section.items); } if (part) { part.stats = this.computeStats(part.sections); } scopeMap.stats = this.computeStats(scopeMap.parts); return scopeMap; }, /** * Gets the map of a particular scope from a current context * @param {Object} map - The assessment test map * @param {Object} context - The current session context * @param {String} [scope] - The name of the scope. Can be: test, part, section (default: test) * @returns {object} The scoped map */ getScopeMapFromContext: function getScopeMapFromContext(map, context, scope) { // need a clone of the map as we will change some properties var scopeMap = _.cloneDeep(map || {}); var part; var section; // gets the current part and section if (context && context.testPartId) { part = this.getPart(scopeMap, context.testPartId); } if (context && context.sectionId) { section = this.getSection(scopeMap, context.sectionId); } // reduce the map to the scope part if (scope && scope !== 'test') { scopeMap.parts = {}; if (part) { scopeMap.parts[context.testPartId] = part; } } // reduce the map to the scope section if (part && (scope === 'section' || scope === 'testSection')) { part.sections = {}; if (section) { part.sections[context.sectionId] = section; } } // update the stats to reflect the scope if (section) { section.stats = this.computeItemStats(section.items); } if (part) { part.stats = this.computeStats(part.sections); } scopeMap.stats = this.computeStats(scopeMap.parts); return scopeMap; }, /** * Gets the test part containing a particular position * @param {Object} map - The assessment test map * @param {Number} position - The position of the item * @returns {Object} */ getItemPart: function getItemPart(map, position) { var jump = this.getJump(map, position); return this.getPart(map, jump && jump.part); }, /** * Gets the test section containing a particular position * @param {Object} map - The assessment test map * @param {Number} position - The position of the item * @returns {Object} */ getItemSection: function getItemSection(map, position) { var jump = this.getJump(map, position); var part = this.getPart(map, jump && jump.part); var sections = part && part.sections; return sections && sections[jump && jump.section]; }, /** * Gets the item located at a particular position * @param {Object} map - The assessment test map * @param {Number} position - The position of the item * @returns {Object} */ getItemAt: function getItemAt(map, position) { var jump = this.getJump(map, position); var part = this.getPart(map, jump && jump.part); var sections = part && part.sections; var section = sections && sections[jump && jump.section]; var items = section && section.items; return items && items[jump && jump.identifier]; }, /** * Gets the identifier of an existing item * @param {Object} map - The assessment test map * @param {Number|String} position - The position of the item, can already be the identifier * @returns {String} */ getItemIdentifier: function getItemIdentifier(map, position) { var item; if (_.isFinite(position)) { item = this.getItemAt(map, position); } else { item = this.getItem(map, position); } return item && item.id; }, /** * Applies a callback on each item of the provided map * @param {Object} map - The assessment test map * @param {Function} callback(item, section, part, map) - A callback to apply on each item * @returns {Object} */ each: function each(map, callback) { if (_.isFunction(callback)) { _.forEach(map && map.parts, function (part) { _.forEach(part && part.sections, function (section) { _.forEach(section && section.items, function (item) { callback(item, section, part, map); }); }); }); } return map; }, /** * Update the map stats from a particular item * @param {Object} map - The assessment test map * @param {Number} position - The position of the item * @returns {Object} */ updateItemStats: function updateItemStats(map, position) { var jump = this.getJump(map, position); var part = this.getPart(map, jump && jump.part); var sections = part && part.sections; var section = sections && sections[jump && jump.section]; if (section) { section.stats = this.computeItemStats(section.items); } if (part) { part.stats = this.computeStats(part.sections); } if (map) { map.stats = this.computeStats(map.parts); } return map; }, /** * Computes the stats for a list of items * @param {Object} items * @returns {itemStats} */ computeItemStats: function computeItemStats(items) { return _.reduce(items, function accStats(acc, item) { if (!item.informational) { acc.questions++; if (item.answered) { acc.answered++; } if (item.viewed) { acc.questionsViewed++; } } if (item.flagged) { acc.flagged++; } if (item.viewed) { acc.viewed++; } acc.total++; return acc; }, getEmptyStats()); }, /** * Computes the global stats of a collection of stats * @param {Object} collection * @returns {itemStats} */ computeStats: function computeStats(collection) { return _.reduce(collection, function accStats(acc, item) { acc.questions += item.stats.questions; acc.answered += item.stats.answered; acc.flagged += item.stats.flagged; acc.viewed += item.stats.viewed; acc.total += item.stats.total; acc.questionsViewed += item.stats.questionsViewed; return acc; }, getEmptyStats()); }, /** * Patch a testMap with a partial testMap. * * If the currentMap is null or the scope is test, * we just use the partialMap as it is. * * Indexes, position and stats will be (re)built. * * @param {Object} currentMap - the map to patch * @param {Object} partialMap - the patch * @param {String} partialMap.scope - indicate the scope of the patch (test, part or section) * @returns {Object} the patched testMap * @throws {TypeError} if the partialMap is no a map */ patch: function patch(currentMap, partialMap) { var self = this; var targetMap; if (!_.isPlainObject(partialMap) || !partialMap.parts) { throw new TypeError('Invalid test map format'); } if (!currentMap || partialMap.scope === 'test') { targetMap = _.cloneDeep(partialMap); } else { targetMap = _.cloneDeep(currentMap); _.forEach(partialMap.parts, function (partialPart, targetPartId) { if (partialMap.scope === 'part') { //replace the target part targetMap.parts[targetPartId] = _.cloneDeep(partialPart); } if (partialMap.scope === 'section') { _.forEach(partialPart.sections, function (partialSection, targetSectionId) { //replace the target section targetMap.parts[targetPartId].sections[targetSectionId] = _.cloneDeep(partialSection); //compte new section stats targetMap.parts[targetPartId].sections[targetSectionId].stats = self.computeItemStats(targetMap.parts[targetPartId].sections[targetSectionId].items); }); } //compte new/updated part stats targetMap.parts[targetPartId].stats = self.computeStats(targetMap.parts[targetPartId].sections); }); //compte updated test stats targetMap.stats = this.computeStats(targetMap.parts); } //the updated map can have a different size than the original targetMap = this.reindex(targetMap); return targetMap; }, /** * Rebuild the indexes, positions of all map parts. * Then recreate the jump table. * * @param {Object} map - the map to reindex * @returns {Object} the brand new map * @throws {TypeError} if the map is no a map */ reindex: function reindex(map) { var offset = 0; var offsetPart = 0; var offsetSection = 0; var lastPartId; var lastSectionId; if (!_.isPlainObject(map) || !map.parts) { throw new TypeError('Invalid test map format'); } //remove the jump table map.jumps = []; //browse the test map, by position _.sortBy(map && map.parts, 'position').forEach(function (part) { _.sortBy(part && part.sections, 'position').forEach(function (section) { _.sortBy(section && section.items, 'position').forEach(function (item) { if (lastPartId !== part.id) { offsetPart = 0; lastPartId = part.id; part.position = offset; } if (lastSectionId !== section.id) { offsetSection = 0; lastSectionId = section.id; section.position = offset; } item.position = offset; item.index = offsetSection + 1; item.positionInPart = offsetPart; item.positionInSection = offsetSection; map.jumps[offset] = { identifier: item.id, section: section.id, part: part.id, position: offset }; offset++; offsetSection++; offsetPart++; }); }); }); return map; }, /** * Create the jump table for a test map * * @param {Object} map - the map * @returns {Object} the brand new map with a jump table * @throws {TypeError} if the map is no a map */ createJumpTable: function createJumpTable(map) { if (!_.isPlainObject(map) || !map.parts) { throw new TypeError('Invalid test map format'); } map.jumps = []; this.each(map, function (item, section, part) { var offset = item.position; map.jumps[offset] = { identifier: item.id, section: section.id, part: part.id, position: offset }; }); return map; } }; return map; });