jcputney/scorm-again

View on GitHub
src/Scorm2004API.js

Summary

Maintainability
A
0 mins
Test Coverage
F
58%
// @flow
import BaseAPI from './BaseAPI';
import {
  ADL,
  CMI,
  CMICommentsObject,
  CMIInteractionsCorrectResponsesObject,
  CMIInteractionsObject,
  CMIInteractionsObjectivesObject,
  CMIObjectivesObject,
} from './cmi/scorm2004_cmi';
import * as Utilities from './utilities';
import APIConstants from './constants/api_constants';
import ErrorCodes from './constants/error_codes';
import Responses from './constants/response_constants';
import ValidLanguages from './constants/language_constants';
import Regex from './constants/regex';

const scorm2004_constants = APIConstants.scorm2004;
const global_constants = APIConstants.global;
const scorm2004_error_codes = ErrorCodes.scorm2004;
const correct_responses = Responses.correct;
const scorm2004_regex = Regex.scorm2004;

/**
 * API class for SCORM 2004
 */
export default class Scorm2004API extends BaseAPI {
  #version: '1.0';

  /**
   * Constructor for SCORM 2004 API
   * @param {object} settings
   */
  constructor(settings: {}) {
    const finalSettings = {
      ...{
        mastery_override: false,
      }, ...settings,
    };

    super(scorm2004_error_codes, finalSettings);

    this.cmi = new CMI();
    this.adl = new ADL();

    // Rename functions to match 2004 Spec and expose to modules
    this.Initialize = this.lmsInitialize;
    this.Terminate = this.lmsTerminate;
    this.GetValue = this.lmsGetValue;
    this.SetValue = this.lmsSetValue;
    this.Commit = this.lmsCommit;
    this.GetLastError = this.lmsGetLastError;
    this.GetErrorString = this.lmsGetErrorString;
    this.GetDiagnostic = this.lmsGetDiagnostic;
  }

  /**
   * Getter for #version
   * @return {string}
   */
  get version() {
    return this.#version;
  }

  /**
   * @return {string} bool
   */
  lmsInitialize() {
    this.cmi.initialize();
    return this.initialize('Initialize');
  }

  /**
   * @return {string} bool
   */
  lmsTerminate() {
    const result = this.terminate('Terminate', true);

    if (result === global_constants.SCORM_TRUE) {
      if (this.adl.nav.request !== '_none_') {
        switch (this.adl.nav.request) {
          case 'continue':
            this.processListeners('SequenceNext');
            break;
          case 'previous':
            this.processListeners('SequencePrevious');
            break;
          case 'choice':
            this.processListeners('SequenceChoice');
            break;
          case 'exit':
            this.processListeners('SequenceExit');
            break;
          case 'exitAll':
            this.processListeners('SequenceExitAll');
            break;
          case 'abandon':
            this.processListeners('SequenceAbandon');
            break;
          case 'abandonAll':
            this.processListeners('SequenceAbandonAll');
            break;
        }
      } else if (this.settings.autoProgress) {
        this.processListeners('SequenceNext');
      }
    }

    return result;
  }

  /**
   * @param {string} CMIElement
   * @return {string}
   */
  lmsGetValue(CMIElement) {
    return this.getValue('GetValue', true, CMIElement);
  }

  /**
   * @param {string} CMIElement
   * @param {any} value
   * @return {string}
   */
  lmsSetValue(CMIElement, value) {
    return this.setValue('SetValue', 'Commit', true, CMIElement, value);
  }

  /**
   * Orders LMS to store all content parameters
   *
   * @return {string} bool
   */
  lmsCommit() {
    return this.commit('Commit');
  }

  /**
   * Returns last error code
   *
   * @return {string}
   */
  lmsGetLastError() {
    return this.getLastError('GetLastError');
  }

  /**
   * Returns the errorNumber error description
   *
   * @param {(string|number)} CMIErrorCode
   * @return {string}
   */
  lmsGetErrorString(CMIErrorCode) {
    return this.getErrorString('GetErrorString', CMIErrorCode);
  }

  /**
   * Returns a comprehensive description of the errorNumber error.
   *
   * @param {(string|number)} CMIErrorCode
   * @return {string}
   */
  lmsGetDiagnostic(CMIErrorCode) {
    return this.getDiagnostic('GetDiagnostic', CMIErrorCode);
  }

  /**
   * Sets a value on the CMI Object
   *
   * @param {string} CMIElement
   * @param {any} value
   * @return {string}
   */
  setCMIValue(CMIElement, value) {
    return this._commonSetCMIValue('SetValue', true, CMIElement, value);
  }

  /**
   * Gets or builds a new child element to add to the array.
   *
   * @param {string} CMIElement
   * @param {any} value
   * @param {boolean} foundFirstIndex
   * @return {any}
   */
  getChildElement(CMIElement, value, foundFirstIndex) {
    let newChild;

    if (this.stringMatches(CMIElement, 'cmi\\.objectives\\.\\d+')) {
      newChild = new CMIObjectivesObject();
    } 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];
      if (this.isInitialized()) {
        if (!interaction.type) {
          this.throwSCORMError(
              scorm2004_error_codes.DEPENDENCY_NOT_ESTABLISHED);
        } else {
          this.checkDuplicateChoiceResponse(interaction, value);

          const response_type = correct_responses[interaction.type];
          if (response_type) {
            this.checkValidResponseType(response_type, value, interaction.type);
          } else {
            this.throwSCORMError(scorm2004_error_codes.GENERAL_SET_FAILURE,
                'Incorrect Response Type: ' + interaction.type);
          }
        }
      }
      if (this.lastErrorCode === 0) {
        newChild = new CMIInteractionsCorrectResponsesObject();
      }
    } else if (foundFirstIndex && this.stringMatches(CMIElement,
        'cmi\\.interactions\\.\\d+\\.objectives\\.\\d+')) {
      newChild = new CMIInteractionsObjectivesObject();
    } else if (!foundFirstIndex &&
        this.stringMatches(CMIElement, 'cmi\\.interactions\\.\\d+')) {
      newChild = new CMIInteractionsObject();
    } else if (this.stringMatches(CMIElement,
        'cmi\\.comments_from_learner\\.\\d+')) {
      newChild = new CMICommentsObject();
    } else if (this.stringMatches(CMIElement,
        'cmi\\.comments_from_lms\\.\\d+')) {
      newChild = new CMICommentsObject(true);
    }

    return newChild;
  }

  /**
   * Checks for valid response types
   * @param {object} response_type
   * @param {any} value
   * @param {string} interaction_type
   */
  checkValidResponseType(response_type, value, interaction_type) {
    let nodes = [];
    if (response_type?.delimiter) {
      nodes = String(value).split(response_type.delimiter);
    } else {
      nodes[0] = value;
    }

    if (nodes.length > 0 && nodes.length <= response_type.max) {
      this.checkCorrectResponseValue(interaction_type, nodes, value);
    } else if (nodes.length > response_type.max) {
      this.throwSCORMError(scorm2004_error_codes.GENERAL_SET_FAILURE,
          'Data Model Element Pattern Too Long');
    }
  }

  /**
   * Checks for duplicate 'choice' responses.
   * @param {CMIInteractionsObject} interaction
   * @param {any} value
   */
  checkDuplicateChoiceResponse(interaction, value) {
    const interaction_count = interaction.correct_responses._count;
    if (interaction.type === 'choice') {
      for (let i = 0; i < interaction_count && this.lastErrorCode ===
      0; i++) {
        const response = interaction.correct_responses.childArray[i];
        if (response.pattern === value) {
          this.throwSCORMError(scorm2004_error_codes.GENERAL_SET_FAILURE);
        }
      }
    }
  }

  /**
   * Validate correct response.
   * @param {string} CMIElement
   * @param {*} value
   */
  validateCorrectResponse(CMIElement, value) {
    const parts = CMIElement.split('.');
    const index = Number(parts[2]);
    const pattern_index = Number(parts[4]);
    const interaction = this.cmi.interactions.childArray[index];

    const interaction_count = interaction.correct_responses._count;
    this.checkDuplicateChoiceResponse(interaction, value);

    const response_type = correct_responses[interaction.type];
    if (typeof response_type.limit === 'undefined' || interaction_count <=
        response_type.limit) {
      this.checkValidResponseType(response_type, value, interaction.type);

      if (this.lastErrorCode === 0 &&
          (!response_type.duplicate ||
              !this.checkDuplicatedPattern(interaction.correct_responses,
                  pattern_index, value)) ||
          (this.lastErrorCode === 0 && value === '')) {
        // do nothing, we want the inverse
      } else {
        if (this.lastErrorCode === 0) {
          this.throwSCORMError(scorm2004_error_codes.GENERAL_SET_FAILURE,
              'Data Model Element Pattern Already Exists');
        }
      }
    } else {
      this.throwSCORMError(scorm2004_error_codes.GENERAL_SET_FAILURE,
          'Data Model Element Collection Limit Reached');
    }
  }

  /**
   * Gets a value from the CMI Object
   *
   * @param {string} CMIElement
   * @return {*}
   */
  getCMIValue(CMIElement) {
    return this._commonGetCMIValue('GetValue', true, CMIElement);
  }

  /**
   * Returns the message that corresponds to errorNumber.
   *
   * @param {(string|number)} errorNumber
   * @param {boolean} detail
   * @return {string}
   */
  getLmsErrorMessageDetails(errorNumber, detail) {
    let basicMessage = '';
    let detailMessage = '';

    // Set error number to string since inconsistent from modules if string or number
    errorNumber = String(errorNumber);
    if (scorm2004_constants.error_descriptions[errorNumber]) {
      basicMessage = scorm2004_constants.error_descriptions[errorNumber].basicMessage;
      detailMessage = scorm2004_constants.error_descriptions[errorNumber].detailMessage;
    }

    return detail ? detailMessage : basicMessage;
  }

  /**
   * Check to see if a correct_response value has been duplicated
   * @param {CMIArray} correct_response
   * @param {number} current_index
   * @param {*} value
   * @return {boolean}
   */
  checkDuplicatedPattern = (correct_response, current_index, value) => {
    let found = false;
    const count = correct_response._count;
    for (let i = 0; i < count && !found; i++) {
      if (i !== current_index && correct_response.childArray[i] === value) {
        found = true;
      }
    }
    return found;
  };

  /**
   * Checks for a valid correct_response value
   * @param {string} interaction_type
   * @param {Array} nodes
   * @param {*} value
   */
  checkCorrectResponseValue(interaction_type, nodes, value) {
    const response = correct_responses[interaction_type];
    const formatRegex = new RegExp(response.format);
    for (let i = 0; i < nodes.length && this.lastErrorCode === 0; i++) {
      if (interaction_type.match(
          '^(fill-in|long-fill-in|matching|performance|sequencing)$')) {
        nodes[i] = this.removeCorrectResponsePrefixes(nodes[i]);
      }

      if (response?.delimiter2) {
        const values = nodes[i].split(response.delimiter2);
        if (values.length === 2) {
          const matches = values[0].match(formatRegex);
          if (!matches) {
            this.throwSCORMError(scorm2004_error_codes.TYPE_MISMATCH);
          } else {
            if (!values[1].match(new RegExp(response.format2))) {
              this.throwSCORMError(scorm2004_error_codes.TYPE_MISMATCH);
            }
          }
        } else {
          this.throwSCORMError(scorm2004_error_codes.TYPE_MISMATCH);
        }
      } else {
        const matches = nodes[i].match(formatRegex);
        if ((!matches && value !== '') ||
            (!matches && interaction_type === 'true-false')) {
          this.throwSCORMError(scorm2004_error_codes.TYPE_MISMATCH);
        } else {
          if (interaction_type === 'numeric' && nodes.length > 1) {
            if (Number(nodes[0]) > Number(nodes[1])) {
              this.throwSCORMError(scorm2004_error_codes.TYPE_MISMATCH);
            }
          } else {
            if (nodes[i] !== '' && response.unique) {
              for (let j = 0; j < i && this.lastErrorCode === 0; j++) {
                if (nodes[i] === nodes[j]) {
                  this.throwSCORMError(scorm2004_error_codes.TYPE_MISMATCH);
                }
              }
            }
          }
        }
      }
    }
  }

  /**
   * Remove prefixes from correct_response
   * @param {string} node
   * @return {*}
   */
  removeCorrectResponsePrefixes(node) {
    let seenOrder = false;
    let seenCase = false;
    let seenLang = false;

    const prefixRegex = new RegExp(
        '^({(lang|case_matters|order_matters)=([^}]+)})');
    let matches = node.match(prefixRegex);
    let langMatches = null;
    while (matches) {
      switch (matches[2]) {
        case 'lang':
          langMatches = node.match(scorm2004_regex.CMILangcr);
          if (langMatches) {
            const lang = langMatches[3];
            if (lang !== undefined && lang.length > 0) {
              if (ValidLanguages[lang.toLowerCase()] === undefined) {
                this.throwSCORMError(scorm2004_error_codes.TYPE_MISMATCH);
              }
            }
          }
          seenLang = true;
          break;
        case 'case_matters':
          if (!seenLang && !seenOrder && !seenCase) {
            if (matches[3] !== 'true' && matches[3] !== 'false') {
              this.throwSCORMError(scorm2004_error_codes.TYPE_MISMATCH);
            }
          }

          seenCase = true;
          break;
        case 'order_matters':
          if (!seenCase && !seenLang && !seenOrder) {
            if (matches[3] !== 'true' && matches[3] !== 'false') {
              this.throwSCORMError(scorm2004_error_codes.TYPE_MISMATCH);
            }
          }

          seenOrder = true;
          break;
        default:
          break;
      }
      node = node.substr(matches[1].length);
      matches = node.match(prefixRegex);
    }

    return node;
  }

  /**
   * Replace the whole API with another
   * @param {Scorm2004API} newAPI
   */
  replaceWithAnotherScormAPI(newAPI) {
    // Data Model
    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 cmiExport = this.renderCMIToJSONObject();

    if (terminateCommit) {
      cmiExport.cmi.total_time = this.cmi.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) {
      if (this.cmi.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) {
              console.debug('Setting Completion Status: Completed');
              this.cmi.completion_status = 'completed';
            } else {
              console.debug('Setting Completion Status: Incomplete');
              this.cmi.completion_status = 'incomplete';
            }
          }
          if (this.cmi.scaled_passing_score && this.cmi.score.scaled) {
            if (this.cmi.score.scaled >= this.cmi.scaled_passing_score) {
              console.debug('Setting Success Status: Passed');
              this.cmi.success_status = 'passed';
            } else {
              console.debug('Setting Success Status: Failed');
              this.cmi.success_status = 'failed';
            }
          }
        }
      }
    }

    let navRequest = false;
    if (this.adl.nav.request !== (this.startingData?.adl?.nav?.request) &&
        this.adl.nav.request !== '_none_') {
      this.adl.nav.request = encodeURIComponent(this.adl.nav.request);
      navRequest = true;
    }

    const commitObject = this.renderCommitCMI(terminateCommit ||
        this.settings.alwaysSendTotalTime);

    if (this.apiLogLevel === global_constants.LOG_LEVEL_DEBUG) {
      console.debug('Commit (terminated: ' +
            (terminateCommit ? 'yes' : 'no') + '): ');
      console.debug(commitObject);
    }
    if (this.settings.lmsCommitUrl) {
      const result = this.processHttpRequest(this.settings.lmsCommitUrl,
          commitObject, terminateCommit);

      // 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 {
      return global_constants.SCORM_TRUE;
    }
  }
}