diff --git a/INTERSECTION_QUERY.md b/INTERSECTION_QUERY.md
new file mode 100644
index 0000000..bf0ebba
--- /dev/null
+++ b/INTERSECTION_QUERY.md
@@ -0,0 +1,141 @@
+# Intersection query syntax
+
+## Preamble
+
+### What is an intersection
+* Equal intersection is when the first and second model are equal
+* Descendant intersection is when the first model is a descendant of the second
+* Ancestor intersection is when the first model is a ancestor of the second
+* Intersections identify models which have overlapping interests in the hierarchy
+
+### Query API
+```js
+const queryString = "#a-300 #performance"
+Adapt.scoring.getSubsetsByQuery(queryString)
+Adapt.scoring.getSubsetByQuery(queryString)
+const pathString = "a-300.performance"
+Adapt.scoring.getSubsetByPath(pathString)
+```
+
+### IntersectionSet
+All sets representing any collection of models are likely to extend the most basic interface `IntersectionSet`.
+
+It is possible to use these literal attributes for queries on `IntersectionSet`:
+* `#setId` or `[id=setId]` or `[#setId]` the set with id `setId`, ids are unique
+* `setType` or `[type=setType]` all sets with type `setType`
+* `(isEnabled)` or `(isEnabled=true)` and `(isEnabled=false)` filtered by `isEnabled`
+* `(isOptional)` or `(isOptional=true)` and `(isOptional=false)` filtered by `isOptional`
+* `(isAvailable)` or `(isAvailable=true)` and `(isAvailable=false)` filtered by `isAvailable`
+* `(isModelAvailableInHierarchy)` or `(isModelAvailableInHierarchy=true)` and `(isModelAvailableInHierarchy=false)` filtered by `isModelAvailableInHierarchy`, this returns true if the set is `_isAvailable` and not detached from the hierarchy
+* `(isPopulated)` or `(isPopulated=true)` and `(isPopulated=false)` filtered by `isPopulated`, this returns true if the set has `models`.
+* `(isNotPopulated)` is an alias for `(isPopulated=false)`
+
+These intersection attributes are available for querying all set instances:
+* `[modelId=a-300]` will select all sets intersecting model `a-300`
+
+### AdaptModelSet
+The `AdaptModelSet` instances allow selections and intersections over the Adapt models.
+
+For each `AdaptModel`, in the `core/js/data` API, there is a corresponding `AdaptModelSet`. Each set inherits the same `id`, `model` and `modelId` from its `AdaptModel` and has `models` of `[model]`. These sets inherit all of the query attributes from `IntersectionSet`.
+
+These literal attributes are available for query on `AdaptModelSet` instances:
+* `#a-300` or `[id=a-300]` or `[#a-300]` the set with id `a-300`, ids are unique
+* `adapt` or `[type=adapt]` all sets with type `adapt`
+* `[modelTypeGroup=course|contentobject|menu|page|group|article|block|component|question]` all sets with the specified typegroup
+* `[modelType=course|menu|page|article|block|component]` all sets with the specified type
+* `(isComplete)` or `(isComplete=true)` and `(isComplete=false)` filtered by `isComplete`
+* `(isIncomplete)` is an alias for `(isComplete=false)`
+* `(isPassed)` is an alias for `(isComplete)`
+* `(isFailed)` is always `false`
+
+### ScoringSet
+For all sets representing a scoring collection of models, such as questions, they likely extend `ScoringSet`.
+
+Here you can use these additional literal attributes for queries:
+* `(isComplete)` or `(isComplete=true)` and `(isComplete=false)` filtered by `isComplete`, is determined by the specific type of scoring set
+* `(isIncomplete)` is an alias for `(isComplete=false)`
+* `(isPassed)` is determined by the specific type of scoring set
+* `(isFailed)` is and alias `(isComplete,isPassed=false)`
+
+### TotalSets
+This is a set of sets, it can sum the scores of the registered or intersecting sets.
+
+It extends `ScoringSet` with the caveat that it sums properties from registered or intersecting scoring sets and completion sets rather than registered or intersecting models.
+
+It represents the overall completion, score, correctness, pass and fail of the course.
+
+It has these literal attributes:
+* `#total` or `[id=total]` or `[#total]` the set with id `total`, ids are unique
+* `total` or `[type=total]` all sets with type `total`
+
+### Selection query syntax
+All selection query can have three optional parts, as `first[second](third)`.
+
+The first part defaults to an empty string, which means all sets. It can select either by set type or by set id using the shorthand `setType` or `#setId`. For native sets, `adapt` would return all native `AdaptModelSet` sets and `#a-300` would return only the `AdaptModelSet` representing the article `a-300`, as set ids are unique.
+
+The second part is an optional list of attributes about which to multiply the first part. Such that `adapt[modelId=a-300,modelType=block]` will return all `AdaptModelSets` which intersect `modelId=a-300` and all `AdaptModelSets` which have `modelType=block`.
+
+The third part is an optional list of attributes which filter the selected sets on the specified attributes. `adapt[modelId=a-300,modelType=block](isComplete)` will return all `AdaptModelSets` which intersect `modelId=a-300` and all `AdaptModelSets` which have `modelType=block`, where their `isComplete` properties are `true`.
+
+tldr: `selectionQuery = typeOrId[multipliedByAttributes](filteredByAttributes)`
+
+### Selection query examples
+Selection queries follow the `selectionQuery` syntax of `typeOrId[multipliedByAttributes](filteredByAttributes)`.
+
+These queries only select from available sets, they do not cause intersections or cloned sets to be created:
+* `"assessment"` sets of `AssessmentSet`, `type=assessment`
+* `"#assessment-300"` the `AssessmentSet` with `id=assessment-300`, ids are unique
+* `"#a-300"` the `AdaptModelSet` with `id=a-300`, ids are unique
+* `"assessment[id=assessment-300]"` the `AssessmentSet` with `id=assessment-01`, ids are unique
+* `"assessment[id=assessment-300,id=assessment-400]"` sets of `AssessmentSet` with `id=assessment-300` and `id=assessment-400`, ids are unique
+* `"[#assessment-300,#assessment-400]"` does the same as above
+* `"assessment[modelId=a-05]"` the `AssessmentSet` of `type=assessment` intersecting model `a-05`
+* `"adapt[modelType=article]"` sets of `AdaptModelSet` representing articles
+* `"adapt[modelComponent=mcq]"` sets of `AdaptModelSet` representing mcqs
+* `"adapt[modelComponent=mcq,modelComponent=gmcq]"` sets of `AdaptModelSet` representing mcqs and gmcqs
+* `"adapt[modelTypeGroup=question]"` sets of `AdaptModelSet` representing questions
+* `"adapt[modelComponent=mcq,modelComponent=gmcq](isComplete)"` sets of `AdaptModelSet` representing all completed mcqs and gmcqs
+
+### Selection query multiplications
+The query interface parts `first` and `second` are multiplied.
+
+The multiplication happens in columns, each entry in each column is multiplied into a list of all possible combinations. Such that `[[1], [2,4]] = [[1,2],[1,4]]`. The resultant combinations are used to perform the selection query accordingly.
+
+tldr: When multiplying the selection parts `article` and `[#a-200,#a-300]`, by using `article[#a-200,#a-300]`, we ask the computer to select both `article[#a-200]` and `article[#a-300]`.
+
+### Intersection query examples
+The `intersectionQuery` syntax is `selectionQuery selectionQuery selectionQuery...` or `typeOrId[multipliedByAttributes](filteredByAttributes) typeOrId[multipliedByAttributes](filteredByAttributes) typeOrId[multipliedByAttributes](filteredByAttributes)...`, where the space signifies a multiplication.
+
+An intersection always produces a new set, created from the class of the final column:
+* `"assessment[id=assessment-01] assessment[id=assessment-05]"` creates an intersection of set `id=assessment-01` and `id=assessment-05`, returning a `type=assessment`, `AssessmentSet` instance
+* `"assessment[id=assessment-01,id=assessment-05] assessment[id=assessment-10,assessment-15]"` creates four intersection sets, of type `assessment` or `AssessmentSet`, intersecting `#assessment-01` with `#assessment-10`, `#assessment-01` with `#assessment-15`, `#assessment-05` with `#assessment-10` and `assessment-05` with `assessment-15`
+* `"[id=assessment-01,id=assessment-05] [id=assessment-10,id=assessment-15]"` would do the same as above, ids are unique
+* `"[#assessment-01,#assessment-05] [#assessment-10,#assessment-15]"` would do the same as above, ids are unique
+* `"[modelType=article] assessment"` returns intersection sets of all `modelType=articles` sets with all `type=assessment` sets, return `AssessmentSet` intersection set instances
+* `"[modelType=article] assessment(isPopulated)"` returns all article assessments intersections that have models after their intersections
+* `"[modelType=article](isComplete) assessment(isPopulated)"` returns all article assessments intersections that have models from completed articles
+
+### Intersection query multiplications
+With `intersectionQuery`, all of the separate `selectionQuery` sections are multiplied together before a resultant set is produced from the final class.
+
+The multiplication happens in columns, each selection entry in each column is multiplied into a list of all possible combinations. Such that `[[1], [2,4]] = [[1,2],[1,4]]`. The resultant combinations are used to perform the intersection accordingly.
+
+When multiplying the intersection parts `[modelType=article]` and `assessment`, by using `[modelType=article] assessment`, we ask the computer to select all articles and all assessments and then multiply them together and give the resultant intersections, which would be of type assessment.
+
+Anti-pattern walkthrough: In the above example, `[modelType=article] assessment`, if we had 20 articles and 2 assessments, we'd have 40 resultant intersected assessment sets. If each assessment belonged to only one article then we would have 38 useless intersections of articles intersecting assessment where there is no relation. As intersections reduce the number of models in the resultant set, and they reduce the models intersecting with the set models used to product it, we could solve this problem by using `[modelType=article] assessment(isPopulated)`. Using an `(isPopulated)` filter would return only all of the article assessment intersections which have models left after intersection. In this can it would probably be easier just to fetch the assessments directly using `assessment[modelType=article]`, rather than using an intersection, as the assessment lives on an article.
+
+## Primary use-case
+We have a performance metric that covers questions in the whole course and we have an assessment in one article that intersects some of the same questions. We want to know the sum of the performance score for just the assessment questions.
+
+To do this, assuming the `AssessmentSet` sits on article `a-300` and has the article's blocks as its models and assuming a `PerformanceSet`, with id `performance` and a `score` property, sits on the course object and has all of the questions in the course as its models, the following example will satisfy our use-case:
+```js
+const intersectedSets = Adapt.scoring.getSubsetsByQuery("#a-300 #performance")
+const firstIntersectedSet = intersectedSets[0]
+const assessmentPerformanceScore = firstIntersectedSet.score
+// or, as we expect only one intersected set
+const firstIntersectedSet = Adapt.scoring.getSubsetByQuery("#a-300 #performance")
+const assessmentPerformanceScore = firstIntersectedSet.score
+```
+Machine translation: Select two columns of sets, column 1 should have sets where `id = 'a-300'` and column 2 should have sets where `id = 'performance'`. Multiply both columns, such that there is a list of every combination. Clone each item in column 2 of the list and reduce its `.models` by those models intersecting its combination from column 1. Return the array of intersected cloned sets. Select the first. Return the value of its `.score` property.
+
+English translation: Return a performance metric score for just questions intersecting article `a-300`.
diff --git a/LIFECYCLE.md b/LIFECYCLE.md
new file mode 100644
index 0000000..8f462cc
--- /dev/null
+++ b/LIFECYCLE.md
@@ -0,0 +1,34 @@
+# Lifecycle
+Each Set which inherits from [`LifecycleSet`](js/LifecycleSet.js) has a lifecycle and order. The primary purpose of the lifecycle is to aid in the startup and reset of sets, with the order determining the Set's position in the lifecycle phase execution.
+
+## Phases
+There are 8 external lifecycle phases, 6 set callback functions and 2 internal triggers for each set. The set callback functions are called by the external phase controller ['Lifecycle'](js/Lifecycle.js) and the phase renderer ['LifecycleRenderer'](js/LifecycleRenderer.js). Each cycle is grouped at 30 frames per second, on a browser animation frame, and before each cycle is performed, the relevant sets are grouped, sorted and processed in phase and position order.
+
+### External phases
+| Name | Description |
+| --- | --- |
+| init | All sets are sent here after being instantiated and registered |
+| restore | All sets are sent here after the init phase |
+| start | All sets are sent here after the restore phase |
+| reset | All sets are sent here if `Adapt.scoring.reset()` is called |
+| restart | Sets are sent here if any set on the model, or the model on which they sit has been reset using `.reset()` |
+| leave | Sets are sent here if the user leaves the content object in which its `modelId` sits |
+| visit | Sets are sent here if the user visits the content object in which its `modelId` sits |
+| update | Sets are sent here if any intersecting model changes across its `_isAvailable`, `_isInteractionComplete`, `_isActive` or `_isVisited` attributes or if any intersecting set calls `.update()` |
+
+## Set callback functions
+| Name | Description |
+| --- | --- |
+| onInit | Called after it is instantiated and registered, in the init phase |
+| onRestore | Called after onInit, in the restore phase, return true/false to signify restore |
+| onStart | Called after onRestore, if `wasRestored = false` |
+| onRestart | Called on the set after reset |
+| onLeave | Called when leaving their content object |
+| onVisit | Called when visiting their content object |
+| onUpdate | Called when any intersecting model changes across its `_isAvailable`, `_isInteractionComplete`, `_isActive` or `_isVisited` attributes or if any intersecting set calls `.update()` |
+
+## Set trigger functions
+| Name | Description |
+| --- | --- |
+| update | Triggers the update phase for all sets intersecting the modelId |
+| reset | Triggers the restart phase for all sets at the modelId |
diff --git a/README.md b/README.md
index 995cac6..393081b 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,9 @@ An extension to expose an API for a collection of scoring sets used throughout t
A scoring set consists of a collection of models from which scores (`minScore`, `maxScore`, `score`, `scaledScore`, `correctness`, `scaledCorrectness`), completion and passing statuses can be derived. Each set is only responsible for values derived from it's models, and has no perception of those from other scoring sets. A set may be a collection of content, such as an [assessment](https://github.com/adaptlearning/adapt-contrib-scoringAssessment); a collection of scores assigned directly to content; a collection of other scoring sets.
-Each plugin will register a new set type with the Scoring API, and identify its associations with other models, along with any extended functionality specific to that plugin. This will allow sets to be evaluated for any intersections within the course structure by comparing overlapping hierachies. Scoring sets allow multiple scores to be categorised as required, providing the ability to evaluate user performance across different areas.
+Each plugin will register a new set type with the Scoring API, and identify its associations with other models, along with any extended functionality specific to that plugin. This will allow sets to be evaluated for any intersections within the course structure by comparing overlapping hierarchies. Scoring sets allow multiple scores to be categorised as required, providing the ability to evaluate user performance across different areas.
+
+A `TotalSets` scoring set is used to represent all sets contributing to the scoring and completion of the course scoring objective.
### Attributes
@@ -16,9 +18,9 @@ Scoring sets should be modular in the JSON configuration, with each set added as
**title** (string): A title for the set. Not required, but exposed should it be used for reporting purposes.
-**_isScoreIncluded** (boolean): Determines whether the set should be included in the overall score.
+**_isScoreIncluded** (boolean): Determines whether the set should be included in `TotalSets`, contributing to the overall score.
-**_isCompletionRequired** (boolean): Determines whether the set should be included in the completion checks.
+**_isCompletionRequired** (boolean): Determines whether the set should be included in `TotalSets` and evaluated when checking completion.
### Events
@@ -28,6 +30,8 @@ The following events are triggered for each scoring set:
**Adapt#scoring:set:register**
**Adapt#scoring:[set.type]:restored**
**Adapt#scoring:set:restored**
+**Adapt#scoring:[set.type]:update**
+**Adapt#scoring:set:update**
**Adapt#scoring:[set.type]:complete**
**Adapt#scoring:set:complete**
**Adapt#scoring:[set.type]:passed**
@@ -68,13 +72,21 @@ The attributes listed below are used in *course.json* to configure the overall s
## Events
-The following events are triggered:
+The following [lifecycle](LIFECYCLE.md) events are triggered:
+
+**Adapt#scoring:lifecycle:restored**
+**Adapt#scoring:lifecycle:start**
+**Adapt#scoring:lifecycle:update**
+**Adapt#scoring:lifecycle:reset**
+
+For overall scoring and completion, the events triggered by `TotalSets` should be utilised:
-**Adapt#scoring:update**
-**Adapt#scoring:reset**
-**Adapt#scoring:restored**
-**Adapt#scoring:complete**
-**Adapt#scoring:pass**
+**Adapt#scoring:total:register**
+**Adapt#scoring:total:restored**
+**Adapt#scoring:total:update**
+**Adapt#scoring:total:complete**
+**Adapt#scoring:total:passed**
+**Adapt#scoring:total:reset**
For backward compatibility the following events are triggered if `"_isBackwardCompatible": true`:
diff --git a/js/AdaptModelSet.js b/js/AdaptModelSet.js
index 8934559..aee10ae 100644
--- a/js/AdaptModelSet.js
+++ b/js/AdaptModelSet.js
@@ -1,97 +1,91 @@
import ScoringSet from './ScoringSet';
-import {
- isAvailableInHierarchy
-} from './utils';
+import data from 'core/js/data';
+/**
+ * A set which represents each AdaptModel from the `core/js/data` API.
+ * Used for set intersection queries only, not for scoring.
+ */
export default class AdaptModelSet extends ScoringSet {
- initialize(options = {}, subsetParent = null) {
- this._model = options.model;
+ initialize(options = {}) {
super.initialize({
- ...options,
- _id: this.model.get('_id'),
+ _id: options._model.get('_id'),
_type: 'adapt',
- title: this.model.get('title')
- }, subsetParent);
+ _title: options._model.get('title'),
+ _models: [options._model],
+ ...options
+ });
}
/**
- * Intentionally empty to override super Class
- * @override
+ * Comparison function for type groups.
+ * query example: `[modelTypeGroup=question]`
+ * @param {string} group One of course|contentobject|menu|page|group|article|block|component|question
+ * @returns {boolean}
*/
- _initializeObjective() {}
-
- /**
- * Intentionally empty to override super Class
- * @override
- */
- _resetObjective() {}
+ modelTypeGroup(typeGroup) {
+ return this.model.isTypeGroup(typeGroup);
+ }
/**
- * Intentionally empty to override super Class
- * @override
+ * Comparison property for model types.
+ * query example: `[modelType=block]`
+ * @returns {string} One of course|menu|page|article|block|component
*/
- _completeObjective() {}
+ get modelType() {
+ return this.model.get('_type');
+ }
/**
- * Intentionally empty to prevent super Class event triggers
- * @override
+ * Comparison property for model component strings.
+ * query example: `[modelComponent=mcq]`
+ * @returns {string} One of mcq|gmcq|slider|graphic|... etc
*/
- update() {}
-
- modelTypeGroup(group) {
- return this.model.isTypeGroup(group);
+ get modelComponent() {
+ return this.model.get('_component');
}
- get modelType() {
- return this.model.get('_type');
+ /** @override */
+ get order() {
+ if (!data.isReady) return 0;
+ // Reverse order by ancestor distance such that children execute first and parents last
+ return 100 - this.model.getAncestorModels(true).length;
}
- get modelComponent() {
- return this.model.get('_component');
+ /** @override */
+ get isPassed() {
+ return null;
}
- get model() {
- return this._model;
+ /** @override */
+ get isFailed() {
+ return null;
}
- /**
- * @override
- */
- get rawModels() {
- return [this.model];
+ /** @override */
+ get isOptional() {
+ return this.model.get('_isOptional');
}
- /**
- * @override
- */
+ /** @override */
get isAvailable() {
- return isAvailableInHierarchy(this.model);
+ return this.model.get('_isAvailable');
}
- /**
- * @override
- */
- get isComplete() {
- return this.model.get('_isComplete');
+ get feedback() {
+ if (!this.isSubmitted) return;
+ return this.model.getFeedback();
}
- /**
- * @override
- */
- get isPassed() {
+ /** @override */
+ get journal() {
return null;
}
- /**
- * Intentionally empty to prevent super Class event triggers
- * @override
- */
- onCompleted() {}
+ /** @override */
+ get objective() {
+ if (!this.model.get('_recordObjective')) return;
+ return super.objective;
+ }
- /**
- * Intentionally empty to prevent super Class event triggers
- * @override
- */
- onPassed() {}
}
diff --git a/js/IntersectionSet.js b/js/IntersectionSet.js
new file mode 100644
index 0000000..cb90cf1
--- /dev/null
+++ b/js/IntersectionSet.js
@@ -0,0 +1,424 @@
+import Adapt from 'core/js/adapt';
+import {
+ getAllSets,
+ filterSetsByType,
+ filterSetsByIntersectingModelId,
+ findSetById
+} from './utils/sets';
+import {
+ isModelAvailableInHierarchy,
+ findDescendantModels,
+ getModelChildren,
+ filterModelsByIntersectingModels
+} from './utils/models';
+import {
+ createIntersectedSet
+} from './utils/intersection';
+import {
+ unique
+} from './utils/math';
+import Backbone from 'backbone';
+import Logging from 'core/js/logging';
+
+/**
+ * Assign a unique id to the set if none is given.
+ * @param {IntersectionSet} set
+ */
+function assignAutoId(set) {
+ if (set.id) return;
+ if (!set.type && !set.modelId) {
+ Logging.error(`Cannot register a scoring set with no id or type or modelId ${set.constructor.name}`);
+ }
+ const prefix = set.type || set.modelId || 'unknown';
+ // Find unused index "prefix-1"
+ const allSets = getAllSets();
+ let index = 0;
+ let id = '';
+ while ((id = prefix + '-' + index++) && allSets.find(set => set.id === id));
+ set.id = id;
+ Logging.debug(`Created id ${set.id} for ${set.constructor.name}`);
+}
+
+/**
+ * Set at which intersections and queries can be performed.
+ */
+export default class IntersectionSet extends Backbone.Controller {
+
+ /**
+ * @param {Object} options
+ * @param {string} [options._id=null] Unique set id
+ * @param {string} [options._type=null] Type of set
+ * @param {string} [options._title=null] Set title
+ * @param {Backbone.Model} [options._model=null] Model of set configuration or orientation
+ * @param {Backbone.Model[]} [options._models=null] Models which belong to the set
+ * @param {string} [options.title=null] Human readable alternative for _title
+ * @param {Backbone.Model} [options.model=null] Human readable alternative for _model
+ * @param {Backbone.Model[]} [options.models=null] Human readable alternative for _models
+ * @param {IntersectionSet} [options.intersectionParent=null] System defined intersection parent
+ */
+ initialize({
+ // To help with cloning, option names should reflect the enumerable properties on the instance
+ _id = null,
+ _type = null,
+ _title = '',
+ _model = null,
+ _models = null,
+ // There are a few human readable alternatives for easily confused properties
+ title = '',
+ model = null,
+ models = null,
+ // When creating an intersected clone, a parent is needed
+ intersectionParent = null
+ } = {}) {
+ this.intersectionParent = intersectionParent;
+ this.id = _id;
+ this.type = _type;
+ // Make sure to use the human readable alternatives where the enumerable ones are not provided
+ this.title = _title ?? title;
+ (_model ?? model) && (this.model = _model ?? model);
+ (_models ?? models) && (this.models = _models ?? models);
+ this.register();
+ }
+
+ /**
+ * Create a clone of this instance, intersected over the intersectionParent.
+ * This will reduce this.models by intersecting with the intersectionParent.models.
+ * @param {IntersectionSet} intersectionParent
+ * @returns {IntersectionSet}
+ */
+ intersect(intersectionParent) {
+ // Create a clone of this instance, assign the clone an intersectionParent by which to
+ // filter its models accordingly
+ // Only the enumerable instance properties of this instance are passed through to the clone
+ // i.e. _id, _type, _title, _model, _models not id, type, title, model, models
+ const Class = Object.getPrototypeOf(this).constructor;
+ const options = { ...this, intersectionParent };
+ return new Class(options);
+ }
+
+ /**
+ * Register the set.
+ * @private
+ * @fires Adapt#scoring:[set.type]:register
+ * @fires Adapt#scoring:set:register
+ */
+ register() {
+ // Only register configured sets, intersection subsets are dynamically created when required
+ if (this.isIntersectedSet) return;
+ assignAutoId(this);
+ Adapt.scoring.register(this);
+ Adapt.trigger(`scoring:${this.type}:register scoring:set:register`, this);
+ }
+
+ /**
+ * Unique id for this set. Will be automatically generated if not set.
+ * query example: `#id` or `[#id]` or `[id=id]`
+ * @returns {string}
+ */
+ get id() {
+ return this._id;
+ }
+
+ set id(value) {
+ this._id = value;
+ }
+
+ /**
+ * Returns the set type.
+ * query example: `type` or `[type=type]`
+ * @returns {string}
+ */
+ get type() {
+ return this._type;
+ }
+
+ set type(value) {
+ this._type = value;
+ }
+
+ /**
+ * Returns the set title.
+ * @returns {string}
+ */
+ get title() {
+ return this._title;
+ }
+
+ set title(value) {
+ this._title = value;
+ }
+
+ /**
+ * Lifecycle processing order.
+ * @returns {number}
+ */
+ get order() {
+ return 400;
+ }
+
+ /**
+ * Returns whether the set is enabled.
+ * query example: `(isEnabled)` or `(isEnabled=false)`
+ * @returns {boolean}
+ */
+ get isEnabled() {
+ return true;
+ }
+
+ /**
+ * Returns whether the set is optional.
+ * query example: `(isOptional)` or `(isOptional=false)`
+ * @returns {boolean}
+ */
+ get isOptional() {
+ return false;
+ }
+
+ /**
+ * Returns whether the set is available.
+ * query example: `(isAvailable)` or `(isAvailable=false)`
+ * @returns {boolean}
+ */
+ get isAvailable() {
+ return true;
+ }
+
+ /**
+ * Returns whether the set's model is available in the model hierarchy.
+ * @return {boolean}
+ */
+ get isModelAvailableInHierarchy() {
+ return isModelAvailableInHierarchy(this.model);
+ }
+
+ /**
+ * Check to see if there are any child models.
+ * query example: `(isPopulated)` or `(isPopulated=false)`
+ * @returns {boolean}
+ */
+ get isPopulated() {
+ // TODO: do these need to be over this.availableModels instead ?
+ return Boolean(this.models?.length);
+ }
+
+ /**
+ * Check to see if there are any child models.
+ * query example: `(isNotPopulated)` alias for `(isPopulated=false)`
+ * @returns {boolean}
+ */
+ get isNotPopulated() {
+ return (this.isPopulated === false);
+ }
+
+ /**
+ * Returns all intersected subsets.
+ * @returns {IntersectionSet[]}
+ */
+ get intersectedSubsets() {
+ let sets = getAllSets({ excludeParent: this });
+ sets = sets.map(set => createIntersectedSet([this, set]));
+ return sets;
+ }
+
+ /**
+ * Returns all intersected subsets that contain models.
+ * @returns {IntersectionSet[]}
+ */
+ get populatedIntersectedSubsets() {
+ return this.intersectedSubsets.filter(set => set.isPopulated);
+ }
+
+ /**
+ * Returns the parent set over which this set is intersected.
+ * @returns {IntersectionSet|null}
+ */
+ get intersectionParent() {
+ return this._intersectionParent;
+ }
+
+ set intersectionParent(value) {
+ this._intersectionParent = value;
+ }
+
+ /**
+ * Is an intersected clone.
+ * @returns {boolean}
+ */
+ get isIntersectedSet() {
+ return Boolean(this.intersectionParent);
+ }
+
+ /**
+ * If an intersected set, returns this set including its ancestors.
+ * @returns {IntersectionSet[]}
+ */
+ get subsetPath() {
+ let subject = this;
+ const path = [];
+ while (subject) {
+ path.push(subject);
+ subject = subject.intersectionParent;
+ }
+ return path.reverse();
+ }
+
+ /**
+ * The config or origin model at which the set is oriented.
+ * @returns {Backbone.Model}
+ */
+ get model() {
+ return this._model;
+ }
+
+ set model(value) {
+ this._model = value;
+ }
+
+ /**
+ * The config or origin model id, used for querying.
+ * query example: `[modelId=modelId]`
+ * @returns {string}
+ */
+ get modelId() {
+ return this.model?.get('_id');
+ }
+
+ /**
+ * Returns a unique array of subject models, filtered according to hierarchy intersections
+ * with the intersectionParent.models.
+ * By default will return the children and detached models of this.model.
+ * If this.model is set, then the overridden models will be used.
+ * @returns {Backbone.Model[]}
+ */
+ get models() {
+ const models = this._models ?? getModelChildren(this._model, { allowDetached: true });
+ return this.filterModels(models, this.intersectionParent?.models);
+ }
+
+ set models(value) {
+ this._models = value;
+ }
+
+ /**
+ * Returns models filtered according to hierarchy intersections with the intersectionParent.models.
+ * @param {Backbone.Model[]} models
+ * @returns {Backbone.Model[]}
+ */
+ filterModels(models) {
+ return unique(filterModelsByIntersectingModels(models, this.intersectionParent?.models));
+ }
+
+ /**
+ * Returns all `_isAvailable` models, excluding detached models.
+ * @returns {ComponentModel[]}
+ */
+ get availableModels() {
+ return this.models.filter(isModelAvailableInHierarchy);
+ }
+
+ /**
+ * Returns all component models regardless of `_isAvailable`.
+ * @returns {ComponentModel[]}
+ */
+ get components() {
+ return this.models.reduce((components, model) => {
+ const models = model.isTypeGroup('component')
+ ? [model]
+ : findDescendantModels(model, 'component');
+ return components.concat(models);
+ }, []);
+ }
+
+ /**
+ * Returns all `_isAvailable` component models, excluding detached models.
+ * @returns {ComponentModel[]}
+ */
+ get availableComponents() {
+ return this.components.filter(isModelAvailableInHierarchy);
+ }
+
+ /**
+ * Returns all question models regardless of `_isAvailable`.
+ * @returns {QuestionModel[]}
+ */
+ get questions() {
+ return this.components.filter(model => model.isTypeGroup('question'));
+ }
+
+ /**
+ * Returns all `_isAvailable` question models, excluding detached models.
+ * @returns {QuestionModel[]}
+ */
+ get availableQuestions() {
+ return this.questions.filter(isModelAvailableInHierarchy);
+ }
+
+ /**
+ * Returns all presentation component models regardless of `_isAvailable`.
+ * @returns {QuestionModel[]}
+ */
+ get presentationComponents() {
+ return this.components.filter(model => !model.isTypeGroup('question'));
+ }
+
+ /**
+ * Returns all `_isAvailable` presentation component models, excluding detached models.
+ * @returns {QuestionModel[]}
+ */
+ get availablePresentationComponents() {
+ return this.presentationComponents.filter(isModelAvailableInHierarchy);
+ }
+
+ /**
+ * Returns all trackable components - excludes trickle etc.
+ * @returns {ComponentModel[]}
+ */
+ get trackableComponents() {
+ return this.components.filter(model => model.get('_isTrackable') !== false);
+ }
+
+ /**
+ * Returns all `_isAvailable` trackable components - excludes trickle etc, excluding detached models.
+ * @returns {ComponentModel[]}
+ */
+ get availableTrackableComponents() {
+ return this.trackableComponents.filter(isModelAvailableInHierarchy);
+ }
+
+ /**
+ * Returns intersected subsets by id.
+ * @param {string} setId
+ * @returns {IntersectionSet[]}
+ */
+ getSubsetById(setId) {
+ const sets = getAllSets({ excludeParent: this });
+ const set = findSetById(sets, setId);
+ if (!set) return null;
+ return createIntersectedSet([this, set]);
+ }
+
+ /**
+ * Returns intersected subsets by type.
+ * @param {string} setType
+ * @returns {IntersectionSet[]}
+ */
+ getSubsetsByType(setType) {
+ let sets = getAllSets({ excludeParent: this });
+ sets = filterSetsByType(sets, setType);
+ sets = sets.map(set => createIntersectedSet([this, set]));
+ return sets;
+ }
+
+ /**
+ * Returns intersected subsets by modelId.
+ * @param {string} modelId
+ * @returns {IntersectionSet[]}
+ */
+ getSubsetsByIntersectingModelId(modelId) {
+ let sets = getAllSets({ excludeParent: this });
+ sets = filterSetsByIntersectingModelId(sets, modelId);
+ sets = sets.map(set => createIntersectedSet([this, set]));
+ return sets;
+ }
+
+}
diff --git a/js/Lifecycle.js b/js/Lifecycle.js
new file mode 100644
index 0000000..8a32943
--- /dev/null
+++ b/js/Lifecycle.js
@@ -0,0 +1,314 @@
+import Data from 'core/js/data';
+import Adapt from 'core/js/adapt';
+import AdaptModelSet from './AdaptModelSet';
+import {
+ filterSetsByIntersectingModels,
+ filterSetsByIntersectingModelId,
+ filterSetsByLocalModelId,
+ filterSetsByModelId,
+ findSetById,
+ getAllSets
+} from './utils/sets';
+import offlineStorage from 'core/js/offlineStorage';
+import LifecycleRenderer from './LifecycleRenderer';
+import wait from 'core/js/wait';
+import AdaptModel from 'core/js/models/adaptModel';
+import Backbone from 'backbone';
+/** @typedef {import("./IntersectionSet").default} IntersectionSet */
+/** @typedef {import("core/js/modelEvent").default} ModelEvent */
+/** @typedef {import("core/js/location").default} Location */
+
+/**
+ * Lifecycle controller.
+ */
+export default class Lifecycle extends Backbone.Controller {
+
+ initialize({ scoring }) {
+ this.scoring = scoring;
+ this.listenTo(Data, {
+ loading: this.onDataLoading,
+ add: this.onDataAddAdaptModel,
+ remove: this.onDataRemoveAdaptModel,
+ 'change:_isAvailable': this.onAdaptModelChange
+ });
+ this.listenTo(Adapt, {
+ 'scoring:register': this.onScoringSetRegister,
+ 'scoring:deregister': this.onScoringSetDeregister,
+ 'adapt:start': this.onAdaptStart,
+ 'router:location': this.onRouterLocation
+ });
+ }
+
+ /**
+ * Creates a new AdaptModelSet when a new AdaptModel is added to the data API.
+ * Listens to the reset events on the new model.
+ * @listens Data#add
+ * @listens model#reset
+ * @param {Backbone.Model} model
+ */
+ onDataAddAdaptModel(model) {
+ new AdaptModelSet({ _model: model });
+ this.listenTo(model, 'reset', (...args) =>
+ this.onAdaptModelReset(model, ...args)
+ );
+ }
+
+ /**
+ * Removes the AdaptModelSet and stops listening when the model is removed from the data API.
+ * @listens Data#remove
+ * @param {Backbone.Model} model
+ */
+ onDataRemoveAdaptModel(model) {
+ const allSets = getAllSets();
+ const oldSet = findSetById(allSets, model.get('_id'));
+ if (!oldSet) return;
+ this.scoring.deregister(oldSet);
+ this.stopListening(oldSet);
+ this.stopListening(model);
+ }
+
+ /**
+ * Listens to a newly registered Set's reset and update events.
+ * @listens AdaptModelSet#reset
+ * @listens AdaptModelSet#update
+ * @param {IntersectionSet} newSet
+ */
+ onScoringSetRegister(newSet) {
+ this.listenTo(newSet, {
+ reset: this.onScoringSetReset,
+ update: this.onScoringSetUpdate
+ });
+ }
+
+ /**
+ * Stops listening to a deregistered set.
+ * @param {IntersectionSet} oldSet
+ */
+ onScoringSetDeregister(oldSet) {
+ this.stopListening(oldSet);
+ }
+
+ /**
+ * Begins the lifecycle of the registered sets and listens to bubbled Adapt events.
+ * @listens Adapt#adapt:start
+ * @listens Adapt.course#bubble:change:_isInteractionComplete
+ * @listens Adapt.course#bubble:change:_isActive
+ * @listens Adapt.course#bubble:change:_isVisited
+ */
+ async onAdaptStart() {
+ this.listenTo(Adapt.course, {
+ 'bubble:change:_isInteractionComplete bubble:change:_isActive bubble:change:_isVisited': this.onAdaptModelChangeBubble
+ });
+ wait.begin();
+ await this.init();
+ await this.onOfflineStorageReady();
+ // only run update async so that restore, start and update
+ // are batch processed together
+ this.restore();
+ this.start();
+ await this.update(getAllSets());
+ this._isStarted = true;
+ wait.end();
+ }
+
+ /**
+ * Waits for offline storage to be ready.
+ */
+ async onOfflineStorageReady() {
+ if (offlineStorage.ready) return;
+ return new Promise(resolve => Adapt.once('offlineStorage:ready', resolve));
+ }
+
+ /**
+ * Adds sets to the update phase which intersect the changed model.
+ * @param {Backbone.Model} model
+ */
+ async onAdaptModelChange(model) {
+ if (!this._isStarted) return;
+ const allSets = getAllSets();
+ this.update(filterSetsByIntersectingModelId(allSets, model.get('_id')), model);
+ }
+
+ /**
+ * Adds sets to the update phase which intersect the changed model.
+ * @param {ModelEvent} event
+ */
+ async onAdaptModelChangeBubble(event) {
+ if (!this._isStarted) return;
+ const adaptModel = event.deepPath.findLast(model => model instanceof AdaptModel);
+ if (!adaptModel) return;
+ const allSets = getAllSets();
+ this.update(filterSetsByIntersectingModelId(allSets, adaptModel.get('_id')), adaptModel);
+ }
+
+ /**
+ * Adds sets to the leave and visit phase which are local to the previous and current location.
+ * @param {Location} location
+ */
+ async onRouterLocation(location) {
+ // run together for batch processing
+ const allSets = getAllSets();
+ const leaveSets = filterSetsByLocalModelId(allSets, location._previousId)
+ .filter(set => set.isModelAvailableInHierarchy);
+ this.leave(leaveSets);
+ const visitSets = filterSetsByLocalModelId(allSets, location._currentId)
+ .filter(set => set.isModelAvailableInHierarchy);
+ this.visit(visitSets);
+ }
+
+ /**
+ * Adds sets to the restart phase which are on the model id.
+ * @param {Backbone.Model} model
+ */
+ onAdaptModelReset(model) {
+ // TODO: determine if we should restart the sets on this model id only or all descendant sets as well
+ const sets = filterSetsByModelId(getAllSets(), model.get('_id'));
+ this.restart(sets);
+ }
+
+ /**
+ * Adds sets to the reset phase which intersect the set models.
+ * @param {IntersectionSet} set
+ */
+ onScoringSetReset(set) {
+ const sets = filterSetsByIntersectingModels(getAllSets(), set.models);
+ this.restart(sets);
+ }
+
+ /**
+ * Adds sets to the update phase which intersect the set.modelId.
+ * @param {IntersectionSet} set
+ */
+ onScoringSetUpdate(set) {
+ const sets = filterSetsByIntersectingModelId(getAllSets(), set.modelId);
+ this.update(sets);
+ }
+
+ /**
+ * Send all sets into the init phase.
+ */
+ async init() {
+ const sets = getAllSets();
+ await this.renderer.render.init(sets);
+ }
+
+ /**
+ * Send all sets into the restore phase.
+ * @fires Adapt#scoring:lifecycle:restored
+ */
+ async restore() {
+ const sets = getAllSets();
+ await this.renderer.render.restore(sets);
+ Adapt.trigger('scoring:lifecycle:restored', this.scoring);
+ }
+
+ /**
+ * Send all sets into the start phase.
+ * @fires Adapt#scoring:lifecycle:start
+ */
+ async start() {
+ const sets = getAllSets();
+ await this.renderer.render.start(sets);
+ Adapt.trigger('scoring:lifecycle:start', this.scoring);
+ }
+
+ /**
+ * Send all sets into the reset phase.
+ * @fires Adapt#scoring:lifecycle:reset
+ */
+ async reset() {
+ const sets = getAllSets();
+ await this.renderer.render.reset(sets);
+ Adapt.trigger('scoring:lifecycle:reset', this.scoring);
+ }
+
+ /**
+ * Send givens sets into the restart phase.
+ * @param {IntersectionSet[]} sets
+ */
+ async restart(sets) {
+ sets = sets.filter(set => !set.intersectionParent);
+ await this.renderer.render.restart(sets);
+ }
+
+ /**
+ * Send givens sets into the leave phase.
+ * @param {IntersectionSet[]} sets
+ */
+ async leave(sets) {
+ sets = sets.filter(set => !set.intersectionParent);
+ await this.renderer.render.leave(sets);
+ }
+
+ /**
+ * Send givens sets into the visit phase.
+ * @param {IntersectionSet[]} sets
+ */
+ async visit(sets) {
+ sets = sets.filter(set => !set.intersectionParent);
+ await this.renderer.render.visit(sets);
+ }
+
+ /**
+ * Send givens sets into the update phase.
+ * @param {IntersectionSet[]} sets
+ * @param {Backbone.Model} model
+ * @fires Adapt#scoring:lifecycle:update
+ */
+ async update(sets, model = null) {
+ sets = sets.filter(set => !set.intersectionParent);
+ if (model) sets.forEach(set => set?.journal?.addPendingUpdate?.(model, sets));
+ await this.renderer.render.update(sets);
+ Adapt.trigger('scoring:lifecycle:update', this.scoring);
+ }
+
+ /**
+ * Returns the renderer.
+ * @returns {LifecycleRenderer}
+ */
+ get renderer() {
+ return renderer;
+ }
+
+}
+
+const renderer = new LifecycleRenderer({
+ // Interval at which set lifecycle events are grouped for execution
+ fps: 30,
+ // Order and function of lifecycle events
+ lifecycleDefinition: {
+ // init calls set.onInit, it is an initialisation lifecycle event
+ async init(set) {
+ await set.onInit?.();
+ },
+ // restore calls set.onRestore, it is an initialisation lifecycle event
+ async restore(set) {
+ set.wasRestored = Boolean(await set.onRestore?.());
+ },
+ // start calls set.onStart, only when wasRestored is false, it is an initialisation lifecycle event
+ async start(set) {
+ !set.wasRestored && await set.onStart?.();
+ },
+ // reset calls set.reset only from Adapt.scoring.reset(), it tries to reset all models based upon set.canReset
+ async reset(set) {
+ set.canReset && await set.reset?.();
+ },
+ // restart calls set.onRestart when any intersecting model or set is reset
+ async restart(set) {
+ await set.onRestart?.();
+ },
+ // leave calls set.onLeave when exiting an intersecting contentobject
+ async leave(set) {
+ await set.onLeave?.();
+ },
+ // visit calls set.onVisit when entering an intersecting contentobject
+ async visit(set) {
+ await set.onVisit?.();
+ },
+ // update calls set.onUpdate after all other lifecycle events have been executed
+ async update(set) {
+ await set.onUpdate?.();
+ set?.journal?.update();
+ }
+ }
+});
diff --git a/js/LifecycleRenderer.js b/js/LifecycleRenderer.js
new file mode 100644
index 0000000..350f9cc
--- /dev/null
+++ b/js/LifecycleRenderer.js
@@ -0,0 +1,164 @@
+import wait from 'core/js/wait';
+import Backbone from 'backbone';
+// eslint-disable-next-line no-unused-vars
+import Logging from 'core/js/logging';
+/** @typedef {import("./IntersectionSet").default} IntersectionSet */
+
+/**
+ * Transforms a lifecycle definition into fps batched, phase queues, where the
+ * queues are executed in phase and definition order at each cycle.
+ */
+export default class LifecycleRenderer extends Backbone.Controller {
+
+ /**
+ * @param {Object} options
+ * @param {Object} options.lifecycleDefinition Object of async key function pairs
+ * @param {number} options.fps Frames per second of batched cycles
+ */
+ initialize({
+ lifecycleDefinition,
+ fps
+ } = {}) {
+ this.PHASE_NAMES = Object.keys(lifecycleDefinition);
+ this.PHASE_HANDLERS = lifecycleDefinition;
+ this.phaseQueuedSets = {};
+ this.hasStartedWaiting = false;
+ this._render = {};
+ this._batchRenderId = 0;
+ const renderer = this.startBatchRender.bind(this);
+ // Throttle gives lifecycle executions batches a reasonable size
+ this.startBatchRender = _.throttle(() => {
+ if (this.areQueuesEmpty) return;
+ // requestAnimationFrame means that changes are rendered with the browser allowing for smooth updates
+ requestAnimationFrame(renderer);
+ }, 1000 / fps);
+ this.createRenderFunctionsAndQueues();
+ }
+
+ /**
+ * Returns an object of dynamically created named queue addition functions.
+ * @returns {Object}
+ */
+ get render() {
+ return this._render;
+ }
+
+ /**
+ * Creates the named queue addition functions.
+ */
+ createRenderFunctionsAndQueues() {
+ for (const name of this.PHASE_NAMES) {
+ this.phaseQueuedSets[name] = [];
+ this.render[name] = this.addNonDuplicatePhaseQueueSets.bind(this, name);
+ // alternative immediate renderer
+ // this.render[name] = this.renderImmediatePhaseSets.bind(this, name);
+ }
+ }
+
+ /**
+ * Returns the sets in the phase queue.
+ * @param {string} phaseName
+ * @returns {IntersectionSet[]}
+ */
+ getPhaseQueueSets(phaseName) {
+ return this.phaseQueuedSets[phaseName];
+ }
+
+ /**
+ * Add the sets to the relevant phase queue if they're not in there already.
+ * Starts the batch renderer.
+ * @param {string} phaseName
+ * @param {IntersectionSet} sets
+ */
+ async addNonDuplicatePhaseQueueSets (phaseName, sets) {
+ if (!sets?.length) return;
+ const phaseSets = this.getPhaseQueueSets(phaseName);
+ const missingSets = sets.filter(set => !phaseSets.includes(set));
+ if (!missingSets?.length) return;
+ this.startAdaptWait();
+ phaseSets.push(...missingSets);
+ this.startBatchRender();
+ await this.onBatchRendered();
+ };
+
+ /**
+ * Force adapt to wait.
+ */
+ startAdaptWait() {
+ if (this.hasStartedWaiting) return;
+ wait.begin();
+ this.hasStartedWaiting = true;
+ }
+
+ /**
+ * Render the batch.
+ * @fires this#rendered
+ */
+ async startBatchRender () {
+ this._batchRenderId++;
+ if (isNaN(this._batchRenderId)) this._batchRenderId = 0;
+ while (!this.tryEndAdaptWait()) {
+ // Fetch the next phase with entries for execution
+ const phaseName = this.PHASE_NAMES.find(phaseName => this.getPhaseQueueSets(phaseName)?.length);
+ if (!phaseName) break;
+ const handler = this.PHASE_HANDLERS[phaseName];
+ const phaseSets = this.getPhaseQueueSets(phaseName);
+ if (!phaseSets?.length) continue;
+ const sets = phaseSets?.slice(0);
+ phaseSets.length = 0;
+ sets.sort((a, b) => a.order - b.order);
+ // to see which phases and sets are firing and in which batches
+ // eslint-disable-next-line no-unused-vars
+ Logging.debug(`lifecycle batch(${this._batchRenderId}) render, phase:`, phaseName, ' for:', sets.map(set => set.id).join(','));
+ for (const set of sets) {
+ await handler(set);
+ }
+ }
+ this.trigger('rendered');
+ }
+
+ /**
+ * Tries to stop adapt from waiting after all of the queues are empty.
+ * @returns {boolean} Signifying if waiting has ended
+ */
+ tryEndAdaptWait() {
+ if (!this.areQueuesEmpty) return false;
+ this.hasStartedWaiting = false;
+ wait.end();
+ return true;
+ }
+
+ /**
+ * Returns true/false if the queues are empty.
+ * @returns {Boolean}
+ */
+ get areQueuesEmpty() {
+ return Object.values(this.phaseQueuedSets).every(queue => !queue.length);
+ }
+
+ /**
+ * Resolves when the next batch has been rendered.
+ */
+ async onBatchRendered() {
+ return new Promise(resolve => this.once('rendered', resolve));
+ }
+
+ /**
+ * This function is not used, but it is here to demonstrate what immediate
+ * vs batched rendering looks like.
+ * It renders phases on the sets immediately, rather than deduplicating and
+ * batch processing them, it results in many more event calls but should give
+ * otherwise identical behaviour (assuming the sets behave properly).
+ */
+ async renderImmediatePhaseSets (phaseName, sets) {
+ if (!sets?.length) return;
+ sets.sort((a, b) => a.order - b.order);
+ // To see which phases and sets are firing
+ Logging.debug('lifecycle immediate render, phase:', phaseName, ' for:', sets.map(set => set.id).join(','));
+ for (const set of sets) {
+ await this.PHASE_HANDLERS[phaseName](set);
+ }
+ wait.end();
+ }
+
+}
diff --git a/js/LifecycleSet.js b/js/LifecycleSet.js
new file mode 100644
index 0000000..0d04fcd
--- /dev/null
+++ b/js/LifecycleSet.js
@@ -0,0 +1,128 @@
+import Adapt from 'core/js/adapt';
+import Logging from 'core/js/logging';
+import State from './State';
+import IntersectionSet from './IntersectionSet';
+import LifecycleUpdateJournal from './LifecycleUpdateJournal';
+
+/**
+ * Set at which intersections and queries can be performed.
+ * Set at which lifecycle phases, callbacks and triggers can be utilised.
+ */
+export default class LifecycleSet extends IntersectionSet {
+
+ /**
+ * State object for the set.
+ * @note Can only save and restore arrays of arrays, arrays of only numbers and arrays of only booleans.
+ * i.e. [[1,2,3,4],[true,false,true,false]] or [true,false,true] etc.
+ * @type {State}
+ */
+ get state() {
+ if (this.isIntersectedSet) return;
+ return (this._state = this._state || new State({ set: this }));
+ }
+
+ /**
+ * The journal for recording the updates to the set.
+ * @returns {LifecycleUpdateJournal}
+ */
+ get journal() {
+ if (this.isIntersectedSet) return;
+ return (this._journal = this._journal || new LifecycleUpdateJournal({ set: this }));
+ }
+
+ /**
+ * Signifies if onRestore returned true/false.
+ * @returns {boolean}
+ */
+ get wasRestored() {
+ return this._wasRestored;
+ }
+
+ set wasRestored(value) {
+ this._wasRestored = value;
+ }
+
+ /**
+ * Called after initialize on every model.
+ * @protected
+ */
+ async onInit() {}
+
+ /**
+ * Called after init on every model.
+ * Restore data from previous sessions.
+ * @protected
+ * @fires Adapt#scoring:[set.type]:restored
+ * @fires Adapt#scoring:set:restored
+ * @returns {Boolean} Signify if the set was restored or not
+ */
+ async onRestore() {
+ if (this.isIntersectedSet) return;
+ Adapt.trigger(`scoring:${this.type}:restored scoring:set:restored`, this);
+ }
+
+ /**
+ * Called on each set after onRestore, only if onRestore returns false.
+ * @protected
+ */
+ async onStart() {}
+
+ /**
+ * Called on each set after a reset or intersecting set or model is reset.
+ * @protected
+ */
+ async onRestart() {}
+
+ /**
+ * Called on each local set when its contentobject is visited.
+ * @protected
+ */
+ async onVisit() {}
+
+ /**
+ * Called on each local set when its contentobject is left.
+ * @protected
+ */
+ async onLeave() {}
+
+ /**
+ * Called on each set when any intersecting model has changes to
+ * _isAvailable, _isActive, _isVisited or _isInteractionComplete or
+ * an intersecting set called `.update()`.
+ * @protected
+ */
+ async onUpdate() {}
+
+ /**
+ * Add this set and all set intersecting the modelId to the update phase.
+ * @fires Adapt#scoring:[set.type]:update
+ * @fires Adapt#scoring:set:update
+ */
+ async update() {
+ if (this.isIntersectedSet) return;
+ Adapt.trigger(`scoring:${this.type}:update scoring:set:update`, this);
+ Logging.debug(`${this.id} update`);
+ this.trigger('update', this);
+ }
+
+ /**
+ * Returns a boolean if this set can be reset.
+ * @returns {boolean}
+ */
+ get canReset() {
+ return true;
+ }
+
+ /**
+ * Resets the set and restarts all sets on the modelId.
+ * @fires Adapt#scoring:[set.type]:reset
+ * @fires Adapt#scoring:set:reset
+ */
+ async reset() {
+ if (this.isIntersectedSet || !this.canReset) return;
+ Adapt.trigger(`scoring:${this.type}:reset scoring:set:reset`, this);
+ Logging.debug(`${this.id} reset`);
+ this.trigger('reset', this);
+ }
+
+}
diff --git a/js/LifecycleUpdateJournal.js b/js/LifecycleUpdateJournal.js
new file mode 100644
index 0000000..6120050
--- /dev/null
+++ b/js/LifecycleUpdateJournal.js
@@ -0,0 +1,43 @@
+/** @typedef {import("./ScoringSet").default} ScoringSet */
+
+/**
+ * A journal for recording the models and sets that triggered set updates in the current lifecycle.
+ */
+export default class LifecycleUpdateJournal {
+
+ /**
+ * @param {Object} options
+ * @param {ScoringSet} options.set
+ */
+ constructor({ set } = {}) {
+ this.set = set;
+ this.pendingUpdateModels = new Set();
+ this.pendingUpdateSets = new Set();
+ }
+
+ /**
+ * Add the model and intersecting sets which caused the set update to be triggered.
+ * @param {Backbone.Model} model Source model
+ * @param {ScoringSet[]} [sets] Intersecting sets
+ */
+ addPendingUpdate(model, sets) {
+ this.pendingUpdateModels.add(model);
+ sets?.forEach(set => this.pendingUpdateSets.add(set));
+ }
+
+ /**
+ * Update lifecycle phase has ended
+ */
+ update() {
+ this.clear();
+ }
+
+ /**
+ * Clear for next pending updates.
+ */
+ clear() {
+ this.pendingUpdateModels.clear();
+ this.pendingUpdateSets.clear();
+ }
+
+}
diff --git a/js/Objective.js b/js/Objective.js
new file mode 100644
index 0000000..27eec7e
--- /dev/null
+++ b/js/Objective.js
@@ -0,0 +1,84 @@
+import offlineStorage from 'core/js/offlineStorage';
+import COMPLETION_STATE from 'core/js/enums/completionStateEnum';
+/** @typedef {import("./ScoringSet").default} ScoringSet */
+
+/**
+ * Registers an objective with the offlineStorage API.
+ * see SCORM cmi.objectives
+ */
+export default class Objective {
+
+ /**
+ * @param {Object} options
+ * @param {ScoringSet} options.set
+ */
+ constructor({ set } = {}) {
+ this.set = set;
+ this.id = this.set.id;
+ this.description = this.set.title;
+ }
+
+ /**
+ * Define the objective for reporting purposes.
+ * Set initial status.
+ */
+ register() {
+ offlineStorage.set('objectiveDescription', this.id, this.description);
+ this.setStatus();
+ }
+
+ /**
+ * Set the objective score.
+ */
+ setScore() {
+ offlineStorage.set('objectiveScore', this.id, this.set.score, this.set.minScore, this.set.maxScore);
+ }
+
+ /**
+ * Reset the objective score.
+ * Depending on the set logic, this may be overriden to prevent resets.
+ */
+ resetScore() {
+ this.setScore();
+ }
+
+ /**
+ * Set the appropriate objective completion and success status.
+ * Will update to the latest data/attempt, unless overriden accordingly in a set.
+ */
+ setStatus() {
+ const isAvailable = this.set.isAvailable;
+ const isStarted = this.set.isStarted;
+ const isIncomplete = this.set.isIncomplete;
+ const isComplete = this.isComplete;
+ const isPassed = this.isPassed;
+ let completionStatus = COMPLETION_STATE.UNKNOWN.asLowerCase;
+ let successStatus = COMPLETION_STATE.UNKNOWN.asLowerCase;
+ if (isAvailable && !isStarted) completionStatus = COMPLETION_STATE.NOTATTEMPTED.asLowerCase;
+ if (isAvailable && isStarted && isIncomplete) completionStatus = COMPLETION_STATE.INCOMPLETE.asLowerCase;
+ if (isAvailable && isComplete) {
+ completionStatus = COMPLETION_STATE.COMPLETED.asLowerCase;
+ if (this.set.hasPassmark) successStatus = (isPassed ? COMPLETION_STATE.PASSED : COMPLETION_STATE.FAILED).asLowerCase;
+ }
+ offlineStorage.set('objectiveStatus', this.id, completionStatus, successStatus);
+ }
+
+ /**
+ * Returns whether the objective for the set is completed.
+ * Depending on the set logic, this can differ to set completion.
+ * @returns {boolean}
+ */
+ get isComplete() {
+ return this.set.isComplete;
+ }
+
+ /**
+ * Returns whether the objective for the set is passed.
+ * Depending on the set logic, this can differ to whether the set was passed.
+ * @returns {boolean|null}
+ */
+ get isPassed() {
+ return this.set.isPassed;
+ }
+
+}
diff --git a/js/Passmark.js b/js/Passmark.js
index 373bb99..2c168ac 100644
--- a/js/Passmark.js
+++ b/js/Passmark.js
@@ -1,3 +1,6 @@
+/**
+ * Stores the configuration for passing.
+ */
export default class Passmark {
constructor({
@@ -15,7 +18,7 @@ export default class Passmark {
}
/**
- * Returns whether the passmark is required
+ * Returns whether the passmark is required.
* @returns {boolean}
*/
get isEnabled() {
@@ -23,7 +26,7 @@ export default class Passmark {
}
/**
- * Returns whether the subsets need to be passed
+ * Returns whether the subsets need to be passed.
* @returns {boolean}
*/
get requiresPassedSubsets() {
@@ -31,7 +34,7 @@ export default class Passmark {
}
/**
- * Returns the score required for passing
+ * Returns the score required for passing.
* @returns {number}
*/
get score() {
@@ -39,7 +42,7 @@ export default class Passmark {
}
/**
- * Returns the correctness required for passing
+ * Returns the correctness required for passing.
* @returns {number}
*/
get correctness() {
@@ -47,7 +50,7 @@ export default class Passmark {
}
/**
- * Returns whether the `score` and `correctness` are to be used as a percentage
+ * Returns whether the `score` and `correctness` are to be used as a percentage.
* @returns {boolean}
*/
get isScaled() {
diff --git a/js/ScoringSet.js b/js/ScoringSet.js
index 7dade1a..334aaa9 100644
--- a/js/ScoringSet.js
+++ b/js/ScoringSet.js
@@ -1,27 +1,24 @@
import Adapt from 'core/js/adapt';
import Logging from 'core/js/logging';
-import OfflineStorage from 'core/js/offlineStorage';
-import COMPLETION_STATE from 'core/js/enums/completionStateEnum';
+import LifecycleSet from './LifecycleSet';
+import Objective from './Objective';
+import ScoringUpdateJournal from './ScoringUpdateJournal';
import {
- filterModels,
- getScaledScoreFromMinMax,
- getSubsets,
- getSubsetsByType,
- getSubsetsByModelId,
- getSubsetById,
- getSubSetByPath,
- getSubsetsByQuery,
- isAvailableInHierarchy
-} from './utils';
-import Backbone from 'backbone';
-import _ from 'underscore';
+ getScaledScoreFromMinMax
+} from './utils/scoring';
+import {
+ sum
+} from './utils/math';
+import {
+ hasHashChanged
+} from './utils/hash';
/**
* The class provides an abstract that describes a set of models which can be extended with custom
* scoring and completion behaviour.
* Derivative class instances should act as both a root set of models (test-blocks) and an
* intersected set of models (retention-question-components vs test-blocks).
- * Set intersections are performed by comparing overlapping hierachies, such that a model will be
+ * Set intersections are performed by comparing overlapping hierarchies, such that a model will be
* considered in both sets when it is equal to, a descendant of or an ancestor of a model in the intersecting
* set. A test-block may contain a retention-question-component, a retention-question-component
* may be contained in a test-block and a test-block may be equal to a test-block.
@@ -31,349 +28,94 @@ import _ from 'underscore';
* give a subset of retention-question-components.
* Intersected sets will always only include models from their prospective set.
*/
-export default class ScoringSet extends Backbone.Controller {
-
- initialize({
- _id = null,
- _type = null,
- title = '',
- _isScoreIncluded = false,
- _isCompletionRequired = false
- } = {}, subsetParent = null) {
- this._subsetParent = subsetParent;
- this._id = _id;
- this._type = _type;
- this._title = title;
- this._isScoreIncluded = _isScoreIncluded;
- this._isCompletionRequired = _isCompletionRequired;
- this._modifiers = [];
- this.register();
- this._setupListeners();
- }
-
- /**
- * Register the set
- * @fires Adapt#scoring:[set.type]:register
- * @fires Adapt#scoring:set:register
- */
- register() {
- if (this.subsetParent) return;
- Adapt.scoring.register(this);
- Adapt.trigger(`scoring:${this.type}:register scoring:set:register`, this);
- }
-
- /**
- * @protected
- */
- _setupListeners() {
- if (this.subsetParent || this.type === 'adapt') return;
- this.listenTo(Adapt, 'questionView:submitted', this.onQuestionSubmitted);
- if (OfflineStorage.ready) return this.restore();
- this.listenTo(Adapt, 'offlineStorage:ready', this.restore);
- }
-
- /**
- * Restore data from previous sessions
- * @listens Adapt#offlineStorage:ready
- * @fires Adapt#scoring:[set.type]:restored
- * @fires Adapt#scoring:set:restored
- */
- restore() {
- if (this.subsetParent) return;
- Adapt.trigger(`scoring:${this.type}:restored scoring:set:restored`, this);
- }
-
- init() {
- this._setObjectiveStatus = _.debounce(this._setObjectiveStatus, 100);
- this._wasAvailable = this.isAvailable;
- this._wasIncomplete = this.isIncomplete;
- this._wasComplete = this.isComplete;
- this._wasPassed = this.isPassed;
- this._initializeObjective();
- }
-
- /**
- * Executed on data changes
- * @param {[Backbone.Model]} updatedModels
- */
- update(updatedModels) {
- const isComplete = this.isComplete;
- const isPassed = this.isPassed;
- if (isComplete && !this._wasComplete && this._wasAvailable) this.onCompleted();
- if (isPassed && !this._wasPassed && this._wasAvailable) this.onPassed();
- if (this.hasStatusChanged) this._setObjectiveStatus();
- this._wasAvailable = this.isAvailable;
- this._wasIncomplete = this.isIncomplete;
- this._wasComplete = isComplete;
- this._wasPassed = isPassed;
- updatedModels.forEach(model => this._addModifiers(model));
- this._logUpdate();
- this._modifiers = [];
- }
-
- /**
- * Reset the set
- * @fires Adapt#scoring:[set.type]:reset
- * @fires Adapt#scoring:set:reset
- */
- reset() {
- if (this.subsetParent) return;
- Adapt.trigger(`scoring:${this.type}:reset scoring:set:reset`, this);
- Logging.info(`${this.id} reset`);
- this._resetObjective();
- }
-
- /**
- * Filter modules by intersection
- * @param {Backbone.Model} models
- * @returns {[Backbone.Model]}
- */
- filterModels(models) {
- return filterModels(this, models);
- }
-
- /**
- * @param {string} setId
- * @returns {[ScoringSet]}
- */
- getSubsetById(setId) {
- return getSubsetById(setId, this);
- }
-
- /**
- * @param {string} setType
- * @returns {[ScoringSet]}
- */
- getSubsetsByType(setType) {
- return getSubsetsByType(setType, this);
- }
-
- /**
- * @param {string} modelId
- * @returns {[ScoringSet]}
- */
- getSubsetsByModelId(modelId) {
- return getSubsetsByModelId(modelId, this);
- }
-
- /**
- * @param {string|[string]} path
- * @returns {[ScoringSet]}
- */
- getSubsetByPath(path) {
- return getSubSetByPath(path, this);
- }
- /**
- * @param {string} query
- * @returns {[ScoringSet]}
- */
- getSubsetsByQuery(query) {
- return getSubsetsByQuery(query, this);
- }
-
- /**
- * Returns subsets populated by child models
- * @param {ScoringSet} set
- * @returns {[ScoringSet]}
- */
- getPopulatedSubset(subset) {
- return subset.filter(set => set.isPopulated);
- }
-
- /**
- * Returns the minimum score for the specified model
- * @param {Backbone.Model} model
- * @returns {number}
- */
- getMinScoreByModel(model) {
- if (!this.rawQuestions.includes(model)) return 0;
- return model.minScore;
- }
-
- /**
- * Returns the maxiumum score for the specified model
- * @param {Backbone.Model} model
- * @returns {number}
- */
- getMaxScoreByModel(model) {
- if (!this.rawQuestions.includes(model)) return 0;
- return model.maxScore;
- }
+/**
+ * Set at which intersections and queries can be performed.
+ * Set at which lifecycle phases, callbacks and triggers can be utilised.
+ * Set at which scoring, correctness and completion calculations can be performed.
+ */
+export default class ScoringSet extends LifecycleSet {
/**
- * Returns the score for the specified model
- * @param {Backbone.Model} model
- * @returns {number}
+ * @param {Object} [options]
+ * @param {string} [options._id=null] Unique set id
+ * @param {string} [options._type=null] Type of set
+ * @param {string} [options._title=null] Set title
+ * @param {Backbone.Model} [options._model=null] Model of set configuration or orientation
+ * @param {Backbone.Model[]} [options._models=null] Models which belong to the set
+ * @param {string} [options.title=null] Human readable alternative for _title
+ * @param {Backbone.Model} [options.model=null] Human readable alternative for _model
+ * @param {Backbone.Model[]} [options.models=null] Human readable alternative for _models
+ * @param {IntersectionSet} [options.intersectionParent=null] System defined intersection parent
+ * @param {boolean} [options._isScoreIncluded=false]
+ * @param {boolean} [options._isCompletionRequired=false]
*/
- getScoreByModel(model) {
- if (!this.rawQuestions.includes(model)) return 0;
- return model.score;
+ initialize(options = {}) {
+ super.initialize(options);
+ const {
+ _isScoreIncluded = false,
+ _isCompletionRequired = false
+ } = options;
+ this.isScoreIncluded = _isScoreIncluded;
+ this.isCompletionRequired = _isCompletionRequired;
}
- /**
- * Returns a percentage score for the specified model - relative to a positive minimum or zero and maximum values
- * @param {Backbone.Model} model
- * @returns {number}
- */
- getScaledScoreByModel(model) {
- if (!this.rawQuestions.includes(model)) return 0;
- return getScaledScoreFromMinMax(this.getScoreByModel(model), this.getMinScoreByModel(model), this.getMaxScoreByModel(model));
+ /** @override */
+ get order() {
+ return 500;
}
/**
- * Returns the parent set if a dynamically created query set
+ * Returns whether the set should be included in the total score.
+ * @returns {boolean}
*/
- get subsetParent() {
- return this._subsetParent;
- }
-
- get subsetPath() {
- let subject = this;
- const path = [];
- while (subject) {
- path.push(subject);
- subject = subject.subsetParent;
- }
- return path.reverse();
- }
-
- get id() {
- return this._id;
- }
-
- get type() {
- return this._type;
- }
-
- get title() {
- return this._title;
- }
-
get isScoreIncluded() {
return !this.isOptional && this.isAvailable && this._isScoreIncluded;
}
+ set isScoreIncluded(value) {
+ this._isScoreIncluded = value;
+ }
+
/**
- * Returns whether the set needs to be completed
+ * Returns whether the set needs to be completed.
* @returns {boolean}
*/
get isCompletionRequired() {
return !this.isOptional && this.isAvailable && this._isCompletionRequired;
}
- /**
- * Returns all models regardless of `_isAvailable`
- * @returns {[Backbone.Model]}
- */
- get rawModels() {
- Logging.error(`rawModels must be overriden for ${this.constructor.name}`);
- }
-
- /**
- * Returns all component models regardless of `_isAvailable`
- * @returns {[ComponentModel]}
- */
- get rawComponents() {
- return this.rawModels.reduce((components, model) => {
- const models = model.isTypeGroup('component')
- ? [model]
- : model.findDescendantModels('component');
- return components.concat(models);
- }, []);
- }
-
- /**
- * Returns all question models regardless of `_isAvailable`
- * @returns {[QuestionModel]}
- */
- get rawQuestions() {
- return this.rawComponents.filter(model => model.isTypeGroup('question'));
- }
-
- /**
- * Returns all presentation component models regardless of `_isAvailable`
- * @returns {[QuestionModel]}
- */
- get rawPresentationComponents() {
- return this.rawComponents.filter(model => !model.isTypeGroup('question'));
- }
-
- /**
- * Returns a unique array of models, filtered for `_isAvailable` and intersecting subsets hierarchies
- * Always finish by calling `this.filterModels(models)`
- * @returns {[Backbone.Model]}
- */
- get models() {
- return this.filterModels(this.rawModels);
- }
-
- /**
- * Returns all `_isAvailable` component models
- * @returns {[ComponentModel]}
- */
- get components() {
- return this.rawComponents.filter(isAvailableInHierarchy);
- }
-
- /**
- * Returns all trackable components - excludes trickle etc.
- * @returns {[ComponentModel]}
- */
- get trackableComponents() {
- return this.components.filter(model => model.get('_isTrackable') !== false);
- }
-
- /**
- * Returns all `_isAvailable` question models
- * @returns {[QuestionModel]}
- */
- get questions() {
- return this.rawQuestions.filter(isAvailableInHierarchy);
- }
-
- /**
- * Returns all `_isAvailable` presentation component models
- * @returns {[ComponentModel]}
- */
- get presentationComponents() {
- return this.rawPresentationComponents.filter(isAvailableInHierarchy);
- }
-
- /**
- * Returns all prospective subsets
- * @returns {[ScoringSet]}
- */
- get subsets() {
- return getSubsets(this);
+ set isCompletionRequired(value) {
+ this._isCompletionRequired = value;
}
/**
- * Returns the minimum score
+ * Returns the minimum score.
* @returns {number}
*/
get minScore() {
- return this.questions.reduce((score, model) => score + this.getMinScoreByModel(model), 0);
+ return sum(this.availableQuestions, 'minScore');
}
/**
- * Returns the maxiumum score
+ * Returns the maximum score.
* @returns {number}
*/
get maxScore() {
- return this.questions.reduce((score, model) => score + this.getMaxScoreByModel(model), 0);
+ return sum(this.availableQuestions, 'maxScore');
}
/**
- * Returns the score
+ * Returns the score.
* @returns {number}
*/
get score() {
- return this.questions.reduce((score, model) => score + this.getScoreByModel(model), 0);
+ return sum(this.availableQuestions, 'score');
}
/**
- * Returns a percentage score relative to a positive minimum or zero and maximum values
+ * Returns a percentage score relative to a positive minimum or zero and maximum values.
+ * Each set's influence scales with its point range when scaledScores are summed across sets.
* @returns {number}
*/
get scaledScore() {
@@ -381,311 +123,239 @@ export default class ScoringSet extends Backbone.Controller {
}
/**
- * Returns a score as a string to include "+" operator for positive scores
+ * Returns the average scaledScore. For a single set this equals scaledScore.
+ * Each set contributes equally regardless of its point range when aggregated.
+ * @returns {number}
+ */
+ get averageScaledScore() {
+ return this.scaledScore;
+ }
+
+ /**
+ * Returns a score as a string to include "+" operator for positive scores.
* @returns {string}
*/
- get scoreAsstring() {
+ get scoreAsString() {
const score = this.score;
return (score > 0) ? `+${score.toString()}` : score.toString();
}
/**
- * Returns the number of correctly answered questions
+ * Returns the number of correctly answered available questions.
* @note Assumes the same number of questions are used in each attempt
* @returns {number}
*/
get correctness() {
- return this.questions.reduce((count, model) => count + (model.get('_isCorrect') ? 1 : 0), 0);
+ return sum(this.availableQuestions, model => (model.get('_isCorrect') ? 1 : 0));
}
/**
- * Returns the percentage of correctly answered questions
+ * Returns the number of available questions.
* @returns {number}
*/
- get scaledCorrectness() {
- return getScaledScoreFromMinMax(this.correctness, 0, this.questions.length);
+ get maxCorrectness() {
+ return this.availableQuestions.length;
}
/**
- * Returns whether the set can be reset
- * @returns {boolean}
+ * Returns the percentage of correctly answered questions.
+ * @returns {number}
*/
- get canReset() {
- return false;
+ get scaledCorrectness() {
+ return getScaledScoreFromMinMax(this.correctness, 0, this.maxCorrectness);
}
/**
- * Returns the list of modifiers which impacted the last update
- * @returns {Array}
+ * Returns whether the set is correct.
+ * query example: `(isCorrect)` or `(isCorrect=false)`
+ * @returns {boolean|null}
*/
- get modifiers() {
- return this._modifiers;
+ get isCorrect() {
+ if (!this.isSubmitted) return null;
+ return (this.correctness === this.maxCorrectness);
}
/**
- * Returns whether the set is optional
- * @returns {boolean}
+ * Returns whether the set is partly correct.
+ * query example: `(isPartlyCorrect)` or `(isPartlyCorrect=false)`
+ * @returns {boolean|null}
*/
- get isOptional() {
- return false;
+ get isPartlyCorrect() {
+ if (!this.isSubmitted) return null;
+ return this.correctness > 0 && this.correctness < this.maxCorrectness;
}
/**
- * Returns whether the set is available
- * @returns {boolean}
+ * Returns whether the set is incorrect.
+ * query example: `(isIncorrect)` or `(isIncorrect=false)`
+ * @returns {boolean|null}
*/
- get isAvailable() {
- return true;
+ get isIncorrect() {
+ if (!this.isSubmitted) return null;
+ return this.correctness === 0;
}
/**
- * Returns whether the set is started
+ * Returns whether the set is submitted.
+ * query example: `(isSubmitted)` or `(isSubmitted=false)`
* @returns {boolean}
*/
- get isStarted() {
- return this.models.some(model => model.get('_isVisited'));
+ get isSubmitted() {
+ const availableQuestions = this.availableQuestions;
+ return availableQuestions.length > 0 && availableQuestions.every(model => model.get('_isSubmitted'));
}
/**
- * Returns whether the set is started and incomplete
+ * Returns whether the set is started.
+ * query example: `(isStarted)` or `(isStarted=false)`
* @returns {boolean}
*/
- get isIncomplete() {
- return this.isStarted && !this.isComplete;
+ get isStarted() {
+ return this.availableModels.some(model => model.get('_isVisited'));
}
/**
- * Returns whether the objective for the set is completed.
- * Depending on the set logic, this can differ to `_isComplete`.
+ * Returns whether the set is completed.
+ * query example: `(isComplete)` or `(isComplete=false)`
* @returns {boolean}
*/
- get isObjectiveComplete() {
- return this.isComplete;
+ get isComplete() {
+ const availableModels = this.availableModels;
+ return availableModels.length > 0 && availableModels.every(model => model.get('_isComplete'));
}
/**
- * Returns whether the set is completed
+ * Returns whether the set is incomplete.
+ * query example: `(isIncomplete)` alias for `(isComplete=false)`
* @returns {boolean}
*/
- get isComplete() {
- Logging.error(`isComplete must be overriden for ${this.constructor.name}`);
+ get isIncomplete() {
+ return this.isComplete === false;
}
/**
- * Returns whether the objective for the set is passed.
- * Depending on the set logic, this can differ to `isPassed`.
+ * Returns whether a passmark is configured and enabled for this set.
* @returns {boolean}
*/
- get isObjectivePassed() {
- return this.isPassed;
+ get hasPassmark() {
+ return this.passmark?.isEnabled ?? false;
}
/**
- * Returns whether the configured passmark has been achieved
- * @returns {boolean}
+ * Returns whether the configured passmark has been achieved.
+ * Subclasses with a passmark override this to return a boolean verdict.
+ * query example: `(isPassed)`
+ * @returns {boolean|null}
*/
get isPassed() {
- Logging.error(`isPassed must be overriden for ${this.constructor.name}`);
- }
-
- get isFailed() {
- return (this.isPassed === false);
+ return null;
}
/**
- * Check whether the status has changed since the last `update`
- * @returns {boolean}
- */
- get hasStatusChanged() {
- return this.isAvailable !== this._wasAvailable ||
- this.isIncomplete !== this._wasIncomplete ||
- this.isComplete !== this._wasComplete ||
- this.isPassed !== this._wasPassed;
- }
-
- /**
- * Check to see if there are any child models
- * @returns {boolean}
+ * Returns whether the configured passmark has been failed.
+ * Returns null when no passmark is configured, false when incomplete or passed.
+ * query example: `(isFailed)` alias for `(isComplete,isPassed=false)`
+ * @returns {boolean|null}
*/
- get isPopulated() {
- return Boolean(this.models?.length);
- }
-
- get isNotPopulated() {
- return (this.isPopulated === false);
+ get isFailed() {
+ if (!this.hasPassmark) return null;
+ return this.isComplete && !this.isPassed;
}
/**
- * Returns the data to log
- * @returns {object}
+ * The journal for recording the updates to the set.
+ * @returns {ScoringUpdateJournal}
*/
- get logData() {
- const data = {
- id: this.id,
- type: this.type,
- minScore: this.minScore,
- maxScore: this.maxScore,
- score: this.score,
- scaledScore: this.scaledScore,
- isComplete: this.isComplete,
- isPassed: this.isPassed
- };
- if (this.modifiers.length) data.modifiers = this.modifiers;
- return data;
+ get journal() {
+ if (this.isIntersectedSet) return;
+ return (this._journal = this._journal || new ScoringUpdateJournal({ set: this }));
}
/**
- * Return whether the logData has changed since the last update
- * @returns {boolean}
+ * The objective object for the set. See SCORM cmi.objectives.
+ * @returns {Objective}
*/
- get hasLogDataChanged() {
- // delete previous modifiers entry before comparing logs for changes
- const clonedLastLogData = structuredClone(this._lastLogData ?? {});
- delete clonedLastLogData.modifiers;
- return !(_.isEqual(clonedLastLogData, this.logData));
+ get objective() {
+ if (this.isIntersectedSet) return;
+ return (this._objective = this._objective || new Objective({ set: this }));
}
/**
- * Add modifier details for how the set has been updated
+ * Update the current status hashes to help determine what has changed during the update phase of the lifecycle.
* @protected
- * @param {Backbone.Model} model
*/
- _addModifiers(model) {
- if (!this.hasLogDataChanged) return;
- const isAvailabilityChange = Object.hasOwn(model.changed, '_isAvailable');
- if (isAvailabilityChange) {
- this._addAvailabilityModifiers(model);
- return;
+ _setStatusHash() {
+ const isAvailable = this.isAvailable;
+ const isComplete = this.isComplete;
+ const isPassed = this.isPassed;
+ this._isAvailableChange = hasHashChanged(this, [isAvailable], '_isAvailableHash');
+ this._isCompleteChange = hasHashChanged(this, [isComplete], '_isCompleteHash');
+ this._isPassedChange = hasHashChanged(this, [isPassed], '_isPassedHash');
+ this._isStatusChange = hasHashChanged(this, [
+ isAvailable,
+ this.isStarted,
+ this.isIncomplete,
+ isComplete,
+ isPassed
+ ], '_statusHash');
+ }
+
+ /** @override */
+ async onInit() {
+ if (this.isIntersectedSet) return;
+ if (this.type !== 'adapt') {
+ this.listenTo(Adapt, 'questionView:submitted', this.onQuestionSubmitted);
}
- this._addCompletionModifiers(model);
- }
-
- /**
- * Add modifier details for how the set has been updated by availability changes
- * @protected
- * @param {Backbone.Model} model
- */
- _addAvailabilityModifiers(model) {
- const models = model.hasManagedChildren ? model.getChildren() : [model];
- const questions = filterIntersectingHierarchy(this.rawQuestions, models);
- questions.forEach(questionModel => {
- const isAvailable = isAvailableInHierarchy(questionModel);
- const minScore = this.getMinScoreByModel(questionModel);
- const maxScore = this.getMaxScoreByModel(questionModel);
- const score = this.getScoreByModel(questionModel);
- const data = {
- modelId: questionModel.get('_id'),
- minScore: isAvailable ? minScore : -minScore,
- maxScore: isAvailable ? maxScore : -maxScore
- };
- if (questionModel.get('_isSubmitted')) data.score = isAvailable ? score : -score;
- this.modifiers.push(data);
- });
- }
-
- /**
- * Add modifier details for how the set has been updated by completion changes
- * @protected
- * @param {Backbone.Model} model
- */
- _addCompletionModifiers(model) {
- this.modifiers.push({
- modelId: model.get('_id'),
- score: this.getScoreByModel(model)
- });
- }
-
- /**
- * Log the data as JSON following an update
- * @protected
- */
- _logUpdate() {
- if (!this.hasLogDataChanged) return;
- const logData = this.logData;
- Logging.info('scoring:update', JSON.stringify(logData));
- this._lastLogData = logData;
- }
-
- /**
- * Define the objective for reporting purposes
- * @protected
- */
- _initializeObjective() {
- if (this.subsetParent || this.isStarted) return;
- OfflineStorage.set('objectiveDescription', this.id, this.title);
- this._setObjectiveStatus();
- }
-
- /**
- * Reset the objective data
- * @protected
- */
- _resetObjective() {
- if (this.subsetParent || this.isObjectiveComplete || !this.hasStatusChanged) return;
- this._setObjectiveScore();
- this._setObjectiveStatus();
+ await super.onInit();
}
- /**
- * Complete the objective.
- * Will update to the latest data/attempt unless overriden in a subset.
- * @protected
- */
- _completeObjective() {
- if (this.subsetParent) return;
- this._setObjectiveScore();
- this._setObjectiveStatus();
+ /** @override */
+ async onRestore() {
+ if (this.isIntersectedSet) return;
+ this._setStatusHash();
+ if (!this.isStarted) this.objective?.register();
+ await super.onRestore();
}
- /**
- * Set the objective score
- * @protected
- */
- _setObjectiveScore() {
- if (this.subsetParent) return;
- OfflineStorage.set('objectiveScore', this.id, this.score, this.minScore, this.maxScore);
+ /** @override */
+ async onRestart() {
+ if (this.isIntersectedSet) return;
+ this.objective?.resetScore();
+ await super.onRestart();
}
- /**
- * Set the appropriate objective completion and success status.
- * Will update to the latest data/attempt, unless controlled accordingly in a subset.
- * @protected
- */
- _setObjectiveStatus() {
- if (this.subsetParent) return;
- const isAvailable = this.isAvailable;
- const isIncomplete = this.isIncomplete;
- const isComplete = this.isObjectiveComplete;
- const isPassed = this.isObjectivePassed;
- let completionStatus = COMPLETION_STATE.UNKNOWN.asLowerCase;
- let successStatus = COMPLETION_STATE.UNKNOWN.asLowerCase;
- if (isAvailable && !isIncomplete) completionStatus = COMPLETION_STATE.NOTATTEMPTED.asLowerCase;
- if (isAvailable && isIncomplete) completionStatus = COMPLETION_STATE.INCOMPLETE.asLowerCase;
- if (isAvailable && isComplete) {
- completionStatus = COMPLETION_STATE.COMPLETED.asLowerCase;
- if (this.passmark.isEnabled) successStatus = (isPassed ? COMPLETION_STATE.PASSED : COMPLETION_STATE.FAILED).asLowerCase;
- }
- OfflineStorage.set('objectiveStatus', this.id, completionStatus, successStatus);
+ /** @override */
+ async onUpdate() {
+ if (this.isIntersectedSet) return;
+ this._setStatusHash();
+ if (this.isComplete && this._isCompleteChange && !this._isAvailableChange) await this.onCompleted();
+ if (this.isPassed && this._isPassedChange && !this._isAvailableChange) await this.onPassed();
+ if (this._isStatusChange) this.objective?.setStatus();
+ await super.onUpdate();
}
/**
+ * Is executed on lifecycle update phase when isComplete=true.
* @fires Adapt#scoring:[set.type]:complete
* @fires Adapt#scoring:set:complete
*/
- onCompleted() {
- if (this.subsetParent) return;
- Adapt.trigger(`scoring:${this.type}:complete scoring:set:complete`, this);
+ async onCompleted() {
+ if (this.isIntersectedSet) return;
+ const events = `scoring:${this.type}:complete scoring:set:complete`;
+ Adapt.trigger(events, this);
Logging.info(`${this.id} completed`);
- this._completeObjective();
+ this.objective?.setScore();
}
/**
+ * Is executed on lifecycle update phase when isPassed=true.
* @fires Adapt#scoring:[set.type]:passed
* @fires Adapt#scoring:set:passed
*/
- onPassed() {
- if (this.subsetParent) return;
- Adapt.trigger(`scoring:${this.type}:passed scoring:set:passed`, this);
+ async onPassed() {
+ if (this.isIntersectedSet) return;
+ const events = `scoring:${this.type}:passed scoring:set:passed`;
+ Adapt.trigger(events, this);
Logging.info(`${this.id} passed`);
}
@@ -695,7 +365,7 @@ export default class ScoringSet extends Backbone.Controller {
*/
onQuestionSubmitted(view) {
const model = view.model;
- if (!this.questions.includes(model)) return;
+ if (!this.availableQuestions.includes(model)) return;
model.addContextActivity(this.id, this.type, this.title);
}
diff --git a/js/ScoringUpdateJournal.js b/js/ScoringUpdateJournal.js
new file mode 100644
index 0000000..4162bfc
--- /dev/null
+++ b/js/ScoringUpdateJournal.js
@@ -0,0 +1,126 @@
+import LifecycleUpdateJournal from './LifecycleUpdateJournal';
+import {
+ filterModelsByIntersectingModels,
+ isModelAvailableInHierarchy
+} from './utils/models';
+import _ from 'underscore';
+import Logging from 'core/js/logging';
+/** @typedef {import("./ScoringSet").default} ScoringSet */
+
+/**
+ * A journal for recording the models and sets that triggered set updates in the current lifecycle.
+ */
+export default class ScoringUpdateJournal extends LifecycleUpdateJournal {
+
+ /**
+ * Log the updates to the set based on the pending update models and sets, then clear the pending updates.
+ * @override
+ */
+ update() {
+ this.log();
+ this.clear();
+ }
+
+ /**
+ * Log the updates to the set based on the pending update models and sets, then clear the pending updates.
+ */
+ log() {
+ const setData = this.setData;
+ const hasSetDataChanged = !(_.isEqual(this._lastSetData, setData));
+ if (!hasSetDataChanged) return;
+ const data = { ...setData };
+ const sources = this.sourceData;
+ if (sources.length) {
+ data.sources = sources;
+ }
+ Logging.info('scoring:update', JSON.stringify(data));
+ this._lastSetData = setData;
+ }
+
+ /**
+ * Returns the set data to log.
+ * @returns {object}
+ */
+ get setData() {
+ return {
+ id: this.set.id,
+ type: this.set.type,
+ minScore: this.set.minScore,
+ maxScore: this.set.maxScore,
+ score: this.set.score,
+ scaledScore: this.set.scaledScore,
+ isComplete: this.set.isComplete,
+ isPassed: this.set.isPassed
+ };
+ }
+
+ /**
+ * Returns the pending update models score data for logging.
+ * For availability changes, all intersecting set questions are included with scores negated if now unavailable.
+ * @returns {{ modelId: string, minScore?: number, maxScore?: number, score?: number }[]}
+ */
+ get sourceData() {
+ const sources = [];
+ for (const model of this.pendingUpdateModels) {
+ const isAvailabilityChange = Object.hasOwn(model.changed, '_isAvailable');
+ if (isAvailabilityChange) {
+ // If the parent availability has changed, we log the score
+ // changes for all current child questions in the set.
+ const models = model.hasManagedChildren ? model.getChildren() : [model];
+ const modelSetQuestions = filterModelsByIntersectingModels(this.set.questions, models);
+ modelSetQuestions.forEach(questionModel => {
+ const isAvailable = isModelAvailableInHierarchy(questionModel);
+ const minScore = this.getMinScoreByModel(questionModel);
+ const maxScore = this.getMaxScoreByModel(questionModel);
+ const score = this.getScoreByModel(questionModel);
+ // The score changes are logged as negative values
+ // if the question is now unavailable.
+ const data = {
+ modelId: questionModel.get('_id'),
+ minScore: isAvailable ? minScore : -minScore,
+ maxScore: isAvailable ? maxScore : -maxScore
+ };
+ if (questionModel.get('_isSubmitted')) data.score = isAvailable ? score : -score;
+ sources.push(data);
+ });
+ continue;
+ }
+ sources.push({
+ modelId: model.get('_id'),
+ score: this.getScoreByModel(model)
+ });
+ }
+ return sources;
+ }
+
+ /**
+ * Returns the minimum score for the specified model.
+ * @param {Backbone.Model} model
+ * @returns {number}
+ */
+ getMinScoreByModel(model) {
+ if (!this.set.questions.includes(model)) return 0;
+ return model.minScore;
+ }
+
+ /**
+ * Returns the maximum score for the specified model.
+ * @param {Backbone.Model} model
+ * @returns {number}
+ */
+ getMaxScoreByModel(model) {
+ if (!this.set.questions.includes(model)) return 0;
+ return model.maxScore;
+ }
+
+ /**
+ * Returns the score for the specified model.
+ * @param {Backbone.Model} model
+ * @returns {number}
+ */
+ getScoreByModel(model) {
+ if (!this.set.questions.includes(model)) return 0;
+ return model.score;
+ }
+
+}
diff --git a/js/State.js b/js/State.js
new file mode 100644
index 0000000..830bdc6
--- /dev/null
+++ b/js/State.js
@@ -0,0 +1,73 @@
+import OfflineStorage from 'core/js/offlineStorage';
+/** @typedef {import("./LifecycleSet").default} LifecycleSet */
+
+/**
+ * Saves and restores state by { name: { id: 'data' } } in the offlineStorage API.
+ * @note Can only save and restore arrays of arrays, arrays of only numbers and arrays of only booleans
+ * i.e. [[1,2,3,4],[true,false,true,false]] or [true,false,true] etc.
+ */
+export default class State {
+
+ /**
+ * @param {Object} options
+ * @param {LifecycleSet} options.set
+ * @param {string} [options.name=null]
+ * @param {string} [options.id=null]
+ */
+ constructor({ set, name = null, id = null } = {}) {
+ this.set = set;
+ this._name = name;
+ this._id = id;
+ }
+
+ /**
+ * The namespace in offlineStorage under which to save the id property.
+ * @returns {string}
+ */
+ get name() {
+ return this._name ?? this.set.type;
+ }
+
+ /**
+ * The id against which to store the data in offlineStorage.
+ */
+ get id() {
+ return this._id ?? this.set.id;
+ }
+
+ /**
+ * Returns the saved data.
+ * @note Can only save and restore arrays of arrays, arrays of only numbers and arrays of only booleans.
+ * i.e. [[1,2,3,4],[true,false,true,false]] or [true,false,true] etc.
+ * @returns {any}
+ */
+ restore() {
+ const storedData = OfflineStorage.get(this.name)?.[this.id];
+ if (!storedData) return null;
+ return OfflineStorage.deserialize(storedData);
+ }
+
+ /**
+ * Saves the data.
+ * @note Can only save and restore arrays of arrays, arrays of only numbers and arrays of only booleans.
+ * i.e. [[1,2,3,4],[true,false,true,false]] or [true,false,true] etc.
+ * @returns {boolean} If offlineStorage was updated
+ */
+ save (data) {
+ const store = OfflineStorage.get(this.name) ?? {};
+ const newValue = OfflineStorage.serialize(data);
+ if (store[this.id] === newValue) return false;
+ store[this.id] = newValue;
+ OfflineStorage.set(this.name, store);
+ return true;
+ }
+
+ /**
+ * Clears the saved data in the namespace.
+ */
+ clear() {
+ const store = OfflineStorage.get(this.name) ?? {};
+ delete store[this.id];
+ OfflineStorage.set(this.name, store);
+ }
+}
diff --git a/js/StateModels.js b/js/StateModels.js
new file mode 100644
index 0000000..75666b3
--- /dev/null
+++ b/js/StateModels.js
@@ -0,0 +1,38 @@
+import State from 'extensions/adapt-contrib-scoring/js/State';
+import data from 'core/js/data';
+/** @typedef {import("core/js/models/adaptModel").default} AdaptModel */
+
+/**
+ * An extension of State.
+ * Save and restore a collection of models.
+ */
+export default class StateModels extends State {
+
+ /**
+ * Returns the saved models.
+ * @note Uses trackingPosition which can be disrupted by changes to the order or substance of sub tracking id elements,
+ * such as changing the order of or the components in their block if the tracking id is on the blocks.
+ * @returns {AdaptModel[]}
+ */
+ restore() {
+ const models = super.restore()?.map(trackingPosition => data.findByTrackingPosition(trackingPosition)) ?? [];
+ if (!models?.length) return null;
+ return models;
+ }
+
+ /**
+ * Saves the given models.
+ * @note Uses trackingPosition which can be disrupted by changes to the order or substance of sub tracking id elements,
+ * such as changing the order of or the components in their block if the tracking id is on the blocks.
+ * @param {AdaptModel[]} models
+ */
+ save(models) {
+ models = models.filter(model => model.get('_isTrackable') !== false);
+ const hasModels = (models.length > 0);
+ const data = hasModels
+ ? models.map(model => model.trackingPosition)
+ : null;
+ return super.save(data);
+ }
+
+}
diff --git a/js/StateSetModelChildren.js b/js/StateSetModelChildren.js
new file mode 100644
index 0000000..2f31bf3
--- /dev/null
+++ b/js/StateSetModelChildren.js
@@ -0,0 +1,48 @@
+import StateModels from './StateModels';
+/** @typedef {import("core/js/models/adaptModel").default} AdaptModel */
+
+/**
+ * An extension of StateModels.
+ * Saves and restores the collection of children from the set model.
+ * Shares a space under offlineStorage ch[modelId] so that multiple
+ * plugins can coordinate on the model children.
+ */
+export default class StateSetModelChildren extends StateModels {
+
+ /**
+ * Define a shared children namespace of 'ch' in offlineStorage.
+ * @returns {string}
+ */
+ get name() {
+ return 'ch';
+ }
+
+ /**
+ * Use the set.modelId for each model children.
+ * @returns {string}
+ */
+ get id() {
+ return this.set.modelId;
+ }
+
+ /**
+ * Restores the set.model.getChildren().
+ * @returns {boolean} If restore had models
+ */
+ restore() {
+ const models = super.restore();
+ if (!models) return false;
+ this.set.model.getChildren().reset(models);
+ return true;
+ }
+
+ /**
+ * Saves the set.model.getChildren().
+ * @returns {boolean} If offlineStorage has been updated
+ */
+ save() {
+ const models = this.set.model.getChildren().toArray();
+ return super.save(models);
+ }
+
+}
diff --git a/js/TotalSets.js b/js/TotalSets.js
new file mode 100644
index 0000000..1ef93a3
--- /dev/null
+++ b/js/TotalSets.js
@@ -0,0 +1,193 @@
+import Adapt from 'core/js/adapt';
+import Passmark from './Passmark';
+import ScoringSet from './ScoringSet';
+import TotalSetsUpdateJournal from './TotalSetsUpdateJournal';
+import {
+ createIntersectedSet
+} from './utils/intersection';
+import {
+ filterSetsByIntersectingModels,
+ isEverySetPassed
+} from './utils/sets';
+import {
+ unique,
+ sum
+} from './utils/math';
+import {
+ getAverageScaledScore
+} from './utils/scoring';
+
+/**
+ * A set of sets, it can sum the scores of the registered or intersecting sets.
+ *
+ * It extends `ScoringSet` with the caveat that it sums properties from registered or
+ * intersecting scoring sets and completion sets rather than registered or intersecting models.
+ *
+ * It represents the overall completion, score, correctness, pass and fail of the course.
+ */
+export default class TotalSets extends ScoringSet {
+
+ /**
+ * @param {Object} [options]
+ * @param {Backbone.Model} [options._model=null] Model of set configuration
+ * @param {Backbone.Model} [options.model=null] Human readable alternative for _model
+ * @param {IntersectionSet} [options.intersectionParent=null] System defined intersection parent
+ */
+ initialize(options = {}) {
+ this._config = (options._model ?? options.model).get('_scoring');
+ super.initialize({
+ ...options,
+ _id: this._config._id || 'total',
+ _title: this._config.title || 'Total score',
+ _type: 'total',
+ _isScoreIncluded: false,
+ _isCompletionRequired: false
+ });
+ this._passmark = new Passmark(this._config?._passmark);
+ }
+
+ /** @override */
+ get order() {
+ return 600;
+ }
+
+ /**
+ * Returns all models from sets marked with `_isScoreIncluded` or `_isCompletionRequired`, filtered and intersected where appropriate.
+ * @override
+ */
+ get models() {
+ const allScoringSets = Adapt.scoring.sets.filter(({ isScoreIncluded }) => isScoreIncluded);
+ const allCompletionSets = Adapt.scoring.sets.filter(({ isCompletionRequired }) => isCompletionRequired);
+ const models = unique([
+ ...allScoringSets.flatMap(({ models }) => models),
+ ...allCompletionSets.flatMap(({ models }) => models)
+ ]);
+ return this.filterModels(models);
+ }
+
+ /**
+ * Returns all sets marked with `_isScoreIncluded` which intersect the models.
+ * @returns {ScoringSet[]}
+ */
+ get scoringSets() {
+ const allScoringSets = Adapt.scoring.sets.filter(({ isScoreIncluded }) => isScoreIncluded);
+ const sets = filterSetsByIntersectingModels(allScoringSets, this.models);
+ if (this.isIntersectedSet) {
+ return sets.map(set => createIntersectedSet([this, set]));
+ }
+ return sets;
+ }
+
+ /**
+ * Returns all sets marked with `_isCompletionRequired` which intersect the models.
+ * @returns {ScoringSet[]}
+ */
+ get completionSets() {
+ const allCompletionSets = Adapt.scoring.sets.filter(({ isCompletionRequired }) => isCompletionRequired);
+ const sets = filterSetsByIntersectingModels(allCompletionSets, this.models);
+ if (this.isIntersectedSet) {
+ return sets.map(set => createIntersectedSet([this, set]));
+ }
+ return sets;
+ }
+
+ /**
+ * Returns the minimum score of all `_isScoreIncluded` subsets.
+ * @override
+ */
+ get minScore() {
+ return sum(this.scoringSets, 'minScore');
+ }
+
+ /**
+ * Returns the maximum score of all `_isScoreIncluded` subsets.
+ * @override
+ */
+ get maxScore() {
+ return sum(this.scoringSets, 'maxScore');
+ }
+
+ /**
+ * Returns the score of all `_isScoreIncluded` subsets.
+ * @override
+ */
+ get score() {
+ return sum(this.scoringSets, 'score');
+ }
+
+ /**
+ * Returns the average scaledScore across all `_isScoreIncluded` subsets.
+ * @override
+ */
+ get averageScaledScore() {
+ return getAverageScaledScore(this.scoringSets);
+ }
+
+ /** @override */
+ get correctness() {
+ return sum(this.scoringSets, 'correctness');
+ }
+
+ /** @override */
+ get maxCorrectness() {
+ return sum(this.scoringSets, 'maxCorrectness');
+ }
+
+ /**
+ * Returns the passmark model.
+ * @returns {Passmark}
+ */
+ get passmark() {
+ return this._passmark;
+ }
+
+ /**
+ * Returns whether all registered sets marked with `_isCompletionRequired` are completed.
+ * @override
+ */
+ get isComplete() {
+ const completionSets = this.completionSets;
+ return completionSets.length > 0 && completionSets.every(set => set.isComplete);
+ }
+
+ /**
+ * Returns whether the configured passmark has been achieved for `_isScoreIncluded` sets.
+ * If passmark is disabled, don't evaluate.
+ * If _passmark._requiresPassedSubsets then all scoring subsets have to be passed.
+ * @override
+ */
+ get isPassed() {
+ if (!this.hasPassmark) return null;
+ if (!this.isComplete) return false; // must be completed for a pass
+ const isScaled = this.passmark.isScaled;
+ const score = (isScaled) ? this.scaledScore : this.score;
+ const correctness = (isScaled) ? this.scaledCorrectness : this.correctness;
+ const isPassed = score >= this.passmark.score && correctness >= this.passmark.correctness;
+ return this.passmark.requiresPassedSubsets ? isPassed && isEverySetPassed(this.scoringSets) : isPassed;
+ }
+
+ /**
+ * Returns whether any registered sets marked with `_isScoreIncluded` are failed and cannot be reset.
+ * If passmark is disabled, don't evaluate.
+ * @override
+ */
+ get isFailed() {
+ if (!this.hasPassmark) return null;
+ return this.isComplete && !this.isPassed && !this.canReset;
+ }
+
+ /**
+ * Returns whether any registered sets marked with `_isScoreIncluded` can be reset.
+ * @override
+ */
+ get canReset() {
+ return this.scoringSets.some(set => set.canReset);
+ }
+
+ /** @override */
+ get journal() {
+ if (this.isIntersectedSet) return;
+ return (this._journal = this._journal || new TotalSetsUpdateJournal({ set: this }));
+ }
+
+}
diff --git a/js/TotalSetsUpdateJournal.js b/js/TotalSetsUpdateJournal.js
new file mode 100644
index 0000000..f91bbce
--- /dev/null
+++ b/js/TotalSetsUpdateJournal.js
@@ -0,0 +1,63 @@
+import ScoringUpdateJournal from './ScoringUpdateJournal';
+import {
+ filterModelsByIntersectingModels,
+ isModelAvailableInHierarchy
+} from './utils/models';
+import {
+ sum
+} from './utils/math';
+import {
+ getSubsetsByQuery
+} from './utils/query';
+/** @typedef {import("./TotalSets").default} TotalSets */
+/** @typedef {import("./ScoringSet").default} ScoringSet */
+
+/**
+ * A journal for recording the models and sets that triggered set updates in the current lifecycle.
+ */
+export default class TotalSetsUpdateJournal extends ScoringUpdateJournal {
+
+ /**
+ * Returns the pending update sets score data for logging.
+ * For availability changes, all intersecting sets are included with scores negated if now unavailable.
+ * @override
+ * @returns {{ setId: string, minScore?: number, maxScore?: number, score?: number }[]}
+ */
+ get sourceData() {
+ const sources = [];
+ for (const model of this.pendingUpdateModels) {
+ const isAvailabilityChange = Object.hasOwn(model.changed, '_isAvailable');
+ if (isAvailabilityChange) {
+ // If the parent availability has changed, we log the score
+ // changes for all changed sets and their questions.
+ const models = model.hasManagedChildren ? model.getChildren() : [model];
+ const relevantSets = this.set.scoringSets.filter(set => this.pendingUpdateSets.has(set));
+ relevantSets.forEach(set => {
+ const questions = filterModelsByIntersectingModels(set.questions, models);
+ const isAvailable = isModelAvailableInHierarchy(model);
+ const journal = set.journal;
+ const minScore = sum(questions, questionModel => journal.getMinScoreByModel(questionModel));
+ const maxScore = sum(questions, questionModel => journal.getMaxScoreByModel(questionModel));
+ const score = sum(questions, questionModel => journal.getScoreByModel(questionModel));
+ const data = {
+ setId: set.id,
+ minScore: isAvailable ? minScore : -minScore,
+ maxScore: isAvailable ? maxScore : -maxScore
+ };
+ if (score !== 0) data.score = isAvailable ? score : -score;
+ sources.push(data);
+ });
+ continue;
+ }
+ const modelIntersectedTotalSets = getSubsetsByQuery(`#${model.get('_id')} ${this.set.type}`)[0]?.scoringSets ?? [];
+ modelIntersectedTotalSets.forEach(set => {
+ sources.push({
+ setId: set.id,
+ score: set.score
+ });
+ });
+ }
+ return sources;
+ }
+
+}
diff --git a/js/adapt-contrib-scoring.js b/js/adapt-contrib-scoring.js
index d589657..3e5f05a 100644
--- a/js/adapt-contrib-scoring.js
+++ b/js/adapt-contrib-scoring.js
@@ -1,445 +1,216 @@
import Adapt from 'core/js/adapt';
-import Data from 'core/js/data';
-import Logging from 'core/js/logging';
+import data from 'core/js/data';
+import Lifecycle from './Lifecycle';
import AdaptModelSet from './AdaptModelSet';
+import IntersectionSet from './IntersectionSet';
+import LifecycleSet from './LifecycleSet';
+import ScoringSet from './ScoringSet';
+import TotalSets from './TotalSets';
import Passmark from './Passmark';
+import Objective from './Objective';
+import LifecycleUpdateJournal from './LifecycleUpdateJournal';
+import ScoringUpdateJournal from './ScoringUpdateJournal';
+import TotalSetsUpdateJournal from './TotalSetsUpdateJournal';
+import State from './State';
+import StateModels from './StateModels';
+import StateSetModelChildren from './StateSetModelChildren';
import {
- filterIntersectingHierarchy,
- getSubsetById,
- getSubsetsByType,
- getSubsetsByModelId,
- getSubSetByPath,
- getSubsetsByQuery,
- getScaledScoreFromMinMax
-} from './utils';
+ getSubsetsByQuery
+} from './utils/query';
+import {
+ getSetById,
+ getSetsByType,
+ getSetsByIntersectingModelId
+} from './utils/sets';
+import {
+ getPathSetsIntersected
+} from './utils/intersection';
+import {
+ isBackwardCompatible,
+ setupBackwardCompatibility
+} from './compatibility';
import './helpers';
import Backbone from 'backbone';
-import _ from 'underscore';
+export * from './utils/hash';
+export * from './utils/intersection';
+export * from './utils/math';
+export * from './utils/models';
+export * from './utils/query';
+export * from './utils/scoring';
+export * from './utils/sets';
export {
- filterModels,
- filterIntersectingHierarchy,
- hasIntersectingHierarchy,
- createIntersectionSubset,
- getRawSets,
- getSubsets,
- getSubsetById,
- getSubsetsByType,
- getSubsetsByModelId,
- getSubSetByPath,
- getSubsetsByQuery,
- getScaledScoreFromMinMax,
- isAvailableInHierarchy
-} from './utils';
-
-class Scoring extends Backbone.Controller {
+ AdaptModelSet,
+ IntersectionSet,
+ LifecycleSet,
+ ScoringSet,
+ TotalSets,
+ Passmark,
+ Objective,
+ LifecycleUpdateJournal,
+ ScoringUpdateJournal,
+ TotalSetsUpdateJournal,
+ State,
+ StateSetModelChildren,
+ StateModels
+};
+
+/**
+ * Scoring API based upon making sets of questions with custom scoring, correctness
+ * and completion behaviour.
+ */
+export class Scoring extends Backbone.Controller {
initialize() {
- this.listenTo(Data, {
- loading: this.onDataLoading,
- add: this._addAdaptModelSet,
- remove: this._removeAdaptModelSet
+ // Create a LifeCycle instance for rendering lifecycle changes
+ this.lifecycle = new Lifecycle({
+ scoring: this
});
- this.listenTo(Adapt, {
- 'app:dataReady': this.onAppDataReady,
- 'adapt:start': this.onAdaptStart
+ // Listen to relevant events for loading, restore and completion
+ this.listenTo(data, {
+ loading: this.onDataLoading
});
- }
-
- init() {
- this.subsets.forEach(set => set.init());
- this._wasComplete = this.isComplete;
- this._wasPassed = this.isPassed;
- }
-
- /**
- * Register a configured root scoring set.
- * This is usually performed automatically upon ScoringSet instantiation.
- * @param {ScoringSet} newSet
- * @fires Adapt#{set.type}:register
- * @fires Adapt#scoring:register
- */
- register(newSet) {
- const hasDuplicatedId = this._rawSets.some(set => set.id === newSet.id);
- if (hasDuplicatedId) throw new Error(`Cannot register two sets with the same id: ${newSet.id}`);
- this._rawSets.push(newSet);
- Adapt.trigger(`${newSet.type}:register scoring:register`, newSet);
- }
-
- /**
- * Deregister a configured root scoring set
- * @param {ScoringSet} oldSet
- * @fires Adapt#{set.type}:deregister
- * @fires Adapt#scoring:deregister
- */
- deregister(oldSet) {
- const setIndex = this._rawSets.findIndex(set => set.id === oldSet.id);
- this._rawSets.splice(setIndex, 1);
- Adapt.trigger(`${oldSet.type}:deregister scoring:deregister`, oldSet);
- }
-
- /**
- * Force all registered sets to recalculate their states
- * @property {Scoring}
- * @fires Adapt#scoring:update
- */
- update() {
- const queuedChanges = [...new Set(this._queuedChanges)];
- const updateSubsets = !queuedChanges?.length
- ? this.subsets
- : [...new Set(queuedChanges.reduce((subsets, model) => subsets.concat(getSubsetsByModelId(model?.get('_id'))), []))];
- updateSubsets.forEach(set => {
- const changedSubsetModels = filterIntersectingHierarchy(this._queuedChanges, set.rawModels);
- set.update(changedSubsetModels);
+ this.listenTo(Adapt, {
+ 'app:dataReady': this.onAppDataReady
});
- this._queuedChanges = [];
- if (!updateSubsets.length) return;
- const isComplete = this.isComplete;
- if (isComplete && !this._wasComplete) this.onCompleted();
- const isPassed = this.isPassed;
- if (isPassed && !this._wasPassed) this.onPassed();
- this._wasComplete = isComplete;
- this._wasPassed = isPassed;
- Adapt.trigger('scoring:update', this);
- }
-
- /**
- * Reset all subsets which can be reset
- * @fires Adapt#scoring:reset
- */
- reset() {
- this.subsets.forEach(set => set.canReset && set.reset());
- Adapt.trigger('scoring:reset', this);
- }
-
- /**
- * Returns registered root sets of type
- * @param {string} type
- * @returns {[ScoringSet]}
- */
- getSubsetsByType(type) {
- return getSubsetsByType(type);
}
/**
- * Returns registered root sets intersecting the given model id
- * @param {string} id
- * @returns {[ScoringSet]}
- */
- getSubsetsByModelId(id) {
- return getSubsetsByModelId(id);
- }
-
- /**
- * Returns sets or intersection sets by query
- * @param {string} query
- * @returns {[ScoringSet]}
- */
- getSubsetsByQuery(query) {
- return getSubsetsByQuery(query);
- }
-
- /**
- * Returns a registered root set by id
- * @param {string} id
- * @returns {ScoringSet}
+ * Clear the sets.
+ * @listens Data#loading
*/
- getSubsetById(id) {
- return getSubsetById(id);
+ onDataLoading() {
+ this.clear();
}
/**
- * Returns a root set or intersection set by path
- * @param {string|[string]} path
- * @returns {ScoringSet}
+ * Configure the main scoring passmark with TotalSets and setup backward compatibility
+ * for legacy adapt-contrib-assessment related components and extensions.
+ * @listens Adapt#app:dataReady
*/
- getSubsetByPath(path) {
- return getSubSetByPath(path);
- }
-
- get id() {
- return this._id;
- }
-
- get title() {
- return this._title;
+ onAppDataReady() {
+ this.total = new TotalSets({ model: Adapt.course });
+ if (!this.total.isEnabled) return;
+ setupBackwardCompatibility(this);
}
/**
- * Returns whether the plugin is functioning as `Adapt.assessment`
+ * Returns a boolean if adapt-contrib-assessment related compatibility is enabled.
+ * @return {boolean}
*/
get isBackwardCompatible() {
- return this._isBackwardCompatible;
- }
-
- /**
- * @private
- */
- get _compatibilityState() {
- const state = {
- isComplete: this.isComplete,
- isPercentageBased: this.passmark.isScaled,
- isPass: this.isPassed,
- maxScore: this.maxScore,
- minScore: this.minScore,
- score: this.score,
- scoreToPass: this.passmark.score,
- scoreAsPercent: this.scaledScore,
- correctCount: this.correctness,
- correctAsPercent: this.scaledCorrectness,
- correctToPass: this.passmark.correctness,
- questionCount: this.questions.length,
- assessmentsComplete: this.scoringSets.filter(set => set.isComplete).length,
- assessments: this.scoringSets.length,
- canRetry: this.canReset
- };
- return state;
- }
-
- /**
- * Returns root sets marked with `_isCompletionRequired`
- * @returns {[ScoringSet]}
- */
- get completionSets() {
- return this._rawSets.filter(({ isCompletionRequired }) => isCompletionRequired);
- }
-
- /**
- * Returns root sets marked with `_isScoreIncluded`
- * @returns {[ScoringSet]}
- */
- get scoringSets() {
- return this._rawSets.filter(({ isScoreIncluded }) => isScoreIncluded);
- }
-
- /**
- * Returns unique subset models
- * @returns {[Backbone.Model]}
- */
- get models() {
- const models = this.subsets.reduce((models, set) => models.concat(set.models), []);
- return [...new Set(models)];
- }
-
- /**
- * Returns unique subsets question models
- * @returns {[QuestionModel]}
- */
- get questions() {
- const questions = this.scoringSets.reduce((questions, set) => questions.concat(set.questions), []);
- return [...new Set(questions)];
- }
-
- /**
- * Returns registered root sets
- * @returns {[ScoringSet]}
- */
- get subsets() {
- return this._rawSets;
- }
-
- /**
- * Returns the minimum score of all `_isScoreIncluded` subsets
- * @returns {number}
- */
- get minScore() {
- return this.scoringSets.reduce((score, set) => score + set.minScore, 0);
- }
-
- /**
- * Returns the maximum score of all `_isScoreIncluded` subsets
- * @returns {number}
- */
- get maxScore() {
- return this.scoringSets.reduce((score, set) => score + set.maxScore, 0);
- }
-
- /**
- * Returns the score of all `_isScoreIncluded` subsets
- * @returns {number}
- */
- get score() {
- return this.scoringSets.reduce((score, set) => score + set.score, 0);
- }
-
- /**
- * Returns a percentage score relative to a positive minimum or zero and maximum values
- * @returns {number}
- */
- get scaledScore() {
- return getScaledScoreFromMinMax(this.score, this.minScore, this.maxScore);
- }
-
- /**
- * Returns the number of correctly answered questions
- * @returns {number}
- */
- get correctness() {
- return this.scoringSets.reduce((count, set) => count + set.correctness, 0);
- }
-
- /**
- * Returns the percentage of correctly answered questions
- * @returns {number}
- */
- get scaledCorrectness() {
- return getScaledScoreFromMinMax(this.correctness, 0, this.questions.length);
- }
-
- /**
- * Returns the passmark model
- * @returns {Passmark}
- */
- get passmark() {
- return this._passmark;
+ return isBackwardCompatible(this);
}
/**
- * Returns whether any root sets marked with `_isScoreIncluded` can be reset
- * @todo Add `canReset` to `ScoringSet`?
- * @returns {boolean}
+ * Returns registered sets.
+ * @returns {IntersectionSet[]}
*/
- get canReset() {
- return this.scoringSets.some(set => set?.canReset);
+ get sets() {
+ return this._sets;
}
/**
- * Returns whether all root sets marked with `_isCompletionRequired` are completed
- * @returns {boolean}
+ * Removes all registered sets.
*/
- get isComplete() {
- return this.completionSets.every(set => set.isComplete);
+ clear() {
+ this._sets?.forEach(set => this.deregister(set));
+ this._sets = [];
}
/**
- * Returns whether the configured passmark has been achieved for `_isScoreIncluded` sets.
- * If _passmark._requiresPassedSubsets then all scoring subsets have to be passed.
- * @override
- * @returns {boolean}
- */
- get isPassed() {
- //if (!this.isComplete) return false; // must be completed for a pass
- //if (!this.passmark.isEnabled && this.isComplete) return true; // always pass if complete and passmark is disabled
- const isEverySubsetPassed = this.scoringSets.every(set => set.isPassed)
- const isScaled = this.passmark.isScaled;
- const score = (isScaled) ? this.scaledScore : this.score;
- const correctness = (isScaled) ? this.scaledCorrectness : this.correctness;
- const isPassed = score >= this.passmark.score && correctness >= this.passmark.correctness;
- return this.passmark.requiresPassedSubsets ? isPassed && isEverySubsetPassed : isPassed;
- }
-
- /**
- * Returns whether any root sets marked with `_isScoreIncluded` are failed and cannot be reset
- * @todo Add `canReset` to `ScoringSet`?
- * @returns {boolean}
- */
- get isFailed() {
- return !this.isPassed && !this.canReset;
- }
-
- /**
- * @private
- */
- _setupBackwardCompatibility() {
- Adapt.assessment = {
- get: id => this.getSubsetById(id)?.model,
- getState: () => this._compatibilityState
- };
- }
-
- /**
- * @private
- * @param {Backbone.Model} model
- */
- _addAdaptModelSet(model) {
- new AdaptModelSet({ model });
- }
-
- /**
- * @private
- * @param {Backbone.Model} model
+ * Register a configured scoring set.
+ * This is usually performed automatically upon IntersectionSet instantiation.
+ * @param {IntersectionSet} newSet
+ * @fires Adapt#{set.type}:register
+ * @fires Adapt#scoring:register
*/
- _removeAdaptModelSet(model) {
- const set = getSubsetById(model.get('_id'));
- this.deregister(set);
+ register(newSet) {
+ const hasDuplicatedId = this.sets.some(set => set.id === newSet.id);
+ if (hasDuplicatedId) throw new Error(`Cannot register two sets with the same id: ${newSet.id}`);
+ this.sets.push(newSet);
+ this.sets.sort((a, b) => a.order - b.order);
+ Adapt.trigger(`${newSet.type}:register scoring:register`, newSet);
}
/**
- * @private
+ * Deregister a configured scoring set.
+ * @param {IntersectionSet} oldSet
+ * @fires Adapt#{set.type}:deregister
+ * @fires Adapt#scoring:deregister
*/
- _setupListeners() {
- this._debouncedUpdate = _.debounce(this.update, 50);
- this._queuedChanges = [];
- this.listenTo(Data, 'change:_isAvailable change:_isInteractionComplete', this._updateQueue);
+ deregister(oldSet) {
+ const setIndex = this.sets.findIndex(set => set.id === oldSet.id);
+ if (setIndex === -1) return;
+ this.sets.splice(setIndex, 1);
+ this.sets.sort((a, b) => a.order - b.order);
+ Adapt.trigger(`${oldSet.type}:deregister scoring:deregister`, oldSet);
}
/**
- * @private
+ * Force all registered sets to recalculate their states.
+ * @fires Adapt#scoring:update via lifecycle
*/
- _removeListeners() {
- this.stopListening(Data, 'change:_isAvailable change:_isInteractionComplete', this._updateQueue);
+ async update() {
+ const sets = this.sets;
+ if (!sets.length) return;
+ await this.lifecycle.update(sets);
}
/**
- * @private
+ * Reset all registered sets which can be reset.
+ * @fires Adapt#scoring:reset via lifecycle
*/
- _updateQueue(model) {
- this._queuedChanges.push(model);
- this._debouncedUpdate();
+ async reset() {
+ const sets = this.sets;
+ if (!sets.length) return;
+ await this.lifecycle.reset();
}
/**
- * @listens Data#loading
+ * Returns a registered set by id.
+ * @param {string} id
+ * @returns {IntersectionSet}
*/
- onDataLoading() {
- this._removeListeners();
- this._rawSets = [];
+ getSetById(id) {
+ return getSetById(id);
}
/**
- * @listens Adapt#app:dataReady
+ * Returns registered sets of type.
+ * @param {string} type
+ * @returns {IntersectionSet[]}
*/
- onAppDataReady() {
- this._config = Adapt.course.get('_scoring');
- this._id = this._config?._id;
- this._title = this._config?.title;
- this._passmark = new Passmark(this._config?._passmark);
- this._isBackwardCompatible = this._config?._isBackwardCompatible ?? false;
- if (this.isBackwardCompatible) this._setupBackwardCompatibility();
+ getSetsByType(type) {
+ return getSetsByType(type);
}
/**
- * @listens Adapt#adapt:start
- * @fires Adapt#assessment:restored
- * @fires Adapt#scoring:restored
+ * Returns registered sets intersecting the given model id.
+ * @param {string} id
+ * @returns {IntersectionSet[]}
*/
- onAdaptStart() {
- if (this.isBackwardCompatible) Adapt.trigger('assessment:restored', this._compatibilityState);
- Adapt.trigger('scoring:restored', this);
- // delay any listeners until all models have been restored
- this._setupListeners();
- this.init();
- this.update();
+ getSetsByIntersectingModelId(id) {
+ return getSetsByIntersectingModelId(id);
}
/**
- * @fires Adapt#assessment:complete
- * @fires Adapt#scoring:complete
- * @property {Scoring}
+ * Returns a registered set or intersection set by id path.
+ * example: id.id.id
+ * @param {string|[string]} path
+ * @returns {IntersectionSet}
*/
- onCompleted() {
- if (this.isBackwardCompatible) Adapt.trigger('assessment:complete', this._compatibilityState);
- Adapt.trigger('scoring:complete', this);
- Logging.debug('scoring completed');
+ getSubsetByPath(path) {
+ return getPathSetsIntersected(path);
}
/**
- * @fires Adapt#scoring:pass
- * @property {Scoring}
+ * Returns registered sets or intersection sets by query.
+ * @param {string} query
+ * @returns {IntersectionSet[]}
*/
- onPassed() {
- Adapt.trigger('scoring:pass', this);
- Logging.debug('scoring passed');
+ getSubsetsByQuery(query) {
+ return getSubsetsByQuery(query);
}
}
diff --git a/js/compatibility.js b/js/compatibility.js
new file mode 100644
index 0000000..d38f806
--- /dev/null
+++ b/js/compatibility.js
@@ -0,0 +1,78 @@
+import Adapt from 'core/js/adapt';
+import {
+ findSetById
+} from './utils/sets';
+/** @typedef {import("./adapt-contrib-scoring").Scoring} Scoring */
+
+// Compatibility layer for adapt-contrib-assessment components and extensions
+
+/**
+ * Returns whether the plugin is functioning as `Adapt.assessment`
+ * @returns {boolean}
+ */
+export function isBackwardCompatible(scoring) {
+ return scoring.total._config?._isBackwardCompatible ?? false;
+}
+
+/**
+ * Polyfill for Adapt.assessment.
+ * @param {Scoring} scoring
+ */
+export function setupBackwardCompatibility(scoring) {
+ if (!isBackwardCompatible(scoring)) return;
+ Adapt.assessment = {
+ get: id => {
+ return findSetById(Adapt.scoring.sets, id)?.model;
+ },
+ getState: () => getCompatibilityState(scoring)
+ };
+ Adapt
+ .off('scoring:total:restored', onScoringRestored)
+ .on('scoring:total:restored', onScoringRestored);
+ Adapt
+ .off('scoring:total:complete', onScoringComplete)
+ .on('scoring:total:complete', onScoringComplete);
+}
+
+/**
+ * Polyfill for assessmentState.
+ * @param {Scoring} scoring
+ */
+export function getCompatibilityState(scoring) {
+ const state = {
+ isComplete: scoring.total.isComplete,
+ isPercentageBased: scoring.total.passmark.isScaled,
+ isPass: scoring.total.isPassed ?? scoring.total.isComplete,
+ maxScore: scoring.total.maxScore,
+ minScore: scoring.total.minScore,
+ score: scoring.total.score,
+ scoreToPass: scoring.total.passmark.score,
+ scoreAsPercent: scoring.total.scaledScore,
+ correctCount: scoring.total.correctness,
+ correctAsPercent: scoring.total.scaledCorrectness,
+ correctToPass: scoring.total.passmark.correctness,
+ questionCount: scoring.total.availableQuestions.length,
+ assessmentsComplete: scoring.total.scoringSets.filter(set => set.isComplete).length,
+ assessments: scoring.total.scoringSets.length,
+ canRetry: scoring.total.canReset
+ };
+ return state;
+}
+
+/**
+ * Polyfill for triggering assessment:restored event.
+ * @param {Scoring} scoring
+ * @fires Adapt#assessment:restored
+ */
+function onScoringRestored(scoring) {
+ Adapt.trigger('assessment:restored', getCompatibilityState(scoring));
+}
+
+/**
+ * Polyfill for triggering assessment:complete event.
+ * @param {Scoring} scoring
+ * @fires Adapt#assessment:complete
+ */
+function onScoringComplete(scoring) {
+ Adapt.trigger('assessment:complete', getCompatibilityState(scoring));
+}
diff --git a/js/helpers.js b/js/helpers.js
index afc12df..e7194fe 100644
--- a/js/helpers.js
+++ b/js/helpers.js
@@ -1,24 +1,96 @@
import Handlebars from 'handlebars';
import {
getSubsetsByQuery
-} from './utils';
+} from './utils/query';
+import {
+ sum
+} from './utils/math';
+import {
+ getScaledScoreFromMinMax,
+ getAverageScaledScore
+} from './utils/scoring';
+/** @typedef {import("./IntersectionSet").default} IntersectionSet */
+
+// Global handlebars helpers
+
+/**
+ * Returns the intersected subsets of the intersection query string for the handlebars context.
+ * @see {@link ../INTERSECTION_QUERY.md INTERSECTION_QUERY}
+ * @param {string} query
+ * @param {*} context
+ * @returns {IntersectionSet[]}
+ */
+export function getSubsetsFromQueryContext(query, context) {
+ if (!context) throw Error('No context for getSubsetsFromQueryContext helper.');
+ const modelId = context?.data?.root?._id;
+ if (modelId) query = query.replace(/\bthis\b/g, modelId);
+ return getSubsetsByQuery(query);
+}
const helpers = {
+ /**
+ * Returns the intersected subsets of the intersection query string.
+ * @see {@link ../INTERSECTION_QUERY.md INTERSECTION_QUERY}
+ * @example