From 8e6f6c47e9904ef98b9c6e98ebc740a53384ea4e Mon Sep 17 00:00:00 2001 From: Jonathan Putney Date: Fri, 15 Nov 2019 17:24:30 -0500 Subject: [PATCH] Working on the saving of data --- src/AICC.js | 11 +- src/BaseAPI.js | 193 ++++++++++++++++++++++++--------- src/Scorm12API.js | 102 ++++++++++++++++- src/Scorm2004API.js | 117 ++++++++++++++++++-- src/cmi/scorm12_cmi.js | 2 +- src/constants/api_constants.js | 14 +++ src/utilities.js | 63 +++++++++++ test/Scorm12API.spec.js | 68 +++++++++++- test/cmi/aicc_cmi.spec.js | 4 +- test/cmi/scorm12_cmi.spec.js | 4 +- test/utilities.spec.js | 56 ++++++++++ 11 files changed, 559 insertions(+), 75 deletions(-) diff --git a/src/AICC.js b/src/AICC.js index 75b1176..c9bb9c8 100644 --- a/src/AICC.js +++ b/src/AICC.js @@ -13,9 +13,16 @@ import { export default class AICC extends Scorm12API { /** * Constructor to create AICC API object + * @param {object} settings */ - constructor() { - super(); + constructor(settings: {}) { + const finalSettings = { + ...{ + mastery_override: false, + }, ...settings, + }; + + super(finalSettings); this.cmi = new CMI(); this.nav = new NAV(); diff --git a/src/BaseAPI.js b/src/BaseAPI.js index ef6522f..8cf1926 100644 --- a/src/BaseAPI.js +++ b/src/BaseAPI.js @@ -1,19 +1,8 @@ // @flow import {CMIArray} from './cmi/common'; import {ValidationError} from './exceptions'; - -const api_constants = { - SCORM_TRUE: 'true', - SCORM_FALSE: 'false', - STATE_NOT_INITIALIZED: 0, - STATE_INITIALIZED: 1, - STATE_TERMINATED: 2, - LOG_LEVEL_DEBUG: 1, - LOG_LEVEL_INFO: 2, - LOG_LEVEL_WARNING: 3, - LOG_LEVEL_ERROR: 4, - LOG_LEVEL_NONE: 5, -}; +import {scorm12_error_codes} from './constants/error_codes'; +import {global_constants} from './constants/api_constants'; /** * Base API class for AICC, SCORM 1.2, and SCORM 2004. Should be considered @@ -22,24 +11,34 @@ const api_constants = { export default class BaseAPI { #timeout; #error_codes; + #settings = { + autocommit: false, + autocommitSeconds: 60, + lmsCommitUrl: false, + dataCommitFormat: 'json', // valid formats are 'json' or 'flattened', 'params' + }; cmi; + startingData: {}; /** * Constructor for Base API class. Sets some shared API fields, as well as * sets up options for the API. * @param {object} error_codes + * @param {object} settings */ - constructor(error_codes) { + constructor(error_codes, settings) { if (new.target === BaseAPI) { throw new TypeError('Cannot construct BaseAPI instances directly'); } - this.currentState = api_constants.STATE_NOT_INITIALIZED; - this.apiLogLevel = api_constants.LOG_LEVEL_ERROR; + this.currentState = global_constants.STATE_NOT_INITIALIZED; + this.apiLogLevel = global_constants.LOG_LEVEL_ERROR; this.lastErrorCode = 0; this.listenerArray = []; this.#timeout = null; this.#error_codes = error_codes; + + this.settings = settings; } /** @@ -53,26 +52,42 @@ export default class BaseAPI { callbackName: String, initializeMessage?: String, terminationMessage?: String) { - let returnValue = api_constants.SCORM_FALSE; + let returnValue = global_constants.SCORM_FALSE; if (this.isInitialized()) { this.throwSCORMError(this.#error_codes.INITIALIZED, initializeMessage); } else if (this.isTerminated()) { this.throwSCORMError(this.#error_codes.TERMINATED, terminationMessage); } else { - this.currentState = api_constants.STATE_INITIALIZED; + this.currentState = global_constants.STATE_INITIALIZED; this.lastErrorCode = 0; - returnValue = api_constants.SCORM_TRUE; + returnValue = global_constants.SCORM_TRUE; this.processListeners(callbackName); } this.apiLog(callbackName, null, 'returned: ' + returnValue, - api_constants.LOG_LEVEL_INFO); + global_constants.LOG_LEVEL_INFO); this.clearSCORMError(returnValue); return returnValue; } + /** + * Getter for #settings + * @return {object} + */ + get settings() { + return this.#settings; + } + + /** + * Setter for #settings + * @param {object} settings + */ + set settings(settings: Object) { + this.#settings = {...this.#settings, ...settings}; + } + /** * Terminates the current run of the API * @param {string} callbackName @@ -82,19 +97,19 @@ export default class BaseAPI { terminate( callbackName: String, checkTerminated: boolean) { - let returnValue = api_constants.SCORM_FALSE; + let returnValue = global_constants.SCORM_FALSE; if (this.checkState(checkTerminated, this.#error_codes.TERMINATION_BEFORE_INIT, this.#error_codes.MULTIPLE_TERMINATION)) { if (checkTerminated) this.lastErrorCode = 0; - this.currentState = api_constants.STATE_TERMINATED; - returnValue = api_constants.SCORM_TRUE; + this.currentState = global_constants.STATE_TERMINATED; + returnValue = global_constants.SCORM_TRUE; this.processListeners(callbackName); } this.apiLog(callbackName, null, 'returned: ' + returnValue, - api_constants.LOG_LEVEL_INFO); + global_constants.LOG_LEVEL_INFO); this.clearSCORMError(returnValue); return returnValue; @@ -123,7 +138,7 @@ export default class BaseAPI { } this.apiLog(callbackName, CMIElement, ': returned: ' + returnValue, - api_constants.LOG_LEVEL_INFO); + global_constants.LOG_LEVEL_INFO); this.clearSCORMError(returnValue); return returnValue; @@ -143,7 +158,7 @@ export default class BaseAPI { checkTerminated: boolean, CMIElement, value) { - let returnValue = api_constants.SCORM_FALSE; + let returnValue = global_constants.SCORM_FALSE; if (this.checkState(checkTerminated, this.#error_codes.STORE_BEFORE_INIT, this.#error_codes.STORE_AFTER_TERM)) { @@ -153,7 +168,7 @@ export default class BaseAPI { } catch (e) { if (e instanceof ValidationError) { this.lastErrorCode = e.errorCode; - returnValue = api_constants.SCORM_FALSE; + returnValue = global_constants.SCORM_FALSE; } else { this.throwSCORMError(this.#error_codes.GENERAL); } @@ -162,12 +177,20 @@ export default class BaseAPI { } if (returnValue === undefined) { - returnValue = api_constants.SCORM_FALSE; + returnValue = global_constants.SCORM_FALSE; + } + + // If we didn't have any errors while setting the data, go ahead and + // schedule a commit, if autocommit is turned on + if (String(this.lastErrorCode) === '0') { + if (this.#settings.autocommit && this.#timeout === undefined) { + this.scheduleCommit(this.#settings.autocommitSeconds * 1000); + } } this.apiLog(callbackName, CMIElement, ': ' + value + ': result: ' + returnValue, - api_constants.LOG_LEVEL_INFO); + global_constants.LOG_LEVEL_INFO); this.clearSCORMError(returnValue); return returnValue; @@ -182,17 +205,29 @@ export default class BaseAPI { commit( callbackName: String, checkTerminated: boolean) { - let returnValue = api_constants.SCORM_FALSE; + this.clearScheduledCommit(); + + let returnValue = global_constants.SCORM_FALSE; if (this.checkState(checkTerminated, this.#error_codes.COMMIT_BEFORE_INIT, this.#error_codes.COMMIT_AFTER_TERM)) { + const result = this.storeData(false); + if (result.errorCode && result.errorCode > 0) { + this.throwSCORMError(result.errorCode); + } + returnValue = result.result ? + result.result : global_constants.SCORM_FALSE; + + this.apiLog(callbackName, 'HttpRequest', ' Result: ' + returnValue, + global_constants.LOG_LEVEL_DEBUG); + if (checkTerminated) this.lastErrorCode = 0; - returnValue = api_constants.SCORM_TRUE; + this.processListeners(callbackName); } this.apiLog(callbackName, null, 'returned: ' + returnValue, - api_constants.LOG_LEVEL_INFO); + global_constants.LOG_LEVEL_INFO); this.clearSCORMError(returnValue); return returnValue; @@ -209,7 +244,7 @@ export default class BaseAPI { this.processListeners(callbackName); this.apiLog(callbackName, null, 'returned: ' + returnValue, - api_constants.LOG_LEVEL_INFO); + global_constants.LOG_LEVEL_INFO); return returnValue; } @@ -230,7 +265,7 @@ export default class BaseAPI { } this.apiLog(callbackName, null, 'returned: ' + returnValue, - api_constants.LOG_LEVEL_INFO); + global_constants.LOG_LEVEL_INFO); return returnValue; } @@ -251,7 +286,7 @@ export default class BaseAPI { } this.apiLog(callbackName, null, 'returned: ' + returnValue, - api_constants.LOG_LEVEL_INFO); + global_constants.LOG_LEVEL_INFO); return returnValue; } @@ -296,13 +331,13 @@ export default class BaseAPI { if (messageLevel >= this.apiLogLevel) { switch (messageLevel) { - case api_constants.LOG_LEVEL_ERROR: + case global_constants.LOG_LEVEL_ERROR: console.error(logMessage); break; - case api_constants.LOG_LEVEL_WARNING: + case global_constants.LOG_LEVEL_WARNING: console.warn(logMessage); break; - case api_constants.LOG_LEVEL_INFO: + case global_constants.LOG_LEVEL_INFO: console.info(logMessage); break; } @@ -426,12 +461,12 @@ export default class BaseAPI { _commonSetCMIValue( methodName: String, scorm2004: boolean, CMIElement, value) { if (!CMIElement || CMIElement === '') { - return api_constants.SCORM_FALSE; + return global_constants.SCORM_FALSE; } const structure = CMIElement.split('.'); let refObject = this; - let returnValue = api_constants.SCORM_FALSE; + let returnValue = global_constants.SCORM_FALSE; let foundFirstIndex = false; const invalidErrorMessage = `The data model element passed to ${methodName} (${CMIElement}) is not a valid SCORM data model element.`; @@ -455,7 +490,7 @@ export default class BaseAPI { if (!scorm2004 || this.lastErrorCode === 0) { refObject[attribute] = value; - returnValue = api_constants.SCORM_TRUE; + returnValue = global_constants.SCORM_TRUE; } } } else { @@ -496,10 +531,10 @@ export default class BaseAPI { } } - if (returnValue === api_constants.SCORM_FALSE) { + if (returnValue === global_constants.SCORM_FALSE) { this.apiLog(methodName, null, `There was an error setting the value for: ${CMIElement}, value of: ${value}`, - api_constants.LOG_LEVEL_WARNING); + global_constants.LOG_LEVEL_WARNING); } return returnValue; @@ -604,9 +639,9 @@ export default class BaseAPI { if (refObject === null || refObject === undefined) { if (!scorm2004) { if (attribute === '_children') { - this.throwSCORMError(202); + this.throwSCORMError(scorm12_error_codes.CHILDREN_ERROR); } else if (attribute === '_count') { - this.throwSCORMError(203); + this.throwSCORMError(scorm12_error_codes.COUNT_ERROR); } } } else { @@ -620,7 +655,7 @@ export default class BaseAPI { * @return {boolean} */ isInitialized() { - return this.currentState === api_constants.STATE_INITIALIZED; + return this.currentState === global_constants.STATE_INITIALIZED; } /** @@ -629,7 +664,7 @@ export default class BaseAPI { * @return {boolean} */ isNotInitialized() { - return this.currentState === api_constants.STATE_NOT_INITIALIZED; + return this.currentState === global_constants.STATE_NOT_INITIALIZED; } /** @@ -638,7 +673,7 @@ export default class BaseAPI { * @return {boolean} */ isTerminated() { - return this.currentState === api_constants.STATE_TERMINATED; + return this.currentState === global_constants.STATE_TERMINATED; } /** @@ -702,7 +737,7 @@ export default class BaseAPI { } this.apiLog('throwSCORMError', null, errorNumber + ': ' + message, - api_constants.LOG_LEVEL_ERROR); + global_constants.LOG_LEVEL_ERROR); this.lastErrorCode = String(errorNumber); } @@ -713,11 +748,24 @@ export default class BaseAPI { * @param {string} success */ clearSCORMError(success: String) { - if (success !== undefined && success !== api_constants.SCORM_FALSE) { + if (success !== undefined && success !== global_constants.SCORM_FALSE) { this.lastErrorCode = 0; } } + /** + * Attempts to store the data to the LMS, logs data if no LMS configured + * APIs that inherit BaseAPI should override this function + * + * @param {boolean} _calculateTotalTime + * @return {string} + * @abstract + */ + storeData(_calculateTotalTime) { + throw new Error( + 'The storeData method has not been implemented'); + } + /** * Loads CMI data from a JSON object. * @@ -733,6 +781,8 @@ export default class BaseAPI { CMIElement = CMIElement || 'cmi'; + this.startingData = json; + for (const key in json) { if ({}.hasOwnProperty.call(json, key) && json[key]) { const currentCMIElement = CMIElement + '.' + key; @@ -769,10 +819,22 @@ export default class BaseAPI { * @return {object} */ renderCMIToJSONObject() { - const cmi = this.cmi; // Do we want/need to return fields that have no set value? // return JSON.stringify({ cmi }, (k, v) => v === undefined ? null : v, 2); - return JSON.parse(JSON.stringify(cmi)); + return JSON.parse(this.renderCMIToJSONString()); + } + + /** + * Render the cmi object to the proper format for LMS commit + * APIs that inherit BaseAPI should override this function + * + * @param {boolean} _terminateCommit + * @return {*} + * @abstract + */ + renderCommitCMI(_terminateCommit) { + throw new Error( + 'The storeData method has not been implemented'); } /** @@ -783,6 +845,33 @@ export default class BaseAPI { this.cmi.getCurrentTotalTime(); } + /** + * Send the request to the LMS + * @param {string} url + * @param {object|Array} params + * @return {object} + */ + processHttpRequest(url: String, params) { + const httpReq = new XMLHttpRequest(); + httpReq.open('POST', url, false); + httpReq.setRequestHeader('Content-Type', + 'application/x-www-form-urlencoded'); + try { + if (params instanceof Array) { + httpReq.send(params.join('&')); + } else { + httpReq.send(params); + } + } catch (e) { + return { + 'result': global_constants.SCORM_FALSE, + 'errorCode': this.#error_codes.GENERAL, + }; + } + + return JSON.parse(httpReq.responseText); + } + /** * Throws a SCORM error * diff --git a/src/Scorm12API.js b/src/Scorm12API.js index 9730325..d8c7053 100644 --- a/src/Scorm12API.js +++ b/src/Scorm12API.js @@ -8,9 +8,8 @@ import { CMIObjectivesObject, } from './cmi/scorm12_cmi'; import * as Utilities from './utilities'; -import {scorm12_constants} from './constants/api_constants'; +import {global_constants, scorm12_constants} from './constants/api_constants'; import {scorm12_error_codes} from './constants/error_codes'; -import {scorm12_regex} from './constants/regex'; const constants = scorm12_constants; @@ -20,9 +19,16 @@ const constants = scorm12_constants; export default class Scorm12API extends BaseAPI { /** * Constructor for SCORM 1.2 API + * @param {object} settings */ - constructor() { - super(scorm12_error_codes); + constructor(settings: {}) { + const finalSettings = { + ...{ + mastery_override: false, + }, ...settings, + }; + + super(scorm12_error_codes, finalSettings); this.cmi = new CMI(); // Rename functions to match 1.2 Spec and expose to modules @@ -149,9 +155,11 @@ export default class Scorm12API extends BaseAPI { if (this.stringMatches(CMIElement, 'cmi\\.objectives\\.\\d')) { newChild = new CMIObjectivesObject(); - } else if (foundFirstIndex && this.stringMatches(CMIElement, 'cmi\\.interactions\\.\\d\\.correct_responses\\.\\d')) { + } else if (foundFirstIndex && this.stringMatches(CMIElement, + 'cmi\\.interactions\\.\\d\\.correct_responses\\.\\d')) { newChild = new CMIInteractionsCorrectResponsesObject(); - } else if (foundFirstIndex && this.stringMatches(CMIElement, 'cmi\\.interactions\\.\\d\\.objectives\\.\\d')) { + } else if (foundFirstIndex && this.stringMatches(CMIElement, + 'cmi\\.interactions\\.\\d\\.objectives\\.\\d')) { newChild = new CMIInteractionsObjectivesObject(); } else if (this.stringMatches(CMIElement, 'cmi\\.interactions\\.\\d')) { newChild = new CMIInteractionsObject(); @@ -201,4 +209,86 @@ export default class Scorm12API extends BaseAPI { // Data Model this.cmi = newAPI.cmi; } + + /** + * Render the cmi object to the proper format for LMS commit + * + * @param {boolean} terminateCommit + * @return {object|Array} + */ + renderCommitCMI(terminateCommit: boolean) { + const cmiExport = this.renderCMIToJSONObject(); + + if (terminateCommit) { + cmiExport.cmi.core.total_time = this.getCurrentTotalTime(); + } + + const result = []; + const flattened = Utilities.flatten(cmiExport); + switch (this.settings.dataCommitFormat) { + case 'flattened': + return Utilities.flatten(cmiExport); + case 'params': + for (const item in flattened) { + if ({}.hasOwnProperty.call(flattened, item)) { + result.push(`${item}=${flattened[item]}`); + } + } + return result; + case 'json': + default: + return cmiExport; + } + } + + /** + * Attempts to store the data to the LMS + * + * @param {boolean} terminateCommit + * @return {string} + */ + storeData(terminateCommit: boolean) { + if (terminateCommit) { + const originalStatus = this.cmi.core.lesson_status; + if (originalStatus === 'not attempted') { + this.cmi.core.lesson_status = 'completed'; + } + + if (this.cmi.core.lesson_mode === 'normal') { + if (this.cmi.core.credit === 'credit') { + if (this.settings.mastery_override && + this.cmi.student_data.mastery_score !== '' && + this.cmi.core.score.raw !== '') { + if (parseFloat(this.cmi.core.score.raw) >= + parseFloat(this.cmi.student_data.mastery_score)) { + this.cmi.core.lesson_status = 'passed'; + } else { + this.cmi.core.lesson_status = 'failed'; + } + } + } + } else if (this.cmi.core.lesson_mode === 'browse') { + if ((this.startingData?.cmi?.core?.lesson_status || '') === '' && + originalStatus === 'not attempted') { + this.cmi.core.lesson_status = 'browsed'; + } + } + } + + const commitObject = this.renderCommitCMI(terminateCommit); + + if (this.settings.lmsCommitUrl) { + if (this.apiLogLevel === global_constants.LOG_LEVEL_DEBUG) { + console.debug('Commit (terminated: ' + + (terminateCommit ? 'yes' : 'no') + '): '); + console.debug(commitObject); + } + return this.processHttpRequest(this.settings.lmsCommitUrl, commitObject); + } else { + console.log('Commit (terminated: ' + + (terminateCommit ? 'yes' : 'no') + '): '); + console.log(commitObject); + return global_constants.SCORM_TRUE; + } + } } diff --git a/src/Scorm2004API.js b/src/Scorm2004API.js index fb197b0..bca50a1 100644 --- a/src/Scorm2004API.js +++ b/src/Scorm2004API.js @@ -9,8 +9,8 @@ import { CMIInteractionsObjectivesObject, CMIObjectivesObject, } from './cmi/scorm2004_cmi'; -import * as Util from './utilities'; -import {scorm2004_constants} from './constants/api_constants'; +import * as Utilities from './utilities'; +import {global_constants, scorm2004_constants} from './constants/api_constants'; import {scorm2004_error_codes} from './constants/error_codes'; import {correct_responses} from './constants/response_constants'; import {valid_languages} from './constants/language_constants'; @@ -26,9 +26,16 @@ export default class Scorm2004API extends BaseAPI { /** * Constructor for SCORM 2004 API + * @param {object} settings */ - constructor() { - super(scorm2004_error_codes); + constructor(settings: {}) { + const finalSettings = { + ...{ + mastery_override: false, + }, ...settings, + }; + + super(scorm2004_error_codes, finalSettings); this.cmi = new CMI(); this.adl = new ADL(); @@ -146,7 +153,8 @@ export default class Scorm2004API extends BaseAPI { if (this.stringMatches(CMIElement, 'cmi\\.objectives\\.\\d')) { newChild = new CMIObjectivesObject(); - } else if (foundFirstIndex && this.stringMatches(CMIElement, 'cmi\\.interactions\\.\\d\\.correct_responses\\.\\d')) { + } else if (foundFirstIndex && this.stringMatches(CMIElement, + 'cmi\\.interactions\\.\\d\\.correct_responses\\.\\d')) { const parts = CMIElement.split('.'); const index = Number(parts[2]); const interaction = this.cmi.interactions.childArray[index]; @@ -183,13 +191,16 @@ export default class Scorm2004API extends BaseAPI { if (this.lastErrorCode === 0) { newChild = new CMIInteractionsCorrectResponsesObject(); } - } else if (foundFirstIndex && this.stringMatches(CMIElement, 'cmi\\.interactions\\.\\d\\.objectives\\.\\d')) { + } else if (foundFirstIndex && this.stringMatches(CMIElement, + 'cmi\\.interactions\\.\\d\\.objectives\\.\\d')) { newChild = new CMIInteractionsObjectivesObject(); } else if (this.stringMatches(CMIElement, 'cmi\\.interactions\\.\\d')) { newChild = new CMIInteractionsObject(); - } else if (this.stringMatches(CMIElement, 'cmi\\.comments_from_learner\\.\\d')) { + } else if (this.stringMatches(CMIElement, + 'cmi\\.comments_from_learner\\.\\d')) { newChild = new CMICommentsObject(); - } else if (this.stringMatches(CMIElement, 'cmi\\.comments_from_lms\\.\\d')) { + } else if (this.stringMatches(CMIElement, + 'cmi\\.comments_from_lms\\.\\d')) { newChild = new CMICommentsObject(true); } @@ -420,4 +431,94 @@ export default class Scorm2004API extends BaseAPI { this.cmi = newAPI.cmi; this.adl = newAPI.adl; } + + /** + * Render the cmi object to the proper format for LMS commit + * + * @param {boolean} terminateCommit + * @return {object|Array} + */ + renderCommitCMI(terminateCommit: boolean) { + const cmi = this.renderCMIToJSONObject(); + + if (terminateCommit) { + cmi.total_time = this.getCurrentTotalTime(); + } + + const result = []; + const flattened = Utilities.flatten(cmi); + switch (this.settings.dataCommitFormat) { + case 'flattened': + return Utilities.flatten(cmi); + case 'params': + for (const item in flattened) { + if ({}.hasOwnProperty.call(flattened, item)) { + result.push(`${item}=${flattened[item]}`); + } + } + return result; + case 'json': + default: + return cmi; + } + } + + /** + * Attempts to store the data to the LMS + * + * @param {boolean} terminateCommit + * @return {string} + */ + storeData(terminateCommit: boolean) { + if (terminateCommit) { + if (this.cmi.lesson_mode === 'normal') { + if (this.cmi.credit === 'credit') { + if (this.cmi.completion_threshold && this.cmi.progress_measure) { + if (this.cmi.progress_measure >= this.cmi.completion_threshold) { + this.cmi.completion_status = 'completed'; + } else { + this.cmi.completion_status = 'incomplete'; + } + } + if (this.cmi.scaled_passing_score !== null && + this.cmi.score.scaled !== '') { + if (this.cmi.score.scaled >= this.cmi.scaled_passing_score) { + this.cmi.success_status = 'passed'; + } else { + this.cmi.success_status = 'failed'; + } + } + } + } + } + + let navRequest = false; + if (this.adl.nav.request !== (this.startingData?.adl?.nav?.request || '')) { + this.adl.nav.request = encodeURIComponent(this.adl.nav.request); + navRequest = true; + } + + const commitObject = this.renderCommitCMI(terminateCommit); + + if (this.settings.lmsCommitUrl) { + if (this.apiLogLevel === global_constants.LOG_LEVEL_DEBUG) { + console.debug('Commit (terminated: ' + + (terminateCommit ? 'yes' : 'no') + '): '); + console.debug(commitObject); + } + const result = this.processHttpRequest(this.settings.lmsCommitUrl, + commitObject); + // check if this is a sequencing call, and then call the necessary JS + if (navRequest && result.navRequest !== undefined && + result.navRequest !== '') { + Function(`"use strict";(() => { ${result.navRequest} })()`)(); + } + return result; + } else { + console.log('Commit (terminated: ' + + (terminateCommit ? 'yes' : 'no') + '): '); + console.log(commitObject); + return global_constants.SCORM_TRUE; + } + } } diff --git a/src/cmi/scorm12_cmi.js b/src/cmi/scorm12_cmi.js index 9d40773..c0e4730 100644 --- a/src/cmi/scorm12_cmi.js +++ b/src/cmi/scorm12_cmi.js @@ -290,7 +290,7 @@ class CMICore extends BaseCMI { #student_name = ''; #lesson_location = ''; #credit = ''; - #lesson_status = ''; + #lesson_status = 'not attempted'; #entry = ''; #total_time = ''; #lesson_mode = 'normal'; diff --git a/src/constants/api_constants.js b/src/constants/api_constants.js index 0daead5..538f831 100644 --- a/src/constants/api_constants.js +++ b/src/constants/api_constants.js @@ -1,4 +1,18 @@ // @flow + +export const global_constants = { + SCORM_TRUE: 'true', + SCORM_FALSE: 'false', + STATE_NOT_INITIALIZED: 0, + STATE_INITIALIZED: 1, + STATE_TERMINATED: 2, + LOG_LEVEL_DEBUG: 1, + LOG_LEVEL_INFO: 2, + LOG_LEVEL_WARNING: 3, + LOG_LEVEL_ERROR: 4, + LOG_LEVEL_NONE: 5, +}; + export const scorm12_constants = { // Children lists cmi_children: 'core,suspend_data,launch_data,comments,objectives,student_data,student_preference,interactions', diff --git a/src/utilities.js b/src/utilities.js index c2f7347..2991a64 100644 --- a/src/utilities.js +++ b/src/utilities.js @@ -157,3 +157,66 @@ export function addHHMMSSTimeStrings( const secondSeconds = getTimeAsSeconds(second, timeRegex); return getSecondsAsHHMMSS(firstSeconds + secondSeconds); } + +/** + * Flatten a JSON object down to string paths for each values + * @param {object} data + * @return {object} + */ +export function flatten(data) { + const result = {}; + + /** + * Recurse through the object + * @param {*} cur + * @param {*} prop + */ + function recurse(cur, prop) { + if (Object(cur) !== cur) { + result[prop] = cur; + } else if (Array.isArray(cur)) { + for (let i = 0, l = cur.length; i < l; i++) { + recurse(cur[i], prop + '[' + i + ']'); + if (l === 0) result[prop] = []; + } + } else { + let isEmpty = true; + for (const p in cur) { + if ({}.hasOwnProperty.call(cur, p)) { + isEmpty = false; + recurse(cur[p], prop ? prop + '.' + p : p); + } + } + if (isEmpty && prop) result[prop] = {}; + } + } + + recurse(data, ''); + return result; +} + +/** + * Un-flatten a flat JSON object + * @param {object} data + * @return {object} + */ +export function unflatten(data) { + 'use strict'; + if (Object(data) !== data || Array.isArray(data)) return data; + const regex = /\.?([^.[\]]+)|\[(\d+)]/g; + const result = {}; + for (const p in data) { + if ({}.hasOwnProperty.call(data, p)) { + let cur = result; + let prop = ''; + let m = regex.exec(p); + while (m) { + cur = cur[prop] || (cur[prop] = (m[2] ? [] : {})); + prop = m[2] || m[1]; + m = regex.exec(p); + } + cur[prop] = data[p]; + } + } + return result[''] || result; +} diff --git a/test/Scorm12API.spec.js b/test/Scorm12API.spec.js index 0d0e8eb..223ff73 100644 --- a/test/Scorm12API.spec.js +++ b/test/Scorm12API.spec.js @@ -4,13 +4,13 @@ import Scorm12API from '../src/Scorm12API'; import * as h from './api_helpers'; import {scorm12_error_codes} from '../src/constants/error_codes'; -const api = () => { - const API = new Scorm12API(); +const api = (settings = {}) => { + const API = new Scorm12API(settings); API.apiLogLevel = 1; return API; }; -const apiInitialized = () => { - const API = api(); +const apiInitialized = (settings = {}) => { + const API = api(settings); API.lmsInitialize(); return API; }; @@ -271,4 +271,64 @@ describe('SCORM 1.2 API Tests', () => { firstAPI.cmi.core.student_id, ).to.equal('student_2'); }); + + describe('storeData()', () => { + it('should set cmi.core.lesson_status to "completed"', () => { + const scorm12API = api(); + scorm12API.storeData(true); + expect(scorm12API.cmi.core.lesson_status).to.equal('completed'); + }); + it('should set cmi.core.lesson_status to "browsed"', () => { + const scorm12API = api(); + scorm12API.cmi.core.lesson_mode = 'browse'; + scorm12API.storeData(true); + expect(scorm12API.cmi.core.lesson_status).to.equal('browsed'); + }); + it('should set cmi.core.lesson_status to "browsed" - Initial Status', + () => { + const scorm12API = api(); + scorm12API.startingData = {'cmi': {'core': {'lesson_status': ''}}}; + scorm12API.cmi.core.lesson_mode = 'browse'; + scorm12API.storeData(true); + expect(scorm12API.cmi.core.lesson_status).to.equal('browsed'); + }); + it('should set cmi.core.lesson_status to "passed" - mastery_override: true', + () => { + const scorm12API = api({mastery_override: true}); + scorm12API.cmi.core.credit = 'credit'; + scorm12API.cmi.student_data.mastery_score = '60.0'; + scorm12API.cmi.core.score.raw = '75.0'; + scorm12API.storeData(true); + expect(scorm12API.cmi.core.lesson_status).to.equal('passed'); + }); + it('should set cmi.core.lesson_status to "failed" - mastery_override: true', + () => { + const scorm12API = api({mastery_override: true}); + scorm12API.cmi.core.credit = 'credit'; + scorm12API.cmi.student_data.mastery_score = '60.0'; + scorm12API.cmi.core.score.raw = '55.0'; + scorm12API.storeData(true); + expect(scorm12API.cmi.core.lesson_status).to.equal('failed'); + }); + it('should set cmi.core.lesson_status to "passed" - mastery_override: false', + () => { + const scorm12API = api({mastery_override: false}); + scorm12API.cmi.core.lesson_status = 'failed'; // module author wanted the user to pass, so we don't override + scorm12API.cmi.core.credit = 'credit'; + scorm12API.cmi.student_data.mastery_score = '60.0'; + scorm12API.cmi.core.score.raw = '75.0'; + scorm12API.storeData(true); + expect(scorm12API.cmi.core.lesson_status).to.equal('failed'); + }); + it('should set cmi.core.lesson_status to "failed" - mastery_override: false', + () => { + const scorm12API = api({mastery_override: false}); + scorm12API.cmi.core.lesson_status = 'passed'; // module author wanted the user to pass, so we don't override + scorm12API.cmi.core.credit = 'credit'; + scorm12API.cmi.student_data.mastery_score = '60.0'; + scorm12API.cmi.core.score.raw = '55.0'; + scorm12API.storeData(true); + expect(scorm12API.cmi.core.lesson_status).to.equal('passed'); + }); + }); }); diff --git a/test/cmi/aicc_cmi.spec.js b/test/cmi/aicc_cmi.spec.js index 468630b..6452515 100644 --- a/test/cmi/aicc_cmi.spec.js +++ b/test/cmi/aicc_cmi.spec.js @@ -91,6 +91,7 @@ describe('AICC CMI Tests', () => { h.checkRead({ cmi: cmi(), fieldName: 'cmi.core.lesson_status', + expectedValue: 'not attempted', }); h.checkValidValues({ cmi: cmi(), @@ -334,6 +335,7 @@ describe('AICC CMI Tests', () => { h.checkRead({ cmi: cmi(), fieldName: 'cmi.core.lesson_status', + expectedValue: 'not attempted', }); h.checkValidValues({ cmi: cmi(), @@ -517,7 +519,7 @@ describe('AICC CMI Tests', () => { ). to. equal( - '{"suspend_data":"","launch_data":"","comments":"","comments_from_lms":"","core":{"student_id":"","student_name":"","lesson_location":"","credit":"","lesson_status":"","entry":"","total_time":"","lesson_mode":"normal","exit":"","session_time":"00:00:00","score":{"raw":"","min":"","max":"100"}},"objectives":{"0":{"id":"","status":"","score":{"raw":"","min":"","max":"100"}}},"student_data":{"mastery_score":"","max_time_allowed":"","time_limit_action":"","tries":{"0":{"status":"","time":"","score":{"raw":"","min":"","max":"100"}}}},"student_preference":{"audio":"","language":"","speed":"","text":""},"interactions":{"0":{"id":"","time":"","type":"","weighting":"","student_response":"","result":"","latency":"","objectives":{},"correct_responses":{}}},"evaluation":{"comments":{"0":{"content":"","location":"","time":""}}}}'); + '{"suspend_data":"","launch_data":"","comments":"","comments_from_lms":"","core":{"student_id":"","student_name":"","lesson_location":"","credit":"","lesson_status":"not attempted","entry":"","total_time":"","lesson_mode":"normal","exit":"","session_time":"00:00:00","score":{"raw":"","min":"","max":"100"}},"objectives":{"0":{"id":"","status":"","score":{"raw":"","min":"","max":"100"}}},"student_data":{"mastery_score":"","max_time_allowed":"","time_limit_action":"","tries":{"0":{"status":"","time":"","score":{"raw":"","min":"","max":"100"}}}},"student_preference":{"audio":"","language":"","speed":"","text":""},"interactions":{"0":{"id":"","time":"","type":"","weighting":"","student_response":"","result":"","latency":"","objectives":{},"correct_responses":{}}},"evaluation":{"comments":{"0":{"content":"","location":"","time":""}}}}'); }); }); diff --git a/test/cmi/scorm12_cmi.spec.js b/test/cmi/scorm12_cmi.spec.js index b56ac34..978556f 100644 --- a/test/cmi/scorm12_cmi.spec.js +++ b/test/cmi/scorm12_cmi.spec.js @@ -155,6 +155,7 @@ describe('SCORM 1.2 CMI Tests', () => { h.checkRead({ cmi: cmi(), fieldName: 'cmi.core.lesson_status', + expectedValue: 'not attempted', }); h.checkValidValues({ cmi: cmi(), @@ -415,6 +416,7 @@ describe('SCORM 1.2 CMI Tests', () => { h.checkRead({ cmi: cmiInitialized(), fieldName: 'cmi.core.lesson_status', + expectedValue: 'not attempted', }); h.checkValidValues({ cmi: cmiInitialized(), @@ -590,7 +592,7 @@ describe('SCORM 1.2 CMI Tests', () => { ). to. equal( - '{"suspend_data":"","launch_data":"","comments":"","comments_from_lms":"","core":{"student_id":"","student_name":"","lesson_location":"","credit":"","lesson_status":"","entry":"","total_time":"","lesson_mode":"normal","exit":"","session_time":"00:00:00","score":{"raw":"","min":"","max":"100"}},"objectives":{"0":{"id":"","status":"","score":{"raw":"","min":"","max":"100"}}},"student_data":{"mastery_score":"","max_time_allowed":"","time_limit_action":""},"student_preference":{"audio":"","language":"","speed":"","text":""},"interactions":{"0":{"id":"","time":"","type":"","weighting":"","student_response":"","result":"","latency":"","objectives":{},"correct_responses":{}}}}'); + '{"suspend_data":"","launch_data":"","comments":"","comments_from_lms":"","core":{"student_id":"","student_name":"","lesson_location":"","credit":"","lesson_status":"not attempted","entry":"","total_time":"","lesson_mode":"normal","exit":"","session_time":"00:00:00","score":{"raw":"","min":"","max":"100"}},"objectives":{"0":{"id":"","status":"","score":{"raw":"","min":"","max":"100"}}},"student_data":{"mastery_score":"","max_time_allowed":"","time_limit_action":""},"student_preference":{"audio":"","language":"","speed":"","text":""},"interactions":{"0":{"id":"","time":"","type":"","weighting":"","student_response":"","result":"","latency":"","objectives":{},"correct_responses":{}}}}'); }); }); diff --git a/test/utilities.spec.js b/test/utilities.spec.js index 2c0cb53..ad80ab4 100644 --- a/test/utilities.spec.js +++ b/test/utilities.spec.js @@ -274,4 +274,60 @@ describe('Utility Tests', () => { ).to.equal('01:05:30.5'); }); }); + + describe('flatten()', () => { + it('Should return flattened object', () => { + expect( + Utilities.flatten({ + 'cmi': { + 'core': { + 'learner_id': 'jputney', + 'learner_name': 'Jonathan', + }, + 'objectives': { + '0': { + 'id': 'AAA', + }, + '1': { + 'id': 'BBB', + }, + }, + }, + }), + ).to.eql({ + 'cmi.core.learner_id': 'jputney', + 'cmi.core.learner_name': 'Jonathan', + 'cmi.objectives.0.id': 'AAA', + 'cmi.objectives.1.id': 'BBB', + }); + }); + }); + + describe('unflatten()', () => { + it('Should return flattened object', () => { + expect( + Utilities.unflatten({ + 'cmi.core.learner_id': 'jputney', + 'cmi.core.learner_name': 'Jonathan', + 'cmi.objectives.0.id': 'AAA', + 'cmi.objectives.1.id': 'BBB', + }), + ).to.eql({ + 'cmi': { + 'core': { + 'learner_id': 'jputney', + 'learner_name': 'Jonathan', + }, + 'objectives': { + '0': { + 'id': 'AAA', + }, + '1': { + 'id': 'BBB', + }, + }, + }, + }); + }); + }); });