Skip to content

Commit

Permalink
New: Added cmi.objectives support (fixes #279).
Browse files Browse the repository at this point in the history
  • Loading branch information
danielghost committed Jun 19, 2023
1 parent 2cde7ac commit 6bb98cb
Show file tree
Hide file tree
Showing 4 changed files with 179 additions and 48 deletions.
6 changes: 6 additions & 0 deletions js/adapt-offlineStorage-scorm.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,12 @@ export default class OfflineStorageScorm extends Backbone.Controller {
switch (name.toLowerCase()) {
case 'interaction':
return this.scorm.recordInteraction(...args);
case 'objectivedescription':
return this.scorm.recordObjectiveDescription(...args);
case 'objectivestatus':
return this.scorm.recordObjectiveStatus(...args);
case 'objectivescore':
return this.scorm.recordObjectiveScore(...args);
case 'location':
return this.scorm.setLessonLocation(...args);
case 'score':
Expand Down
43 changes: 40 additions & 3 deletions js/adapt-stateful-session.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ export default class StatefulSession extends Backbone.Controller {
}

beginSession() {
this.listenTo(Adapt, 'app:dataReady', this.restoreSession);
this.listenTo(Adapt, {
'app:dataReady': this.restoreSession,
'adapt:start': this.onAdaptStart
});
this._trackingIdType = Adapt.build.get('trackingIdType') || 'block';
// suppress SCORM errors if 'nolmserrors' is found in the querystring
if (window.location.search.indexOf('nolmserrors') !== -1) {
Expand Down Expand Up @@ -59,8 +62,6 @@ export default class StatefulSession extends Backbone.Controller {
restoreSession() {
this.setupLearnerInfo();
this.restoreSessionState();
// defer call because AdaptModel.check*Status functions are asynchronous
_.defer(this.setupEventListeners.bind(this));
}

setupLearnerInfo() {
Expand Down Expand Up @@ -93,13 +94,16 @@ export default class StatefulSession extends Backbone.Controller {
setupEventListeners() {
this.removeEventListeners();
this.listenTo(Adapt.components, 'change:_isComplete', this.debouncedSaveSession);
this.listenTo(Adapt.contentObjects, 'change:_isComplete', this.onContentObjectCompleteChange);
this.listenTo(Adapt.course, 'change:_isComplete', this.debouncedSaveSession);
if (this._shouldStoreResponses) {
this.listenTo(data, 'change:_isSubmitted change:_userAnswer', this.debouncedSaveSession);
}
this.listenTo(Adapt, {
'app:dataReady': this.restoreSession,
'adapt:start': this.onAdaptStart,
'app:languageChanged': this.onLanguageChanged,
'pageView:ready': this.onPageViewReady,
'questionView:recordInteraction': this.onQuestionRecordInteraction,
'tracking:complete': this.onTrackingComplete
});
Expand Down Expand Up @@ -161,7 +165,25 @@ export default class StatefulSession extends Backbone.Controller {
logging.info(`course._isComplete: ${courseComplete}, course._isAssessmentPassed: ${assessmentPassed}, ${this._trackingIdType} completion: ${completionString}`);
}

initializeContentObjectives() {
Adapt.contentObjects.forEach(model => {
if (model.isTypeGroup('course')) return;
const id = model.get('_id');
const description = model.get('title') || model.get('displayTitle');
offlineStorage.set('objectiveDescription', id, description);
if (model.get('_isVisited')) return;
const completionStatus = 'not attempted';
offlineStorage.set('objectiveStatus', id, completionStatus);
});
}

onAdaptStart() {
this.setupEventListeners();
this.initializeContentObjectives();
}

onLanguageChanged() {
this.stopListening(Adapt.contentObjects, 'change:_isComplete', this.onContentObjectCompleteChange);
const config = Adapt.spoor.config;
if (config?._reporting?._resetStatusOnLanguageChange !== true) return;
offlineStorage.set('status', 'incomplete');
Expand All @@ -171,6 +193,14 @@ export default class StatefulSession extends Backbone.Controller {
if (document.visibilityState === 'hidden') this.scorm.commit();
}

onPageViewReady(view) {
const model = view.model;
if (model.get('_isComplete')) return;
const id = model.get('_id');
const completionStatus = COMPLETION_STATE.INCOMPLETE.asLowerCase;
offlineStorage.set('objectiveStatus', id, completionStatus);
}

onQuestionRecordInteraction(questionView) {
if (!this._shouldRecordInteractions) return;
// View functions are deprecated: getResponseType, getResponse, isCorrect, getLatency
Expand All @@ -188,6 +218,13 @@ export default class StatefulSession extends Backbone.Controller {
offlineStorage.set('interaction', id, response, result, latency, responseType);
}

onContentObjectCompleteChange(model) {
if (model.isTypeGroup('course')) return;
const id = model.get('_id');
const completionStatus = (model.get('_isComplete') ? COMPLETION_STATE.COMPLETED : COMPLETION_STATE.INCOMPLETE).asLowerCase;
offlineStorage.set('objectiveStatus', id, completionStatus);
}

onTrackingComplete(completionData) {
const config = Adapt.spoor.config;
this.saveSessionState();
Expand Down
2 changes: 2 additions & 0 deletions js/scorm/cookieLMS.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ export function start () {
configure();
this.initialize({
'cmi.interactions': [],
'cmi.objectives': [],
'cmi.core.lesson_status': 'not attempted',
'cmi.suspend_data': '',
'cmi.core.student_name': 'Surname, Sam',
Expand Down Expand Up @@ -199,6 +200,7 @@ export function start () {
configure();
this.initialize({
'cmi.interactions': [],
'cmi.objectives': [],
'cmi.completion_status': 'not attempted',
'cmi.suspend_data': '',
'cmi.learner_name': 'Surname, Sam',
Expand Down
176 changes: 131 additions & 45 deletions js/scorm/wrapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -211,21 +211,9 @@ class ScormWrapper {

getStatus() {
const status = this.getValue(this.isSCORM2004() ? 'cmi.completion_status' : 'cmi.core.lesson_status');

switch (status.toLowerCase()) { // workaround for some LMSes (e.g. Arena) not adhering to the all-lowercase rule
case 'passed':
case 'completed':
case 'incomplete':
case 'failed':
case 'browsed':
case 'not attempted':
case 'not_attempted': // mentioned in SCORM 2004 docs but not sure it ever gets used
case 'unknown': // the SCORM 2004 version of not attempted
return status;
default:
this.handleDataError(new ScormError(SERVER_STATUS_UNSUPPORTED, { status }));
return null;
}
if (this.isValidCompletionStatus(status)) return status;
this.handleDataError(new ScormError(SERVER_STATUS_UNSUPPORTED, { status }));
return null;
}

setStatus(status) {
Expand All @@ -252,36 +240,8 @@ class ScormWrapper {
}

setScore(score, minScore = 0, maxScore = 100, isPercentageBased = true) {
if (this.isSCORM2004()) {
// `raw`, `min`, `max` sum absolute values assigned to questions
this.setValue('cmi.score.raw', score);
this.setValue('cmi.score.min', minScore);
this.setValue('cmi.score.max', maxScore);
// range split into negative/positive ranges (rather than minScore-maxScore) depending on score
const range = (score < 0) ? Math.abs(minScore) : maxScore;
// `scaled` converted to -1-1 range to indicate negative/positive weighting now that negative values can be assigned to questions
const scaledScore = score / range;
this.setValue('cmi.score.scaled', scaledScore.toFixed(7));
return;
}
if (isPercentageBased) {
// convert values to 0-100 range
// negative scores are capped to 0 due to SCORM 1.2 limitations
score = (score < 0) ? 0 : Math.round((score / maxScore) * 100);
minScore = 0;
maxScore = 100;
} else {
const validate = (attribute, value) => {
const isValid = value >= 0 && score <= 100;
if (!isValid) this.logger.warn(`${attribute} must be between 0-100.`);
}
validate('cmi.core.score.raw', score);
validate('cmi.core.score.min', minScore);
validate('cmi.core.score.max', maxScore);
}
this.setValue('cmi.core.score.raw', score);
if (this.isSupported('cmi.core.score.min')) this.setValue('cmi.core.score.min', minScore);
if (this.isSupported('cmi.core.score.max')) this.setValue('cmi.core.score.max', maxScore);
const cmiPrefix = this.isSCORM2004() ? 'cmi' : 'cmi.core';
this.recordScore(cmiPrefix, ...arguments);
}

getLessonLocation() {
Expand Down Expand Up @@ -612,6 +572,33 @@ class ScormWrapper {

}

recordScore(cmiPrefix, score, minScore = 0, maxScore = 100, isPercentageBased = true) {
if (this.isSCORM2004()) {
// range split into negative/positive ranges (rather than minScore-maxScore) depending on score
const range = (score < 0) ? Math.abs(minScore) : maxScore;
// `scaled` converted to -1-1 range to indicate negative/positive weighting now that negative values can be assigned to questions
const scaledScore = score / range;
this.setValue(`${cmiPrefix}.score.scaled`, parseFloat(scaledScore.toFixed(7)));
} else if (isPercentageBased) {
// convert values to 0-100 range
// negative scores are capped to 0 due to SCORM 1.2 limitations
score = (score < 0) ? 0 : Math.round((score / maxScore) * 100);
minScore = 0;
maxScore = 100;
} else {
const validate = (attribute, value) => {
const isValid = value >= 0 && score <= 100;
if (!isValid) this.logger.warn(`${attribute} must be between 0-100.`);
}
validate(`${cmiPrefix}.score.raw`, score);
validate(`${cmiPrefix}.score.min`, minScore);
validate(`${cmiPrefix}.score.max`, maxScore);
}
this.setValue(`${cmiPrefix}.score.raw`, score);
if (this.isSupported(`${cmiPrefix}.score.min`)) this.setValue(`${cmiPrefix}.score.min`, minScore);
if (this.isSupported(`${cmiPrefix}.score.max`)) this.setValue(`${cmiPrefix}.score.max`, maxScore);
}

getInteractionCount() {
const count = this.getValue('cmi.interactions._count');
return count === '' ? 0 : count;
Expand Down Expand Up @@ -690,6 +677,105 @@ class ScormWrapper {
scormRecordInteraction.call(this, id, response, correct, latency, type);
}

getObjectiveCount() {
const count = this.getValue('cmi.objectives._count');
return count === '' ? 0 : count;
}

getObjectiveIndexById(id) {
const count = this.getObjectiveCount();
for (let i = 0; i < count; i++) {
const storedId = this.getValue(`cmi.objectives.${i}.id`);
if (storedId === id) return i;
}
return count;
}

recordObjectiveDescription(id, description) {
if (!this.isSCORM2004() || !description) return;
id = id.trim();
const index = this.getObjectiveIndexById(id);
const cmiPrefix = `cmi.objectives.${index}`;
this.setValue(`${cmiPrefix}.id`, id);
this.setValue(`${cmiPrefix}.description`, description);
}

recordObjectiveScore(id, score, minScore = 0, maxScore = 100, isPercentageBased = true) {
if (!this.isSupported('cmi.objectives._count')) {
this.logger.info('ScormWrapper::recordObjectiveScore: cmi.objectives are not supported by this LMS...');
return;
}
id = id.trim();
const index = this.getObjectiveIndexById(id);
const cmiPrefix = `cmi.objectives.${index}`;
this.setValue(`${cmiPrefix}.id`, id);
this.recordScore(cmiPrefix, score, minScore, maxScore, isPercentageBased);
}

recordObjectiveStatus(id, completionStatus, successStatus = 'unknown') {
if (!this.isSupported('cmi.objectives._count')) {
this.logger.info('ScormWrapper::recordObjectiveStatus: cmi.objectives are not supported by this LMS...');
return;
}
if (!this.isValidCompletionStatus(completionStatus)) {
this.handleDataError(new ScormError(CLIENT_STATUS_UNSUPPORTED, { completionStatus }));
return;
}
if (this.isSCORM2004() && !this.isValidSuccessStatus(successStatus)) {
this.handleDataError(new ScormError(CLIENT_STATUS_UNSUPPORTED, { successStatus }));
return;
}
id = id.trim();
const index = this.getObjectiveIndexById(id);
const cmiPrefix = `cmi.objectives.${index}`;
this.setValue(`${cmiPrefix}.id`, id);
if (this.isSCORM2004()) {
this.setValue(`${cmiPrefix}.completion_status`, completionStatus);
this.setValue(`${cmiPrefix}.success_status`, successStatus);
return;
}
if (completionStatus === 'completed' && successStatus !== 'unknown') completionStatus = successStatus;
this.setValue(`${cmiPrefix}.status`, completionStatus);
}

isValidCompletionStatus(status) {
status = status.toLowerCase(); // workaround for some LMSs (e.g. Arena) not adhering to the all-lowercase rule
if (this.isSCORM2004()) {
switch(status) {
case 'completed':
case 'incomplete':
case 'not attempted':
case 'not_attempted': // mentioned in SCORM 2004 spec - mapped to 'not attempted'
case 'unknown':
return true;
}
} else {
switch(status) {
case 'passed':
case 'completed':
case 'incomplete':
case 'failed':
case 'browsed':
case 'not attempted':
return true;
}
}
return false;
}

isValidSuccessStatus(status) {
status = status.toLowerCase(); // workaround for some LMSs (e.g. Arena) not adhering to the all-lowercase rule
if (this.isSCORM2004()) {
switch(status) {
case 'passed':
case 'failed':
case 'unknown':
return true;
}
}
return false;
}

showDebugWindow() {

if (this.logOutputWin && !this.logOutputWin.closed) {
Expand Down

0 comments on commit 6bb98cb

Please sign in to comment.