From 47a69e37edb0818ab170b1408a7f7116083f92ef Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Thu, 24 Jul 2025 22:11:12 +0100 Subject: [PATCH 01/35] Breaking: Version 2 improvements (fixes #29) --- INTERSECTION_QUERY.md | 136 ++++++++++ LIFECYCLE.md | 33 +++ README.md | 2 +- js/AdaptModelSet.js | 117 +++++---- js/IntersectionSet.js | 424 ++++++++++++++++++++++++++++++ js/Lifecycle.js | 307 ++++++++++++++++++++++ js/LifecycleRenderer.js | 165 ++++++++++++ js/LifecycleSet.js | 99 +++++++ js/Objective.js | 53 ++++ js/Passmark.js | 3 + js/ScoringSet.js | 435 +++++++++---------------------- js/State.js | 74 ++++++ js/StateModels.js | 38 +++ js/StateSetModelChildren.js | 48 ++++ js/TotalSets.js | 232 +++++++++++++++++ js/adapt-contrib-scoring.js | 496 ++++++++++-------------------------- js/compatibility.js | 79 ++++++ js/helpers.js | 9 +- js/utils.js | 334 ------------------------ js/utils/hash.js | 28 ++ js/utils/intersection.js | 94 +++++++ js/utils/math.js | 70 +++++ js/utils/models.js | 186 ++++++++++++++ js/utils/query.js | 112 ++++++++ js/utils/scoring.js | 16 ++ js/utils/sets.js | 110 ++++++++ 26 files changed, 2633 insertions(+), 1067 deletions(-) create mode 100644 INTERSECTION_QUERY.md create mode 100644 LIFECYCLE.md create mode 100644 js/IntersectionSet.js create mode 100644 js/Lifecycle.js create mode 100644 js/LifecycleRenderer.js create mode 100644 js/LifecycleSet.js create mode 100644 js/Objective.js create mode 100644 js/State.js create mode 100644 js/StateModels.js create mode 100644 js/StateSetModelChildren.js create mode 100644 js/TotalSets.js create mode 100644 js/compatibility.js delete mode 100644 js/utils.js create mode 100644 js/utils/hash.js create mode 100644 js/utils/intersection.js create mode 100644 js/utils/math.js create mode 100644 js/utils/models.js create mode 100644 js/utils/query.js create mode 100644 js/utils/scoring.js create mode 100644 js/utils/sets.js diff --git a/INTERSECTION_QUERY.md b/INTERSECTION_QUERY.md new file mode 100644 index 0000000..2b9ece3 --- /dev/null +++ b/INTERSECTION_QUERY.md @@ -0,0 +1,136 @@ +# intersection query syntax + +## Preamble + +### 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 +For all sets representing any other collection of models, they likely extend `IntersectionSet` on which it is possible to use these literal attributes for queries: +* `#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 query 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` + +These intersection attributes are available on `AdaptModelSet` set instances: +* `[modelId=a-300]` will select all sets intersecting model `a-300` + +### 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 root or intersecting sets. + +It extends `ScoringSet` with the caveat that it sums properties from root or intersecting scoring sets and completion sets rather than root 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 also 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`. + +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)` + +### Single column selection queries examples +They 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 `type=assessment` +* `"#assessment-300"` the set with `id=assessment-300`, ids are unique +* `"#a-300"` the AdaptModelSet with `id=a-300`, ids are unique +* `"assessment[id=assessment-300]"` the set with `id=assessment-01`, ids are unique +* `"assessment[id=assessment-300,id=assessment-400]"` the sets with `id=assessment-300` and `id=assessment-400`, ids are unique +* `"[#assessment-300,#assessment-400]"` does the same as above +* `"assessment[modelId=a-05]"` sets 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 +With 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. + +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]`. + +### Multiple column intersection queries examples +They follow the `intersectionQuery` syntax of `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 create 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` instance +* `"assessment[id=assessment-01,id=assessment-05] assessment[id=assessment-10,assessment-15]"` creates four intersection sets, of type `assessment`, 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 all `modelType=articles` sets intersected with all `type=assessment` sets +* `"[modelType=article] assessment(isPopulated)"` returns all article assessments that have models after their intersections +* `"[modelType=article](isComplete) assessment(isPopulated)"` returns all article assessments that have models from completed articles + +### Intersection query multiplications +With the intersection query interface 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 by only those models which intersect the multiplication 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 intersecting models. In this can it would probably be easier just to fetch the assessments directly using `assessment[modelType=article]`, rather than using an intersection. + +## 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 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..c46d8c4 --- /dev/null +++ b/LIFECYCLE.md @@ -0,0 +1,33 @@ +# lifecycle +Each Set which inherits from `LifecycleSet` in the scoring system 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 their position in the lifecycle phase execution. + +## phases +There are 8 external lifecycle phases, 6 internal phases and 2 internal phase triggers for each set. The internal phases have callback functions executed by the external phases. Each cycle is grouped at 30 frames per second, on a browser animation frame. 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()` | + +## Internal phase callback functions +| Name | Description | +| --- | --- | +| onInit | After it is instantiated and registered, in the init phase | +| onRestore | After onInit, in the restore phase, return true/false to signify restore | +| onStart | After onRestore, if `wasRestored = false`. Called on the set after reset. | +| onLeave | When leaving their content object | +| onVisit | When visiting their content object | +| onUpdate | When any intersecting model changes across its `_isAvailable`, `_isInteractionComplete`, `_isActive` or `_isVisited` attributes or if any intersecting set calls `.update()` | + +## Internal phase 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..f0b0998 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ 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. ### Attributes diff --git a/js/AdaptModelSet.js b/js/AdaptModelSet.js index 57883c4..c7ce6da 100644 --- a/js/AdaptModelSet.js +++ b/js/AdaptModelSet.js @@ -1,88 +1,109 @@ -import ScoringSet from './ScoringSet'; +import IntersectionSet from './IntersectionSet'; +import data from 'core/js/data'; -export default class AdaptModelSet extends ScoringSet { +/** + * 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 IntersectionSet { - 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() {} - - /** - * Intentionally empty to override super Class - * @override - */ - _completeObjective() {} + modelTypeGroup(typeGroup) { + return this.model.isTypeGroup(typeGroup); + } /** - * Intentionally empty to prevent super Class event triggers - * @override + * Comparison property for model types + * query example: `[modelType=block]` + * @returns {string} One of course|menu|page|article|block|component */ - update() {} - - modelTypeGroup(group) { - return this.model.isTypeGroup(group); - } - get modelType() { return this.model.get('_type'); } + /** + * Comparison property for model component strings + * query example: `[modelComponent=mcq]` + * @returns {string} One of mcq|gmcq|slider|graphic|... etc + */ get modelComponent() { return this.model.get('_component'); } - get model() { - return this._model; + /** @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; } /** - * @override + * Returns whether the set is complete + * query example: `(isComplete)` or `(isComplete=false)` + * @returns {boolean} */ - get models() { - return [this.model]; + get isComplete() { + return this.model.get('_isComplete'); } /** - * @override + * Returns whether the set is incomplete + * query example: `(isIncomplete)` alias for `(isComplete=false)` + * @returns {boolean} */ - get isComplete() { - return this._model.get('_isComplete'); + get isIncomplete() { + return (this.isComplete === false); } /** - * @override + * Returns whether the set is passed + * query example: `(isPassed)` alias for `(isComplete)` + * @returns {boolean} */ get isPassed() { - return null; + return this.isComplete; } /** - * Intentionally empty to prevent super Class event triggers - * @override + * Returns whether the set is isFailed + * query example: `(isFailed)` + * @returns {boolean} */ - onCompleted() {} + get isFailed() { + return false; + } /** - * Intentionally empty to prevent super Class event triggers - * @override + * Returns whether the set is optional + * query example: `(isOptional)` + * @returns {boolean} */ - onPassed() {} -} + get isOptional() { + return this.model.get('_isOptional'); + } + /** + * Returns whether the set is available + * query example: `(_isAvailable)` + * @returns {boolean} + */ + get isAvailable() { + return this.model.get('_isAvailable'); + } + +} diff --git a/js/IntersectionSet.js b/js/IntersectionSet.js new file mode 100644 index 0000000..8e0affd --- /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); + // Do not register intersected sets + if (this.isIntersectedSet) return; + this.register(); + } + + /** + * Create a clone of this instance, intersection 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 to + // Filter its models accordingly + const Class = Object.getPrototypeOf(this).constructor; + // 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 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 root sets as 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 + * @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..9e6ee6a --- /dev/null +++ b/js/Lifecycle.js @@ -0,0 +1,307 @@ +import Data from 'core/js/data'; +import Adapt from 'core/js/adapt'; +import AdaptModelSet from './AdaptModelSet'; +import { + 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) + ); + // this.listenTo(newSet, 'reset', this.onScoringSetReset); + } + + /** + * 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 {InteractionSet} 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); + } + + /** + * Listens to bubbled events Adapt events and begins the lifecycle of the registered sets + * @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 + 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'))); + } + + /** + * 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'))); + } + + /** + * 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 are on the set.modelId + * @param {IntersectionSet} set + */ + onScoringSetReset(set) { + // TODO: determine if we should restart the sets on this model id only or all descendant sets as well + if (!set.model) return; + const sets = filterSetsByModelId(getAllSets(), set.modelId); + 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:restored + */ + async restore () { + const sets = getAllSets(); + await this.renderer.render.restore(sets); + Adapt.trigger('scoring:restored', this.scoring); + } + + /** + * Send all sets into the start phase + * @fires Adapt#scoring:start + */ + async start () { + const sets = getAllSets(); + await this.renderer.render.start(sets); + Adapt.trigger('scoring:start', this.scoring); + } + + /** + * Send all sets into the reset phase + */ + async reset () { + const sets = getAllSets(); + await this.renderer.render.reset(sets); + } + + /** + * Send givens sets into the restart phase + */ + async restart (sets) { + sets = sets.filter(set => !set.intersectionParent); + await this.renderer.render.restart(sets); + } + + /** + * Send givens sets into the leave phase + */ + async leave (sets) { + sets = sets.filter(set => !set.intersectionParent); + await this.renderer.render.leave(sets); + } + + /** + * Send givens sets into the visit phase + */ + async visit (sets) { + sets = sets.filter(set => !set.intersectionParent); + await this.renderer.render.visit(sets); + } + + /** + * Send givens sets into the update phase + * @fires Adapt#scoring:update + */ + async update (sets) { + sets = sets.filter(set => !set.intersectionParent); + await this.renderer.render.update(sets); + Adapt.trigger('scoring: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.onStart when any intersecting model or set is reset + async restart(set) { + await set.onStart?.(); + }, + // 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?.(); + } + } +}); diff --git a/js/LifecycleRenderer.js b/js/LifecycleRenderer.js new file mode 100644 index 0000000..add707b --- /dev/null +++ b/js/LifecycleRenderer.js @@ -0,0 +1,165 @@ +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 rendereds + */ + 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..613570f --- /dev/null +++ b/js/LifecycleSet.js @@ -0,0 +1,99 @@ +import Adapt from 'core/js/adapt'; +import Logging from 'core/js/logging'; +import State from './State'; +import IntersectionSet from './IntersectionSet'; + +/** + * 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 })); + } + + /** + * Signifies if onRestored returned true/false + * @returns {boolean} + */ + get wasRestored() { + return this._wasRestored; + } + + set wasRestored(value) { + this._wasRestored = value; + } + + /** + * Called after initialize on every model + */ + async onInit() {} + + /** + * Called after init on every model + * Restore data from previous sessions + * @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 + * Called on each set after a reset or intersecting set or model is reset + */ + async onStart() {} + + /** + * Called on each local set when its contentobject is visited + */ + async onVisit() {} + + /** + * Called on each local set when its contentobject is left + */ + async onLeave() {} + + /** + * Called on each set when any intersecting model has changes to + * _isAvailable, _isActive, _isVisited or _isInteractionComplete or + * an intersecting set called `.update()` + */ + 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); + } + + /** + * 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) return; + Adapt.trigger(`scoring:${this.type}:reset scoring:set:reset`, this); + Logging.debug(`${this.id} reset`); + this.trigger('reset', this); + } + +} diff --git a/js/Objective.js b/js/Objective.js new file mode 100644 index 0000000..48b3113 --- /dev/null +++ b/js/Objective.js @@ -0,0 +1,53 @@ +import offlineStorage from 'core/js/offlineStorage'; +import COMPLETION_STATE from 'core/js/enums/completionStateEnum'; + +/** @typedef {import("../IntersectionSet").default} IntersectionSet */ + +/** + * Registers an objective with the offlineStorage API + * see SCORM cmi.objectives + */ +export default class Objective { + + /** + * @param {Object} options + * @param {IntersectionSet} options.set + */ + constructor({ set } = {}) { + this.set = set; + this.id = this.set.id; + this.description = this.set.title; + } + + /** + * Define the objective for reporting purposes + */ + init() { + const completionStatus = COMPLETION_STATE.NOTATTEMPTED.asLowerCase; + offlineStorage.set('objectiveDescription', this.id, this.description); + if (this.set.isComplete) return; + offlineStorage.set('objectiveStatus', this.id, completionStatus); + } + + /** + * Reset the objective data + */ + reset() { + if (this.set.isComplete) return; + const completionStatus = COMPLETION_STATE.INCOMPLETE.asLowerCase; + offlineStorage.set('objectiveScore', this.id, this.set.score, this.set.minScore, this.set.maxScore); + offlineStorage.set('objectiveStatus', this.id, completionStatus); + } + + /** + * Complete the objective + * TODO: Always updates to latest data - is this desired? + */ + complete() { + const completionStatus = COMPLETION_STATE.COMPLETED.asLowerCase; + const successStatus = (this.set.isPassed ? COMPLETION_STATE.PASSED : COMPLETION_STATE.FAILED).asLowerCase; + offlineStorage.set('objectiveScore', this.id, this.set.score, this.set.minScore, this.set.maxScore); + offlineStorage.set('objectiveStatus', this.id, completionStatus, successStatus); + } + +} diff --git a/js/Passmark.js b/js/Passmark.js index 373bb99..4f2f77f 100644 --- a/js/Passmark.js +++ b/js/Passmark.js @@ -1,3 +1,6 @@ +/** + * Stores the configuration for passing + */ export default class Passmark { constructor({ diff --git a/js/ScoringSet.js b/js/ScoringSet.js index 7df8cf2..fda67bf 100644 --- a/js/ScoringSet.js +++ b/js/ScoringSet.js @@ -1,26 +1,20 @@ 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 { - filterModels, - getScaledScoreFromMinMax, - getSubsets, - getSubsetsByType, - getSubsetsByModelId, - getSubsetById, - getSubSetByPath, - getSubsetsByQuery, - isAvailableInHierarchy -} from './utils'; -import Backbone from 'backbone'; + getScaledScoreFromMinMax +} from './utils/scoring'; +import { + sum +} from './utils/math'; +import Objective from './Objective'; /** * 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. @@ -30,174 +24,55 @@ import Backbone from 'backbone'; * 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; - // only register root sets as subsets are dynamically created when required - if (!this.subsetParent) this.register(); - this._setupListeners(); - } - - /** - * Register the set - * @fires Adapt#scoring:[set.type]:register - * @fires Adapt#scoring:set:register - */ - register() { - Adapt.scoring.register(this); - Adapt.trigger(`scoring:${this.type}:register scoring:set:register`, this); - } - - /** - * @protected - */ - _setupListeners() { - 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() { - Adapt.trigger(`scoring:${this.type}:restored scoring:set:restored`, this); - } - - init() { - this._wasComplete = this.isComplete; - this._wasPassed = this.isPassed; - this._initializeObjective(); - } - - /** - * Executed on data changes - */ - update() { - 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; - } - - /** - * Reset the set - * @fires Adapt#scoring:[set.type]:reset - * @fires Adapt#scoring:set:reset - */ - reset() { - Adapt.trigger(`scoring:${this.type}:reset scoring:set:reset`, this); - Logging.debug(`${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); - } +/** + * 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 { /** - * @param {string} query - * @returns {[ScoringSet]} + * @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] */ - getSubsetsByQuery(query) { - return getSubsetsByQuery(query, this); + initialize(options = {}) { + super.initialize(options); + const { + _isScoreIncluded = false, + _isCompletionRequired = false + } = options; + this.isScoreIncluded = _isScoreIncluded; + this.isCompletionRequired = _isCompletionRequired; } - /** - * Returns subsets populated by child models - * @param {ScoringSet} set - * @returns {[ScoringSet]} - */ - getPopulatedSubset(subset) { - return subset.filter(set => set.isPopulated); + /** @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 {boolean} @@ -206,92 +81,8 @@ export default class ScoringSet extends Backbone.Controller { return !this.isOptional && this.isAvailable && this._isCompletionRequired; } - /** - * 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() { - Logging.error(`models must be overriden for ${this.constructor.name}`); - } - - /** - * Check to see if there are any child models - * @returns {boolean} - */ - get isPopulated() { - return Boolean(this.models?.length); - } - - get isNotPopulated() { - return (this.isPopulated === false); - } - - /** - * Returns all component models regardless of `_isAvailable` - * @returns {[ComponentModel]} - */ - get rawComponents() { - return this.model.findDescendantModels('component'); - } - - /** - * Returns all question models regardless of `_isAvailable` - * @returns {[QuestionModel]} - */ - get rawQuestions() { - return this.model.findDescendantModels('question'); - } - - /** - * Returns all presentation component models regardless of `_isAvailable` - * @returns {[QuestionModel]} - */ - get rawPresentationComponents() { - return this.rawComponents.filter(model => !model.isTypeGroup('question')); - } - - /** - * Returns all `_isAvailable` component models - * @returns {[ComponentModel]} - */ - get components() { - return this.models.reduce((components, model) => { - model.isTypeGroup('component') ? components.push(model) : components.push(...model.findDescendantModels('component')); - return components; - }, []).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.components.filter(model => model.isTypeGroup('question')); - } - - /** - * Returns all `_isAvailable` presentation component models - * @returns {[QuestionModel]} - */ - get presentationComponents() { - return this.components.filter(model => !model.isTypeGroup('question')); - } - - /** - * Returns all prospective subsets - * @returns {[ScoringSet]} - */ - get subsets() { - return getSubsets(this); + set isCompletionRequired(value) { + this._isCompletionRequired = value; } /** @@ -299,15 +90,15 @@ export default class ScoringSet extends Backbone.Controller { * @returns {number} */ get minScore() { - return this.questions.reduce((score, set) => score + set.minScore, 0); + return sum(this.availableQuestions, 'minScore'); } /** - * Returns the maxiumum score + * Returns the maximum score * @returns {number} */ get maxScore() { - return this.questions.reduce((score, set) => score + set.maxScore, 0); + return sum(this.availableQuestions, 'maxScore'); } /** @@ -315,7 +106,7 @@ export default class ScoringSet extends Backbone.Controller { * @returns {number} */ get score() { - return this.questions.reduce((score, set) => score + set.score, 0); + return sum(this.availableQuestions, 'score'); } /** @@ -330,133 +121,135 @@ export default class ScoringSet extends Backbone.Controller { * 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} - */ - get canReset() { - return false - } - - /** - * Returns whether the set is optional - * @returns {boolean} + * Returns the percentage of correctly answered questions + * @returns {number} */ - get isOptional() { - return false + get scaledCorrectness() { + return getScaledScoreFromMinMax(this.correctness, 0, this.maxCorrectness); } /** - * Returns whether the set is available + * Returns whether the set is completed + * query example: `(isComplete)` or `(isComplete=false)` * @returns {boolean} */ - get isAvailable() { - return true + get isComplete() { + return this.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 configured passmark has been achieved + * query example: `(isPassed)` * @returns {boolean} */ get isPassed() { - Logging.error(`isPassed must be overriden for ${this.constructor.name}`); + Logging.error(`isPassed must be overridden for ${this.constructor.name}`); } + /** + * Returns whether the configured passmark has been failed + * query example: `(isFailed)` alias for `(isComplete,isPassed=false)` + * @returns {boolean} + */ get isFailed() { - return (this.isPassed === false); + return (this.isComplete && this.isPassed === false); } /** - * Define the objective for reporting purposes - * @protected + * The objective object for the set. See SCORM cmi.objectives + * @returns {Objective} */ - _initializeObjective() { - if (this.subsetParent) return; - const id = this.id; - const description = this.title; - const completionStatus = COMPLETION_STATE.NOTATTEMPTED.asLowerCase; - OfflineStorage.set('objectiveDescription', id, description); - if (this.isComplete) return; - OfflineStorage.set('objectiveStatus', id, completionStatus); + get objective() { + if (this.isIntersectedSet) return; + return (this._objective = this._objective || new Objective({ set: this })); } - /** - * Reset the objective data - * @protected - */ - _resetObjective() { - if (this.subsetParent || this.isComplete) return; - const id = this.id; - const completionStatus = COMPLETION_STATE.INCOMPLETE.asLowerCase; - OfflineStorage.set('objectiveScore', id, this.score, this.minScore, this.maxScore); - OfflineStorage.set('objectiveStatus', id, completionStatus); + /** @override */ + async onInit() { + if (this.isIntersectedSet) return; + this.objective?.init(); + super.onInit(); } - /** - * Complete the objective - * @todo Always updates to latest data - is this desired? - * @protected - */ - _completeObjective() { - if (this.subsetParent) return; - const id = this.id; - const completionStatus = COMPLETION_STATE.COMPLETED.asLowerCase; - const successStatus = (this.isPassed ? COMPLETION_STATE.PASSED : COMPLETION_STATE.FAILED).asLowerCase; - OfflineStorage.set('objectiveScore', id, this.score, this.minScore, this.maxScore); - OfflineStorage.set('objectiveStatus', id, completionStatus, successStatus); + /** @override */ + async onRestore() { + if (this.isIntersectedSet) return; + this._wasComplete = this.isComplete; + this._wasPassed = this.isPassed; + super.onRestore(); + } + + /** @override */ + async onUpdate() { + if (this.isIntersectedSet) 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; + super.onUpdate(); } /** + * Is executed on lifecycle update phase when isComplete=true * @fires Adapt#scoring:[set.type]:complete * @fires Adapt#scoring:set:complete */ - onCompleted() { + async onCompleted() { + if (this.isIntersectedSet) return; Adapt.trigger(`scoring:${this.type}:complete scoring:set:complete`, this); Logging.debug(`${this.id} completed`); - this._completeObjective(); + this.objective?.complete(); } /** + * Is executed on lifecycle update phase when isPassed=true * @fires Adapt#scoring:[set.type]:passed * @fires Adapt#scoring:set:passed */ - onPassed() { + async onPassed() { + if (this.isIntersectedSet) return; Adapt.trigger(`scoring:${this.type}:passed scoring:set:passed`, this); Logging.debug(`${this.id} passed`); } + /** @override */ + async reset() { + if (this.isIntersectedSet) return; + super.reset(); + this.objective?.reset(); + } } diff --git a/js/State.js b/js/State.js new file mode 100644 index 0000000..8b2d882 --- /dev/null +++ b/js/State.js @@ -0,0 +1,74 @@ +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..bbf3398 --- /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 change 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 change 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..c6162d9 --- /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 + * Save and restore the collection of children from the set model + * Share a space under 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..9046f71 --- /dev/null +++ b/js/TotalSets.js @@ -0,0 +1,232 @@ +import Adapt from 'core/js/adapt'; +import Passmark from './Passmark'; +import Logging from 'core/js/logging'; +import ScoringSet from './ScoringSet'; +import { + getScaledScoreFromMinMax +} from './utils/scoring'; +import { + createIntersectedSet +} from './utils/intersection'; +import { + filterSetsByIntersectingModels +} from './utils/sets'; +import { + unique, + sum +} from './utils/math'; + +/** + * A set of sets, it can sum the scores of the root or intersecting sets + * + * It extends `ScoringSet` with the caveat that it sums properties from root or + * intersecting scoring sets and completion sets rather than root 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); + this._wasComplete = false; + this._wasPassed = false; + } + + /** + * Returns all models from sets marked with `_isScoreIncluded` or `_isCompletionRequired`, filtered and intersected where appropriate + * @returns {[Backbone.Model]} + */ + 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 + * @returns {number} + */ + get minScore() { + return sum(this.scoringSets, 'minScore'); + } + + /** + * Returns the maximum score of all `_isScoreIncluded` subsets + * @returns {number} + */ + get maxScore() { + return sum(this.scoringSets, 'maxScore'); + } + + /** + * Returns the score of all `_isScoreIncluded` subsets + * @returns {number} + */ + get score() { + return sum(this.scoringSets, 'score'); + } + + /** + * 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 available questions + * @returns {number} + */ + get correctness() { + return sum(this.scoringSets, 'correctness'); + } + + /** + * Returns the number of available questions + * @returns {number} + */ + get maxCorrectness() { + return sum(this.scoringSets, 'maxCorrectness'); + } + + /** + * Returns the percentage of correctly answered questions + * @returns {number} + */ + get scaledCorrectness() { + return getScaledScoreFromMinMax(this.correctness, 0, this.maxCorrectness); + } + + /** + * Returns the passmark model + * @returns {Passmark} + */ + get passmark() { + return this._passmark; + } + + /** + * Returns whether all root sets marked with `_isCompletionRequired` are completed + * @returns {boolean} + */ + get isComplete() { + return this.completionSets.every(set => set.isComplete); + } + + get isIncomplete() { + return (this.isComplete === false); + } + + /** + * Returns whether the configured passmark has been achieved for `_isScoreIncluded` sets. + * If _passmark._requiresPassedSubsets then all scoring subsets have to be passed. + * @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.isComplete && !this.isPassed && !this.canReset; + } + + /** + * Returns whether any root sets marked with `_isScoreIncluded` can be reset + * @todo Add `canReset` to `ScoringSet`? + * @returns {boolean} + */ + get canReset() { + return this.scoringSets.some(set => set?.canReset); + } + + /** @override */ + onRestore() { + if (this.isIntersectedSet) return; + this._wasComplete = this.isComplete; + this._wasPassed = this.isPassed; + } + + /** @override */ + onUpdate() { + if (this.isIntersectedSet) 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; + } + + /** @override */ + onCompleted() { + if (this.isIntersectedSet) return; + Adapt.trigger('scoring:complete', Adapt.scoring); + Logging.debug('scoring completed'); + } + + /** @override */ + onPassed() { + if (this.isIntersectedSet) return; + Adapt.trigger('scoring:pass', Adapt.scoring); + Logging.debug('scoring passed'); + } + +} diff --git a/js/adapt-contrib-scoring.js b/js/adapt-contrib-scoring.js index 60447ba..e823f4c 100644 --- a/js/adapt-contrib-scoring.js +++ b/js/adapt-contrib-scoring.js @@ -1,54 +1,108 @@ import Adapt from 'core/js/adapt'; -import Data from 'core/js/data'; -import Logging from 'core/js/logging'; -import AdaptModelSet from './AdaptModelSet'; -import Passmark from './Passmark'; import { - getSubsetById, - getSubsetsByType, - getSubsetsByModelId, - getSubSetByPath, - getSubsetsByQuery, - getScaledScoreFromMinMax -} from './utils'; + getSubsetsByQuery +} from './utils/query'; +import { + filterSetsByType, + filterSetsByIntersectingModelId, + findSetById +} from './utils/sets'; +import { + getPathSetsIntersected +} from './utils/intersection'; +import { + isBackwardCompatible, + setupBackwardCompatibility +} from './compatibility'; import './helpers'; import Backbone from 'backbone'; -import _ from 'underscore'; - +import Lifecycle from './Lifecycle'; +import data from 'core/js/data'; +import AdaptModelSet from './AdaptModelSet'; +import IntersectionSet from './IntersectionSet'; +import LifecycleSet from './LifecycleSet'; +import ScoringSet from './ScoringSet'; +import Objective from './Objective'; +import State from './State'; +import StateModels from './StateModels'; +import StateSetModelChildren from './StateSetModelChildren'; +import TotalSets from './TotalSets'; + +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, + Objective, + State, + StateSetModelChildren, + StateModels +}; + +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 + }); + // Listen to relevant events for loading, restore and completion + this.listenTo(data, { + loading: this.onDataLoading }); this.listenTo(Adapt, { - 'app:dataReady': this.onAppDataReady, - 'adapt:start': this.onAdaptStart + 'app:dataReady': this.onAppDataReady }); } - init() { - this.subsets.forEach(set => set.init()); - this._wasComplete = this.isComplete; - this._wasPassed = this.isPassed; + /** + * Clear the sets + * @listens Data#loading + */ + onDataLoading() { + this.clear(); + } + + /** + * Configure the main scoring passmark with TotalSets and setup backward compatibility + * for legacy adapt-contrib-assessment related components and extensions + * @listens Adapt#app:dataReady + */ + onAppDataReady() { + this.total = new TotalSets({ model: Adapt.course }); + if (!this.total.isEnabled) return; + setupBackwardCompatibility(this); + } + + /** + * Returns a boolean if adapt-contrib-assessment related compatibility is enabled + * @return {boolean} + */ + get isBackwardCompatible() { + return isBackwardCompatible(this); + } + + /** + * Returns registered root sets + * @returns {IntersectionSet[]} + */ + get sets() { + return this._sets; + } + + /** + * Removes all registered root sets + */ + clear() { + this._sets?.forEach(set => this.deregister(set)); + this._sets = []; } /** @@ -59,9 +113,10 @@ class Scoring extends Backbone.Controller { * @fires Adapt#scoring:register */ register(newSet) { - const hasDuplicatedId = this._rawSets.some(set => set.id === newSet.id); + 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._rawSets.push(newSet); + this.sets.push(newSet); + this.sets.sort((a, b) => a.order - b.order); Adapt.trigger(`${newSet.type}:register scoring:register`, newSet); } @@ -72,29 +127,20 @@ class Scoring extends Backbone.Controller { * @fires Adapt#scoring:deregister */ deregister(oldSet) { - const setIndex = this._rawSets.findIndex(set => set.id === oldSet.id); - this._rawSets.splice(setIndex, 1); + const setIndex = this.sets.findIndex(set => set.id === oldSet.id); + this.sets.splice(setIndex, 1); + this.sets.sort((a, b) => a.order - b.order); Adapt.trigger(`${oldSet.type}:deregister scoring:deregister`, oldSet); } /** * Force all registered sets to recalculate their states - * @property {Scoring} * @fires Adapt#scoring:update */ - update() { - const updateSubsets = !this._queuedChanges?.length - ? this.subsets - : this._queuedChanges.reduce((updateSubsets, model) => updateSubsets.concat(getSubsetsByModelId(model?.get('_id'))), []); - updateSubsets.forEach(set => set.update()); - 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; + async update() { + const sets = this.sets; + if (!sets.length) return; + await this.lifecycle.update(sets); Adapt.trigger('scoring:update', this); } @@ -102,339 +148,67 @@ class Scoring extends Backbone.Controller { * Reset all subsets which can be reset * @fires Adapt#scoring:reset */ - reset() { - this.subsets.forEach(set => set.canReset && set.reset()); + async reset() { + const sets = this.sets; + if (!sets.length) return; + await this.lifecycle.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 + * Returns a registered root set by id * @param {string} id - * @returns {[ScoringSet]} + * @returns {IntersectionSet} */ - getSubsetsByModelId(id) { - return getSubsetsByModelId(id); + getSetById(id) { + return findSetById(this.sets, id); } /** - * Returns sets or intersection sets by query - * @param {string} query - * @returns {[ScoringSet]} + * Returns registered root sets of type + * @param {string} type + * @returns {IntersectionSet[]} */ - getSubsetsByQuery(query) { - return getSubsetsByQuery(query); + getSetsByType(type) { + return filterSetsByType(this.sets, type); } /** - * Returns a registered root set by id + * Returns registered root sets intersecting the given model id * @param {string} id - * @returns {ScoringSet} + * @returns {IntersectionSet[]} */ - getSubsetById(id) { - return getSubsetById(id); + getSetsByIntersectingModelId(id) { + return filterSetsByIntersectingModelId(this.sets, id); } /** - * Returns a root set or intersection set by path + * Returns a root set or intersection set by id path + * example: id.id.id * @param {string|[string]} path - * @returns {ScoringSet} + * @returns {IntersectionSet} */ getSubsetByPath(path) { - return getSubSetByPath(path); - } - - get id() { - return this._id; - } - - get title() { - return this._title; - } - - /** - * Returns whether the plugin is functioning as `Adapt.assessment` - */ - 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); + const sets = getPathSetsIntersected(path); + return sets; } /** - * 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; - } - - /** - * Returns whether any root sets marked with `_isScoreIncluded` can be reset - * @todo Add `canReset` to `ScoringSet`? - * @returns {boolean} - */ - get canReset() { - return this.scoringSets.some(set => set?.canReset); - } - - /** - * Returns whether all root sets marked with `_isCompletionRequired` are completed - * @returns {boolean} - */ - get isComplete() { - return this.completionSets.every(set => set.isComplete); - } - - /** - * 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 - */ - _removeAdaptModelSet(model) { - const set = getSubsetById(model.get('_id')); - this.deregister(set); - } - - /** - * @private - */ - _setupListeners() { - this._debouncedUpdate = _.debounce(this.update, 50); - this._queuedChanges = []; - this.listenTo(Data, 'change:_isAvailable change:_isInteractionComplete', this._updateQueue); - } - - /** - * @private - */ - _removeListeners() { - this.stopListening(Data, 'change:_isAvailable change:_isInteractionComplete', this._updateQueue); - } - - /** - * @private - */ - _updateQueue(model) { - this._queuedChanges.push(model); - this._debouncedUpdate(); - } - - /** - * @listens Data#loading - */ - onDataLoading() { - this._removeListeners(); - this._rawSets = []; - } - - /** - * @listens Adapt#app:dataReady - */ - 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(); - } - - /** - * @listens Adapt#adapt:start - * @fires Adapt#assessment:restored - * @fires Adapt#scoring:restored - */ - 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(); - } - - /** - * @fires Adapt#assessment:complete - * @fires Adapt#scoring:complete - * @property {Scoring} + * Returns sets or intersection sets by query + * @param {string} query + * @returns {IntersectionSet[]} */ - onCompleted() { - if (this.isBackwardCompatible) Adapt.trigger('assessment:complete', this._compatibilityState); - Adapt.trigger('scoring:complete', this); - Logging.debug('scoring completed'); + getSubsetsByQuery(query) { + return getSubsetsByQuery(query); } /** - * @fires Adapt#scoring:pass - * @property {Scoring} + * Returns a set or intersection set by query + * @param {string} query + * @returns {IntersectionSet} */ - onPassed() { - Adapt.trigger('scoring:pass', this); - Logging.debug('scoring passed'); + getSubsetByQuery(query) { + return getSubsetsByQuery(query)[0]; } } diff --git a/js/compatibility.js b/js/compatibility.js new file mode 100644 index 0000000..eff363c --- /dev/null +++ b/js/compatibility.js @@ -0,0 +1,79 @@ +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:restored', onScoringRestored) + .on('scoring:restored', onScoringRestored); + Adapt + .off('scoring:complete', onScoringComplete) + .on('scoring: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, + 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..bd4714c 100644 --- a/js/helpers.js +++ b/js/helpers.js @@ -1,14 +1,19 @@ import Handlebars from 'handlebars'; import { getSubsetsByQuery -} from './utils'; +} from './utils/query'; +import { + sum +} from './utils/math'; + +// Global handlebars helpers const helpers = { scoreQuery(query, context) { const modelId = context?.data?.root?._id; query = query.replace('this', `#${modelId}`); const sets = getSubsetsByQuery(query); - return sets.reduce((score, set) => score + set.score, 0); + return sum(sets, 'score'); } // score(context) { diff --git a/js/utils.js b/js/utils.js deleted file mode 100644 index ab94363..0000000 --- a/js/utils.js +++ /dev/null @@ -1,334 +0,0 @@ -import Adapt from 'core/js/adapt'; -import Data from 'core/js/data'; - -/** @typedef {import("./ScoringSet").default} ScoringSet */ - -/** - * Returns set model arrays by applying standard uniqueness, `_isAvailable` and subset intersection filters - * @param {ScoringSet} set - * @param {[Backbone.Models]} models - * @returns {[Backbone.Models]} - */ -export function filterModels(set, models) { - if (!models) return null; - models = [...new Set(models)]; - if (set.subsetParent?.models) { - // Return only this set's items intersecting with or with intersecting descendants or ancestors from the parent list - models = filterIntersectingHierarchy(models, set.subsetParent.models); - } - return models.filter(isAvailableInHierarchy); -} - -/** - * Returns models from listA which are present in listB, are descendents of listB or have listB models as descendents - * @param {[Backbone.Model]} listA - * @param {[Backbone.Model]} listB - * @returns {[Backbone.Model]} - */ -export function filterIntersectingHierarchy(listA, listB) { - const listBModels = listB.reduce((allDescendents, model) => allDescendents.concat([model], model.getAllDescendantModels()), []); - const listBModelsIndex = _.indexBy(listBModels, model => model.get('_id')); - return listA.filter(model => { - const isADescendentOfB = listBModelsIndex[model.get('_id')]; - if (isADescendentOfB) return true; - const listAModels = [model].concat(model.getAllDescendantModels()); - const listAModelsIndex = _.indexBy(listAModels, model => model.get('_id')); - const isBDescendentOfA = Boolean(Object.keys(listAModelsIndex).find(key => listBModelsIndex[key])); - if (isBDescendentOfA) return true; - return false; - }); -} - -/** - * Return a boolean to indicate if any model from listA is present in listB, is a descendents of listB or has listB models as descendents - * @param {[Backbone.Model]} listA - * @param {[Backbone.Model]} listB - * @returns {boolean} - */ -export function hasIntersectingHierarchy(listA, listB) { - const listBModels = listB.reduce((allDescendents, model) => allDescendents.concat([model], model.getAllDescendantModels()), []); - const listBModelsIndex = _.indexBy(listBModels, model => model.get('_id')); - return Boolean(listA.find(model => { - const isADescendentOfB = listBModelsIndex[model.get('_id')]; - if (isADescendentOfB) return true; - const listAModels = [model].concat(model.getAllDescendantModels()); - const listAModelsIndex = _.indexBy(listAModels, model => model.get('_id')); - const isBDescendentOfA = Boolean(Object.keys(listAModelsIndex).find(key => listBModelsIndex[key])); - if (isBDescendentOfA) return true; - return false; - })); -} - -/** - * Returns a subset from the given sets, reduces from left to right, returning the class of the furthest right most set. - * This effectively makes a pipe of parent-child related sets which use each parent in turn to reduce the models in the next subset - * @param {[ScoringSet]} sets - * @returns {ScoringSet} - */ -export function createIntersectionSubset(sets, filters = null) { - const subsetParent = sets[0]; - const columnFilters = filters?.[0]; - if (columnFilters && !applyFilters(columnFilters, subsetParent)) { - return null; - } - if (sets.length === 1) return subsetParent; - return sets.slice(1).reduce((subsetParent, set, index) => { - if (!subsetParent) return null; - if (!set) return subsetParent; - const Class = Object.getPrototypeOf(set).constructor; - const queryInstance = new Class(set, subsetParent); - const columnFilters = filters?.[index + 1]; - if (columnFilters && !applyFilters(columnFilters, queryInstance)) { - return null; - } - return queryInstance; - }, subsetParent); -} - -/** - * Returns all sets or all sets without the specified excludeParent - * @param {ScoringSet} [excludeParent] - */ -export function getRawSets(excludeParent = null) { - return excludeParent ? - Adapt.scoring.subsets.filter(set => !(set.id === excludeParent.id && set.type === excludeParent.type)) : - Adapt.scoring.subsets; -} - -/** - * Returns all root set or the intersection sets from subsetParent - * @param {ScoringSet} subsetParent - * @returns {[ScoringSet]} - */ -export function getSubsets(subsetParent = undefined) { - let sets = getRawSets(subsetParent); - if (subsetParent) { - // Create intersection sets between the found sets and the subsetParent - sets = sets.map(set => createIntersectionSubset([subsetParent, set])); - } - return sets; -} - -/** - * Returns all root set of type or the intersection sets from subsetParent of type - * @param {string} type - * @param {ScoringSet} [subsetParent] - * @returns {[ScoringSet]} - */ -export function getSubsetsByType(type, subsetParent = undefined) { - let sets = getRawSets(subsetParent).filter(set => type === set.type); - if (subsetParent) { - // Create intersection sets between the found sets and the subsetParent - sets = sets.map(set => createIntersectionSubset([subsetParent, set])); - } - return sets; -} - -/** - * Returns all root sets or the intersection sets from subsetParent which also intersect the given model - * @param {string} id - * @param {ScoringSet} [subsetParent] - * @returns {[ScoringSet]} - */ -export function getSubsetsByModelId(id, subsetParent = undefined) { - const models = [Data.findById(id)]; - let sets = getRawSets(subsetParent).filter(set => hasIntersectingHierarchy(set.models, models)); - if (subsetParent) { - // Create intersection sets between the found sets and the subsetParent - sets = sets.map(set => createIntersectionSubset([subsetParent, set])); - } - return sets; -} - -/** - * Returns the root set by id or the intersection from the subsetParent by id - * @param {string} id - * @param {ScoringSet} [subsetParent] - * @returns {ScoringSet} - */ -export function getSubsetById(id, subsetParent = undefined) { - const sets = getRawSets(subsetParent); - let set = sets.find(set => id === set.id); - if (subsetParent) { - // Create an intersection set between the found set and the subsetParent - set = createIntersectionSubset([subsetParent, set]); - } - return set; -} - -/** - * Create intersection subset from an id path - * @param {[string]|string} path - * @param {ScoringSet} [subsetParent] - * @returns {ScoringSet} - */ -export function getSubSetByPath(path, subsetParent = undefined) { - if (typeof path === 'string') { - // Allow 'id.id.id' style lookup - path = path.split('.'); - } - // Fetch all of the sets named in the path in order - const sets = path.map(id => getSubsetById(id)); - if (subsetParent) { - // Add subsetParent as the starting set - sets.unshift(subsetParent); - } - // Create an intersection set from all found sets in order - return createIntersectionSubset(sets); -} - -/** - * Returns the percentage position (between -100-100) of score between minScore and maxScore - * @param {number} score - * @param {number} minScore - * @param {number} maxScore - * @returns {number} - */ -export function getScaledScoreFromMinMax(score, minScore, maxScore) { - // range split into negative/positive ranges (rather than min-max normalization) depending on score - const range = (score < 0) ? Math.abs(minScore) : maxScore; - return Math.round((score / range) * 100); -} - -/** - * @param {Backbone.Model} model - * @returns {boolean} - */ -export function isAvailableInHierarchy(model) { - return model.getAncestorModels(true).every(model => model.get('_isAvailable')); -} - -/** - * Returns an array of all combinations of the matrix row values - * @param {[[any]]} matrix - * @returns {[any]} - */ -export function matrixMultiply(matrix) { - if (matrix.length === 0) return []; - const partLengths = matrix.map(part => part.length); // how large each row is - if (partLengths.some(length => length === 0)) return []; // cannot multiply an empty row - const subPartIndices = new Array(matrix.length).fill(0) // how far we've gone in each row - let isEnded = false; - const sumsToPerform = []; - while (isEnded === false) { - sumsToPerform.push(subPartIndices.reduce((sum, subPartIndex, partIndex) => { - sum.push(matrix[partIndex][subPartIndex]); - return sum; - }, [])); - isEnded = !subPartIndices.some((subPartIndex, partIndex) => { - subPartIndex++; - if (subPartIndex < partLengths[partIndex]) { - subPartIndices[partIndex] = subPartIndex; - return true; - } - subPartIndices[partIndex] = 0; - return false; - }); - } - return sumsToPerform; -} - -const majorPartRegExp = /([^ []*(?:[[(]{1}[^\])]+[\])]{1})*)/g; -const attributePartRegEx = /[[(]{1}[^\])]+[\])]{1}/g; -/** - * Takes a subset intersection query string and transforms it into an array of filter objects - * @param {string} query - * @returns {[[{}]]} - */ -export function parseQuery(query = '') { - const queryMajors = query.split(majorPartRegExp).map(section => section?.trim()).filter(Boolean); - const filterParts = queryMajors.map(queryMajor => { - const attributeQueryParts = queryMajor.match(attributePartRegEx); - const openingQueryPart = queryMajor.replace(attributePartRegEx, ''); - const majorFilterPart = []; - if (openingQueryPart[0] === '#') { - // select by id - majorFilterPart.push({ - id: openingQueryPart.slice(1) - }); - } else if (openingQueryPart) { - // select by type - majorFilterPart.push({ - type: openingQueryPart - }); - } - if (attributeQueryParts) { - const getAttributeParts = (attributeQueryParts, character) => { - return attributeQueryParts.filter(part => part[0] === character).map(attributeQueryPart => { - const attributeQueryPartMiddle = attributeQueryPart.slice(1, -1); - const attributeQueryPartSections = attributeQueryPartMiddle.split(',').map(section => section.trim()).filter(Boolean); - return attributeQueryPartSections.map(section => { - if (section[0] === '#') return { id: section.slice(1) }; - const [name, value] = section.split('=').map(section => section.trim()).filter(Boolean); - return { [name]: value }; - }); - }); - }; - const multiplyAttributeParts = getAttributeParts(attributeQueryParts, '['); - const filterAttributeParts = getAttributeParts(attributeQueryParts, '('); - const flattenedFilterAttributeObjects = filterAttributeParts.map(part => Object.assign({}, ...part)); - const multipliedAttributeParts = matrixMultiply([majorFilterPart, ...multiplyAttributeParts].filter(item => item?.length)); - const flattenedMultiplyObjects = multipliedAttributeParts.map(query => Object.assign({}, ...query)); - flattenedMultiplyObjects.push(flattenedFilterAttributeObjects); - return flattenedMultiplyObjects; - } - majorFilterPart.push([]); - return majorFilterPart; - }); - return filterParts; -} - -export function applyFilter(filter, set) { - for (const k in filter) { - const setValue = set[k]; - const filterValue = filter[k]; - if (typeof setValue === 'function') { - if (!setValue.call(set, filterValue)) return false; // check for modelTypeGroup('question') - continue; - } - if (filterValue === undefined) { - if (!setValue) return false; // check for Boolean(isComplete) - } else if (String(setValue) !== String(filterValue)) { - return false; // check for id==='a-05' - } - } - return true; -} - -export function applyFilters(filters, set) { - return filters.every(filter => applyFilter(filter, set)); -} - -/** - * Takes a subset intersection query string and returns the resultant intersected subsets - * @param {string} query - * @param {ScoringSet} [subsetParent] - * @returns {[ScoringSet]} - */ -export function getSubsetsByQuery(query, subsetParent = undefined) { - const allSubsets = getSubsets(subsetParent); - const parsedQueryMatrix = parseQuery(query); - const subsetQueryMatrix = parsedQueryMatrix.map(row => { - const selectionFilters = row.slice(0, -1); - return selectionFilters - .map((selectionFilter) => { - // Apply modelIdFilter - const hasModelIdFilter = Object.prototype.hasOwnProperty.call(selectionFilter, 'modelId'); - const filterSubsets = !hasModelIdFilter - ? allSubsets - : getSubsetsByModelId(selectionFilter.modelId, subsetParent); - if (hasModelIdFilter) delete selectionFilter.modelId; - // Return only filtered sets - return filterSubsets.filter(set => applyFilter(selectionFilter, set)); - }) - .flat(); - }); - const intersectionQueryLists = matrixMultiply(subsetQueryMatrix); - let columnInclusionFilters = parsedQueryMatrix.map(row => row.lastItem); - if (subsetParent) columnInclusionFilters = [[]].concat(columnInclusionFilters); - const intersectedSubsets = intersectionQueryLists.map(intersectionQueryList => { - if (subsetParent) return createIntersectionSubset([subsetParent, ...intersectionQueryList], columnInclusionFilters); - return createIntersectionSubset(intersectionQueryList, columnInclusionFilters); - }).filter(Boolean); - return intersectedSubsets; -} diff --git a/js/utils/hash.js b/js/utils/hash.js new file mode 100644 index 0000000..6c0c3b0 --- /dev/null +++ b/js/utils/hash.js @@ -0,0 +1,28 @@ +/** + * Turn any JSON into a semi-unique hash string, representing a state + * @param {any} dataToHash + * @returns {string} + */ +export function hash(dataToHash) { + const text = JSON.stringify(dataToHash); + let hash = 5381; + let index = text.length; + while (index) hash = (hash * 33) ^ text.charCodeAt(--index); + return String(hash >>> 0); +} + +/** + * Useful for determining if a state has changed from a series of JSON variables + * Store the latest hash of dataToHash at subject[_subjectKey] = hash + * On subsequent call, return true/false indicating that the data has changed + * @param {Object} subject The object on which to store the previous hash + * @param {any} dataToHash Any JSON to hash as a state + * @param {string} [subjectKey="_hash"] + * @returns {boolean} + */ +export function hasHashChanged(subject, dataToHash, subjectKey = '_previousHash') { + const hashed = hash(dataToHash); + if (subject[subjectKey] === hashed) return false; + subject[subjectKey] = hashed; + return true; +} diff --git a/js/utils/intersection.js b/js/utils/intersection.js new file mode 100644 index 0000000..3269a1a --- /dev/null +++ b/js/utils/intersection.js @@ -0,0 +1,94 @@ +import { + getAllSets, + findSetById +} from './sets'; + +/** @typedef {import("../IntersectionSet").default} IntersectionSet */ + +/** + * Return an intersection of all of the sets along a path + * @param {[string]|string} path + * @returns {IntersectionSet} + */ +export function getPathSetsIntersected(path) { + if (typeof path === 'string') { + // Allow 'id.id.id' style lookup + path = path.split('.'); + } + // Fetch all of the sets named in the path in order + const allSets = getAllSets(); + const sets = path.map(id => findSetById(allSets, id)); + return createIntersectedSet(sets); +} + +/** + * Returns a cloned intersection subset from the given array of sets + * Reduces from left to right, returning the class of the furthest right most set + * It makes a pipe of parent-child relations which reduce the models in the next subset + * @param {IntersectionSet[]} sets Chain of sets to intersect + * @param {Object} [options] + * @param {Object[][]} [options.filters=null] Part of query interface. An array of arrays of where objects to match at each intersection step. isComplete, isPopulated, etc + * @returns {IntersectionSet} + */ +export function createIntersectedSet(sets, { filters = null } = {}) { + const firstSet = sets[0]; + const firstWheres = filters?.[0]; + if (firstWheres && !isWhereMatch(firstSet, firstWheres)) { + // Return null when initial set is excluded by where + return null; + } + const isOnlyOneSet = (sets.length === 1); + if (isOnlyOneSet) return firstSet; + const intersectionParent = firstSet; + const subsets = sets.slice(1); + const intersectionModel = subsets.reduce((intersectionParent, subset, index) => { + if (!intersectionParent) return null; + if (!subset) return intersectionParent; + const queryInstance = subset.intersect(intersectionParent); + const wheres = filters?.[index + 1]; + if (wheres && !isWhereMatch(queryInstance, wheres)) { + // Return null when intersecting set is excluded by where object + return null; + } + return queryInstance; + }, intersectionParent); + return intersectionModel; +} + +/** + * Match a set against all where objects. + * [{ + * modelTypeGroup: 'question', + * isComplete: undefined, + * isPopulated: false, + * id: 'a-05 + * }] + * @param {IntersectionSet} set + * @param {Object[]|Object} wheres + * @returns {boolean} + */ +export function isWhereMatch(set, wheres) { + if (!Array.isArray(wheres)) wheres = [wheres]; + // return false if not matching every + for (const where of wheres) { + for (const k in where) { + const setValue = set[k]; + const whereValue = where[k]; + const isFunction = (typeof setValue === 'function'); + const isTruthy = (whereValue === undefined); + const isStringComparison = (!isFunction && !isTruthy); + if (isFunction) { + // i.e. check for modelTypeGroup('question') + const resolvedSetValue = setValue.call(set, whereValue); + if (!resolvedSetValue) return false; + } else if (isTruthy) { + // i.e. check for Boolean(isComplete) + if (!setValue) return false; + } else if (isStringComparison) { + // i.e. check for id==='a-05' + if (String(setValue) !== String(whereValue)) return false; + } + } + } + return true; +} diff --git a/js/utils/math.js b/js/utils/math.js new file mode 100644 index 0000000..cefc0df --- /dev/null +++ b/js/utils/math.js @@ -0,0 +1,70 @@ +/** + * Returns only unique items + * @param {any[]} items + * @returns {any[]} + */ +export function unique(items) { + return [...new Set(items)]; +} + +/** + * Performs addition on the property `by` of the items, either by string or by function return value + * @param {Object[]} items A list of items from which to sum a property or function result + * @param {string|Function} by The name of the property to sum or a function returning the value to sum + * @returns {number} + */ +export function sum(items, by) { + switch (typeof by) { + case 'string': + return items.reduce((sum, item) => (sum + (item[by] ?? 0)), 0); + case 'function': + return items.reduce((sum, item, index) => (sum + (by(item, index) ?? 0)), 0); + } +} + +/** + * Returns an array of all combinations of the column row values + * [ + * [1,2], [4,5], [6,7,8] + * ] = [ + * [1,4,6], + * [2,4,6], + * [1,5,6], + * [2,5,6], + * [1,4,7], + * [2,4,7], + * [1,5,7], + * [2,5,7], + * [1,4,8], + * [2,4,8], + * [1,5,8], + * [2,5,8] + * ] + * @param {any[][]} columns + * @returns {any[][]} + */ +export function matrixMultiply(columns) { + if (columns.length === 0) return []; + const partLengths = columns.map(part => part.length); // how large each row is + const hasAnEmptyRow = partLengths.some(length => !length); + if (hasAnEmptyRow) return []; // Cannot multiply an empty row + const columnRowIndexes = new Array(columns.length).fill(0); // how far we've gone in each row + let isEnded = false; + const combinationToPerform = []; + while (isEnded === false) { + combinationToPerform.push(columnRowIndexes.reduce((rowToPerform, rowIndex, columnIndex) => { + rowToPerform.push(columns[columnIndex][rowIndex]); + return rowToPerform; + }, [])); + isEnded = !columnRowIndexes.some((rowIndex, columnIndex) => { + rowIndex++; + if (rowIndex < partLengths[columnIndex]) { + columnRowIndexes[columnIndex] = rowIndex; + return true; + } + columnRowIndexes[columnIndex] = 0; + return false; + }); + } + return combinationToPerform; +} diff --git a/js/utils/models.js b/js/utils/models.js new file mode 100644 index 0000000..6ab99e2 --- /dev/null +++ b/js/utils/models.js @@ -0,0 +1,186 @@ +import Backbone from 'backbone'; +import Data from 'core/js/data'; +import { + unique +} from './math'; + +/** + * Returns a boolean at _isAvailable across the model and its ancestors + * allowDetached returns true if the model is not in its parent.getChildren() collection + * @param {Backbone.Model} model + * @param {Object} [options] + * @param {boolean} [options.allowDetached=false] If true, the model can be detached from its parent + * @returns {boolean} + */ +export function isModelAvailableInHierarchy(model, { allowDetached = false } = {}) { + const ancestors = model.getAncestorModels(true); + return ancestors.every(model => { + if (!model.get('_isAvailable')) return false; + if (allowDetached) return true; + return isModelAttached(model); + }); +} + +/** + * Returns a boolean if the model is in its parent.getChildren() collection + * @param {Backbone.Model} model + * @returns {boolean} + */ +function isModelAttached(model) { + const parent = model.getParent(); + if (!parent) return true; + const isModelAttached = parent.getChildren().has(model); + return isModelAttached; +} + +/** + * Alternative version of getChildren that can also return detached children + * Uses Data.findById(parentId) rather than model.getChildren() + * @param {Backbone.Model} model + * @param {Object} [options] + * @param {boolean} [options.allowDetached=true] If false, the children cannot be detached from their parent + * @returns {Backbone.Model[]} + */ +export function getModelChildren(model, { allowDetached = true } = {}) { + const models = modelsByParentId[model.get('_id')].slice(0); + if (!allowDetached) return models.filter(isModelAttached); + return models; +} + +/** + * Alternative version of findDescendantModels that can also return detached children + * Uses Data.findById(parentId) rather than model.getChildren() + * @param {Backbone.Model} model + * @param {string} typeGroup + * @param {Object} [options] + * @param {boolean} [options.allowDetached=true] If false, the children cannot be detached from their parent + * @returns {Backbone.Model[]} + */ +export function findDescendantModels(model, typeGroup, { allowDetached = true } = {}) { + const models = getAllDescendantModels(model) + .filter(model => model.isTypeGroup(typeGroup)); + if (!allowDetached) return models.filter(isModelAttached); + return models; +} + +// Keep an index of the models by parent id +let modelsByParentId = {}; +const observer = Object.assign({}, Backbone.Events); +function indexModelsByParentId() { + modelsByParentId = Data.groupBy(model => model.get('_parentId')); +} +observer.listenTo(Data, { + add: indexModelsByParentId, + remove: indexModelsByParentId +}); + +/** + * Return true only if modelsA directly or indirectly intersect with the modelsB + * @param {Backbone.Model[]} modelsA + * @param {Backbone.Model[]} modelsB + * @returns {boolean} + */ +export function areModelsIntersectingModels(modelsA, modelsB) { + if (!modelsA) return false; + modelsA = unique(modelsA); + if (!modelsB) return false; + modelsB = unique(modelsB); + const modelsBIds = modelsB.map(model => model.get('_id')); + const modelsBDescendantIds = Object.keys(modelsB + .flatMap(model => getAllDescendantModels(model)) + .groupBy(model => model.get('_id'))); + const modelsBAncestorIds = Object.keys(modelsB + .flatMap(model => model.getAncestorModels()) + .groupBy(model => model.get('_id'))); + return modelsA.some(modelA => { + const modelAId = modelA.get('_id'); + const isEqual = modelsBIds.includes(modelAId); + if (isEqual) return true; + const isDescendant = modelsBDescendantIds.includes(modelAId); + if (isDescendant) return true; + const isAncestor = modelsBAncestorIds.includes(modelAId); + return isAncestor; + }); +} + +/** + * Return only modelsA that intersect with the modelsB + * 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 + * @param {Backbone.Model[]} modelsA + * @param {Backbone.Model[]} modelsB + * @returns {Backbone.Model[]} + */ +export function filterModelsByIntersectingModels(modelsA, modelsB) { + if (!modelsA) return []; + modelsA = unique(modelsA); + if (!modelsB) return modelsA; + modelsB = unique(modelsB); + const modelsBIds = modelsB.map(model => model.get('_id')); + const modelsBDescendantIds = Object.keys(modelsB + .flatMap(model => getAllDescendantModels(model)) + .groupBy(model => model.get('_id'))); + const modelsBAncestorIds = Object.keys(modelsB + .flatMap(model => model.getAncestorModels()) + .groupBy(model => model.get('_id'))); + return unique(modelsA.filter(modelA => { + const modelAId = modelA.get('_id'); + const isEqual = modelsBIds.includes(modelAId); + if (isEqual) return true; + const isDescendant = modelsBDescendantIds.includes(modelAId); + if (isDescendant) return true; + const isAncestor = modelsBAncestorIds.includes(modelAId); + return isAncestor; + })); +} + +/** + * Alternative version of getAllDescendantModels, which also returns detached children + * Uses Data.findById(parentId) rather than model.getChildren() + * @param {Backbone.Model} model + * @param {Object} [options] + * @param {boolean} [options.isParentFirst=true] + * @returns {Backbone.Model[]} + */ +export function getAllDescendantModels(model, { isParentFirst = true } = {}) { + const descendants = []; + const stack = [ + [model] + ]; + const subject = []; + while (stack.length) { + const currentStackIndex = stack.length - 1; + const parentIndex = currentStackIndex - 1; + const currentStack = stack[currentStackIndex]; + const shouldMoveDown = (!currentStack.length); + if (shouldMoveDown) { + stack.pop(); + subject.pop(); + const hasParent = (parentIndex >= 0); + if (isParentFirst || !hasParent) continue; + // add child first parent + const parent = subject[parentIndex]; + descendants.push(parent); + continue; + } + const nextModel = currentStack.shift(); + subject[currentStackIndex] = nextModel; + if (isParentFirst) { + // add parent first + descendants.push(nextModel); + } + const id = nextModel.get('_id'); + const children = (modelsByParentId[id] ?? []).slice(0); + const hasChildren = Boolean(children.length); + if (hasChildren) { + stack.push(children); + continue; + } + if (isParentFirst) continue; + // add child first + descendants.push(nextModel); + } + return descendants; +} diff --git a/js/utils/query.js b/js/utils/query.js new file mode 100644 index 0000000..cb0251b --- /dev/null +++ b/js/utils/query.js @@ -0,0 +1,112 @@ +import { + getAllSets, + filterSetsByIntersectingModelId +} from './sets'; +import { + createIntersectedSet, + isWhereMatch +} from './intersection'; +import { + matrixMultiply, + unique +} from './math'; + +const queryColumnRegEx = /([^ []*(?:[[(]{1}[^\])]+[\])]{1})*)/g; +const queryColumnAttributeRegEx = /[[(]{1}[^\])]+[\])]{1}/g; + +/** + * Takes a subset intersection query string and returns the resultant intersected subsets + * See (intersection query document)[../../INTERSECTION_QUERY.md] + * @param {string} query + * @returns {IntersectionSet[]} + */ +export function getSubsetsByQuery(query) { + const allSets = getAllSets(); + const columns = parseQuery(query); + const columnsSelects = columns.map(({ selects }) => selects); + const columnsFilters = columns.map(({ filters }) => filters); + const columnsSets = columnsSelects.map(columnSelects => + columnSelects.flatMap(selectWhere => { + // Apply modelId rule as an intersection rather than a literal + const hasModelIdRule = Object.hasOwn(selectWhere, 'modelId'); + const selectSubsets = !hasModelIdRule + ? allSets + : filterSetsByIntersectingModelId(allSets, selectWhere.modelId); + if (hasModelIdRule) { + selectWhere = { ...selectWhere }; + delete selectWhere.modelId; + } + // Return only filtered sets + return selectSubsets.filter(set => isWhereMatch(set, selectWhere)); + }) + ); + const intersections = matrixMultiply(columnsSets); + const intersectedSubsets = unique(intersections.map(columnsSet => { + return createIntersectedSet(columnsSet, { filters: columnsFilters }); + }).filter(Boolean)); + return intersectedSubsets; +} + +/** + * Takes a subset intersection query string and transforms it into an array of select and filter objects + * See (intersection query document)[../../INTERSECTION_QUERY.md] + * @param {string} query + * @returns {Object[][]} + */ +function parseQuery(query = '') { + const columns = query.split(queryColumnRegEx).map(column => column?.trim()).filter(Boolean); + const parsedColumns = columns.map(column => { + const primarySelects = []; + const firstQueryPart = column.replace(queryColumnAttributeRegEx, ''); + const isIdSelector = (firstQueryPart[0] === '#'); + if (isIdSelector) { + // Select by id + primarySelects.push({ + id: firstQueryPart.slice(1) + }); + } else if (firstQueryPart) { + // Select by type + primarySelects.push({ + type: firstQueryPart + }); + } + const attributeQueryParts = column.match(queryColumnAttributeRegEx); + if (!attributeQueryParts) { + return { + selects: primarySelects, + filters: [] + }; + } + const multiplyByObjects = parseQueryAttributes(attributeQueryParts, '['); + // Multiply the primary select by id or type, by the select by attributes + const multipliedObjects = matrixMultiply([primarySelects, ...multiplyByObjects].filter(item => item?.length)); + const combinedMultipliedSelects = multipliedObjects.map(objects => Object.assign({}, ...objects)); + // Separate the filter attributes for after the selects are performed + const filterObjects = parseQueryAttributes(attributeQueryParts, '('); + const filters = filterObjects.map(objects => Object.assign({}, ...objects)); + return { + selects: combinedMultipliedSelects, + filters + }; + }); + return parsedColumns; +} + +/** + * Parses query attributes and returns attribute name: value pair objects. + * See (intersection query document)[../../INTERSECTION_QUERY.md] + * @param {string} attributeQueryParts + * @param {string} startCharacter + * @returns {Object[]} + */ +function parseQueryAttributes (attributeQueryParts, startCharacter) { + return attributeQueryParts.filter(part => part[0] === startCharacter).map(attributeQueryPart => { + const attributeQueryPartMiddle = attributeQueryPart.slice(1, -1); + const attributeQueryPartSections = attributeQueryPartMiddle.split(',').map(section => section.trim()).filter(Boolean); + return attributeQueryPartSections.map(section => { + if (section[0] === '#') return { id: section.slice(1) }; + const [name, value] = section.split('=').map(section => section.trim()).filter(Boolean); + return { [name]: value }; + }); + }); +} diff --git a/js/utils/scoring.js b/js/utils/scoring.js new file mode 100644 index 0000000..88e4cb4 --- /dev/null +++ b/js/utils/scoring.js @@ -0,0 +1,16 @@ +/** + * Returns the percentage position (between -100-100) of score between minScore and maxScore + * @param {number} score + * @param {number} minScore + * @param {number} maxScore + * @returns {number} + */ +export function getScaledScoreFromMinMax(score, minScore, maxScore) { + // range split into negative/positive ranges (rather than min-max normalization) depending on score + // this gives a negative range of -100 to 0 where -100 is the min score + // and a positive range of 0 to 100 where 100 is the max score + // -50% and +50% can therefore represent different absolute quantities of score + const range = (score < 0) ? Math.abs(minScore) : maxScore; + if (!range) return 0; + return Math.round((score / range) * 100); +} diff --git a/js/utils/sets.js b/js/utils/sets.js new file mode 100644 index 0000000..de19e11 --- /dev/null +++ b/js/utils/sets.js @@ -0,0 +1,110 @@ +import Adapt from 'core/js/adapt'; +import Data from 'core/js/data'; +import { + areModelsIntersectingModels +} from './models'; +import { + unique +} from './math'; + +/** @typedef {import("../IntersectionSet").default} IntersectionSet */ + +/** + * Returns all sets or all sets without the specified excludeParent + * @param {Object} [options] + * @param {IntersectionSet} [options.excludeParent=null] + * @returns {IntersectionSet[]} + */ +export function getAllSets({ excludeParent = null } = {}) { + if (!excludeParent) return Adapt.scoring.sets; + return Adapt.scoring.sets.filter(set => !(set.id === excludeParent.id && set.type === excludeParent.type)); +} + +/** + * Filters sets by type + * @param {IntersectionSet[]} sets + * @param {string} type + * @returns {IntersectionSet[]} + */ +export function filterSetsByType(sets, type) { + return unique(sets.filter(set => type === set.type)); +} + +/** + * Filter sets which models intersect the given model id + * This is useful for update behaviour on a model change + * @param {IntersectionSet[]} sets + * @param {string} id + * @returns {IntersectionSet[]} + */ +export function filterSetsByIntersectingModelId(sets, id) { + if (!id) return []; + const model = Data.findById(id); + if (!model) return []; + const models = [model]; + return unique(sets.filter(set => areModelsIntersectingModels([set.model, ...set.models].filter(Boolean), models))); +} + +/** + * Filter sets which models intersect the given models + * @param {IntersectionSet[]} sets + * @param {string} id + * @returns {IntersectionSet[]} + */ +export function filterSetsByIntersectingModels(sets, models) { + if (!sets?.length) return []; + if (!models?.length) return []; + sets = unique(sets); + models = unique(models); + return sets.filter(set => areModelsIntersectingModels([set.model, ...set.models].filter(Boolean), models)); +} + +/** + * Filter sets which live on the given model + * This is useful for resetting behaviour over a given model id + * @param {IntersectionSet[]} sets + * @param {string} id + * @returns {IntersectionSet[]} + */ +export function filterSetsByModelId(sets, id) { + return unique(sets.filter(set => set.modelId === id)); +} + +/** + * Filter sets which are descendants from the given model, but also only inside the relevant content object + * This is useful for finding visit and leave subsets on a content object + * @param {IntersectionSet[]} sets + * @param {string} id + * @returns {IntersectionSet[]} + */ +export function filterSetsByLocalModelId(sets, id) { + if (!id) return []; + const model = Data.findById(id); + if (!model) return []; + const localContentObject = model.isTypeGroup('contentobject') ? model : model.findAncestor('contentobject'); + return unique(sets.filter(({ model, modelId }) => { + if (!model) return false; + const isGivenModel = (modelId === id); + if (isGivenModel) return true; + // Do not include any other content object + const isContentObject = model.isTypeGroup('contentobject'); + if (isContentObject) return false; + // Only include models which are in the localContentObject + const modelContentObject = model.findAncestor('contentobject'); + const isInLocalContentObject = (modelContentObject.get('_id') === localContentObject.get('_id')); + if (!isInLocalContentObject) return false; + // The models must be a descendant of the given model + const isDescendantOfGivenModel = model.getAncestorModels().some(model => model.get('_id') === id); + return isDescendantOfGivenModel; + })); +} + +/** + * Finds a set by id + * @param {IntersectionSet[]} sets + * @param {string} id + * @returns {IntersectionSet} + */ +export function findSetById(sets, id) { + return sets.find(set => id === set.id); +} From b6cdba58c64c627c7e8f13b5f5ff96e1af1c7502 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Thu, 24 Jul 2025 22:24:26 +0100 Subject: [PATCH 02/35] Markdown caps --- INTERSECTION_QUERY.md | 6 +++--- LIFECYCLE.md | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/INTERSECTION_QUERY.md b/INTERSECTION_QUERY.md index 2b9ece3..6e68eb5 100644 --- a/INTERSECTION_QUERY.md +++ b/INTERSECTION_QUERY.md @@ -1,8 +1,8 @@ -# intersection query syntax +# Intersection query syntax ## Preamble -### query API +### Query API ```js const queryString = "#a-300 #performance" Adapt.scoring.getSubsetsByQuery(queryString) @@ -119,7 +119,7 @@ When multiplying the intersection parts `[modelType=article]` and `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 by only those models which intersect the multiplication 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 intersecting models. In this can it would probably be easier just to fetch the assessments directly using `assessment[modelType=article]`, rather than using an intersection. -## Primary use-case: +## 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 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: diff --git a/LIFECYCLE.md b/LIFECYCLE.md index c46d8c4..dff41c0 100644 --- a/LIFECYCLE.md +++ b/LIFECYCLE.md @@ -1,8 +1,8 @@ -# lifecycle +# Lifecycle Each Set which inherits from `LifecycleSet` in the scoring system 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 their position in the lifecycle phase execution. -## phases -There are 8 external lifecycle phases, 6 internal phases and 2 internal phase triggers for each set. The internal phases have callback functions executed by the external phases. Each cycle is grouped at 30 frames per second, on a browser animation frame. Before each cycle is performed, the relevant sets are grouped, sorted and processed in phase and position order. +## Phases +There are 8 external lifecycle phases, 6 set callback functions and 2 internal triggers for each set. The set callback functions are executed by the external phase controller. 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 | @@ -16,7 +16,7 @@ There are 8 external lifecycle phases, 6 internal phases and 2 internal phase tr | 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()` | -## Internal phase callback functions +## Set callback functions | Name | Description | | --- | --- | | onInit | After it is instantiated and registered, in the init phase | @@ -26,7 +26,7 @@ There are 8 external lifecycle phases, 6 internal phases and 2 internal phase tr | onVisit | When visiting their content object | | onUpdate | When any intersecting model changes across its `_isAvailable`, `_isInteractionComplete`, `_isActive` or `_isVisited` attributes or if any intersecting set calls `.update()` | -## Internal phase trigger functions +## Set trigger functions | Name | Description | | --- | --- | | update | Triggers the update phase for all sets intersecting the modelId | From f35cfaf4b3f9ee92ebc3f30abb696c97dd75e669 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Thu, 24 Jul 2025 22:44:49 +0100 Subject: [PATCH 03/35] Merdown edits --- INTERSECTION_QUERY.md | 53 +++++++++++++++++++++---------------------- LIFECYCLE.md | 16 ++++++------- 2 files changed, 34 insertions(+), 35 deletions(-) diff --git a/INTERSECTION_QUERY.md b/INTERSECTION_QUERY.md index 6e68eb5..8c7a502 100644 --- a/INTERSECTION_QUERY.md +++ b/INTERSECTION_QUERY.md @@ -12,7 +12,9 @@ Adapt.scoring.getSubsetByPath(pathString) ``` ### IntersectionSet -For all sets representing any other collection of models, they likely extend `IntersectionSet` on which it is possible to use these literal attributes for queries: +All sets representing any a 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` @@ -22,7 +24,7 @@ For all sets representing any other collection of models, they likely extend `In * `(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 query all set instances: +These intersection attributes are available for querying all set instances: * `[modelId=a-300]` will select all sets intersecting model `a-300` ### AdaptModelSet @@ -40,9 +42,6 @@ These literal attributes are available for query on `AdaptModelSet` instances: * `(isPassed)` is an alias for `(isComplete)` * `(isFailed)` is always `false` -These intersection attributes are available on `AdaptModelSet` set instances: -* `[modelId=a-300]` will select all sets intersecting model `a-300` - ### ScoringSet For all sets representing a scoring collection of models, such as questions, they likely extend `ScoringSet`. @@ -66,7 +65,7 @@ It has these literal attributes: ### 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 also 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`. +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`. @@ -74,17 +73,17 @@ The third part is an optional list of attributes which filter the selected sets tldr: `selectionQuery = typeOrId[multipliedByAttributes](filteredByAttributes)` -### Single column selection queries examples -They follow the `selectionQuery` syntax of `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 `type=assessment` -* `"#assessment-300"` the set with `id=assessment-300`, ids are unique -* `"#a-300"` the AdaptModelSet with `id=a-300`, ids are unique -* `"assessment[id=assessment-300]"` the set with `id=assessment-01`, ids are unique -* `"assessment[id=assessment-300,id=assessment-400]"` the sets with `id=assessment-300` and `id=assessment-400`, ids are unique +* `"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]"` sets of `type=assessment` intersecting model `a-05` +* `"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 @@ -92,37 +91,37 @@ These queries only select from available sets, they do not cause intersections o * `"adapt[modelComponent=mcq,modelComponent=gmcq](isComplete)"` sets of `AdaptModelSet` representing all completed mcqs and gmcqs ### Selection query multiplications -With the query interface parts `first` and `second` are multiplied. +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. -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]`. +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]`. -### Multiple column intersection queries examples -They follow the `intersectionQuery` syntax of `selectionQuery selectionQuery selectionQuery...` or `typeOrId[multipliedByAttributes](filteredByAttributes) typeOrId[multipliedByAttributes](filteredByAttributes) typeOrId[multipliedByAttributes](filteredByAttributes)`, where the space signifies a multiplication. +### 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 create 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` instance -* `"assessment[id=assessment-01,id=assessment-05] assessment[id=assessment-10,assessment-15]"` creates four intersection sets, of type `assessment`, intersecting `#assessment-01` with `#assessment-10`, `#assessment-01` with `#assessment-15`, `#assessment-05` with `#assessment-10` and `assessment-05` with `assessment-15` +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 all `modelType=articles` sets intersected with all `type=assessment` sets -* `"[modelType=article] assessment(isPopulated)"` returns all article assessments that have models after their intersections -* `"[modelType=article](isComplete) assessment(isPopulated)"` returns all article assessments that have models from completed articles +* `"[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 the intersection query interface all of the separate `selectionQuery` sections are multiplied together before a resultant set is produced from the final class. +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 by only those models which intersect the multiplication 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 intersecting models. In this can it would probably be easier just to fetch the assessments directly using `assessment[modelType=article]`, rather than using an intersection. +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 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: +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] diff --git a/LIFECYCLE.md b/LIFECYCLE.md index dff41c0..0055d28 100644 --- a/LIFECYCLE.md +++ b/LIFECYCLE.md @@ -1,8 +1,8 @@ # Lifecycle -Each Set which inherits from `LifecycleSet` in the scoring system 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 their position in the lifecycle phase execution. +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 executed by the external phase controller. 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. +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 | @@ -19,12 +19,12 @@ There are 8 external lifecycle phases, 6 set callback functions and 2 internal t ## Set callback functions | Name | Description | | --- | --- | -| onInit | After it is instantiated and registered, in the init phase | -| onRestore | After onInit, in the restore phase, return true/false to signify restore | -| onStart | After onRestore, if `wasRestored = false`. Called on the set after reset. | -| onLeave | When leaving their content object | -| onVisit | When visiting their content object | -| onUpdate | When any intersecting model changes across its `_isAvailable`, `_isInteractionComplete`, `_isActive` or `_isVisited` attributes or if any intersecting set calls `.update()` | +| 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`. 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 | From bb2e6fb3626759cc4e259339f4bd3fcd7b434736 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Thu, 24 Jul 2025 23:10:38 +0100 Subject: [PATCH 04/35] jsdoc updates --- INTERSECTION_QUERY.md | 6 ++++++ js/IntersectionSet.js | 11 ++++++----- js/Lifecycle.js | 5 ++--- js/LifecycleRenderer.js | 2 +- js/StateModels.js | 4 ++-- js/adapt-contrib-scoring.js | 6 +++--- js/utils/models.js | 6 +++++- 7 files changed, 25 insertions(+), 15 deletions(-) diff --git a/INTERSECTION_QUERY.md b/INTERSECTION_QUERY.md index 8c7a502..5fd4909 100644 --- a/INTERSECTION_QUERY.md +++ b/INTERSECTION_QUERY.md @@ -2,6 +2,12 @@ ## 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" diff --git a/js/IntersectionSet.js b/js/IntersectionSet.js index 8e0affd..5038747 100644 --- a/js/IntersectionSet.js +++ b/js/IntersectionSet.js @@ -83,17 +83,17 @@ export default class IntersectionSet extends Backbone.Controller { } /** - * Create a clone of this instance, intersection over the intersectionParent + * 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 to - // Filter its models accordingly - const Class = Object.getPrototypeOf(this).constructor; + // 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); } @@ -105,7 +105,7 @@ export default class IntersectionSet extends Backbone.Controller { * @fires Adapt#scoring:set:register */ register() { - // Only register root sets as subsets are dynamically created when required + // Only register root sets, intersection subsets are dynamically created when required if (this.isIntersectedSet) return; assignAutoId(this); Adapt.scoring.register(this); @@ -278,6 +278,7 @@ export default class IntersectionSet extends Backbone.Controller { /** * The config or origin model id, used for querying + * query example: `[modelId=modelId]` * @returns {string} */ get modelId() { diff --git a/js/Lifecycle.js b/js/Lifecycle.js index 9e6ee6a..49fd8c5 100644 --- a/js/Lifecycle.js +++ b/js/Lifecycle.js @@ -51,7 +51,6 @@ export default class Lifecycle extends Backbone.Controller { this.listenTo(model, 'reset', (...args) => this.onAdaptModelReset(model, ...args) ); - // this.listenTo(newSet, 'reset', this.onScoringSetReset); } /** @@ -90,7 +89,7 @@ export default class Lifecycle extends Backbone.Controller { } /** - * Listens to bubbled events Adapt events and begins the lifecycle of the registered sets + * 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 @@ -104,7 +103,7 @@ export default class Lifecycle extends Backbone.Controller { await this.init(); await this.onOfflineStorageReady(); // only run update async so that restore, start and update - // are batch processed + // are batch processed together this.restore(); this.start(); await this.update(getAllSets()); diff --git a/js/LifecycleRenderer.js b/js/LifecycleRenderer.js index add707b..a866ab3 100644 --- a/js/LifecycleRenderer.js +++ b/js/LifecycleRenderer.js @@ -138,7 +138,7 @@ export default class LifecycleRenderer extends Backbone.Controller { } /** - * Resolves when the next batch has been rendereds + * Resolves when the next batch has been rendered */ async onBatchRendered() { return new Promise(resolve => this.once('rendered', resolve)); diff --git a/js/StateModels.js b/js/StateModels.js index bbf3398..b8808a4 100644 --- a/js/StateModels.js +++ b/js/StateModels.js @@ -10,7 +10,7 @@ export default class StateModels extends State { /** * Returns the saved models - * Note: Uses trackingPosition which can be disrupted by change the order or substance of sub tracking id elements, + * 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[]} */ @@ -22,7 +22,7 @@ export default class StateModels extends State { /** * Saves the given models - * Note: Uses trackingPosition which can be disrupted by change the order or substance of sub tracking id elements, + * 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 */ diff --git a/js/adapt-contrib-scoring.js b/js/adapt-contrib-scoring.js index e823f4c..1488e86 100644 --- a/js/adapt-contrib-scoring.js +++ b/js/adapt-contrib-scoring.js @@ -107,8 +107,8 @@ export class Scoring extends Backbone.Controller { /** * Register a configured root scoring set. - * This is usually performed automatically upon ScoringSet instantiation. - * @param {ScoringSet} newSet + * This is usually performed automatically upon IntersectionSet instantiation. + * @param {IntersectionSet} newSet * @fires Adapt#{set.type}:register * @fires Adapt#scoring:register */ @@ -122,7 +122,7 @@ export class Scoring extends Backbone.Controller { /** * Deregister a configured root scoring set - * @param {ScoringSet} oldSet + * @param {IntersectionSet} oldSet * @fires Adapt#{set.type}:deregister * @fires Adapt#scoring:deregister */ diff --git a/js/utils/models.js b/js/utils/models.js index 6ab99e2..fb89cb0 100644 --- a/js/utils/models.js +++ b/js/utils/models.js @@ -75,7 +75,11 @@ observer.listenTo(Data, { }); /** - * Return true only if modelsA directly or indirectly intersect with the modelsB + * Return true only if any modelsA intersect with any modelsB + * 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 * @param {Backbone.Model[]} modelsA * @param {Backbone.Model[]} modelsB * @returns {boolean} From fc471eb8c361ba6f1a574c9f56912fb5bf9c9778 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Mon, 28 Jul 2025 12:14:12 +0100 Subject: [PATCH 05/35] jsdoc fixes --- js/Lifecycle.js | 2 +- js/LifecycleRenderer.js | 2 +- js/Objective.js | 2 +- js/State.js | 2 +- js/StateModels.js | 4 ++-- js/StateSetModelChildren.js | 8 ++++---- js/TotalSets.js | 8 ++++---- js/adapt-contrib-scoring.js | 4 ++++ 8 files changed, 18 insertions(+), 14 deletions(-) diff --git a/js/Lifecycle.js b/js/Lifecycle.js index 49fd8c5..1b52f62 100644 --- a/js/Lifecycle.js +++ b/js/Lifecycle.js @@ -19,7 +19,7 @@ import Backbone from 'backbone'; /** @typedef {import("core/js/location").default} Location */ /** - * Lifecycle controller + * Lifecycle controller. */ export default class Lifecycle extends Backbone.Controller { diff --git a/js/LifecycleRenderer.js b/js/LifecycleRenderer.js index a866ab3..ab6c8dd 100644 --- a/js/LifecycleRenderer.js +++ b/js/LifecycleRenderer.js @@ -7,7 +7,7 @@ import Logging from 'core/js/logging'; /** * Transforms a lifecycle definition into fps batched, phase queues, where the - * queues are executed in phase and definition order at each cycle + * queues are executed in phase and definition order at each cycle. */ export default class LifecycleRenderer extends Backbone.Controller { diff --git a/js/Objective.js b/js/Objective.js index 48b3113..3735caf 100644 --- a/js/Objective.js +++ b/js/Objective.js @@ -4,7 +4,7 @@ import COMPLETION_STATE from 'core/js/enums/completionStateEnum'; /** @typedef {import("../IntersectionSet").default} IntersectionSet */ /** - * Registers an objective with the offlineStorage API + * Registers an objective with the offlineStorage API. * see SCORM cmi.objectives */ export default class Objective { diff --git a/js/State.js b/js/State.js index 8b2d882..5feae17 100644 --- a/js/State.js +++ b/js/State.js @@ -3,7 +3,7 @@ import OfflineStorage from 'core/js/offlineStorage'; /** @typedef {import("../LifecycleSet").default} LifecycleSet */ /** - * Saves and restores state by { name: { id: 'data' } } in the offlineStorage API + * 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 */ diff --git a/js/StateModels.js b/js/StateModels.js index b8808a4..b4d351a 100644 --- a/js/StateModels.js +++ b/js/StateModels.js @@ -3,8 +3,8 @@ 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 + * An extension of State. + * Save and restore a collection of models. */ export default class StateModels extends State { diff --git a/js/StateSetModelChildren.js b/js/StateSetModelChildren.js index c6162d9..151d70c 100644 --- a/js/StateSetModelChildren.js +++ b/js/StateSetModelChildren.js @@ -2,10 +2,10 @@ import StateModels from './StateModels'; /** @typedef {import("core/js/models/adaptModel").default} AdaptModel */ /** - * An extension of StateModels - * Save and restore the collection of children from the set model - * Share a space under ch[modelId] so that multiple plugins can - * coordinate on the model children + * 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 { diff --git a/js/TotalSets.js b/js/TotalSets.js index 9046f71..a3ce9a9 100644 --- a/js/TotalSets.js +++ b/js/TotalSets.js @@ -17,12 +17,12 @@ import { } from './utils/math'; /** - * A set of sets, it can sum the scores of the root or intersecting sets + * A set of sets, it can sum the scores of the root or intersecting sets. * * It extends `ScoringSet` with the caveat that it sums properties from root or - * intersecting scoring sets and completion sets rather than root or intersecting models + * intersecting scoring sets and completion sets rather than root or intersecting models. * - * It represents the overall completion, score, correctness, pass and fail of the course + * It represents the overall completion, score, correctness, pass and fail of the course. */ export default class TotalSets extends ScoringSet { @@ -49,7 +49,7 @@ export default class TotalSets extends ScoringSet { /** * Returns all models from sets marked with `_isScoreIncluded` or `_isCompletionRequired`, filtered and intersected where appropriate - * @returns {[Backbone.Model]} + * @returns {Backbone.Model[]} */ get models() { const allScoringSets = Adapt.scoring.sets.filter(({ isScoreIncluded }) => isScoreIncluded); diff --git a/js/adapt-contrib-scoring.js b/js/adapt-contrib-scoring.js index 1488e86..74575c6 100644 --- a/js/adapt-contrib-scoring.js +++ b/js/adapt-contrib-scoring.js @@ -46,6 +46,10 @@ export { StateModels }; +/** + * Scoring API based upon making sets of questions with custom scoring, correctness + * and completion behaviour. + */ export class Scoring extends Backbone.Controller { initialize() { From a0213c4f99a3f41d5c4a60a3ce3b245b425c8147 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Mon, 28 Jul 2025 12:14:37 +0100 Subject: [PATCH 06/35] jsdoc fixes --- js/Passmark.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/Passmark.js b/js/Passmark.js index 4f2f77f..1ae1327 100644 --- a/js/Passmark.js +++ b/js/Passmark.js @@ -1,5 +1,5 @@ /** - * Stores the configuration for passing + * Stores the configuration for passing. */ export default class Passmark { From 7b59a5b531875bc496ef71026ef285ae710dad2a Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Mon, 28 Jul 2025 12:40:02 +0100 Subject: [PATCH 07/35] jsdoc updates --- js/AdaptModelSet.js | 18 +++++----- js/IntersectionSet.js | 70 ++++++++++++++++++------------------- js/Lifecycle.js | 44 +++++++++++------------ js/LifecycleRenderer.js | 24 ++++++------- js/LifecycleSet.js | 26 +++++++------- js/Objective.js | 6 ++-- js/Passmark.js | 10 +++--- js/ScoringSet.js | 34 +++++++++--------- js/State.js | 22 ++++++------ js/StateModels.js | 12 +++---- js/StateSetModelChildren.js | 8 ++--- js/TotalSets.js | 28 +++++++-------- js/adapt-contrib-scoring.js | 28 +++++++-------- js/compatibility.js | 8 ++--- js/utils/hash.js | 8 ++--- js/utils/intersection.js | 8 ++--- js/utils/math.js | 6 ++-- js/utils/models.js | 4 +-- js/utils/query.js | 4 +-- js/utils/scoring.js | 2 +- js/utils/sets.js | 20 +++++------ 21 files changed, 195 insertions(+), 195 deletions(-) diff --git a/js/AdaptModelSet.js b/js/AdaptModelSet.js index c7ce6da..4765aeb 100644 --- a/js/AdaptModelSet.js +++ b/js/AdaptModelSet.js @@ -18,7 +18,7 @@ export default class AdaptModelSet extends IntersectionSet { } /** - * Comparison function for type groups + * 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} @@ -28,7 +28,7 @@ export default class AdaptModelSet extends IntersectionSet { } /** - * Comparison property for model types + * Comparison property for model types. * query example: `[modelType=block]` * @returns {string} One of course|menu|page|article|block|component */ @@ -37,7 +37,7 @@ export default class AdaptModelSet extends IntersectionSet { } /** - * Comparison property for model component strings + * Comparison property for model component strings. * query example: `[modelComponent=mcq]` * @returns {string} One of mcq|gmcq|slider|graphic|... etc */ @@ -53,7 +53,7 @@ export default class AdaptModelSet extends IntersectionSet { } /** - * Returns whether the set is complete + * Returns whether the set is complete. * query example: `(isComplete)` or `(isComplete=false)` * @returns {boolean} */ @@ -62,7 +62,7 @@ export default class AdaptModelSet extends IntersectionSet { } /** - * Returns whether the set is incomplete + * Returns whether the set is incomplete. * query example: `(isIncomplete)` alias for `(isComplete=false)` * @returns {boolean} */ @@ -71,7 +71,7 @@ export default class AdaptModelSet extends IntersectionSet { } /** - * Returns whether the set is passed + * Returns whether the set is passed. * query example: `(isPassed)` alias for `(isComplete)` * @returns {boolean} */ @@ -80,7 +80,7 @@ export default class AdaptModelSet extends IntersectionSet { } /** - * Returns whether the set is isFailed + * Returns whether the set is isFailed. * query example: `(isFailed)` * @returns {boolean} */ @@ -89,7 +89,7 @@ export default class AdaptModelSet extends IntersectionSet { } /** - * Returns whether the set is optional + * Returns whether the set is optional. * query example: `(isOptional)` * @returns {boolean} */ @@ -98,7 +98,7 @@ export default class AdaptModelSet extends IntersectionSet { } /** - * Returns whether the set is available + * Returns whether the set is available. * query example: `(_isAvailable)` * @returns {boolean} */ diff --git a/js/IntersectionSet.js b/js/IntersectionSet.js index 5038747..a9181ad 100644 --- a/js/IntersectionSet.js +++ b/js/IntersectionSet.js @@ -83,8 +83,8 @@ export default class IntersectionSet extends Backbone.Controller { } /** - * Create a clone of this instance, intersected over the intersectionParent - * This will reduce this.models by intersecting with the intersectionParent.models + * 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} */ @@ -99,7 +99,7 @@ export default class IntersectionSet extends Backbone.Controller { } /** - * Register the set + * Register the set. * @private * @fires Adapt#scoring:[set.type]:register * @fires Adapt#scoring:set:register @@ -126,7 +126,7 @@ export default class IntersectionSet extends Backbone.Controller { } /** - * Returns the set type + * Returns the set type. * query example: `type` or `[type=type]` * @returns {string} */ @@ -139,7 +139,7 @@ export default class IntersectionSet extends Backbone.Controller { } /** - * Returns the set title + * Returns the set title. * @returns {string} */ get title() { @@ -151,7 +151,7 @@ export default class IntersectionSet extends Backbone.Controller { } /** - * Lifecycle processing order + * Lifecycle processing order. * @returns {number} */ get order() { @@ -159,7 +159,7 @@ export default class IntersectionSet extends Backbone.Controller { } /** - * Returns whether the set is enabled + * Returns whether the set is enabled. * query example: `(isEnabled)` or `(isEnabled=false)` * @returns {boolean} */ @@ -168,7 +168,7 @@ export default class IntersectionSet extends Backbone.Controller { } /** - * Returns whether the set is optional + * Returns whether the set is optional. * query example: `(isOptional)` or `(isOptional=false)` * @returns {boolean} */ @@ -177,7 +177,7 @@ export default class IntersectionSet extends Backbone.Controller { } /** - * Returns whether the set is available + * Returns whether the set is available. * query example: `(isAvailable)` or `(isAvailable=false)` * @returns {boolean} */ @@ -186,7 +186,7 @@ export default class IntersectionSet extends Backbone.Controller { } /** - * Returns whether the set's model is available in the model hierarchy + * Returns whether the set's model is available in the model hierarchy. * @return {boolean} */ get isModelAvailableInHierarchy() { @@ -194,7 +194,7 @@ export default class IntersectionSet extends Backbone.Controller { } /** - * Check to see if there are any child models + * Check to see if there are any child models. * query example: `(isPopulated)` or `(isPopulated=false)` * @returns {boolean} */ @@ -204,7 +204,7 @@ export default class IntersectionSet extends Backbone.Controller { } /** - * Check to see if there are any child models + * Check to see if there are any child models. * query example: `(isNotPopulated)` alias for `(isPopulated=false)` * @returns {boolean} */ @@ -213,7 +213,7 @@ export default class IntersectionSet extends Backbone.Controller { } /** - * Returns all intersected subsets + * Returns all intersected subsets. * @returns {IntersectionSet[]} */ get intersectedSubsets() { @@ -223,7 +223,7 @@ export default class IntersectionSet extends Backbone.Controller { } /** - * Returns all intersected subsets that contain models + * Returns all intersected subsets that contain models. * @returns {IntersectionSet[]} */ get populatedIntersectedSubsets() { @@ -231,7 +231,7 @@ export default class IntersectionSet extends Backbone.Controller { } /** - * Returns the parent set over which this set is intersected + * Returns the parent set over which this set is intersected. * @returns {IntersectionSet|null} */ get intersectionParent() { @@ -243,7 +243,7 @@ export default class IntersectionSet extends Backbone.Controller { } /** - * Is an intersected clone + * Is an intersected clone. * @returns {boolean} */ get isIntersectedSet() { @@ -251,7 +251,7 @@ export default class IntersectionSet extends Backbone.Controller { } /** - * If an intersected set, returns this set including its ancestors + * If an intersected set, returns this set including its ancestors. * @returns {IntersectionSet[]} */ get subsetPath() { @@ -265,7 +265,7 @@ export default class IntersectionSet extends Backbone.Controller { } /** - * The config or origin model at which the set is oriented + * The config or origin model at which the set is oriented. * @returns {Backbone.Model} */ get model() { @@ -277,7 +277,7 @@ export default class IntersectionSet extends Backbone.Controller { } /** - * The config or origin model id, used for querying + * The config or origin model id, used for querying. * query example: `[modelId=modelId]` * @returns {string} */ @@ -287,9 +287,9 @@ export default class IntersectionSet extends Backbone.Controller { /** * 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 + * 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() { @@ -302,7 +302,7 @@ export default class IntersectionSet extends Backbone.Controller { } /** - * Returns models filtered according to hierarchy intersections with the intersectionParent.models + * Returns models filtered according to hierarchy intersections with the intersectionParent.models. * @param {Backbone.Model[]} models * @returns {Backbone.Model[]} */ @@ -311,7 +311,7 @@ export default class IntersectionSet extends Backbone.Controller { } /** - * Returns all `_isAvailable` models, excluding detached models + * Returns all `_isAvailable` models, excluding detached models. * @returns {ComponentModel[]} */ get availableModels() { @@ -319,7 +319,7 @@ export default class IntersectionSet extends Backbone.Controller { } /** - * Returns all component models regardless of `_isAvailable` + * Returns all component models regardless of `_isAvailable`. * @returns {ComponentModel[]} */ get components() { @@ -332,7 +332,7 @@ export default class IntersectionSet extends Backbone.Controller { } /** - * Returns all `_isAvailable` component models, excluding detached models + * Returns all `_isAvailable` component models, excluding detached models. * @returns {ComponentModel[]} */ get availableComponents() { @@ -340,7 +340,7 @@ export default class IntersectionSet extends Backbone.Controller { } /** - * Returns all question models regardless of `_isAvailable` + * Returns all question models regardless of `_isAvailable`. * @returns {QuestionModel[]} */ get questions() { @@ -348,7 +348,7 @@ export default class IntersectionSet extends Backbone.Controller { } /** - * Returns all `_isAvailable` question models, excluding detached models + * Returns all `_isAvailable` question models, excluding detached models. * @returns {QuestionModel[]} */ get availableQuestions() { @@ -356,7 +356,7 @@ export default class IntersectionSet extends Backbone.Controller { } /** - * Returns all presentation component models regardless of `_isAvailable` + * Returns all presentation component models regardless of `_isAvailable`. * @returns {QuestionModel[]} */ get presentationComponents() { @@ -364,7 +364,7 @@ export default class IntersectionSet extends Backbone.Controller { } /** - * Returns all `_isAvailable` presentation component models, excluding detached models + * Returns all `_isAvailable` presentation component models, excluding detached models. * @returns {QuestionModel[]} */ get availablePresentationComponents() { @@ -372,7 +372,7 @@ export default class IntersectionSet extends Backbone.Controller { } /** - * Returns all trackable components - excludes trickle etc + * Returns all trackable components - excludes trickle etc. * @returns {ComponentModel[]} */ get trackableComponents() { @@ -380,7 +380,7 @@ export default class IntersectionSet extends Backbone.Controller { } /** - * Returns all `_isAvailable` trackable components - excludes trickle etc, excluding detached models + * Returns all `_isAvailable` trackable components - excludes trickle etc, excluding detached models. * @returns {ComponentModel[]} */ get availableTrackableComponents() { @@ -388,7 +388,7 @@ export default class IntersectionSet extends Backbone.Controller { } /** - * Returns intersected subsets by id + * Returns intersected subsets by id. * @param {string} setId * @returns {IntersectionSet[]} */ @@ -400,7 +400,7 @@ export default class IntersectionSet extends Backbone.Controller { } /** - * Returns intersected subsets by type + * Returns intersected subsets by type. * @param {string} setType * @returns {IntersectionSet[]} */ @@ -412,7 +412,7 @@ export default class IntersectionSet extends Backbone.Controller { } /** - * Returns intersected subsets by modelId + * Returns intersected subsets by modelId. * @param {string} modelId * @returns {IntersectionSet[]} */ diff --git a/js/Lifecycle.js b/js/Lifecycle.js index 1b52f62..05010cb 100644 --- a/js/Lifecycle.js +++ b/js/Lifecycle.js @@ -40,8 +40,8 @@ export default class Lifecycle extends Backbone.Controller { } /** - * Creates a new AdaptModelSet when a new AdaptModel is added to the data API - * Listens to the reset events on the new model + * 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 @@ -54,7 +54,7 @@ export default class Lifecycle extends Backbone.Controller { } /** - * Removes the AdaptModelSet and stops listening when the model is removed from the data API + * Removes the AdaptModelSet and stops listening when the model is removed from the data API. * @listens Data#remove * @param {Backbone.Model} model */ @@ -68,7 +68,7 @@ export default class Lifecycle extends Backbone.Controller { } /** - * Listens to a newly registered Set's reset and update events + * Listens to a newly registered Set's reset and update events. * @listens AdaptModelSet#reset * @listens AdaptModelSet#update * @param {InteractionSet} newSet @@ -81,7 +81,7 @@ export default class Lifecycle extends Backbone.Controller { } /** - * Stops listening to a deregistered set + * Stops listening to a deregistered set. * @param {IntersectionSet} oldSet */ onScoringSetDeregister(oldSet) { @@ -89,7 +89,7 @@ export default class Lifecycle extends Backbone.Controller { } /** - * Begins the lifecycle of the registered sets and listens to bubbled Adapt events + * 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 @@ -112,7 +112,7 @@ export default class Lifecycle extends Backbone.Controller { } /** - * Waits for offline storage to be ready + * Waits for offline storage to be ready. */ async onOfflineStorageReady() { if (offlineStorage.ready) return; @@ -120,7 +120,7 @@ export default class Lifecycle extends Backbone.Controller { } /** - * Adds sets to the update phase which intersect the changed model + * Adds sets to the update phase which intersect the changed model. * @param {Backbone.Model} model */ async onAdaptModelChange(model) { @@ -130,7 +130,7 @@ export default class Lifecycle extends Backbone.Controller { } /** - * Adds sets to the update phase which intersect the changed model + * Adds sets to the update phase which intersect the changed model. * @param {ModelEvent} event */ async onAdaptModelChangeBubble(event) { @@ -142,7 +142,7 @@ export default class Lifecycle extends Backbone.Controller { } /** - * Adds sets to the leave and visit phase which are local to the previous and current location + * Adds sets to the leave and visit phase which are local to the previous and current location. * @param {Location} location */ async onRouterLocation(location) { @@ -157,7 +157,7 @@ export default class Lifecycle extends Backbone.Controller { } /** - * Adds sets to the restart phase which are on the model id + * Adds sets to the restart phase which are on the model id. * @param {Backbone.Model} model */ onAdaptModelReset(model) { @@ -167,7 +167,7 @@ export default class Lifecycle extends Backbone.Controller { } /** - * Adds sets to the reset phase which are on the set.modelId + * Adds sets to the reset phase which are on the set.modelId. * @param {IntersectionSet} set */ onScoringSetReset(set) { @@ -178,7 +178,7 @@ export default class Lifecycle extends Backbone.Controller { } /** - * Adds sets to the update phase which intersect the set.modelId + * Adds sets to the update phase which intersect the set.modelId. * @param {IntersectionSet} set */ onScoringSetUpdate(set) { @@ -187,7 +187,7 @@ export default class Lifecycle extends Backbone.Controller { } /** - * Send all sets into the init phase + * Send all sets into the init phase. */ async init () { const sets = getAllSets(); @@ -195,7 +195,7 @@ export default class Lifecycle extends Backbone.Controller { } /** - * Send all sets into the restore phase + * Send all sets into the restore phase. * @fires Adapt#scoring:restored */ async restore () { @@ -205,7 +205,7 @@ export default class Lifecycle extends Backbone.Controller { } /** - * Send all sets into the start phase + * Send all sets into the start phase. * @fires Adapt#scoring:start */ async start () { @@ -215,7 +215,7 @@ export default class Lifecycle extends Backbone.Controller { } /** - * Send all sets into the reset phase + * Send all sets into the reset phase. */ async reset () { const sets = getAllSets(); @@ -223,7 +223,7 @@ export default class Lifecycle extends Backbone.Controller { } /** - * Send givens sets into the restart phase + * Send givens sets into the restart phase. */ async restart (sets) { sets = sets.filter(set => !set.intersectionParent); @@ -231,7 +231,7 @@ export default class Lifecycle extends Backbone.Controller { } /** - * Send givens sets into the leave phase + * Send givens sets into the leave phase. */ async leave (sets) { sets = sets.filter(set => !set.intersectionParent); @@ -239,7 +239,7 @@ export default class Lifecycle extends Backbone.Controller { } /** - * Send givens sets into the visit phase + * Send givens sets into the visit phase. */ async visit (sets) { sets = sets.filter(set => !set.intersectionParent); @@ -247,7 +247,7 @@ export default class Lifecycle extends Backbone.Controller { } /** - * Send givens sets into the update phase + * Send givens sets into the update phase. * @fires Adapt#scoring:update */ async update (sets) { @@ -257,7 +257,7 @@ export default class Lifecycle extends Backbone.Controller { } /** - * Returns the renderer + * Returns the renderer. * @returns {LifecycleRenderer} */ get renderer() { diff --git a/js/LifecycleRenderer.js b/js/LifecycleRenderer.js index ab6c8dd..7a184d9 100644 --- a/js/LifecycleRenderer.js +++ b/js/LifecycleRenderer.js @@ -37,7 +37,7 @@ export default class LifecycleRenderer extends Backbone.Controller { } /** - * Returns an object of dynamically created named queue addition functions + * Returns an object of dynamically created named queue addition functions. * @returns {Object} */ get render() { @@ -45,7 +45,7 @@ export default class LifecycleRenderer extends Backbone.Controller { } /** - * Creates the named queue addition functions + * Creates the named queue addition functions. */ createRenderFunctionsAndQueues() { for (const name of this.PHASE_NAMES) { @@ -57,7 +57,7 @@ export default class LifecycleRenderer extends Backbone.Controller { } /** - * Returns the sets in the phase queue + * Returns the sets in the phase queue. * @param {string} phaseName * @returns {IntersectionSet[]} */ @@ -66,8 +66,8 @@ export default class LifecycleRenderer extends Backbone.Controller { } /** - * Add the sets to the relevant phase queue if they're not in there already - * Starts the batch renderer + * 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 */ @@ -83,7 +83,7 @@ export default class LifecycleRenderer extends Backbone.Controller { }; /** - * Force adapt to wait + * Force adapt to wait. */ startAdaptWait() { if (this.hasStartedWaiting) return; @@ -92,7 +92,7 @@ export default class LifecycleRenderer extends Backbone.Controller { } /** - * Render the batch + * Render the batch. * @fires this#rendered */ async startBatchRender () { @@ -119,7 +119,7 @@ export default class LifecycleRenderer extends Backbone.Controller { } /** - * Tries to stop adapt from waiting after all of the queues are empty + * Tries to stop adapt from waiting after all of the queues are empty. * @returns {boolean} Signifying if waiting has ended */ tryEndAdaptWait() { @@ -130,7 +130,7 @@ export default class LifecycleRenderer extends Backbone.Controller { } /** - * Returns true/false if the queues are empty + * Returns true/false if the queues are empty. * @returns {Boolean} */ get areQueuesEmpty() { @@ -138,7 +138,7 @@ export default class LifecycleRenderer extends Backbone.Controller { } /** - * Resolves when the next batch has been rendered + * Resolves when the next batch has been rendered. */ async onBatchRendered() { return new Promise(resolve => this.once('rendered', resolve)); @@ -146,10 +146,10 @@ export default class LifecycleRenderer extends Backbone.Controller { /** * This function is not used, but it is here to demonstrate what immediate - * vs batched rendering looks like + * 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) + * otherwise identical behaviour (assuming the sets behave properly). */ async renderImmediatePhaseSets (phaseName, sets) { if (!sets?.length) return; diff --git a/js/LifecycleSet.js b/js/LifecycleSet.js index 613570f..ff5ad15 100644 --- a/js/LifecycleSet.js +++ b/js/LifecycleSet.js @@ -11,8 +11,8 @@ 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 + * @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() { @@ -21,7 +21,7 @@ export default class LifecycleSet extends IntersectionSet { } /** - * Signifies if onRestored returned true/false + * Signifies if onRestored returned true/false. * @returns {boolean} */ get wasRestored() { @@ -33,13 +33,13 @@ export default class LifecycleSet extends IntersectionSet { } /** - * Called after initialize on every model + * Called after initialize on every model. */ async onInit() {} /** - * Called after init on every model - * Restore data from previous sessions + * Called after init on every model. + * Restore data from previous sessions. * @fires Adapt#scoring:[set.type]:restored * @fires Adapt#scoring:set:restored * @returns {Boolean} Signify if the set was restored or not @@ -50,30 +50,30 @@ export default class LifecycleSet extends IntersectionSet { } /** - * Called on each set after onRestore, only if onRestore returns false - * Called on each set after a reset or intersecting set or model is reset + * Called on each set after onRestore, only if onRestore returns false. + * Called on each set after a reset or intersecting set or model is reset. */ async onStart() {} /** - * Called on each local set when its contentobject is visited + * Called on each local set when its contentobject is visited. */ async onVisit() {} /** - * Called on each local set when its contentobject is left + * Called on each local set when its contentobject is left. */ async onLeave() {} /** * Called on each set when any intersecting model has changes to * _isAvailable, _isActive, _isVisited or _isInteractionComplete or - * an intersecting set called `.update()` + * an intersecting set called `.update()`. */ async onUpdate() {} /** - * Add this set and all set intersecting the modelId to the update phase + * Add this set and all set intersecting the modelId to the update phase. * @fires Adapt#scoring:[set.type]:update * @fires Adapt#scoring:set:update */ @@ -85,7 +85,7 @@ export default class LifecycleSet extends IntersectionSet { } /** - * Resets the set and restarts all sets on the modelId + * Resets the set and restarts all sets on the modelId. * @fires Adapt#scoring:[set.type]:reset * @fires Adapt#scoring:set:reset */ diff --git a/js/Objective.js b/js/Objective.js index 3735caf..978a456 100644 --- a/js/Objective.js +++ b/js/Objective.js @@ -20,7 +20,7 @@ export default class Objective { } /** - * Define the objective for reporting purposes + * Define the objective for reporting purposes. */ init() { const completionStatus = COMPLETION_STATE.NOTATTEMPTED.asLowerCase; @@ -30,7 +30,7 @@ export default class Objective { } /** - * Reset the objective data + * Reset the objective data. */ reset() { if (this.set.isComplete) return; @@ -40,7 +40,7 @@ export default class Objective { } /** - * Complete the objective + * Complete the objective. * TODO: Always updates to latest data - is this desired? */ complete() { diff --git a/js/Passmark.js b/js/Passmark.js index 1ae1327..2c168ac 100644 --- a/js/Passmark.js +++ b/js/Passmark.js @@ -18,7 +18,7 @@ export default class Passmark { } /** - * Returns whether the passmark is required + * Returns whether the passmark is required. * @returns {boolean} */ get isEnabled() { @@ -26,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() { @@ -34,7 +34,7 @@ export default class Passmark { } /** - * Returns the score required for passing + * Returns the score required for passing. * @returns {number} */ get score() { @@ -42,7 +42,7 @@ export default class Passmark { } /** - * Returns the correctness required for passing + * Returns the correctness required for passing. * @returns {number} */ get correctness() { @@ -50,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 fda67bf..f36b0b1 100644 --- a/js/ScoringSet.js +++ b/js/ScoringSet.js @@ -62,7 +62,7 @@ export default class ScoringSet extends LifecycleSet { } /** - * Returns whether the set should be included in the total score + * Returns whether the set should be included in the total score. * @returns {boolean} */ get isScoreIncluded() { @@ -74,7 +74,7 @@ export default class ScoringSet extends LifecycleSet { } /** - * Returns whether the set needs to be completed + * Returns whether the set needs to be completed. * @returns {boolean} */ get isCompletionRequired() { @@ -86,7 +86,7 @@ export default class ScoringSet extends LifecycleSet { } /** - * Returns the minimum score + * Returns the minimum score. * @returns {number} */ get minScore() { @@ -94,7 +94,7 @@ export default class ScoringSet extends LifecycleSet { } /** - * Returns the maximum score + * Returns the maximum score. * @returns {number} */ get maxScore() { @@ -102,7 +102,7 @@ export default class ScoringSet extends LifecycleSet { } /** - * Returns the score + * Returns the score. * @returns {number} */ get score() { @@ -110,7 +110,7 @@ export default class ScoringSet extends LifecycleSet { } /** - * 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. * @returns {number} */ get scaledScore() { @@ -118,7 +118,7 @@ export default class ScoringSet extends LifecycleSet { } /** - * Returns a score as a string to include "+" operator for positive scores + * Returns a score as a string to include "+" operator for positive scores. * @returns {string} */ get scoreAsString() { @@ -127,7 +127,7 @@ export default class ScoringSet extends LifecycleSet { } /** - * Returns the number of correctly answered available questions + * Returns the number of correctly answered available questions. * @note Assumes the same number of questions are used in each attempt * @returns {number} */ @@ -136,7 +136,7 @@ export default class ScoringSet extends LifecycleSet { } /** - * Returns the number of available questions + * Returns the number of available questions. * @returns {number} */ get maxCorrectness() { @@ -144,7 +144,7 @@ export default class ScoringSet extends LifecycleSet { } /** - * Returns the percentage of correctly answered questions + * Returns the percentage of correctly answered questions. * @returns {number} */ get scaledCorrectness() { @@ -152,7 +152,7 @@ export default class ScoringSet extends LifecycleSet { } /** - * Returns whether the set is completed + * Returns whether the set is completed. * query example: `(isComplete)` or `(isComplete=false)` * @returns {boolean} */ @@ -161,7 +161,7 @@ export default class ScoringSet extends LifecycleSet { } /** - * Returns whether the set is incomplete + * Returns whether the set is incomplete. * query example: `(isIncomplete)` alias for `(isComplete=false)` * @returns {boolean} */ @@ -170,7 +170,7 @@ export default class ScoringSet extends LifecycleSet { } /** - * Returns whether the configured passmark has been achieved + * Returns whether the configured passmark has been achieved. * query example: `(isPassed)` * @returns {boolean} */ @@ -179,7 +179,7 @@ export default class ScoringSet extends LifecycleSet { } /** - * Returns whether the configured passmark has been failed + * Returns whether the configured passmark has been failed. * query example: `(isFailed)` alias for `(isComplete,isPassed=false)` * @returns {boolean} */ @@ -188,7 +188,7 @@ export default class ScoringSet extends LifecycleSet { } /** - * The objective object for the set. See SCORM cmi.objectives + * The objective object for the set. See SCORM cmi.objectives. * @returns {Objective} */ get objective() { @@ -224,7 +224,7 @@ export default class ScoringSet extends LifecycleSet { } /** - * Is executed on lifecycle update phase when isComplete=true + * Is executed on lifecycle update phase when isComplete=true. * @fires Adapt#scoring:[set.type]:complete * @fires Adapt#scoring:set:complete */ @@ -236,7 +236,7 @@ export default class ScoringSet extends LifecycleSet { } /** - * Is executed on lifecycle update phase when isPassed=true + * Is executed on lifecycle update phase when isPassed=true. * @fires Adapt#scoring:[set.type]:passed * @fires Adapt#scoring:set:passed */ diff --git a/js/State.js b/js/State.js index 5feae17..e4fcc3e 100644 --- a/js/State.js +++ b/js/State.js @@ -4,8 +4,8 @@ import OfflineStorage from 'core/js/offlineStorage'; /** * 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 + * @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 { @@ -22,7 +22,7 @@ export default class State { } /** - * The namespace in offlineStorage under which to save the id property + * The namespace in offlineStorage under which to save the id property. * @returns {string} */ get name() { @@ -30,16 +30,16 @@ export default class State { } /** - * The id against which to store the data in offlineStorage + * 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 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() { @@ -49,9 +49,9 @@ export default class State { } /** - * 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 + * 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) { @@ -64,7 +64,7 @@ export default class State { } /** - * Clears the saved data in the namespace + * Clears the saved data in the namespace. */ clear() { const store = OfflineStorage.get(this.name) ?? {}; diff --git a/js/StateModels.js b/js/StateModels.js index b4d351a..75666b3 100644 --- a/js/StateModels.js +++ b/js/StateModels.js @@ -9,9 +9,9 @@ import data from 'core/js/data'; 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 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() { @@ -21,9 +21,9 @@ export default class StateModels extends State { } /** - * 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 + * 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) { diff --git a/js/StateSetModelChildren.js b/js/StateSetModelChildren.js index 151d70c..2f31bf3 100644 --- a/js/StateSetModelChildren.js +++ b/js/StateSetModelChildren.js @@ -10,7 +10,7 @@ import StateModels from './StateModels'; export default class StateSetModelChildren extends StateModels { /** - * Define a shared children namespace of 'ch' in offlineStorage + * Define a shared children namespace of 'ch' in offlineStorage. * @returns {string} */ get name() { @@ -18,7 +18,7 @@ export default class StateSetModelChildren extends StateModels { } /** - * Use the set.modelId for each model children + * Use the set.modelId for each model children. * @returns {string} */ get id() { @@ -26,7 +26,7 @@ export default class StateSetModelChildren extends StateModels { } /** - * Restores the set.model.getChildren() + * Restores the set.model.getChildren(). * @returns {boolean} If restore had models */ restore() { @@ -37,7 +37,7 @@ export default class StateSetModelChildren extends StateModels { } /** - * Saves the set.model.getChildren() + * Saves the set.model.getChildren(). * @returns {boolean} If offlineStorage has been updated */ save() { diff --git a/js/TotalSets.js b/js/TotalSets.js index a3ce9a9..0d67226 100644 --- a/js/TotalSets.js +++ b/js/TotalSets.js @@ -48,7 +48,7 @@ export default class TotalSets extends ScoringSet { } /** - * Returns all models from sets marked with `_isScoreIncluded` or `_isCompletionRequired`, filtered and intersected where appropriate + * Returns all models from sets marked with `_isScoreIncluded` or `_isCompletionRequired`, filtered and intersected where appropriate. * @returns {Backbone.Model[]} */ get models() { @@ -62,7 +62,7 @@ export default class TotalSets extends ScoringSet { } /** - * Returns all sets marked with `_isScoreIncluded` which intersect the models + * Returns all sets marked with `_isScoreIncluded` which intersect the models. * @returns {ScoringSet[]} */ get scoringSets() { @@ -75,7 +75,7 @@ export default class TotalSets extends ScoringSet { } /** - * Returns all sets marked with `_isCompletionRequired` which intersect the models + * Returns all sets marked with `_isCompletionRequired` which intersect the models. * @returns {ScoringSet[]} */ get completionSets() { @@ -88,7 +88,7 @@ export default class TotalSets extends ScoringSet { } /** - * Returns the minimum score of all `_isScoreIncluded` subsets + * Returns the minimum score of all `_isScoreIncluded` subsets. * @returns {number} */ get minScore() { @@ -96,7 +96,7 @@ export default class TotalSets extends ScoringSet { } /** - * Returns the maximum score of all `_isScoreIncluded` subsets + * Returns the maximum score of all `_isScoreIncluded` subsets. * @returns {number} */ get maxScore() { @@ -104,7 +104,7 @@ export default class TotalSets extends ScoringSet { } /** - * Returns the score of all `_isScoreIncluded` subsets + * Returns the score of all `_isScoreIncluded` subsets. * @returns {number} */ get score() { @@ -112,7 +112,7 @@ export default class TotalSets extends ScoringSet { } /** - * 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. * @returns {number} */ get scaledScore() { @@ -120,7 +120,7 @@ export default class TotalSets extends ScoringSet { } /** - * Returns the number of correctly answered available questions + * Returns the number of correctly answered available questions. * @returns {number} */ get correctness() { @@ -128,7 +128,7 @@ export default class TotalSets extends ScoringSet { } /** - * Returns the number of available questions + * Returns the number of available questions. * @returns {number} */ get maxCorrectness() { @@ -136,7 +136,7 @@ export default class TotalSets extends ScoringSet { } /** - * Returns the percentage of correctly answered questions + * Returns the percentage of correctly answered questions. * @returns {number} */ get scaledCorrectness() { @@ -144,7 +144,7 @@ export default class TotalSets extends ScoringSet { } /** - * Returns the passmark model + * Returns the passmark model. * @returns {Passmark} */ get passmark() { @@ -152,7 +152,7 @@ export default class TotalSets extends ScoringSet { } /** - * Returns whether all root sets marked with `_isCompletionRequired` are completed + * Returns whether all root sets marked with `_isCompletionRequired` are completed. * @returns {boolean} */ get isComplete() { @@ -180,7 +180,7 @@ export default class TotalSets extends ScoringSet { } /** - * Returns whether any root sets marked with `_isScoreIncluded` are failed and cannot be reset + * Returns whether any root sets marked with `_isScoreIncluded` are failed and cannot be reset. * @todo Add `canReset` to `ScoringSet`? * @returns {boolean} */ @@ -189,7 +189,7 @@ export default class TotalSets extends ScoringSet { } /** - * Returns whether any root sets marked with `_isScoreIncluded` can be reset + * Returns whether any root sets marked with `_isScoreIncluded` can be reset. * @todo Add `canReset` to `ScoringSet`? * @returns {boolean} */ diff --git a/js/adapt-contrib-scoring.js b/js/adapt-contrib-scoring.js index 74575c6..325f54b 100644 --- a/js/adapt-contrib-scoring.js +++ b/js/adapt-contrib-scoring.js @@ -67,7 +67,7 @@ export class Scoring extends Backbone.Controller { } /** - * Clear the sets + * Clear the sets. * @listens Data#loading */ onDataLoading() { @@ -76,7 +76,7 @@ export class Scoring extends Backbone.Controller { /** * Configure the main scoring passmark with TotalSets and setup backward compatibility - * for legacy adapt-contrib-assessment related components and extensions + * for legacy adapt-contrib-assessment related components and extensions. * @listens Adapt#app:dataReady */ onAppDataReady() { @@ -86,7 +86,7 @@ export class Scoring extends Backbone.Controller { } /** - * Returns a boolean if adapt-contrib-assessment related compatibility is enabled + * Returns a boolean if adapt-contrib-assessment related compatibility is enabled. * @return {boolean} */ get isBackwardCompatible() { @@ -94,7 +94,7 @@ export class Scoring extends Backbone.Controller { } /** - * Returns registered root sets + * Returns registered root sets. * @returns {IntersectionSet[]} */ get sets() { @@ -102,7 +102,7 @@ export class Scoring extends Backbone.Controller { } /** - * Removes all registered root sets + * Removes all registered root sets. */ clear() { this._sets?.forEach(set => this.deregister(set)); @@ -125,7 +125,7 @@ export class Scoring extends Backbone.Controller { } /** - * Deregister a configured root scoring set + * Deregister a configured root scoring set. * @param {IntersectionSet} oldSet * @fires Adapt#{set.type}:deregister * @fires Adapt#scoring:deregister @@ -138,7 +138,7 @@ export class Scoring extends Backbone.Controller { } /** - * Force all registered sets to recalculate their states + * Force all registered sets to recalculate their states. * @fires Adapt#scoring:update */ async update() { @@ -149,7 +149,7 @@ export class Scoring extends Backbone.Controller { } /** - * Reset all subsets which can be reset + * Reset all subsets which can be reset. * @fires Adapt#scoring:reset */ async reset() { @@ -160,7 +160,7 @@ export class Scoring extends Backbone.Controller { } /** - * Returns a registered root set by id + * Returns a registered root set by id. * @param {string} id * @returns {IntersectionSet} */ @@ -169,7 +169,7 @@ export class Scoring extends Backbone.Controller { } /** - * Returns registered root sets of type + * Returns registered root sets of type. * @param {string} type * @returns {IntersectionSet[]} */ @@ -178,7 +178,7 @@ export class Scoring extends Backbone.Controller { } /** - * Returns registered root sets intersecting the given model id + * Returns registered root sets intersecting the given model id. * @param {string} id * @returns {IntersectionSet[]} */ @@ -187,7 +187,7 @@ export class Scoring extends Backbone.Controller { } /** - * Returns a root set or intersection set by id path + * Returns a root set or intersection set by id path. * example: id.id.id * @param {string|[string]} path * @returns {IntersectionSet} @@ -198,7 +198,7 @@ export class Scoring extends Backbone.Controller { } /** - * Returns sets or intersection sets by query + * Returns sets or intersection sets by query. * @param {string} query * @returns {IntersectionSet[]} */ @@ -207,7 +207,7 @@ export class Scoring extends Backbone.Controller { } /** - * Returns a set or intersection set by query + * Returns a set or intersection set by query. * @param {string} query * @returns {IntersectionSet} */ diff --git a/js/compatibility.js b/js/compatibility.js index eff363c..e19ee73 100644 --- a/js/compatibility.js +++ b/js/compatibility.js @@ -16,7 +16,7 @@ export function isBackwardCompatible(scoring) { } /** - * Polyfill for Adapt.assessment + * Polyfill for Adapt.assessment. * @param {Scoring} scoring */ export function setupBackwardCompatibility(scoring) { @@ -36,7 +36,7 @@ export function setupBackwardCompatibility(scoring) { } /** - * Polyfill for assessmentState + * Polyfill for assessmentState. * @param {Scoring} scoring */ export function getCompatibilityState(scoring) { @@ -61,7 +61,7 @@ export function getCompatibilityState(scoring) { } /** - * Polyfill for triggering assessment:restored event + * Polyfill for triggering assessment:restored event. * @param {Scoring} scoring * @fires Adapt#assessment:restored */ @@ -70,7 +70,7 @@ function onScoringRestored(scoring) { } /** - * Polyfill for triggering assessment:complete event + * Polyfill for triggering assessment:complete event. * @param {Scoring} scoring * @fires Adapt#assessment:complete */ diff --git a/js/utils/hash.js b/js/utils/hash.js index 6c0c3b0..2e4936e 100644 --- a/js/utils/hash.js +++ b/js/utils/hash.js @@ -1,5 +1,5 @@ /** - * Turn any JSON into a semi-unique hash string, representing a state + * Turn any JSON into a semi-unique hash string, representing a state. * @param {any} dataToHash * @returns {string} */ @@ -12,9 +12,9 @@ export function hash(dataToHash) { } /** - * Useful for determining if a state has changed from a series of JSON variables - * Store the latest hash of dataToHash at subject[_subjectKey] = hash - * On subsequent call, return true/false indicating that the data has changed + * Useful for determining if a state has changed from a series of JSON variables. + * Store the latest hash of dataToHash at subject[_subjectKey] = hash. + * On subsequent call, return true/false indicating that the data has changed. * @param {Object} subject The object on which to store the previous hash * @param {any} dataToHash Any JSON to hash as a state * @param {string} [subjectKey="_hash"] diff --git a/js/utils/intersection.js b/js/utils/intersection.js index 3269a1a..76cb1f3 100644 --- a/js/utils/intersection.js +++ b/js/utils/intersection.js @@ -6,7 +6,7 @@ import { /** @typedef {import("../IntersectionSet").default} IntersectionSet */ /** - * Return an intersection of all of the sets along a path + * Return an intersection of all of the sets along a path. * @param {[string]|string} path * @returns {IntersectionSet} */ @@ -22,9 +22,9 @@ export function getPathSetsIntersected(path) { } /** - * Returns a cloned intersection subset from the given array of sets - * Reduces from left to right, returning the class of the furthest right most set - * It makes a pipe of parent-child relations which reduce the models in the next subset + * Returns a cloned intersection subset from the given array of sets. + * Reduces from left to right, returning the class of the furthest right most set. + * It makes a pipe of parent-child relations which reduce the models in the next subset. * @param {IntersectionSet[]} sets Chain of sets to intersect * @param {Object} [options] * @param {Object[][]} [options.filters=null] Part of query interface. An array of arrays of where objects to match at each intersection step. isComplete, isPopulated, etc diff --git a/js/utils/math.js b/js/utils/math.js index cefc0df..80511c3 100644 --- a/js/utils/math.js +++ b/js/utils/math.js @@ -1,5 +1,5 @@ /** - * Returns only unique items + * Returns only unique items. * @param {any[]} items * @returns {any[]} */ @@ -8,7 +8,7 @@ export function unique(items) { } /** - * Performs addition on the property `by` of the items, either by string or by function return value + * Performs addition on the property `by` of the items, either by string or by function return value. * @param {Object[]} items A list of items from which to sum a property or function result * @param {string|Function} by The name of the property to sum or a function returning the value to sum * @returns {number} @@ -23,7 +23,7 @@ export function sum(items, by) { } /** - * Returns an array of all combinations of the column row values + * Returns an array of all combinations of the column row values. * [ * [1,2], [4,5], [6,7,8] * ] = [ diff --git a/js/utils/models.js b/js/utils/models.js index fb89cb0..b3235f1 100644 --- a/js/utils/models.js +++ b/js/utils/models.js @@ -141,8 +141,8 @@ export function filterModelsByIntersectingModels(modelsA, modelsB) { } /** - * Alternative version of getAllDescendantModels, which also returns detached children - * Uses Data.findById(parentId) rather than model.getChildren() + * Alternative version of getAllDescendantModels, which also returns detached children. + * Uses Data.findById(parentId) rather than model.getChildren(). * @param {Backbone.Model} model * @param {Object} [options] * @param {boolean} [options.isParentFirst=true] diff --git a/js/utils/query.js b/js/utils/query.js index cb0251b..cf7ef14 100644 --- a/js/utils/query.js +++ b/js/utils/query.js @@ -15,7 +15,7 @@ const queryColumnRegEx = /([^ []*(?:[[(]{1}[^\])]+[\])]{1})*)/g; const queryColumnAttributeRegEx = /[[(]{1}[^\])]+[\])]{1}/g; /** - * Takes a subset intersection query string and returns the resultant intersected subsets + * Takes a subset intersection query string and returns the resultant intersected subsets. * See (intersection query document)[../../INTERSECTION_QUERY.md] * @param {string} query * @returns {IntersectionSet[]} @@ -48,7 +48,7 @@ export function getSubsetsByQuery(query) { } /** - * Takes a subset intersection query string and transforms it into an array of select and filter objects + * Takes a subset intersection query string and transforms it into an array of select and filter objects. * See (intersection query document)[../../INTERSECTION_QUERY.md] * @param {string} query * @returns {Object[][]} diff --git a/js/utils/scoring.js b/js/utils/scoring.js index 88e4cb4..7ecfef4 100644 --- a/js/utils/scoring.js +++ b/js/utils/scoring.js @@ -1,5 +1,5 @@ /** - * Returns the percentage position (between -100-100) of score between minScore and maxScore + * Returns the percentage position (between -100-100) of score between minScore and maxScore. * @param {number} score * @param {number} minScore * @param {number} maxScore diff --git a/js/utils/sets.js b/js/utils/sets.js index de19e11..d0b3f2e 100644 --- a/js/utils/sets.js +++ b/js/utils/sets.js @@ -10,7 +10,7 @@ import { /** @typedef {import("../IntersectionSet").default} IntersectionSet */ /** - * Returns all sets or all sets without the specified excludeParent + * Returns all sets or all sets without the specified excludeParent. * @param {Object} [options] * @param {IntersectionSet} [options.excludeParent=null] * @returns {IntersectionSet[]} @@ -21,7 +21,7 @@ export function getAllSets({ excludeParent = null } = {}) { } /** - * Filters sets by type + * Filters sets by type. * @param {IntersectionSet[]} sets * @param {string} type * @returns {IntersectionSet[]} @@ -31,8 +31,8 @@ export function filterSetsByType(sets, type) { } /** - * Filter sets which models intersect the given model id - * This is useful for update behaviour on a model change + * Filter sets which models intersect the given model id. + * This is useful for update behaviour on a model change. * @param {IntersectionSet[]} sets * @param {string} id * @returns {IntersectionSet[]} @@ -46,7 +46,7 @@ export function filterSetsByIntersectingModelId(sets, id) { } /** - * Filter sets which models intersect the given models + * Filter sets which models intersect the given models. * @param {IntersectionSet[]} sets * @param {string} id * @returns {IntersectionSet[]} @@ -60,8 +60,8 @@ export function filterSetsByIntersectingModels(sets, models) { } /** - * Filter sets which live on the given model - * This is useful for resetting behaviour over a given model id + * Filter sets which live on the given model. + * This is useful for resetting behaviour over a given model id. * @param {IntersectionSet[]} sets * @param {string} id * @returns {IntersectionSet[]} @@ -71,8 +71,8 @@ export function filterSetsByModelId(sets, id) { } /** - * Filter sets which are descendants from the given model, but also only inside the relevant content object - * This is useful for finding visit and leave subsets on a content object + * Filter sets which are descendants from the given model, but also only inside the relevant content object. + * This is useful for finding visit and leave subsets on a content object. * @param {IntersectionSet[]} sets * @param {string} id * @returns {IntersectionSet[]} @@ -100,7 +100,7 @@ export function filterSetsByLocalModelId(sets, id) { } /** - * Finds a set by id + * Finds a set by id. * @param {IntersectionSet[]} sets * @param {string} id * @returns {IntersectionSet} From 9c6bb794800f27446cd85e9b2477c0e2833639c6 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Wed, 30 Jul 2025 06:05:09 +0100 Subject: [PATCH 08/35] Formalise canReset --- js/LifecycleSet.js | 8 ++++++++ js/TotalSets.js | 1 - 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/js/LifecycleSet.js b/js/LifecycleSet.js index ff5ad15..5efac01 100644 --- a/js/LifecycleSet.js +++ b/js/LifecycleSet.js @@ -84,6 +84,14 @@ export default class LifecycleSet extends IntersectionSet { this.trigger('update', this); } + /** + * Returns whether any root sets marked with `_isScoreIncluded` 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 diff --git a/js/TotalSets.js b/js/TotalSets.js index 0d67226..b8e9dec 100644 --- a/js/TotalSets.js +++ b/js/TotalSets.js @@ -190,7 +190,6 @@ export default class TotalSets extends ScoringSet { /** * Returns whether any root sets marked with `_isScoreIncluded` can be reset. - * @todo Add `canReset` to `ScoringSet`? * @returns {boolean} */ get canReset() { From 4a06e65b8ac26c936293ae8cdd6369298b09c180 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Wed, 30 Jul 2025 06:06:24 +0100 Subject: [PATCH 09/35] jsdoc typo --- js/LifecycleSet.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/LifecycleSet.js b/js/LifecycleSet.js index 5efac01..289b24b 100644 --- a/js/LifecycleSet.js +++ b/js/LifecycleSet.js @@ -85,7 +85,7 @@ export default class LifecycleSet extends IntersectionSet { } /** - * Returns whether any root sets marked with `_isScoreIncluded` can be reset. + * Returns a boolean if this set can be reset. * @returns {boolean} */ get canReset() { From 59be83fc57044abaf95cb4423b530b58466d4563 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Tue, 12 Aug 2025 08:37:18 +0100 Subject: [PATCH 10/35] Extend adaptmodelset to include more questionmodel interfacing --- js/AdaptModelSet.js | 29 ++++++++--------------------- js/ScoringSet.js | 44 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 23 deletions(-) diff --git a/js/AdaptModelSet.js b/js/AdaptModelSet.js index 4765aeb..6bc1f59 100644 --- a/js/AdaptModelSet.js +++ b/js/AdaptModelSet.js @@ -1,11 +1,11 @@ -import IntersectionSet from './IntersectionSet'; +import ScoringSet from './ScoringSet'; 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 IntersectionSet { +export default class AdaptModelSet extends ScoringSet { initialize(options = {}) { super.initialize({ @@ -52,24 +52,6 @@ export default class AdaptModelSet extends IntersectionSet { return 100 - this.model.getAncestorModels(true).length; } - /** - * Returns whether the set is complete. - * query example: `(isComplete)` or `(isComplete=false)` - * @returns {boolean} - */ - get isComplete() { - return this.model.get('_isComplete'); - } - - /** - * Returns whether the set is incomplete. - * query example: `(isIncomplete)` alias for `(isComplete=false)` - * @returns {boolean} - */ - get isIncomplete() { - return (this.isComplete === false); - } - /** * Returns whether the set is passed. * query example: `(isPassed)` alias for `(isComplete)` @@ -99,11 +81,16 @@ export default class AdaptModelSet extends IntersectionSet { /** * Returns whether the set is available. - * query example: `(_isAvailable)` + * query example: `(isAvailable)` * @returns {boolean} */ get isAvailable() { return this.model.get('_isAvailable'); } + get feedback() { + if (!this.isSubmitted) return; + return this.model.getFeedback(); + } + } diff --git a/js/ScoringSet.js b/js/ScoringSet.js index f36b0b1..bfc37b5 100644 --- a/js/ScoringSet.js +++ b/js/ScoringSet.js @@ -151,6 +151,45 @@ export default class ScoringSet extends LifecycleSet { return getScaledScoreFromMinMax(this.correctness, 0, this.maxCorrectness); } + /** + * Returns whether the set is correct. + * query example: `(isCorrect)` or `(isCorrect=false)` + * @returns {boolean|null} + */ + get isCorrect() { + if (!this.isSubmitted) return null; + return (this.correctness === this.maxCorrectness); + } + + /** + * Returns whether the set is partly correct. + * query example: `(isPartlyCorrect)` or `(isPartlyCorrect=false)` + * @returns {boolean|null} + */ + get isPartlyCorrect() { + if (!this.isSubmitted) return null; + return (this.correctness < this.maxCorrectness); + } + + /** + * Returns whether the set is incorrect. + * query example: `(isIncorrect)` or `(isIncorrect=false)` + * @returns {boolean|null} + */ + get isIncorrect() { + if (!this.isSubmitted) return null; + return (!this.correctness && this.maxCorrectness); + } + + /** + * Returns whether the set is submitted. + * query example: `(isSubmitted)` or `(isSubmitted=false)` + * @returns {boolean} + */ + get isSubmitted() { + return this.model.get('_isSubmitted'); + } + /** * Returns whether the set is completed. * query example: `(isComplete)` or `(isComplete=false)` @@ -181,10 +220,11 @@ export default class ScoringSet extends LifecycleSet { /** * Returns whether the configured passmark has been failed. * query example: `(isFailed)` alias for `(isComplete,isPassed=false)` - * @returns {boolean} + * @returns {boolean|null} */ get isFailed() { - return (this.isComplete && this.isPassed === false); + if (!this.isSubmitted) return null; + return (this.isPassed === false); } /** From 6b89283f2b9b69e453fbd572bb0700ba9fa647d1 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Tue, 19 Aug 2025 17:32:47 +0100 Subject: [PATCH 11/35] Fixed findDescendantModels and getAllDescendantModels for detached models --- js/utils/models.js | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/js/utils/models.js b/js/utils/models.js index b3235f1..235cc54 100644 --- a/js/utils/models.js +++ b/js/utils/models.js @@ -57,9 +57,12 @@ export function getModelChildren(model, { allowDetached = true } = {}) { * @returns {Backbone.Model[]} */ export function findDescendantModels(model, typeGroup, { allowDetached = true } = {}) { - const models = getAllDescendantModels(model) + const models = getAllDescendantModels(model, { + filter: allowDetached + ? null + : isModelAttached + }) .filter(model => model.isTypeGroup(typeGroup)); - if (!allowDetached) return models.filter(isModelAttached); return models; } @@ -146,12 +149,13 @@ export function filterModelsByIntersectingModels(modelsA, modelsB) { * @param {Backbone.Model} model * @param {Object} [options] * @param {boolean} [options.isParentFirst=true] + * @param {Function} [options.filter=null] * @returns {Backbone.Model[]} */ -export function getAllDescendantModels(model, { isParentFirst = true } = {}) { +export function getAllDescendantModels(model, { isParentFirst = true, filter = null } = {}) { const descendants = []; const stack = [ - [model] + [model].filter(model => filter?.(model) ?? true) ]; const subject = []; while (stack.length) { @@ -176,7 +180,9 @@ export function getAllDescendantModels(model, { isParentFirst = true } = {}) { descendants.push(nextModel); } const id = nextModel.get('_id'); - const children = (modelsByParentId[id] ?? []).slice(0); + const children = (modelsByParentId[id] ?? []) + .slice(0) + .filter(child => filter?.(child) ?? true); const hasChildren = Boolean(children.length); if (hasChildren) { stack.push(children); From a1fb354be11279229bd87e786a6a4cd9a61251b4 Mon Sep 17 00:00:00 2001 From: Joseph Replin Date: Fri, 2 Jan 2026 10:36:06 -0600 Subject: [PATCH 12/35] Resolving conflicts & Implementing logging improvements. Logging improvements migrated from https://github.com/adaptlearning/adapt-contrib-scoring/pull/26 --- js/ScoringSet.js | 140 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 134 insertions(+), 6 deletions(-) diff --git a/js/ScoringSet.js b/js/ScoringSet.js index bfc37b5..60ce0d7 100644 --- a/js/ScoringSet.js +++ b/js/ScoringSet.js @@ -7,6 +7,9 @@ import { import { sum } from './utils/math'; +import { + filterIntersectingHierarchy +} from './utils/intersection'; import Objective from './Objective'; /** @@ -54,6 +57,7 @@ export default class ScoringSet extends LifecycleSet { } = options; this.isScoreIncluded = _isScoreIncluded; this.isCompletionRequired = _isCompletionRequired; + this._modifiers = []; } /** @override */ @@ -227,6 +231,52 @@ export default class ScoringSet extends LifecycleSet { return (this.isPassed === false); } + /** + * Returns the list of modifiers which impacted the last update. + * @returns {Array} + */ + get modifiers() { + return this._modifiers; + } + + /** + * Returns the data to log. + * @returns {object} + */ + 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; + } + + /** + * Return whether the logData has changed since the last update. + * @returns {boolean} + */ + get hasLogDataChanged() { + const lastData = this._lastLogData ?? {}; + const currentData = this.logData; + return ( + lastData.id !== currentData.id || + lastData.type !== currentData.type || + lastData.minScore !== currentData.minScore || + lastData.maxScore !== currentData.maxScore || + lastData.score !== currentData.score || + lastData.scaledScore !== currentData.scaledScore || + lastData.isComplete !== currentData.isComplete || + lastData.isPassed !== currentData.isPassed + ); + } + /** * The objective object for the set. See SCORM cmi.objectives. * @returns {Objective} @@ -236,6 +286,78 @@ export default class ScoringSet extends LifecycleSet { return (this._objective = this._objective || new Objective({ set: this })); } + /** + * Add modifier details for how the set has been updated. + * @protected + * @param {Backbone.Model} model + */ + _addModifiers(model) { + if (!this.hasLogDataChanged) return; + const isAvailabilityChange = Object.hasOwn(model.changed, '_isAvailable'); + if (isAvailabilityChange) { + this._addAvailabilityModifiers(model); + return; + } + 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.allQuestions, + models + ); + questions.forEach(questionModel => { + const isAvailable = questionModel.get('_isAvailable'); + const minScore = questionModel.get('minScore') || 0; + const maxScore = questionModel.get('maxScore') || 0; + const score = questionModel.get('score') || 0; + const data = { + modelId: questionModel.get('_id'), + minScore: isAvailable ? minScore : -minScore, + maxScore: isAvailable ? maxScore : -maxScore + }; + const isSubmitted = questionModel.get('_isSubmitted'); + if (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) { + const score = model.get('score') || 0; + this.modifiers.push({ + modelId: model.get('_id'), + score + }); + } + + /** + * 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; + } + /** @override */ async onInit() { if (this.isIntersectedSet) return; @@ -252,7 +374,7 @@ export default class ScoringSet extends LifecycleSet { } /** @override */ - async onUpdate() { + async onUpdate(updatedModels = []) { if (this.isIntersectedSet) return; const isComplete = this.isComplete; if (isComplete && !this._wasComplete) this.onCompleted(); @@ -260,7 +382,10 @@ export default class ScoringSet extends LifecycleSet { if (isPassed && !this._wasPassed) this.onPassed(); this._wasComplete = isComplete; this._wasPassed = isPassed; - super.onUpdate(); + updatedModels.forEach(model => this._addModifiers(model)); + this._logUpdate(); + this._modifiers = []; + super.onUpdate(updatedModels); } /** @@ -270,8 +395,9 @@ export default class ScoringSet extends LifecycleSet { */ async onCompleted() { if (this.isIntersectedSet) return; - Adapt.trigger(`scoring:${this.type}:complete scoring:set:complete`, this); - Logging.debug(`${this.id} completed`); + const events = `scoring:${this.type}:complete scoring:set:complete`; + Adapt.trigger(events, this); + Logging.info(`${this.id} completed`); this.objective?.complete(); } @@ -282,13 +408,15 @@ export default class ScoringSet extends LifecycleSet { */ async onPassed() { if (this.isIntersectedSet) return; - Adapt.trigger(`scoring:${this.type}:passed scoring:set:passed`, this); - Logging.debug(`${this.id} passed`); + const events = `scoring:${this.type}:passed scoring:set:passed`; + Adapt.trigger(events, this); + Logging.info(`${this.id} passed`); } /** @override */ async reset() { if (this.isIntersectedSet) return; + Logging.info(`${this.id} reset`); super.reset(); this.objective?.reset(); } From 8e5cd110cb1a7f30919c60cdb108bd5031e9939a Mon Sep 17 00:00:00 2001 From: Joseph Replin Date: Fri, 2 Jan 2026 10:58:37 -0600 Subject: [PATCH 13/35] Schema nesting issue. --- schema/course.schema.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/schema/course.schema.json b/schema/course.schema.json index e2c135a..efcfc24 100644 --- a/schema/course.schema.json +++ b/schema/course.schema.json @@ -65,13 +65,13 @@ "default": true } } + }, + "_isBackwardCompatible": { + "type": "boolean", + "title": "Enable backward compatibility", + "description": "Determines whether to use legacy assessment events and state for backward compatibility with other plugins", + "default": false } - }, - "_isBackwardCompatible": { - "type": "boolean", - "title": "Enable backward compatibility", - "description": "Determines whether to use legacy assessment events and state for backward compatibility with other plugins", - "default": false } } } From 8650fb544c83a08cf107d316a26bc67edd6d9f37 Mon Sep 17 00:00:00 2001 From: Joseph Replin Date: Fri, 2 Jan 2026 11:40:12 -0600 Subject: [PATCH 14/35] Reverting FW version --- bower.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bower.json b/bower.json index dc7f90a..d67ae8c 100644 --- a/bower.json +++ b/bower.json @@ -1,7 +1,7 @@ { "name": "adapt-contrib-scoring", "version": "1.4.0", - "framework": ">=5.48.2", + "framework": ">=5.31.31", "homepage": "https://github.com/adaptlearning/adapt-contrib-scoring", "issues": "https://github.com/adaptlearning/adapt-contrib-scoring/issues/new", "extension": "scoring", diff --git a/package.json b/package.json index dc7f90a..d67ae8c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "adapt-contrib-scoring", "version": "1.4.0", - "framework": ">=5.48.2", + "framework": ">=5.31.31", "homepage": "https://github.com/adaptlearning/adapt-contrib-scoring", "issues": "https://github.com/adaptlearning/adapt-contrib-scoring/issues/new", "extension": "scoring", From b93a07662792526ddf5095848a05b2b045c1d77a Mon Sep 17 00:00:00 2001 From: Oliver Foster <7974663+oliverfoster@users.noreply.github.com> Date: Tue, 17 Mar 2026 13:52:39 +0000 Subject: [PATCH 15/35] Fix wording in IntersectionSet description Corrected wording for clarity in IntersectionSet section. --- INTERSECTION_QUERY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/INTERSECTION_QUERY.md b/INTERSECTION_QUERY.md index 5fd4909..2055c30 100644 --- a/INTERSECTION_QUERY.md +++ b/INTERSECTION_QUERY.md @@ -18,7 +18,7 @@ Adapt.scoring.getSubsetByPath(pathString) ``` ### IntersectionSet -All sets representing any a collection of models are likely to extend the most basic interface `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 From 80015923bcd9608ae8f7be006ed4d7d22e260448 Mon Sep 17 00:00:00 2001 From: "AzureAD\\DanGhost" Date: Wed, 18 Mar 2026 16:13:03 +0000 Subject: [PATCH 16/35] Update to remove the duplicated event trigger for 'scoring:update'. Moved event trigger for 'scoring:reset' to `Lifecycle` for consistency. Questionable whether a public method to update scoring is required. No longer used directly in scoring plugins as was the case with v1. --- js/Lifecycle.js | 3 +++ js/adapt-contrib-scoring.js | 6 ++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/js/Lifecycle.js b/js/Lifecycle.js index 05010cb..0aa18c7 100644 --- a/js/Lifecycle.js +++ b/js/Lifecycle.js @@ -216,10 +216,12 @@ export default class Lifecycle extends Backbone.Controller { /** * Send all sets into the reset phase. + * @fires Adapt#scoring:reset */ async reset () { const sets = getAllSets(); await this.renderer.render.reset(sets); + Adapt.trigger('scoring:reset', this.scoring); } /** @@ -263,6 +265,7 @@ export default class Lifecycle extends Backbone.Controller { get renderer() { return renderer; } + } const renderer = new LifecycleRenderer({ diff --git a/js/adapt-contrib-scoring.js b/js/adapt-contrib-scoring.js index 325f54b..25d3193 100644 --- a/js/adapt-contrib-scoring.js +++ b/js/adapt-contrib-scoring.js @@ -139,24 +139,22 @@ export class Scoring extends Backbone.Controller { /** * Force all registered sets to recalculate their states. - * @fires Adapt#scoring:update + * @fires Adapt#scoring:update via lifecycle */ async update() { const sets = this.sets; if (!sets.length) return; await this.lifecycle.update(sets); - Adapt.trigger('scoring:update', this); } /** * Reset all subsets which can be reset. - * @fires Adapt#scoring:reset + * @fires Adapt#scoring:reset via lifecycle */ async reset() { const sets = this.sets; if (!sets.length) return; await this.lifecycle.reset(); - Adapt.trigger('scoring:reset', this); } /** From c530c6dae06403e63cb8485bf87b8c646776e2d7 Mon Sep 17 00:00:00 2001 From: "AzureAD\\DanGhost" Date: Thu, 19 Mar 2026 15:15:00 +0000 Subject: [PATCH 17/35] Incorprated the log logic from https://github.com/adaptlearning/adapt-contrib-scoring/pull/26. Amended `AdaptModelSet` to extend `IntersectionSet` rather than `ScoringSet` as this is only required for intersection queries. --- js/AdaptModelSet.js | 4 +- js/Lifecycle.js | 27 +++++---- js/ScoringSet.js | 133 ++++++++++++++++++++++++++++---------------- 3 files changed, 102 insertions(+), 62 deletions(-) diff --git a/js/AdaptModelSet.js b/js/AdaptModelSet.js index 6bc1f59..4a5c86a 100644 --- a/js/AdaptModelSet.js +++ b/js/AdaptModelSet.js @@ -1,11 +1,11 @@ -import ScoringSet from './ScoringSet'; +import IntersectionSet from './IntersectionSet'; 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 { +export default class AdaptModelSet extends IntersectionSet { initialize(options = {}) { super.initialize({ diff --git a/js/Lifecycle.js b/js/Lifecycle.js index 0aa18c7..8fa5352 100644 --- a/js/Lifecycle.js +++ b/js/Lifecycle.js @@ -13,7 +13,6 @@ 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 */ @@ -126,7 +125,7 @@ export default class Lifecycle extends Backbone.Controller { async onAdaptModelChange(model) { if (!this._isStarted) return; const allSets = getAllSets(); - this.update(filterSetsByIntersectingModelId(allSets, model.get('_id'))); + this.update(filterSetsByIntersectingModelId(allSets, model.get('_id')), model); } /** @@ -138,7 +137,7 @@ export default class Lifecycle extends Backbone.Controller { const adaptModel = event.deepPath.findLast(model => model instanceof AdaptModel); if (!adaptModel) return; const allSets = getAllSets(); - this.update(filterSetsByIntersectingModelId(allSets, adaptModel.get('_id'))); + this.update(filterSetsByIntersectingModelId(allSets, adaptModel.get('_id')), adaptModel); } /** @@ -189,7 +188,7 @@ export default class Lifecycle extends Backbone.Controller { /** * Send all sets into the init phase. */ - async init () { + async init() { const sets = getAllSets(); await this.renderer.render.init(sets); } @@ -198,7 +197,7 @@ export default class Lifecycle extends Backbone.Controller { * Send all sets into the restore phase. * @fires Adapt#scoring:restored */ - async restore () { + async restore() { const sets = getAllSets(); await this.renderer.render.restore(sets); Adapt.trigger('scoring:restored', this.scoring); @@ -208,7 +207,7 @@ export default class Lifecycle extends Backbone.Controller { * Send all sets into the start phase. * @fires Adapt#scoring:start */ - async start () { + async start() { const sets = getAllSets(); await this.renderer.render.start(sets); Adapt.trigger('scoring:start', this.scoring); @@ -218,7 +217,7 @@ export default class Lifecycle extends Backbone.Controller { * Send all sets into the reset phase. * @fires Adapt#scoring:reset */ - async reset () { + async reset() { const sets = getAllSets(); await this.renderer.render.reset(sets); Adapt.trigger('scoring:reset', this.scoring); @@ -226,34 +225,40 @@ export default class Lifecycle extends Backbone.Controller { /** * Send givens sets into the restart phase. + * @param {InteractionSet[]} sets */ - async restart (sets) { + async restart(sets) { sets = sets.filter(set => !set.intersectionParent); await this.renderer.render.restart(sets); } /** * Send givens sets into the leave phase. + * @param {InteractionSet[]} sets */ - async leave (sets) { + async leave(sets) { sets = sets.filter(set => !set.intersectionParent); await this.renderer.render.leave(sets); } /** * Send givens sets into the visit phase. + * @param {InteractionSet[]} sets */ - async visit (sets) { + async visit(sets) { sets = sets.filter(set => !set.intersectionParent); await this.renderer.render.visit(sets); } /** * Send givens sets into the update phase. + * @param {InteractionSet[]} sets + * @param {Backbone.Model} model * @fires Adapt#scoring:update */ - async update (sets) { + async update(sets, model = null) { sets = sets.filter(set => !set.intersectionParent); + if (model) sets.forEach(set => set.addPendingUpdateModel?.(model)); await this.renderer.render.update(sets); Adapt.trigger('scoring:update', this.scoring); } diff --git a/js/ScoringSet.js b/js/ScoringSet.js index 60ce0d7..ff5be73 100644 --- a/js/ScoringSet.js +++ b/js/ScoringSet.js @@ -1,6 +1,7 @@ import Adapt from 'core/js/adapt'; import Logging from 'core/js/logging'; import LifecycleSet from './LifecycleSet'; +import Objective from './Objective'; import { getScaledScoreFromMinMax } from './utils/scoring'; @@ -8,9 +9,10 @@ import { sum } from './utils/math'; import { - filterIntersectingHierarchy -} from './utils/intersection'; -import Objective from './Objective'; + filterModelsByIntersectingModels, + isModelAvailableInHierarchy +} from './utils/models'; +import _ from 'underscore'; /** * The class provides an abstract that describes a set of models which can be extended with custom @@ -57,7 +59,48 @@ export default class ScoringSet extends LifecycleSet { } = options; this.isScoreIncluded = _isScoreIncluded; this.isCompletionRequired = _isCompletionRequired; - this._modifiers = []; + this._pendingUpdateModels = []; + this._pendingUpdateModifiers = []; + } + + /** + * Returns the minimum score for the specified model + * @param {Backbone.Model} model + * @returns {number} + */ + getMinScoreByModel(model) { + if (!this.questions.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.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.questions.includes(model)) return 0; + return model.score; + } + + /** + * 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.questions.includes(model)) return 0; + return getScaledScoreFromMinMax(this.getScoreByModel(model), this.getMinScoreByModel(model), this.getMaxScoreByModel(model)); } /** @override */ @@ -235,8 +278,8 @@ export default class ScoringSet extends LifecycleSet { * Returns the list of modifiers which impacted the last update. * @returns {Array} */ - get modifiers() { - return this._modifiers; + get pendingUpdateModifiers() { + return this._pendingUpdateModifiers; } /** @@ -254,7 +297,7 @@ export default class ScoringSet extends LifecycleSet { isComplete: this.isComplete, isPassed: this.isPassed }; - if (this.modifiers.length) data.modifiers = this.modifiers; + if (this.pendingUpdateModifiers.length) data.modifiers = this.pendingUpdateModifiers; return data; } @@ -263,18 +306,10 @@ export default class ScoringSet extends LifecycleSet { * @returns {boolean} */ get hasLogDataChanged() { - const lastData = this._lastLogData ?? {}; - const currentData = this.logData; - return ( - lastData.id !== currentData.id || - lastData.type !== currentData.type || - lastData.minScore !== currentData.minScore || - lastData.maxScore !== currentData.maxScore || - lastData.score !== currentData.score || - lastData.scaledScore !== currentData.scaledScore || - lastData.isComplete !== currentData.isComplete || - lastData.isPassed !== currentData.isPassed - ); + // delete previous modifiers entry before comparing logs for changes + const clonedLastLogData = structuredClone(this._lastLogData ?? {}); + delete clonedLastLogData.modifiers; + return !(_.isEqual(clonedLastLogData, this.logData)); } /** @@ -286,12 +321,21 @@ export default class ScoringSet extends LifecycleSet { return (this._objective = this._objective || new Objective({ set: this })); } + /** + * Add a model as having triggered this set's next update. + * @param {Backbone.Model} model + */ + addPendingUpdateModel(model) { + if (this._pendingUpdateModels.includes(model)) return; + this._pendingUpdateModels.push(model); + } + /** * Add modifier details for how the set has been updated. * @protected * @param {Backbone.Model} model */ - _addModifiers(model) { + _addUpdateModifiers(model) { if (!this.hasLogDataChanged) return; const isAvailabilityChange = Object.hasOwn(model.changed, '_isAvailable'); if (isAvailabilityChange) { @@ -302,48 +346,37 @@ export default class ScoringSet extends LifecycleSet { } /** - * Add modifier details for how the set has been updated by availability - * changes. + * 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.allQuestions, - models - ); + const models = model.hasManagedChildren ? model.getChildren() : [model]; + const questions = filterModelsByIntersectingModels(this.questions, models); questions.forEach(questionModel => { - const isAvailable = questionModel.get('_isAvailable'); - const minScore = questionModel.get('minScore') || 0; - const maxScore = questionModel.get('maxScore') || 0; - const score = questionModel.get('score') || 0; + const isAvailable = isModelAvailableInHierarchy(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 }; - const isSubmitted = questionModel.get('_isSubmitted'); - if (isSubmitted) { - data.score = isAvailable ? score : -score; - } - this.modifiers.push(data); + if (questionModel.get('_isSubmitted')) data.score = isAvailable ? score : -score; + this.pendingUpdateModifiers.push(data); }); } /** - * Add modifier details for how the set has been updated by completion - * changes. + * Add modifier details for how the set has been updated by completion changes. * @protected * @param {Backbone.Model} model */ _addCompletionModifiers(model) { - const score = model.get('score') || 0; - this.modifiers.push({ + this.pendingUpdateModifiers.push({ modelId: model.get('_id'), - score + score: this.getScoreByModel(model) }); } @@ -374,18 +407,19 @@ export default class ScoringSet extends LifecycleSet { } /** @override */ - async onUpdate(updatedModels = []) { + async onUpdate() { if (this.isIntersectedSet) return; const isComplete = this.isComplete; - if (isComplete && !this._wasComplete) this.onCompleted(); + if (isComplete && !this._wasComplete) await this.onCompleted(); const isPassed = this.isPassed; - if (isPassed && !this._wasPassed) this.onPassed(); + if (isPassed && !this._wasPassed) await this.onPassed(); this._wasComplete = isComplete; this._wasPassed = isPassed; - updatedModels.forEach(model => this._addModifiers(model)); + this._pendingUpdateModels.forEach(model => this._addUpdateModifiers(model)); this._logUpdate(); - this._modifiers = []; - super.onUpdate(updatedModels); + this._pendingUpdateModels = []; + this._pendingUpdateModifiers = []; + super.onUpdate(); } /** @@ -420,4 +454,5 @@ export default class ScoringSet extends LifecycleSet { super.reset(); this.objective?.reset(); } + } From 53b5cb6fc2c898aff847ed0b106c7443b3d8da29 Mon Sep 17 00:00:00 2001 From: "AzureAD\\DanGhost" Date: Thu, 2 Apr 2026 19:01:58 +0100 Subject: [PATCH 18/35] Fixed `@typedef` paths and typos. --- js/IntersectionSet.js | 3 +-- js/Lifecycle.js | 2 +- js/LifecycleRenderer.js | 3 +-- js/LifecycleSet.js | 2 +- js/Objective.js | 5 ++--- js/State.js | 3 +-- js/compatibility.js | 1 - 7 files changed, 7 insertions(+), 12 deletions(-) diff --git a/js/IntersectionSet.js b/js/IntersectionSet.js index a9181ad..22ae98f 100644 --- a/js/IntersectionSet.js +++ b/js/IntersectionSet.js @@ -77,8 +77,6 @@ export default class IntersectionSet extends Backbone.Controller { this.title = _title ?? title; (_model ?? model) && (this.model = _model ?? model); (_models ?? models) && (this.models = _models ?? models); - // Do not register intersected sets - if (this.isIntersectedSet) return; this.register(); } @@ -422,4 +420,5 @@ export default class IntersectionSet extends Backbone.Controller { sets = sets.map(set => createIntersectedSet([this, set])); return sets; } + } diff --git a/js/Lifecycle.js b/js/Lifecycle.js index 8fa5352..d8eaa79 100644 --- a/js/Lifecycle.js +++ b/js/Lifecycle.js @@ -13,7 +13,7 @@ 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("./IntersectionSet").default} IntersectionSet */ /** @typedef {import("core/js/modelEvent").default} ModelEvent */ /** @typedef {import("core/js/location").default} Location */ diff --git a/js/LifecycleRenderer.js b/js/LifecycleRenderer.js index 7a184d9..350f9cc 100644 --- a/js/LifecycleRenderer.js +++ b/js/LifecycleRenderer.js @@ -2,8 +2,7 @@ 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 */ +/** @typedef {import("./IntersectionSet").default} IntersectionSet */ /** * Transforms a lifecycle definition into fps batched, phase queues, where the diff --git a/js/LifecycleSet.js b/js/LifecycleSet.js index 289b24b..eeac524 100644 --- a/js/LifecycleSet.js +++ b/js/LifecycleSet.js @@ -21,7 +21,7 @@ export default class LifecycleSet extends IntersectionSet { } /** - * Signifies if onRestored returned true/false. + * Signifies if onRestore returned true/false. * @returns {boolean} */ get wasRestored() { diff --git a/js/Objective.js b/js/Objective.js index 978a456..6578f37 100644 --- a/js/Objective.js +++ b/js/Objective.js @@ -1,7 +1,6 @@ import offlineStorage from 'core/js/offlineStorage'; import COMPLETION_STATE from 'core/js/enums/completionStateEnum'; - -/** @typedef {import("../IntersectionSet").default} IntersectionSet */ +/** @typedef {import("./ScoringSet").default} ScoringSet */ /** * Registers an objective with the offlineStorage API. @@ -11,7 +10,7 @@ export default class Objective { /** * @param {Object} options - * @param {IntersectionSet} options.set + * @param {ScoringSet} options.set */ constructor({ set } = {}) { this.set = set; diff --git a/js/State.js b/js/State.js index e4fcc3e..830bdc6 100644 --- a/js/State.js +++ b/js/State.js @@ -1,6 +1,5 @@ import OfflineStorage from 'core/js/offlineStorage'; - -/** @typedef {import("../LifecycleSet").default} LifecycleSet */ +/** @typedef {import("./LifecycleSet").default} LifecycleSet */ /** * Saves and restores state by { name: { id: 'data' } } in the offlineStorage API. diff --git a/js/compatibility.js b/js/compatibility.js index e19ee73..81d7d9f 100644 --- a/js/compatibility.js +++ b/js/compatibility.js @@ -2,7 +2,6 @@ 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 From 67e9b5aac42737520c11d32322af546e7c523ac4 Mon Sep 17 00:00:00 2001 From: "AzureAD\\DanGhost" Date: Thu, 2 Apr 2026 19:19:45 +0100 Subject: [PATCH 19/35] Reverted `AdaptModelSet` to extend `ScoringSet`. Amended filter methods in `ScoringSet` to query its models rather rather than the single parent model (which isn't always populated in a set). Fixed issues when evaluating correctness. --- js/AdaptModelSet.js | 43 +++++++++++++++++++------------------------ js/ScoringSet.js | 12 ++++++------ 2 files changed, 25 insertions(+), 30 deletions(-) diff --git a/js/AdaptModelSet.js b/js/AdaptModelSet.js index 4a5c86a..6b53063 100644 --- a/js/AdaptModelSet.js +++ b/js/AdaptModelSet.js @@ -1,11 +1,11 @@ -import IntersectionSet from './IntersectionSet'; +import ScoringSet from './ScoringSet'; 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 IntersectionSet { +export default class AdaptModelSet extends ScoringSet { initialize(options = {}) { super.initialize({ @@ -52,38 +52,22 @@ export default class AdaptModelSet extends IntersectionSet { return 100 - this.model.getAncestorModels(true).length; } - /** - * Returns whether the set is passed. - * query example: `(isPassed)` alias for `(isComplete)` - * @returns {boolean} - */ - get isPassed() { - return this.isComplete; + /** @override */ + get isSubmitted() { + return this.model.get('_isSubmitted'); } - /** - * Returns whether the set is isFailed. - * query example: `(isFailed)` - * @returns {boolean} - */ + /** @override */ get isFailed() { return false; } - /** - * Returns whether the set is optional. - * query example: `(isOptional)` - * @returns {boolean} - */ + /** @override */ get isOptional() { return this.model.get('_isOptional'); } - /** - * Returns whether the set is available. - * query example: `(isAvailable)` - * @returns {boolean} - */ + /** @override */ get isAvailable() { return this.model.get('_isAvailable'); } @@ -93,4 +77,15 @@ export default class AdaptModelSet extends IntersectionSet { return this.model.getFeedback(); } + /** @override */ + get objective() { + if (!this.model.get('_recordObjective')) return; + return super.objective; + } + + /** @override */ + _logUpdate() { + + } + } diff --git a/js/ScoringSet.js b/js/ScoringSet.js index ff5be73..649e6a4 100644 --- a/js/ScoringSet.js +++ b/js/ScoringSet.js @@ -215,7 +215,7 @@ export default class ScoringSet extends LifecycleSet { */ get isPartlyCorrect() { if (!this.isSubmitted) return null; - return (this.correctness < this.maxCorrectness); + return this.correctness > 0 && this.correctness < this.maxCorrectness; } /** @@ -225,7 +225,7 @@ export default class ScoringSet extends LifecycleSet { */ get isIncorrect() { if (!this.isSubmitted) return null; - return (!this.correctness && this.maxCorrectness); + return this.correctness === 0; } /** @@ -234,7 +234,7 @@ export default class ScoringSet extends LifecycleSet { * @returns {boolean} */ get isSubmitted() { - return this.model.get('_isSubmitted'); + return this.availableModels.every(model => model.get('_isSubmitted')); } /** @@ -243,7 +243,7 @@ export default class ScoringSet extends LifecycleSet { * @returns {boolean} */ get isComplete() { - return this.model.get('_isComplete'); + return this.availableModels.every(model => model.get('_isComplete')); } /** @@ -252,7 +252,7 @@ export default class ScoringSet extends LifecycleSet { * @returns {boolean} */ get isIncomplete() { - return (this.isComplete === false); + return this.isComplete === false; } /** @@ -261,7 +261,7 @@ export default class ScoringSet extends LifecycleSet { * @returns {boolean} */ get isPassed() { - Logging.error(`isPassed must be overridden for ${this.constructor.name}`); + return this.isComplete; } /** From b54b50a8166c61ed80587ad281ddb781d3039320 Mon Sep 17 00:00:00 2001 From: "AzureAD\\DanGhost" Date: Thu, 2 Apr 2026 19:26:24 +0100 Subject: [PATCH 20/35] Adjusted lifecycle to distinguish between a first attempt and a restart attempt. Marked lifecycle callback hooks as protected methods, as calling these externally defeats the benefits of the LifecycleRenderer. --- LIFECYCLE.md | 3 ++- js/Lifecycle.js | 4 ++-- js/LifecycleSet.js | 13 ++++++++++++- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/LIFECYCLE.md b/LIFECYCLE.md index 0055d28..8f462cc 100644 --- a/LIFECYCLE.md +++ b/LIFECYCLE.md @@ -21,7 +21,8 @@ There are 8 external lifecycle phases, 6 set callback functions and 2 internal t | --- | --- | | 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`. Called on the set after reset. | +| 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()` | diff --git a/js/Lifecycle.js b/js/Lifecycle.js index d8eaa79..067bded 100644 --- a/js/Lifecycle.js +++ b/js/Lifecycle.js @@ -294,9 +294,9 @@ const renderer = new LifecycleRenderer({ async reset(set) { set.canReset && await set.reset?.(); }, - // restart calls set.onStart when any intersecting model or set is reset + // restart calls set.onRestart when any intersecting model or set is reset async restart(set) { - await set.onStart?.(); + await set.onRestart?.(); }, // leave calls set.onLeave when exiting an intersecting contentobject async leave(set) { diff --git a/js/LifecycleSet.js b/js/LifecycleSet.js index eeac524..61fa126 100644 --- a/js/LifecycleSet.js +++ b/js/LifecycleSet.js @@ -34,12 +34,14 @@ export default class LifecycleSet extends IntersectionSet { /** * 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 @@ -51,17 +53,25 @@ export default class LifecycleSet extends IntersectionSet { /** * Called on each set after onRestore, only if onRestore returns false. - * Called on each set after a reset or intersecting set or model is reset. + * @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() {} @@ -69,6 +79,7 @@ export default class LifecycleSet extends IntersectionSet { * 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() {} From 6b579dce64edd8831608876d26a4725f358cd029 Mon Sep 17 00:00:00 2001 From: "AzureAD\\DanGhost" Date: Thu, 2 Apr 2026 19:35:54 +0100 Subject: [PATCH 21/35] Updated objective functionality to incorporate https://github.com/adaptlearning/adapt-contrib-scoringAssessment/pull/17. Resolved reset issues as the objective needs to wait until the set has been reset before updating the score and status - adjusted methods and logic accordingly so score and status can be handled separately by the sets lifecycle methods. --- bower.json | 2 +- js/Objective.js | 62 ++++++++++++++++++++++++++++---------- js/ScoringSet.js | 77 +++++++++++++++++++++++++++++++++++++----------- package.json | 2 +- 4 files changed, 108 insertions(+), 35 deletions(-) diff --git a/bower.json b/bower.json index d67ae8c..dc7f90a 100644 --- a/bower.json +++ b/bower.json @@ -1,7 +1,7 @@ { "name": "adapt-contrib-scoring", "version": "1.4.0", - "framework": ">=5.31.31", + "framework": ">=5.48.2", "homepage": "https://github.com/adaptlearning/adapt-contrib-scoring", "issues": "https://github.com/adaptlearning/adapt-contrib-scoring/issues/new", "extension": "scoring", diff --git a/js/Objective.js b/js/Objective.js index 6578f37..ac60ed6 100644 --- a/js/Objective.js +++ b/js/Objective.js @@ -20,33 +20,65 @@ export default class Objective { /** * Define the objective for reporting purposes. + * Set initial status. */ - init() { - const completionStatus = COMPLETION_STATE.NOTATTEMPTED.asLowerCase; + register() { offlineStorage.set('objectiveDescription', this.id, this.description); - if (this.set.isComplete) return; - offlineStorage.set('objectiveStatus', this.id, completionStatus); + this.setStatus(); } /** - * Reset the objective data. + * Set the objective score. */ - reset() { - if (this.set.isComplete) return; - const completionStatus = COMPLETION_STATE.INCOMPLETE.asLowerCase; + setScore() { offlineStorage.set('objectiveScore', this.id, this.set.score, this.set.minScore, this.set.maxScore); - offlineStorage.set('objectiveStatus', this.id, completionStatus); } /** - * Complete the objective. - * TODO: Always updates to latest data - is this desired? + * Reset the objective score. + * Depending on the set logic, this may be overriden to prevent resets. */ - complete() { - const completionStatus = COMPLETION_STATE.COMPLETED.asLowerCase; - const successStatus = (this.set.isPassed ? COMPLETION_STATE.PASSED : COMPLETION_STATE.FAILED).asLowerCase; - offlineStorage.set('objectiveScore', this.id, this.set.score, this.set.minScore, this.set.maxScore); + 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.passmark?.isEnabled) 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} + */ + get isPassed() { + return this.set.isPassed; + } + } diff --git a/js/ScoringSet.js b/js/ScoringSet.js index 649e6a4..72eebac 100644 --- a/js/ScoringSet.js +++ b/js/ScoringSet.js @@ -12,6 +12,9 @@ import { filterModelsByIntersectingModels, isModelAvailableInHierarchy } from './utils/models'; +import { + hasHashChanged +} from './utils/hash'; import _ from 'underscore'; /** @@ -237,6 +240,15 @@ export default class ScoringSet extends LifecycleSet { return this.availableModels.every(model => model.get('_isSubmitted')); } + /** + * Returns whether the set is started. + * query example: `(isStarted)` or `(isStarted=false)` + * @returns {boolean} + */ + get isStarted() { + return this.availableModels.some(model => model.get('_isVisited')); + } + /** * Returns whether the set is completed. * query example: `(isComplete)` or `(isComplete=false)` @@ -321,6 +333,26 @@ export default class ScoringSet extends LifecycleSet { return (this._objective = this._objective || new Objective({ set: this })); } + /** + * Update the current status hashes to help determine what has changed during the update phase of the lifecycle. + * @protected + */ + _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'); + } + /** * Add a model as having triggered this set's next update. * @param {Backbone.Model} model @@ -394,32 +426,39 @@ export default class ScoringSet extends LifecycleSet { /** @override */ async onInit() { if (this.isIntersectedSet) return; - this.objective?.init(); - super.onInit(); + if (this.type !== 'adapt') { + this.listenTo(Adapt, 'questionView:submitted', this.onQuestionSubmitted); + } + await super.onInit(); } /** @override */ async onRestore() { if (this.isIntersectedSet) return; - this._wasComplete = this.isComplete; - this._wasPassed = this.isPassed; + this._setStatusHash(); + if (!this.isStarted) this.objective?.register(); super.onRestore(); } + /** @override */ + async onRestart() { + if (this.isIntersectedSet) return; + this.objective?.resetScore(); + super.onRestart(); + } + /** @override */ async onUpdate() { if (this.isIntersectedSet) return; - const isComplete = this.isComplete; - if (isComplete && !this._wasComplete) await this.onCompleted(); - const isPassed = this.isPassed; - if (isPassed && !this._wasPassed) await this.onPassed(); - this._wasComplete = isComplete; - this._wasPassed = isPassed; + 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(); this._pendingUpdateModels.forEach(model => this._addUpdateModifiers(model)); this._logUpdate(); this._pendingUpdateModels = []; this._pendingUpdateModifiers = []; - super.onUpdate(); + await super.onUpdate(); } /** @@ -432,7 +471,7 @@ export default class ScoringSet extends LifecycleSet { const events = `scoring:${this.type}:complete scoring:set:complete`; Adapt.trigger(events, this); Logging.info(`${this.id} completed`); - this.objective?.complete(); + this.objective?.setScore(); } /** @@ -447,12 +486,14 @@ export default class ScoringSet extends LifecycleSet { Logging.info(`${this.id} passed`); } - /** @override */ - async reset() { - if (this.isIntersectedSet) return; - Logging.info(`${this.id} reset`); - super.reset(); - this.objective?.reset(); + /** + * @param {QuestionView} view + * @listens Adapt#questionView:submitted + */ + onQuestionSubmitted(view) { + const model = view.model; + if (!this.availableQuestions.includes(model)) return; + model.addContextActivity(this.id, this.type, this.title); } } diff --git a/package.json b/package.json index d67ae8c..dc7f90a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "adapt-contrib-scoring", "version": "1.4.0", - "framework": ">=5.31.31", + "framework": ">=5.48.2", "homepage": "https://github.com/adaptlearning/adapt-contrib-scoring", "issues": "https://github.com/adaptlearning/adapt-contrib-scoring/issues/new", "extension": "scoring", From 383b0e82790cfd8ec729571902c8f80bf6b242f2 Mon Sep 17 00:00:00 2001 From: "AzureAD\\DanGhost" Date: Mon, 13 Apr 2026 17:25:32 +0100 Subject: [PATCH 22/35] Moved the log for the scoring updates functionality into a `Journal` class to keep this logic separate from the `ScoringSet`. --- js/AdaptModelSet.js | 10 +-- js/Journal.js | 151 ++++++++++++++++++++++++++++++++++ js/ScoringSet.js | 158 +++--------------------------------- js/adapt-contrib-scoring.js | 2 + 4 files changed, 168 insertions(+), 153 deletions(-) create mode 100644 js/Journal.js diff --git a/js/AdaptModelSet.js b/js/AdaptModelSet.js index 6b53063..f1c4981 100644 --- a/js/AdaptModelSet.js +++ b/js/AdaptModelSet.js @@ -78,14 +78,14 @@ export default class AdaptModelSet extends ScoringSet { } /** @override */ - get objective() { - if (!this.model.get('_recordObjective')) return; - return super.objective; + get journal() { + // intentionally empty to prevent logging } /** @override */ - _logUpdate() { - + get objective() { + if (!this.model.get('_recordObjective')) return; + return super.objective; } } diff --git a/js/Journal.js b/js/Journal.js new file mode 100644 index 0000000..7c5c010 --- /dev/null +++ b/js/Journal.js @@ -0,0 +1,151 @@ +import Logging from 'core/js/logging'; +import { + filterModelsByIntersectingModels, + isModelAvailableInHierarchy +} from './utils/models'; +import _ from 'underscore'; +/** @typedef {import("./ScoringSet").default} ScoringSet */ + +/** + * A journal for recording the updates to a set. + */ +export default class Journal { + + /** + * @param {Object} options + * @param {ScoringSet} options.set + */ + constructor({ set } = {}) { + this.set = set; + this._pendingUpdateModifiers = []; + } + + /** + * Update the journal for the models pending updates. + * @param {Set} pendingUpdateModels + */ + update(pendingUpdateModels) { + pendingUpdateModels.forEach(model => this._addUpdateModifiers(model)); + this._write(); + this._pendingUpdateModifiers = []; + } + + /** + * 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; + } + + /** + * Returns the list of modifiers which impacted the last update. + * @returns {Array} + */ + get pendingUpdateModifiers() { + return this._pendingUpdateModifiers; + } + + /** + * 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 + }; + } + + /** + * Add modifier details for how the set has been updated. + * @protected + * @param {Backbone.Model} model + */ + _addUpdateModifiers(model) { + const isAvailabilityChange = Object.hasOwn(model.changed, '_isAvailable'); + if (isAvailabilityChange) { + this._addAvailabilityModifiers(model); + return; + } + 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 = filterModelsByIntersectingModels(this.set.questions, models); + questions.forEach(questionModel => { + const isAvailable = isModelAvailableInHierarchy(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.pendingUpdateModifiers.push(data); + }); + } + + /** + * Add modifier details for how the set has been updated by completion changes. + * @protected + * @param {Backbone.Model} model + */ + _addCompletionModifiers(model) { + this.pendingUpdateModifiers.push({ + modelId: model.get('_id'), + score: this.getScoreByModel(model) + }); + } + + /** + * Write the current state to the log if it has changed since the last update. + * @protected + */ + _write() { + const setData = this.setData; + const hasSetDataChanged = !(_.isEqual(this._lastSetData, setData)); + if (!hasSetDataChanged) return; + const data = { ...setData }; + if (this.pendingUpdateModifiers.length) data.modifiers = this.pendingUpdateModifiers; + Logging.info('scoring:update', JSON.stringify(data)); + this._lastSetData = setData; + } + +} diff --git a/js/ScoringSet.js b/js/ScoringSet.js index 72eebac..fdba904 100644 --- a/js/ScoringSet.js +++ b/js/ScoringSet.js @@ -2,20 +2,16 @@ import Adapt from 'core/js/adapt'; import Logging from 'core/js/logging'; import LifecycleSet from './LifecycleSet'; import Objective from './Objective'; +import Journal from './Journal'; import { getScaledScoreFromMinMax } from './utils/scoring'; import { sum } from './utils/math'; -import { - filterModelsByIntersectingModels, - isModelAvailableInHierarchy -} from './utils/models'; import { hasHashChanged } from './utils/hash'; -import _ from 'underscore'; /** * The class provides an abstract that describes a set of models which can be extended with custom @@ -62,48 +58,7 @@ export default class ScoringSet extends LifecycleSet { } = options; this.isScoreIncluded = _isScoreIncluded; this.isCompletionRequired = _isCompletionRequired; - this._pendingUpdateModels = []; - this._pendingUpdateModifiers = []; - } - - /** - * Returns the minimum score for the specified model - * @param {Backbone.Model} model - * @returns {number} - */ - getMinScoreByModel(model) { - if (!this.questions.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.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.questions.includes(model)) return 0; - return model.score; - } - - /** - * 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.questions.includes(model)) return 0; - return getScaledScoreFromMinMax(this.getScoreByModel(model), this.getMinScoreByModel(model), this.getMaxScoreByModel(model)); + this._pendingUpdateModels = new Set(); } /** @override */ @@ -287,41 +242,12 @@ export default class ScoringSet extends LifecycleSet { } /** - * Returns the list of modifiers which impacted the last update. - * @returns {Array} + * The journal for recording the updates to the set. + * @returns {Journal} */ - get pendingUpdateModifiers() { - return this._pendingUpdateModifiers; - } - - /** - * Returns the data to log. - * @returns {object} - */ - 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.pendingUpdateModifiers.length) data.modifiers = this.pendingUpdateModifiers; - return data; - } - - /** - * Return whether the logData has changed since the last update. - * @returns {boolean} - */ - 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 journal() { + if (this.isIntersectedSet) return; + return (this._journal = this._journal || new Journal({ set: this })); } /** @@ -358,69 +284,7 @@ export default class ScoringSet extends LifecycleSet { * @param {Backbone.Model} model */ addPendingUpdateModel(model) { - if (this._pendingUpdateModels.includes(model)) return; - this._pendingUpdateModels.push(model); - } - - /** - * Add modifier details for how the set has been updated. - * @protected - * @param {Backbone.Model} model - */ - _addUpdateModifiers(model) { - if (!this.hasLogDataChanged) return; - const isAvailabilityChange = Object.hasOwn(model.changed, '_isAvailable'); - if (isAvailabilityChange) { - this._addAvailabilityModifiers(model); - return; - } - 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 = filterModelsByIntersectingModels(this.questions, models); - questions.forEach(questionModel => { - const isAvailable = isModelAvailableInHierarchy(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.pendingUpdateModifiers.push(data); - }); - } - - /** - * Add modifier details for how the set has been updated by completion changes. - * @protected - * @param {Backbone.Model} model - */ - _addCompletionModifiers(model) { - this.pendingUpdateModifiers.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; + this._pendingUpdateModels.add(model); } /** @override */ @@ -454,10 +318,8 @@ export default class ScoringSet extends LifecycleSet { 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(); - this._pendingUpdateModels.forEach(model => this._addUpdateModifiers(model)); - this._logUpdate(); - this._pendingUpdateModels = []; - this._pendingUpdateModifiers = []; + this.journal?.update(this._pendingUpdateModels); + this._pendingUpdateModels.clear(); await super.onUpdate(); } diff --git a/js/adapt-contrib-scoring.js b/js/adapt-contrib-scoring.js index 25d3193..d20fb9a 100644 --- a/js/adapt-contrib-scoring.js +++ b/js/adapt-contrib-scoring.js @@ -23,6 +23,7 @@ import IntersectionSet from './IntersectionSet'; import LifecycleSet from './LifecycleSet'; import ScoringSet from './ScoringSet'; import Objective from './Objective'; +import Journal from './Journal'; import State from './State'; import StateModels from './StateModels'; import StateSetModelChildren from './StateSetModelChildren'; @@ -41,6 +42,7 @@ export { LifecycleSet, ScoringSet, Objective, + Journal, State, StateSetModelChildren, StateModels From c2b06d5404a1d9e52ac3d4b169e14436bfc965b3 Mon Sep 17 00:00:00 2001 From: "AzureAD\\DanGhost" Date: Wed, 15 Apr 2026 12:26:39 +0100 Subject: [PATCH 23/35] Fixed issue with `TotalSets` completing each time it was restored because the other sets hadn't yet been restored. Removed unnecessary overrides, with a decision to use inherited `ScoringSet` events and move from `'scoring:*'` to `'scoring:total:*'` - core framework listeners will be updated accoprdingly. Namespaced lifecycle events for clarity. --- README.md | 28 ++++++++++++----- js/AdaptModelSet.js | 5 ---- js/Lifecycle.js | 8 ++--- js/ScoringSet.js | 7 +++-- js/TotalSets.js | 73 ++++++++++----------------------------------- 5 files changed, 43 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index f0b0998..393081b 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ A scoring set consists of a collection of models from which scores (`minScore`, 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 Scoring sets should be modular in the JSON configuration, with each set added as its own object. Each scoring set requires the following base attributes for configuration: @@ -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 f1c4981..1eb695f 100644 --- a/js/AdaptModelSet.js +++ b/js/AdaptModelSet.js @@ -52,11 +52,6 @@ export default class AdaptModelSet extends ScoringSet { return 100 - this.model.getAncestorModels(true).length; } - /** @override */ - get isSubmitted() { - return this.model.get('_isSubmitted'); - } - /** @override */ get isFailed() { return false; diff --git a/js/Lifecycle.js b/js/Lifecycle.js index 067bded..e1f1aa1 100644 --- a/js/Lifecycle.js +++ b/js/Lifecycle.js @@ -200,7 +200,7 @@ export default class Lifecycle extends Backbone.Controller { async restore() { const sets = getAllSets(); await this.renderer.render.restore(sets); - Adapt.trigger('scoring:restored', this.scoring); + Adapt.trigger('scoring:lifecycle:restored', this.scoring); } /** @@ -210,7 +210,7 @@ export default class Lifecycle extends Backbone.Controller { async start() { const sets = getAllSets(); await this.renderer.render.start(sets); - Adapt.trigger('scoring:start', this.scoring); + Adapt.trigger('scoring:lifecycle:start', this.scoring); } /** @@ -220,7 +220,7 @@ export default class Lifecycle extends Backbone.Controller { async reset() { const sets = getAllSets(); await this.renderer.render.reset(sets); - Adapt.trigger('scoring:reset', this.scoring); + Adapt.trigger('scoring:lifecycle:reset', this.scoring); } /** @@ -260,7 +260,7 @@ export default class Lifecycle extends Backbone.Controller { sets = sets.filter(set => !set.intersectionParent); if (model) sets.forEach(set => set.addPendingUpdateModel?.(model)); await this.renderer.render.update(sets); - Adapt.trigger('scoring:update', this.scoring); + Adapt.trigger('scoring:lifecycle:update', this.scoring); } /** diff --git a/js/ScoringSet.js b/js/ScoringSet.js index fdba904..e08b9a1 100644 --- a/js/ScoringSet.js +++ b/js/ScoringSet.js @@ -192,7 +192,8 @@ export default class ScoringSet extends LifecycleSet { * @returns {boolean} */ get isSubmitted() { - return this.availableModels.every(model => model.get('_isSubmitted')); + const availableQuestions = this.availableQuestions; + return availableQuestions.length > 0 && availableQuestions.every(model => model.get('_isSubmitted')); } /** @@ -301,14 +302,14 @@ export default class ScoringSet extends LifecycleSet { if (this.isIntersectedSet) return; this._setStatusHash(); if (!this.isStarted) this.objective?.register(); - super.onRestore(); + await super.onRestore(); } /** @override */ async onRestart() { if (this.isIntersectedSet) return; this.objective?.resetScore(); - super.onRestart(); + await super.onRestart(); } /** @override */ diff --git a/js/TotalSets.js b/js/TotalSets.js index b8e9dec..80951b2 100644 --- a/js/TotalSets.js +++ b/js/TotalSets.js @@ -1,10 +1,6 @@ import Adapt from 'core/js/adapt'; import Passmark from './Passmark'; -import Logging from 'core/js/logging'; import ScoringSet from './ScoringSet'; -import { - getScaledScoreFromMinMax -} from './utils/scoring'; import { createIntersectedSet } from './utils/intersection'; @@ -43,12 +39,16 @@ export default class TotalSets extends ScoringSet { _isCompletionRequired: false }); this._passmark = new Passmark(this._config?._passmark); - this._wasComplete = false; - this._wasPassed = false; + } + + /** @override */ + get order() { + return 600; } /** * Returns all models from sets marked with `_isScoreIncluded` or `_isCompletionRequired`, filtered and intersected where appropriate. + * @override * @returns {Backbone.Model[]} */ get models() { @@ -89,6 +89,7 @@ export default class TotalSets extends ScoringSet { /** * Returns the minimum score of all `_isScoreIncluded` subsets. + * @override * @returns {number} */ get minScore() { @@ -97,6 +98,7 @@ export default class TotalSets extends ScoringSet { /** * Returns the maximum score of all `_isScoreIncluded` subsets. + * @override * @returns {number} */ get maxScore() { @@ -105,22 +107,16 @@ export default class TotalSets extends ScoringSet { /** * Returns the score of all `_isScoreIncluded` subsets. + * @override * @returns {number} */ get score() { return sum(this.scoringSets, 'score'); } - /** - * 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 available questions. + * @override * @returns {number} */ get correctness() { @@ -129,20 +125,13 @@ export default class TotalSets extends ScoringSet { /** * Returns the number of available questions. + * @override * @returns {number} */ get maxCorrectness() { return sum(this.scoringSets, 'maxCorrectness'); } - /** - * Returns the percentage of correctly answered questions. - * @returns {number} - */ - get scaledCorrectness() { - return getScaledScoreFromMinMax(this.correctness, 0, this.maxCorrectness); - } - /** * Returns the passmark model. * @returns {Passmark} @@ -153,19 +142,17 @@ export default class TotalSets extends ScoringSet { /** * Returns whether all root sets marked with `_isCompletionRequired` are completed. + * @override * @returns {boolean} */ get isComplete() { return this.completionSets.every(set => set.isComplete); } - get isIncomplete() { - return (this.isComplete === false); - } - /** * 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() { @@ -181,6 +168,7 @@ export default class TotalSets extends ScoringSet { /** * Returns whether any root sets marked with `_isScoreIncluded` are failed and cannot be reset. + * @override * @todo Add `canReset` to `ScoringSet`? * @returns {boolean} */ @@ -190,42 +178,11 @@ export default class TotalSets extends ScoringSet { /** * Returns whether any root sets marked with `_isScoreIncluded` can be reset. + * @override * @returns {boolean} */ get canReset() { return this.scoringSets.some(set => set?.canReset); } - /** @override */ - onRestore() { - if (this.isIntersectedSet) return; - this._wasComplete = this.isComplete; - this._wasPassed = this.isPassed; - } - - /** @override */ - onUpdate() { - if (this.isIntersectedSet) 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; - } - - /** @override */ - onCompleted() { - if (this.isIntersectedSet) return; - Adapt.trigger('scoring:complete', Adapt.scoring); - Logging.debug('scoring completed'); - } - - /** @override */ - onPassed() { - if (this.isIntersectedSet) return; - Adapt.trigger('scoring:pass', Adapt.scoring); - Logging.debug('scoring passed'); - } - } From 0656f5bfd58050e56cf326edaa1d1c1410bffcf0 Mon Sep 17 00:00:00 2001 From: "AzureAD\\DanGhost" Date: Wed, 15 Apr 2026 19:02:29 +0100 Subject: [PATCH 24/35] Renamed `Journal` to better reflect its purpose. Added a journal for `TotalSets` as the score logic for the modifiers is different, with the previous modifier scores not representing the score associated with that set. --- js/Lifecycle.js | 3 +- js/{Journal.js => LifecycleUpdateJournal.js} | 33 +++++++++--------- js/ScoringSet.js | 17 ++------- js/TotalLifecycleUpdateJournal.js | 36 ++++++++++++++++++++ js/TotalSets.js | 7 ++++ js/adapt-contrib-scoring.js | 4 +-- 6 files changed, 67 insertions(+), 33 deletions(-) rename js/{Journal.js => LifecycleUpdateJournal.js} (85%) create mode 100644 js/TotalLifecycleUpdateJournal.js diff --git a/js/Lifecycle.js b/js/Lifecycle.js index e1f1aa1..0c888d9 100644 --- a/js/Lifecycle.js +++ b/js/Lifecycle.js @@ -258,7 +258,7 @@ export default class Lifecycle extends Backbone.Controller { */ async update(sets, model = null) { sets = sets.filter(set => !set.intersectionParent); - if (model) sets.forEach(set => set.addPendingUpdateModel?.(model)); + if (model) sets.forEach(set => set?.journal?.addPendingUpdateModel?.(model)); await this.renderer.render.update(sets); Adapt.trigger('scoring:lifecycle:update', this.scoring); } @@ -309,6 +309,7 @@ const renderer = new LifecycleRenderer({ // 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/Journal.js b/js/LifecycleUpdateJournal.js similarity index 85% rename from js/Journal.js rename to js/LifecycleUpdateJournal.js index 7c5c010..0ee5525 100644 --- a/js/Journal.js +++ b/js/LifecycleUpdateJournal.js @@ -7,9 +7,9 @@ import _ from 'underscore'; /** @typedef {import("./ScoringSet").default} ScoringSet */ /** - * A journal for recording the updates to a set. + * A journal for recording the lifecycle updates to a set. */ -export default class Journal { +export default class LifecycleUpdateJournal { /** * @param {Object} options @@ -17,16 +17,25 @@ export default class Journal { */ constructor({ set } = {}) { this.set = set; + this._pendingUpdateModels = new Set(); this._pendingUpdateModifiers = []; } + /** + * Add the model as having triggered this set's next update. + * @param {Backbone.Model} model + */ + addPendingUpdateModel(model) { + this._pendingUpdateModels.add(model); + } + /** * Update the journal for the models pending updates. - * @param {Set} pendingUpdateModels */ - update(pendingUpdateModels) { - pendingUpdateModels.forEach(model => this._addUpdateModifiers(model)); + update() { + this._pendingUpdateModels.forEach(model => this._addUpdateModifiers(model)); this._write(); + this._pendingUpdateModels.clear(); this._pendingUpdateModifiers = []; } @@ -60,14 +69,6 @@ export default class Journal { return model.score; } - /** - * Returns the list of modifiers which impacted the last update. - * @returns {Array} - */ - get pendingUpdateModifiers() { - return this._pendingUpdateModifiers; - } - /** * Returns the set data to log. * @returns {object} @@ -118,7 +119,7 @@ export default class Journal { maxScore: isAvailable ? maxScore : -maxScore }; if (questionModel.get('_isSubmitted')) data.score = isAvailable ? score : -score; - this.pendingUpdateModifiers.push(data); + this._pendingUpdateModifiers.push(data); }); } @@ -128,7 +129,7 @@ export default class Journal { * @param {Backbone.Model} model */ _addCompletionModifiers(model) { - this.pendingUpdateModifiers.push({ + this._pendingUpdateModifiers.push({ modelId: model.get('_id'), score: this.getScoreByModel(model) }); @@ -143,7 +144,7 @@ export default class Journal { const hasSetDataChanged = !(_.isEqual(this._lastSetData, setData)); if (!hasSetDataChanged) return; const data = { ...setData }; - if (this.pendingUpdateModifiers.length) data.modifiers = this.pendingUpdateModifiers; + if (this._pendingUpdateModifiers.length) data.modifiers = this._pendingUpdateModifiers; Logging.info('scoring:update', JSON.stringify(data)); this._lastSetData = setData; } diff --git a/js/ScoringSet.js b/js/ScoringSet.js index e08b9a1..2dc0c64 100644 --- a/js/ScoringSet.js +++ b/js/ScoringSet.js @@ -2,7 +2,7 @@ import Adapt from 'core/js/adapt'; import Logging from 'core/js/logging'; import LifecycleSet from './LifecycleSet'; import Objective from './Objective'; -import Journal from './Journal'; +import LifecycleUpdateJournal from './LifecycleUpdateJournal'; import { getScaledScoreFromMinMax } from './utils/scoring'; @@ -58,7 +58,6 @@ export default class ScoringSet extends LifecycleSet { } = options; this.isScoreIncluded = _isScoreIncluded; this.isCompletionRequired = _isCompletionRequired; - this._pendingUpdateModels = new Set(); } /** @override */ @@ -244,11 +243,11 @@ export default class ScoringSet extends LifecycleSet { /** * The journal for recording the updates to the set. - * @returns {Journal} + * @returns {LifecycleUpdateJournal} */ get journal() { if (this.isIntersectedSet) return; - return (this._journal = this._journal || new Journal({ set: this })); + return (this._journal = this._journal || new LifecycleUpdateJournal({ set: this })); } /** @@ -280,14 +279,6 @@ export default class ScoringSet extends LifecycleSet { ], '_statusHash'); } - /** - * Add a model as having triggered this set's next update. - * @param {Backbone.Model} model - */ - addPendingUpdateModel(model) { - this._pendingUpdateModels.add(model); - } - /** @override */ async onInit() { if (this.isIntersectedSet) return; @@ -319,8 +310,6 @@ export default class ScoringSet extends LifecycleSet { 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(); - this.journal?.update(this._pendingUpdateModels); - this._pendingUpdateModels.clear(); await super.onUpdate(); } diff --git a/js/TotalLifecycleUpdateJournal.js b/js/TotalLifecycleUpdateJournal.js new file mode 100644 index 0000000..a146aa7 --- /dev/null +++ b/js/TotalLifecycleUpdateJournal.js @@ -0,0 +1,36 @@ +import LifecycleUpdateJournal from './LifecycleUpdateJournal'; +import { + getSubsetsByQuery +} from './utils/query'; +/** @typedef {import("./TotalSets").default} TotalSets */ + +export default class TotalLifecycleUpdateJournal extends LifecycleUpdateJournal { + + /** @override */ + getMinScoreByModel(model) { + if (!this.set.models.includes(model)) return 0; + return this._getTotalSetsByModelQuery(model).minScore; + } + + /** @override */ + getMaxScoreByModel(model) { + if (!this.set.models.includes(model)) return 0; + return this._getTotalSetsByModelQuery(model).maxScore; + } + + /** @override */ + getScoreByModel(model) { + if (!this.set.models.includes(model)) return 0; + return this._getTotalSetsByModelQuery(model).score; + } + + /** + * Returns the intersected `TotalSets` of the model. + * @param {Backbone.Model} model + * @returns {TotalSets} + */ + _getTotalSetsByModelQuery(model) { + return getSubsetsByQuery(`#${model.get('_id')} ${this.set.type}`)[0]; + } + +} diff --git a/js/TotalSets.js b/js/TotalSets.js index 80951b2..dc69ad6 100644 --- a/js/TotalSets.js +++ b/js/TotalSets.js @@ -1,6 +1,7 @@ import Adapt from 'core/js/adapt'; import Passmark from './Passmark'; import ScoringSet from './ScoringSet'; +import TotalLifecycleUpdateJournal from './TotalLifecycleUpdateJournal'; import { createIntersectedSet } from './utils/intersection'; @@ -185,4 +186,10 @@ export default class TotalSets extends ScoringSet { return this.scoringSets.some(set => set?.canReset); } + /** @override */ + get journal() { + if (this.isIntersectedSet) return; + return (this._journal = this._journal || new TotalLifecycleUpdateJournal({ set: this })); + } + } diff --git a/js/adapt-contrib-scoring.js b/js/adapt-contrib-scoring.js index d20fb9a..0aa72eb 100644 --- a/js/adapt-contrib-scoring.js +++ b/js/adapt-contrib-scoring.js @@ -23,7 +23,7 @@ import IntersectionSet from './IntersectionSet'; import LifecycleSet from './LifecycleSet'; import ScoringSet from './ScoringSet'; import Objective from './Objective'; -import Journal from './Journal'; +import LifecycleUpdateJournal from './LifecycleUpdateJournal'; import State from './State'; import StateModels from './StateModels'; import StateSetModelChildren from './StateSetModelChildren'; @@ -42,7 +42,7 @@ export { LifecycleSet, ScoringSet, Objective, - Journal, + LifecycleUpdateJournal, State, StateSetModelChildren, StateModels From ed442f32bf893375a412a47b253cf04b545fa899 Mon Sep 17 00:00:00 2001 From: "AzureAD\\DanGhost" Date: Wed, 15 Apr 2026 20:55:33 +0100 Subject: [PATCH 25/35] Amended the journal for `TotalSets` to use its sets as the modifiers rather than the models, so it is easier to understand how the scores were updated. Fixed issue with logging modifier score changes when models become unavailable. --- js/AdaptModelSet.js | 2 +- js/Lifecycle.js | 2 +- js/LifecycleUpdateJournal.js | 8 +++- js/TotalLifecycleUpdateJournal.js | 61 ++++++++++++++++++++++++------- 4 files changed, 56 insertions(+), 17 deletions(-) diff --git a/js/AdaptModelSet.js b/js/AdaptModelSet.js index 1eb695f..4b93963 100644 --- a/js/AdaptModelSet.js +++ b/js/AdaptModelSet.js @@ -74,7 +74,7 @@ export default class AdaptModelSet extends ScoringSet { /** @override */ get journal() { - // intentionally empty to prevent logging + return null; } /** @override */ diff --git a/js/Lifecycle.js b/js/Lifecycle.js index 0c888d9..8d880f9 100644 --- a/js/Lifecycle.js +++ b/js/Lifecycle.js @@ -258,7 +258,7 @@ export default class Lifecycle extends Backbone.Controller { */ async update(sets, model = null) { sets = sets.filter(set => !set.intersectionParent); - if (model) sets.forEach(set => set?.journal?.addPendingUpdateModel?.(model)); + if (model) sets.forEach(set => set?.journal?.addPendingUpdate?.(model, sets)); await this.renderer.render.update(sets); Adapt.trigger('scoring:lifecycle:update', this.scoring); } diff --git a/js/LifecycleUpdateJournal.js b/js/LifecycleUpdateJournal.js index 0ee5525..cdd2a26 100644 --- a/js/LifecycleUpdateJournal.js +++ b/js/LifecycleUpdateJournal.js @@ -18,15 +18,18 @@ export default class LifecycleUpdateJournal { constructor({ set } = {}) { this.set = set; this._pendingUpdateModels = new Set(); + this._pendingUpdateSets = new Set(); this._pendingUpdateModifiers = []; } /** - * Add the model as having triggered this set's next update. + * Add the model and intersected sets having triggered this set's next update. * @param {Backbone.Model} model + * @param {ScoringSet[]} [sets] */ - addPendingUpdateModel(model) { + addPendingUpdate(model, sets) { this._pendingUpdateModels.add(model); + sets?.forEach(set => this._pendingUpdateSets.add(set)); } /** @@ -36,6 +39,7 @@ export default class LifecycleUpdateJournal { this._pendingUpdateModels.forEach(model => this._addUpdateModifiers(model)); this._write(); this._pendingUpdateModels.clear(); + this._pendingUpdateSets.clear(); this._pendingUpdateModifiers = []; } diff --git a/js/TotalLifecycleUpdateJournal.js b/js/TotalLifecycleUpdateJournal.js index a146aa7..e4b6fec 100644 --- a/js/TotalLifecycleUpdateJournal.js +++ b/js/TotalLifecycleUpdateJournal.js @@ -1,27 +1,53 @@ import LifecycleUpdateJournal from './LifecycleUpdateJournal'; +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 */ export default class TotalLifecycleUpdateJournal extends LifecycleUpdateJournal { - /** @override */ - getMinScoreByModel(model) { - if (!this.set.models.includes(model)) return 0; - return this._getTotalSetsByModelQuery(model).minScore; - } - - /** @override */ - getMaxScoreByModel(model) { - if (!this.set.models.includes(model)) return 0; - return this._getTotalSetsByModelQuery(model).maxScore; + /** + * @override + * Using intersection queries doesn't log modifier scores correctly when models become unavailable. + * Intersection queries only include available models - retrieve scores from other journals accordingly. + */ + _addAvailabilityModifiers(model) { + const models = model.hasManagedChildren ? model.getChildren() : [model]; + const sets = this.set.scoringSets.filter(set => this._pendingUpdateSets.has(set)); + sets.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 = { + id: set.id, + minScore: isAvailable ? minScore : -minScore, + maxScore: isAvailable ? maxScore : -maxScore + }; + if (score !== 0) data.score = isAvailable ? score : -score; + this._pendingUpdateModifiers.push(data); + }); } /** @override */ - getScoreByModel(model) { - if (!this.set.models.includes(model)) return 0; - return this._getTotalSetsByModelQuery(model).score; + _addCompletionModifiers(model) { + const sets = this._getScoringSetsByModel(model); + sets.forEach(set => { + this._pendingUpdateModifiers.push({ + id: set.id, + score: set.score + }); + }); } /** @@ -33,4 +59,13 @@ export default class TotalLifecycleUpdateJournal extends LifecycleUpdateJournal return getSubsetsByQuery(`#${model.get('_id')} ${this.set.type}`)[0]; } + /** + * Returns the intersected scoring sets of the model. + * @param {Backbone.Model} model + * @returns {ScoringSet[]} + */ + _getScoringSetsByModel(model) { + return this._getTotalSetsByModelQuery(model)?.scoringSets ?? []; + } + } From e24a0dbbc36f184b7964bb7ad73d243ec64de427 Mon Sep 17 00:00:00 2001 From: "AzureAD\\DanGhost" Date: Thu, 16 Apr 2026 12:11:46 +0100 Subject: [PATCH 26/35] Exported some missing set utility functions. --- INTERSECTION_QUERY.md | 4 ++-- js/IntersectionSet.js | 2 +- js/TotalSets.js | 12 +++++----- js/adapt-contrib-scoring.js | 44 ++++++++++++++----------------------- js/utils/intersection.js | 1 - js/utils/sets.js | 28 ++++++++++++++++++++++- 6 files changed, 53 insertions(+), 38 deletions(-) diff --git a/INTERSECTION_QUERY.md b/INTERSECTION_QUERY.md index 2055c30..bf0ebba 100644 --- a/INTERSECTION_QUERY.md +++ b/INTERSECTION_QUERY.md @@ -58,9 +58,9 @@ Here you can use these additional literal attributes for queries: * `(isFailed)` is and alias `(isComplete,isPassed=false)` ### TotalSets -This is a set of sets, it can sum the scores of the root or intersecting sets. +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 root or intersecting scoring sets and completion sets rather than root or intersecting models. +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. diff --git a/js/IntersectionSet.js b/js/IntersectionSet.js index 22ae98f..cb90cf1 100644 --- a/js/IntersectionSet.js +++ b/js/IntersectionSet.js @@ -103,7 +103,7 @@ export default class IntersectionSet extends Backbone.Controller { * @fires Adapt#scoring:set:register */ register() { - // Only register root sets, intersection subsets are dynamically created when required + // Only register configured sets, intersection subsets are dynamically created when required if (this.isIntersectedSet) return; assignAutoId(this); Adapt.scoring.register(this); diff --git a/js/TotalSets.js b/js/TotalSets.js index dc69ad6..22d9f8f 100644 --- a/js/TotalSets.js +++ b/js/TotalSets.js @@ -14,10 +14,10 @@ import { } from './utils/math'; /** - * A set of sets, it can sum the scores of the root or intersecting sets. + * 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 root or - * intersecting scoring sets and completion sets rather than root or intersecting models. + * 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. */ @@ -142,7 +142,7 @@ export default class TotalSets extends ScoringSet { } /** - * Returns whether all root sets marked with `_isCompletionRequired` are completed. + * Returns whether all registered sets marked with `_isCompletionRequired` are completed. * @override * @returns {boolean} */ @@ -168,7 +168,7 @@ export default class TotalSets extends ScoringSet { } /** - * Returns whether any root sets marked with `_isScoreIncluded` are failed and cannot be reset. + * Returns whether any registered sets marked with `_isScoreIncluded` are failed and cannot be reset. * @override * @todo Add `canReset` to `ScoringSet`? * @returns {boolean} @@ -178,7 +178,7 @@ export default class TotalSets extends ScoringSet { } /** - * Returns whether any root sets marked with `_isScoreIncluded` can be reset. + * Returns whether any registered sets marked with `_isScoreIncluded` can be reset. * @override * @returns {boolean} */ diff --git a/js/adapt-contrib-scoring.js b/js/adapt-contrib-scoring.js index 0aa72eb..293fd10 100644 --- a/js/adapt-contrib-scoring.js +++ b/js/adapt-contrib-scoring.js @@ -3,9 +3,9 @@ import { getSubsetsByQuery } from './utils/query'; import { - filterSetsByType, - filterSetsByIntersectingModelId, - findSetById + getSetById, + getSetsByType, + getSetsByIntersectingModelId } from './utils/sets'; import { getPathSetsIntersected @@ -96,7 +96,7 @@ export class Scoring extends Backbone.Controller { } /** - * Returns registered root sets. + * Returns registered sets. * @returns {IntersectionSet[]} */ get sets() { @@ -104,7 +104,7 @@ export class Scoring extends Backbone.Controller { } /** - * Removes all registered root sets. + * Removes all registered sets. */ clear() { this._sets?.forEach(set => this.deregister(set)); @@ -112,7 +112,7 @@ export class Scoring extends Backbone.Controller { } /** - * Register a configured root scoring set. + * Register a configured scoring set. * This is usually performed automatically upon IntersectionSet instantiation. * @param {IntersectionSet} newSet * @fires Adapt#{set.type}:register @@ -127,7 +127,7 @@ export class Scoring extends Backbone.Controller { } /** - * Deregister a configured root scoring set. + * Deregister a configured scoring set. * @param {IntersectionSet} oldSet * @fires Adapt#{set.type}:deregister * @fires Adapt#scoring:deregister @@ -150,7 +150,7 @@ export class Scoring extends Backbone.Controller { } /** - * Reset all subsets which can be reset. + * Reset all registered sets which can be reset. * @fires Adapt#scoring:reset via lifecycle */ async reset() { @@ -160,45 +160,44 @@ export class Scoring extends Backbone.Controller { } /** - * Returns a registered root set by id. + * Returns a registered set by id. * @param {string} id * @returns {IntersectionSet} */ getSetById(id) { - return findSetById(this.sets, id); + return getSetById(id); } /** - * Returns registered root sets of type. + * Returns registered sets of type. * @param {string} type * @returns {IntersectionSet[]} */ getSetsByType(type) { - return filterSetsByType(this.sets, type); + return getSetsByType(type); } /** - * Returns registered root sets intersecting the given model id. + * Returns registered sets intersecting the given model id. * @param {string} id * @returns {IntersectionSet[]} */ getSetsByIntersectingModelId(id) { - return filterSetsByIntersectingModelId(this.sets, id); + return getSetsByIntersectingModelId(id); } /** - * Returns a root set or intersection set by id path. + * Returns a registered set or intersection set by id path. * example: id.id.id * @param {string|[string]} path * @returns {IntersectionSet} */ getSubsetByPath(path) { - const sets = getPathSetsIntersected(path); - return sets; + return getPathSetsIntersected(path); } /** - * Returns sets or intersection sets by query. + * Returns registered sets or intersection sets by query. * @param {string} query * @returns {IntersectionSet[]} */ @@ -206,15 +205,6 @@ export class Scoring extends Backbone.Controller { return getSubsetsByQuery(query); } - /** - * Returns a set or intersection set by query. - * @param {string} query - * @returns {IntersectionSet} - */ - getSubsetByQuery(query) { - return getSubsetsByQuery(query)[0]; - } - } export default (Adapt.scoring = new Scoring()); diff --git a/js/utils/intersection.js b/js/utils/intersection.js index 76cb1f3..0e43bda 100644 --- a/js/utils/intersection.js +++ b/js/utils/intersection.js @@ -2,7 +2,6 @@ import { getAllSets, findSetById } from './sets'; - /** @typedef {import("../IntersectionSet").default} IntersectionSet */ /** diff --git a/js/utils/sets.js b/js/utils/sets.js index d0b3f2e..2b81c63 100644 --- a/js/utils/sets.js +++ b/js/utils/sets.js @@ -6,7 +6,6 @@ import { import { unique } from './math'; - /** @typedef {import("../IntersectionSet").default} IntersectionSet */ /** @@ -20,6 +19,33 @@ export function getAllSets({ excludeParent = null } = {}) { return Adapt.scoring.sets.filter(set => !(set.id === excludeParent.id && set.type === excludeParent.type)); } +/** + * Returns a registered set by id. + * @param {string} id + * @returns {IntersectionSet} + */ +export function getSetById(id) { + return findSetById(getAllSets(), id); +} + +/** + * Returns registered sets of type. + * @param {string} type + * @returns {IntersectionSet[]} + */ +export function getSetsByType(type) { + return filterSetsByType(getAllSets(), type); +} + +/** + * Returns registered sets intersecting the given model id. + * @param {string} id + * @returns {IntersectionSet[]} + */ +export function getSetsByIntersectingModelId(id) { + return filterSetsByIntersectingModelId(getAllSets(), id); +} + /** * Filters sets by type. * @param {IntersectionSet[]} sets From 7ee59ad6720ba7ed062e45c66fd7f32bb726a298 Mon Sep 17 00:00:00 2001 From: "AzureAD\\DanGhost" Date: Thu, 16 Apr 2026 18:13:50 +0100 Subject: [PATCH 27/35] Exported `Passmark` so this can be used by other plugins (could already but would need a direct import of the file, so exposed in same way as other methods and classes). --- js/adapt-contrib-scoring.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/js/adapt-contrib-scoring.js b/js/adapt-contrib-scoring.js index 293fd10..be6ca7a 100644 --- a/js/adapt-contrib-scoring.js +++ b/js/adapt-contrib-scoring.js @@ -1,4 +1,5 @@ import Adapt from 'core/js/adapt'; +import data from 'core/js/data'; import { getSubsetsByQuery } from './utils/query'; @@ -15,9 +16,7 @@ import { setupBackwardCompatibility } from './compatibility'; import './helpers'; -import Backbone from 'backbone'; import Lifecycle from './Lifecycle'; -import data from 'core/js/data'; import AdaptModelSet from './AdaptModelSet'; import IntersectionSet from './IntersectionSet'; import LifecycleSet from './LifecycleSet'; @@ -27,7 +26,9 @@ import LifecycleUpdateJournal from './LifecycleUpdateJournal'; import State from './State'; import StateModels from './StateModels'; import StateSetModelChildren from './StateSetModelChildren'; +import Passmark from './Passmark'; import TotalSets from './TotalSets'; +import Backbone from 'backbone'; export * from './utils/hash'; export * from './utils/intersection'; @@ -45,7 +46,8 @@ export { LifecycleUpdateJournal, State, StateSetModelChildren, - StateModels + StateModels, + Passmark }; /** From dcd16d4998e5d8a66b33aa2539058d818f24d4b7 Mon Sep 17 00:00:00 2001 From: Oliver Foster <7974663+oliverfoster@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:05:02 +0100 Subject: [PATCH 28/35] Journal refactor (#31) --- js/LifecycleSet.js | 10 +++ js/LifecycleUpdateJournal.js | 141 +++--------------------------- js/ScoringSet.js | 6 +- js/ScoringUpdateJournal.js | 126 ++++++++++++++++++++++++++ js/TotalLifecycleUpdateJournal.js | 71 --------------- js/TotalSets.js | 4 +- js/TotalSetsUpdateJournal.js | 63 +++++++++++++ js/adapt-contrib-scoring.js | 33 ++++--- 8 files changed, 237 insertions(+), 217 deletions(-) create mode 100644 js/ScoringUpdateJournal.js delete mode 100644 js/TotalLifecycleUpdateJournal.js create mode 100644 js/TotalSetsUpdateJournal.js diff --git a/js/LifecycleSet.js b/js/LifecycleSet.js index 61fa126..d7f08b8 100644 --- a/js/LifecycleSet.js +++ b/js/LifecycleSet.js @@ -2,6 +2,7 @@ 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. @@ -20,6 +21,15 @@ export default class LifecycleSet extends IntersectionSet { 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} diff --git a/js/LifecycleUpdateJournal.js b/js/LifecycleUpdateJournal.js index cdd2a26..6120050 100644 --- a/js/LifecycleUpdateJournal.js +++ b/js/LifecycleUpdateJournal.js @@ -1,13 +1,7 @@ -import Logging from 'core/js/logging'; -import { - filterModelsByIntersectingModels, - isModelAvailableInHierarchy -} from './utils/models'; -import _ from 'underscore'; /** @typedef {import("./ScoringSet").default} ScoringSet */ /** - * A journal for recording the lifecycle updates to a set. + * A journal for recording the models and sets that triggered set updates in the current lifecycle. */ export default class LifecycleUpdateJournal { @@ -17,140 +11,33 @@ export default class LifecycleUpdateJournal { */ constructor({ set } = {}) { this.set = set; - this._pendingUpdateModels = new Set(); - this._pendingUpdateSets = new Set(); - this._pendingUpdateModifiers = []; + this.pendingUpdateModels = new Set(); + this.pendingUpdateSets = new Set(); } /** - * Add the model and intersected sets having triggered this set's next update. - * @param {Backbone.Model} model - * @param {ScoringSet[]} [sets] + * 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)); + this.pendingUpdateModels.add(model); + sets?.forEach(set => this.pendingUpdateSets.add(set)); } /** - * Update the journal for the models pending updates. + * Update lifecycle phase has ended */ update() { - this._pendingUpdateModels.forEach(model => this._addUpdateModifiers(model)); - this._write(); - this._pendingUpdateModels.clear(); - this._pendingUpdateSets.clear(); - this._pendingUpdateModifiers = []; + this.clear(); } /** - * Returns the minimum score for the specified model. - * @param {Backbone.Model} model - * @returns {number} + * Clear for next pending updates. */ - 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; - } - - /** - * 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 - }; - } - - /** - * Add modifier details for how the set has been updated. - * @protected - * @param {Backbone.Model} model - */ - _addUpdateModifiers(model) { - const isAvailabilityChange = Object.hasOwn(model.changed, '_isAvailable'); - if (isAvailabilityChange) { - this._addAvailabilityModifiers(model); - return; - } - 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 = filterModelsByIntersectingModels(this.set.questions, models); - questions.forEach(questionModel => { - const isAvailable = isModelAvailableInHierarchy(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._pendingUpdateModifiers.push(data); - }); - } - - /** - * Add modifier details for how the set has been updated by completion changes. - * @protected - * @param {Backbone.Model} model - */ - _addCompletionModifiers(model) { - this._pendingUpdateModifiers.push({ - modelId: model.get('_id'), - score: this.getScoreByModel(model) - }); - } - - /** - * Write the current state to the log if it has changed since the last update. - * @protected - */ - _write() { - const setData = this.setData; - const hasSetDataChanged = !(_.isEqual(this._lastSetData, setData)); - if (!hasSetDataChanged) return; - const data = { ...setData }; - if (this._pendingUpdateModifiers.length) data.modifiers = this._pendingUpdateModifiers; - Logging.info('scoring:update', JSON.stringify(data)); - this._lastSetData = setData; + clear() { + this.pendingUpdateModels.clear(); + this.pendingUpdateSets.clear(); } } diff --git a/js/ScoringSet.js b/js/ScoringSet.js index 2dc0c64..cd7571d 100644 --- a/js/ScoringSet.js +++ b/js/ScoringSet.js @@ -2,7 +2,7 @@ import Adapt from 'core/js/adapt'; import Logging from 'core/js/logging'; import LifecycleSet from './LifecycleSet'; import Objective from './Objective'; -import LifecycleUpdateJournal from './LifecycleUpdateJournal'; +import ScoringUpdateJournal from './ScoringUpdateJournal'; import { getScaledScoreFromMinMax } from './utils/scoring'; @@ -243,11 +243,11 @@ export default class ScoringSet extends LifecycleSet { /** * The journal for recording the updates to the set. - * @returns {LifecycleUpdateJournal} + * @returns {ScoringUpdateJournal} */ get journal() { if (this.isIntersectedSet) return; - return (this._journal = this._journal || new LifecycleUpdateJournal({ set: this })); + return (this._journal = this._journal || new ScoringUpdateJournal({ set: this })); } /** 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/TotalLifecycleUpdateJournal.js b/js/TotalLifecycleUpdateJournal.js deleted file mode 100644 index e4b6fec..0000000 --- a/js/TotalLifecycleUpdateJournal.js +++ /dev/null @@ -1,71 +0,0 @@ -import LifecycleUpdateJournal from './LifecycleUpdateJournal'; -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 */ - -export default class TotalLifecycleUpdateJournal extends LifecycleUpdateJournal { - - /** - * @override - * Using intersection queries doesn't log modifier scores correctly when models become unavailable. - * Intersection queries only include available models - retrieve scores from other journals accordingly. - */ - _addAvailabilityModifiers(model) { - const models = model.hasManagedChildren ? model.getChildren() : [model]; - const sets = this.set.scoringSets.filter(set => this._pendingUpdateSets.has(set)); - sets.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 = { - id: set.id, - minScore: isAvailable ? minScore : -minScore, - maxScore: isAvailable ? maxScore : -maxScore - }; - if (score !== 0) data.score = isAvailable ? score : -score; - this._pendingUpdateModifiers.push(data); - }); - } - - /** @override */ - _addCompletionModifiers(model) { - const sets = this._getScoringSetsByModel(model); - sets.forEach(set => { - this._pendingUpdateModifiers.push({ - id: set.id, - score: set.score - }); - }); - } - - /** - * Returns the intersected `TotalSets` of the model. - * @param {Backbone.Model} model - * @returns {TotalSets} - */ - _getTotalSetsByModelQuery(model) { - return getSubsetsByQuery(`#${model.get('_id')} ${this.set.type}`)[0]; - } - - /** - * Returns the intersected scoring sets of the model. - * @param {Backbone.Model} model - * @returns {ScoringSet[]} - */ - _getScoringSetsByModel(model) { - return this._getTotalSetsByModelQuery(model)?.scoringSets ?? []; - } - -} diff --git a/js/TotalSets.js b/js/TotalSets.js index 22d9f8f..de9c87f 100644 --- a/js/TotalSets.js +++ b/js/TotalSets.js @@ -1,7 +1,7 @@ import Adapt from 'core/js/adapt'; import Passmark from './Passmark'; import ScoringSet from './ScoringSet'; -import TotalLifecycleUpdateJournal from './TotalLifecycleUpdateJournal'; +import TotalSetsUpdateJournal from './TotalSetsUpdateJournal'; import { createIntersectedSet } from './utils/intersection'; @@ -189,7 +189,7 @@ export default class TotalSets extends ScoringSet { /** @override */ get journal() { if (this.isIntersectedSet) return; - return (this._journal = this._journal || new TotalLifecycleUpdateJournal({ set: this })); + 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 be6ca7a..880225b 100644 --- a/js/adapt-contrib-scoring.js +++ b/js/adapt-contrib-scoring.js @@ -1,5 +1,19 @@ import Adapt from 'core/js/adapt'; 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 { getSubsetsByQuery } from './utils/query'; @@ -16,18 +30,6 @@ import { setupBackwardCompatibility } from './compatibility'; import './helpers'; -import Lifecycle from './Lifecycle'; -import AdaptModelSet from './AdaptModelSet'; -import IntersectionSet from './IntersectionSet'; -import LifecycleSet from './LifecycleSet'; -import ScoringSet from './ScoringSet'; -import Objective from './Objective'; -import LifecycleUpdateJournal from './LifecycleUpdateJournal'; -import State from './State'; -import StateModels from './StateModels'; -import StateSetModelChildren from './StateSetModelChildren'; -import Passmark from './Passmark'; -import TotalSets from './TotalSets'; import Backbone from 'backbone'; export * from './utils/hash'; @@ -42,12 +44,15 @@ export { IntersectionSet, LifecycleSet, ScoringSet, + TotalSets, + Passmark, Objective, LifecycleUpdateJournal, + ScoringUpdateJournal, + TotalSetsUpdateJournal, State, StateSetModelChildren, - StateModels, - Passmark + StateModels }; /** From a52ae23ec7da111e27181f997f3a5494a14e088c Mon Sep 17 00:00:00 2001 From: "AzureAD\\DanGhost" Date: Fri, 17 Apr 2026 15:27:37 +0100 Subject: [PATCH 29/35] Added guard checks to prevent false positives when checking `isComplete` and `isPassed`. Prevented potential for last registered set to be removed if attempting to deregister a set that hadn't been registered (no path in the current code where deregister receives an unregistered set, but added defensive coding in case a set authour breaks the inheritance chain). --- js/ScoringSet.js | 3 ++- js/TotalSets.js | 5 ++++- js/adapt-contrib-scoring.js | 1 + 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/js/ScoringSet.js b/js/ScoringSet.js index cd7571d..82f4d80 100644 --- a/js/ScoringSet.js +++ b/js/ScoringSet.js @@ -210,7 +210,8 @@ export default class ScoringSet extends LifecycleSet { * @returns {boolean} */ get isComplete() { - return this.availableModels.every(model => model.get('_isComplete')); + const availableModels = this.availableModels; + return availableModels.length > 0 && availableModels.every(model => model.get('_isComplete')); } /** diff --git a/js/TotalSets.js b/js/TotalSets.js index de9c87f..b27f70b 100644 --- a/js/TotalSets.js +++ b/js/TotalSets.js @@ -147,7 +147,8 @@ export default class TotalSets extends ScoringSet { * @returns {boolean} */ get isComplete() { - return this.completionSets.every(set => set.isComplete); + const completionSets = this.completionSets; + return completionSets.length > 0 && completionSets.every(set => set.isComplete); } /** @@ -160,6 +161,8 @@ export default class TotalSets extends ScoringSet { // 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 scoringSets = this.scoringSets; + const isEverySubsetPassed = scoringSets.length > 0 && scoringSets.every(set => set.isPassed); const isScaled = this.passmark.isScaled; const score = (isScaled) ? this.scaledScore : this.score; const correctness = (isScaled) ? this.scaledCorrectness : this.correctness; diff --git a/js/adapt-contrib-scoring.js b/js/adapt-contrib-scoring.js index 880225b..3e5f05a 100644 --- a/js/adapt-contrib-scoring.js +++ b/js/adapt-contrib-scoring.js @@ -141,6 +141,7 @@ export class Scoring extends Backbone.Controller { */ 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); From 5a461a1a731d525e0687049a480a818f11cef1bc Mon Sep 17 00:00:00 2001 From: "AzureAD\\DanGhost" Date: Fri, 17 Apr 2026 15:28:15 +0100 Subject: [PATCH 30/35] Fixed backwards compatibility issue introduced with c2b06d5404a1d9e52ac3d4b169e14436bfc965b3. --- js/compatibility.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/js/compatibility.js b/js/compatibility.js index 81d7d9f..3dc2a79 100644 --- a/js/compatibility.js +++ b/js/compatibility.js @@ -27,11 +27,11 @@ export function setupBackwardCompatibility(scoring) { getState: () => getCompatibilityState(scoring) }; Adapt - .off('scoring:restored', onScoringRestored) - .on('scoring:restored', onScoringRestored); + .off('scoring:total:restored', onScoringRestored) + .on('scoring:total:restored', onScoringRestored); Adapt - .off('scoring:complete', onScoringComplete) - .on('scoring:complete', onScoringComplete); + .off('scoring:total:complete', onScoringComplete) + .on('scoring:total:complete', onScoringComplete); } /** From 2ffa801ac2cc782732363ba3944b8c04fca1df4a Mon Sep 17 00:00:00 2001 From: "AzureAD\\DanGhost" Date: Tue, 21 Apr 2026 18:19:45 +0100 Subject: [PATCH 31/35] Amended passed/failed logic so this is only relevant for sets with a passmark criteria (fixes #28). --- js/AdaptModelSet.js | 7 ++++++- js/Objective.js | 4 ++-- js/ScoringSet.js | 18 ++++++++++++++---- js/TotalSets.js | 20 ++++++++++---------- js/compatibility.js | 2 +- js/utils/sets.js | 12 ++++++++++++ 6 files changed, 45 insertions(+), 18 deletions(-) diff --git a/js/AdaptModelSet.js b/js/AdaptModelSet.js index 4b93963..aee10ae 100644 --- a/js/AdaptModelSet.js +++ b/js/AdaptModelSet.js @@ -52,9 +52,14 @@ export default class AdaptModelSet extends ScoringSet { return 100 - this.model.getAncestorModels(true).length; } + /** @override */ + get isPassed() { + return null; + } + /** @override */ get isFailed() { - return false; + return null; } /** @override */ diff --git a/js/Objective.js b/js/Objective.js index ac60ed6..27eec7e 100644 --- a/js/Objective.js +++ b/js/Objective.js @@ -58,7 +58,7 @@ export default class Objective { if (isAvailable && isStarted && isIncomplete) completionStatus = COMPLETION_STATE.INCOMPLETE.asLowerCase; if (isAvailable && isComplete) { completionStatus = COMPLETION_STATE.COMPLETED.asLowerCase; - if (this.set.passmark?.isEnabled) successStatus = (isPassed ? COMPLETION_STATE.PASSED : COMPLETION_STATE.FAILED).asLowerCase; + if (this.set.hasPassmark) successStatus = (isPassed ? COMPLETION_STATE.PASSED : COMPLETION_STATE.FAILED).asLowerCase; } offlineStorage.set('objectiveStatus', this.id, completionStatus, successStatus); } @@ -75,7 +75,7 @@ export default class Objective { /** * 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} + * @returns {boolean|null} */ get isPassed() { return this.set.isPassed; diff --git a/js/ScoringSet.js b/js/ScoringSet.js index 82f4d80..8ff06c5 100644 --- a/js/ScoringSet.js +++ b/js/ScoringSet.js @@ -223,23 +223,33 @@ export default class ScoringSet extends LifecycleSet { return this.isComplete === false; } + /** + * Returns whether a passmark is configured and enabled for this set. + * @returns {boolean} + */ + get hasPassmark() { + return this.passmark?.isEnabled ?? false; + } + /** * Returns whether the configured passmark has been achieved. + * Subclasses with a passmark override this to return a boolean verdict. * query example: `(isPassed)` - * @returns {boolean} + * @returns {boolean|null} */ get isPassed() { - return this.isComplete; + return null; } /** * 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 isFailed() { - if (!this.isSubmitted) return null; - return (this.isPassed === false); + if (!this.hasPassmark) return null; + return this.isComplete && this.isPassed === false; } /** diff --git a/js/TotalSets.js b/js/TotalSets.js index b27f70b..c934441 100644 --- a/js/TotalSets.js +++ b/js/TotalSets.js @@ -6,7 +6,8 @@ import { createIntersectedSet } from './utils/intersection'; import { - filterSetsByIntersectingModels + filterSetsByIntersectingModels, + isEverySetPassed } from './utils/sets'; import { unique, @@ -153,30 +154,29 @@ export default class TotalSets extends ScoringSet { /** * 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 - * @returns {boolean} + * @returns {boolean|null} */ 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 scoringSets = this.scoringSets; - const isEverySubsetPassed = scoringSets.length > 0 && scoringSets.every(set => set.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 && isEverySubsetPassed : isPassed; + 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 - * @todo Add `canReset` to `ScoringSet`? - * @returns {boolean} + * @returns {boolean|null} */ get isFailed() { + if (!this.hasPassmark) return null; return this.isComplete && !this.isPassed && !this.canReset; } diff --git a/js/compatibility.js b/js/compatibility.js index 3dc2a79..d38f806 100644 --- a/js/compatibility.js +++ b/js/compatibility.js @@ -42,7 +42,7 @@ export function getCompatibilityState(scoring) { const state = { isComplete: scoring.total.isComplete, isPercentageBased: scoring.total.passmark.isScaled, - isPass: scoring.total.isPassed, + isPass: scoring.total.isPassed ?? scoring.total.isComplete, maxScore: scoring.total.maxScore, minScore: scoring.total.minScore, score: scoring.total.score, diff --git a/js/utils/sets.js b/js/utils/sets.js index 2b81c63..8529e39 100644 --- a/js/utils/sets.js +++ b/js/utils/sets.js @@ -134,3 +134,15 @@ export function filterSetsByLocalModelId(sets, id) { export function findSetById(sets, id) { return sets.find(set => id === set.id); } + +/** + * Returns whether every set with a passmark has passed. + * Sets without a passmark are excluded from the check. + * Returns false if no sets with a passmark exist. + * @param {ScoringSet[]} sets + * @returns {boolean} + */ +export function isEverySetPassed(sets) { + const applicableSets = sets.filter(set => set.hasPassmark); + return applicableSets.length > 0 && applicableSets.every(set => set.isPassed); +} From d8fcc39c40a91c9e98e3249faef19919ed097674 Mon Sep 17 00:00:00 2001 From: "AzureAD\\DanGhost" Date: Tue, 21 Apr 2026 18:26:19 +0100 Subject: [PATCH 32/35] Prevent direct calls to `reset` when `canReset:false`. Fixed jsdoc issues in `Lifecycle`. --- js/Lifecycle.js | 18 +++++++++--------- js/LifecycleSet.js | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/js/Lifecycle.js b/js/Lifecycle.js index 8d880f9..2850cb5 100644 --- a/js/Lifecycle.js +++ b/js/Lifecycle.js @@ -70,7 +70,7 @@ export default class Lifecycle extends Backbone.Controller { * Listens to a newly registered Set's reset and update events. * @listens AdaptModelSet#reset * @listens AdaptModelSet#update - * @param {InteractionSet} newSet + * @param {IntersectionSet} newSet */ onScoringSetRegister(newSet) { this.listenTo(newSet, { @@ -195,7 +195,7 @@ export default class Lifecycle extends Backbone.Controller { /** * Send all sets into the restore phase. - * @fires Adapt#scoring:restored + * @fires Adapt#scoring:lifecycle:restored */ async restore() { const sets = getAllSets(); @@ -205,7 +205,7 @@ export default class Lifecycle extends Backbone.Controller { /** * Send all sets into the start phase. - * @fires Adapt#scoring:start + * @fires Adapt#scoring:lifecycle:start */ async start() { const sets = getAllSets(); @@ -215,7 +215,7 @@ export default class Lifecycle extends Backbone.Controller { /** * Send all sets into the reset phase. - * @fires Adapt#scoring:reset + * @fires Adapt#scoring:lifecycle:reset */ async reset() { const sets = getAllSets(); @@ -225,7 +225,7 @@ export default class Lifecycle extends Backbone.Controller { /** * Send givens sets into the restart phase. - * @param {InteractionSet[]} sets + * @param {IntersectionSet[]} sets */ async restart(sets) { sets = sets.filter(set => !set.intersectionParent); @@ -234,7 +234,7 @@ export default class Lifecycle extends Backbone.Controller { /** * Send givens sets into the leave phase. - * @param {InteractionSet[]} sets + * @param {IntersectionSet[]} sets */ async leave(sets) { sets = sets.filter(set => !set.intersectionParent); @@ -243,7 +243,7 @@ export default class Lifecycle extends Backbone.Controller { /** * Send givens sets into the visit phase. - * @param {InteractionSet[]} sets + * @param {IntersectionSet[]} sets */ async visit(sets) { sets = sets.filter(set => !set.intersectionParent); @@ -252,9 +252,9 @@ export default class Lifecycle extends Backbone.Controller { /** * Send givens sets into the update phase. - * @param {InteractionSet[]} sets + * @param {IntersectionSet[]} sets * @param {Backbone.Model} model - * @fires Adapt#scoring:update + * @fires Adapt#scoring:lifecycle:update */ async update(sets, model = null) { sets = sets.filter(set => !set.intersectionParent); diff --git a/js/LifecycleSet.js b/js/LifecycleSet.js index d7f08b8..0d04fcd 100644 --- a/js/LifecycleSet.js +++ b/js/LifecycleSet.js @@ -119,7 +119,7 @@ export default class LifecycleSet extends IntersectionSet { * @fires Adapt#scoring:set:reset */ async reset() { - if (this.isIntersectedSet) return; + 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); From 0ab116e0c89361b92fa185d8ece8ea7450d2d5c2 Mon Sep 17 00:00:00 2001 From: "AzureAD\\DanGhost" Date: Wed, 22 Apr 2026 22:39:28 +0100 Subject: [PATCH 33/35] Updated the handebars helpers. --- js/helpers.js | 70 ++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 58 insertions(+), 12 deletions(-) diff --git a/js/helpers.js b/js/helpers.js index bd4714c..9f0febb 100644 --- a/js/helpers.js +++ b/js/helpers.js @@ -5,25 +5,71 @@ import { import { sum } from './utils/math'; +import { + getScaledScoreFromMinMax +} 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
    {{#each (subsetsQuery '[modelId=this](isScoreIncluded)')}}
  • {{{this._title}}}
  • {{/each}}
- list all sets which intersect this model and are included in the total + * @example
    {{#each (subsetsQuery '[isScoreIncluded](isPassed)')}}
  • {{{this._title}}}
  • {{/each}}
- list all passed sets which are included in the total + * @param {string} query + * @param {*} context + * @returns {IntersectionSet[]} + */ + subsetsQuery(query, context) { + return getSubsetsFromQueryContext(query, context); + }, + + /** + * Returns the summed score for the intersected subsets of the intersection query string. + * @see {@link ../INTERSECTION_QUERY.md INTERSECTION_QUERY} + * @example {{{scoreQuery '#this'}}} - score for this model + * @example {{{scoreQuery '#this total'}}} - summed score for sets which intersect this model and are included in the total + * @param {string} query + * @param {*} context + * @returns {number} + */ scoreQuery(query, context) { - const modelId = context?.data?.root?._id; - query = query.replace('this', `#${modelId}`); - const sets = getSubsetsByQuery(query); + const sets = getSubsetsFromQueryContext(query, context); return sum(sets, 'score'); - } + }, - // score(context) { - // if (!context) throw Error('No context for score helper.'); - // const root = context.data.root; - // const modelId = root._id; - // const model = data.findById(modelId); - // const score = model.score; - // return score; - // } + /** + * Returns the scaledScore for the intersected subsets of the intersection query string. + * @see {@link ../INTERSECTION_QUERY.md INTERSECTION_QUERY} + * @example {{{scaledScoreQuery '#this'}}} - scaledScore for this model + * @example {{{scaledScoreQuery '#this total'}}} - scaledScore for sets which intersect this model and are included in the total + * @param {string} query + * @param {*} context + * @returns {number} + */ + scaledScoreQuery(query, context) { + const sets = getSubsetsFromQueryContext(query, context); + const score = sum(sets, 'score'); + const minScore = sum(sets, 'minScore'); + const maxScore = sum(sets, 'maxScore'); + return getScaledScoreFromMinMax(score, minScore, maxScore); + } }; for (const name in helpers) { From 71a5dabb399f08407cdaee36e0af262b39f9136e Mon Sep 17 00:00:00 2001 From: "AzureAD\\DanGhost" Date: Fri, 24 Apr 2026 13:28:03 +0100 Subject: [PATCH 34/35] Added `averageScaledScore` to allow a distinction between point-weighted and set-weighted scaled scores. See https://github.com/adaptlearning/adapt-contrib-scoring/pull/30/changes#r3129702139. --- js/ScoringSet.js | 10 ++++++++++ js/TotalSets.js | 12 ++++++++++++ js/helpers.js | 23 ++++++++++++++++++++++- js/utils/query.js | 1 + js/utils/scoring.js | 16 ++++++++++++++++ 5 files changed, 61 insertions(+), 1 deletion(-) diff --git a/js/ScoringSet.js b/js/ScoringSet.js index 8ff06c5..77e7334 100644 --- a/js/ScoringSet.js +++ b/js/ScoringSet.js @@ -115,12 +115,22 @@ export default class ScoringSet extends LifecycleSet { /** * 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() { return getScaledScoreFromMinMax(this.score, this.minScore, this.maxScore); } + /** + * 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} diff --git a/js/TotalSets.js b/js/TotalSets.js index c934441..a3fb0d9 100644 --- a/js/TotalSets.js +++ b/js/TotalSets.js @@ -13,6 +13,9 @@ 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. @@ -116,6 +119,15 @@ export default class TotalSets extends ScoringSet { return sum(this.scoringSets, 'score'); } + /** + * Returns the average scaledScore across all `_isScoreIncluded` subsets. + * @override + * @returns {number} + */ + get averageScaledScore() { + return getAverageScaledScore(this.scoringSets); + } + /** * Returns the number of correctly answered available questions. * @override diff --git a/js/helpers.js b/js/helpers.js index 9f0febb..e7194fe 100644 --- a/js/helpers.js +++ b/js/helpers.js @@ -6,7 +6,8 @@ import { sum } from './utils/math'; import { - getScaledScoreFromMinMax + getScaledScoreFromMinMax, + getAverageScaledScore } from './utils/scoring'; /** @typedef {import("./IntersectionSet").default} IntersectionSet */ @@ -45,6 +46,8 @@ const helpers = { * @see {@link ../INTERSECTION_QUERY.md INTERSECTION_QUERY} * @example {{{scoreQuery '#this'}}} - score for this model * @example {{{scoreQuery '#this total'}}} - summed score for sets which intersect this model and are included in the total + * @example {{{scoreQuery 'assessment'}}} - summed score for all assessments + * @example {{{scoreQuery 'total'}}} - score for `TotalSets` * @param {string} query * @param {*} context * @returns {number} @@ -59,6 +62,8 @@ const helpers = { * @see {@link ../INTERSECTION_QUERY.md INTERSECTION_QUERY} * @example {{{scaledScoreQuery '#this'}}} - scaledScore for this model * @example {{{scaledScoreQuery '#this total'}}} - scaledScore for sets which intersect this model and are included in the total + * @example {{{scoreQuery 'assessment'}}} - summed scaledScore for all assessments + * @example {{{scaledScoreQuery 'total'}}} - scaledScore for `TotalSets` * @param {string} query * @param {*} context * @returns {number} @@ -69,6 +74,22 @@ const helpers = { const minScore = sum(sets, 'minScore'); const maxScore = sum(sets, 'maxScore'); return getScaledScoreFromMinMax(score, minScore, maxScore); + }, + + /** + * Returns the average scaledScore for the intersected subsets of the intersection query string. + * @see {@link ../INTERSECTION_QUERY.md INTERSECTION_QUERY} + * @example {{{averageScaledScoreQuery '#this'}}} - avaerage scaledScore for this model + * @example {{{averageScaledScoreQuery '#this total'}}} - average scaledScore for sets which intersect this model and are included in the total + * @example {{{averageScaledScoreQuery 'assessment'}}} - average scaledScore for all assessments + * @example {{{averageScaledScoreQuery 'total'}}} - average scaledScore for `TotalSets` + * @param {string} query + * @param {*} context + * @returns {number} + */ + averageScaledScoreQuery(query, context) { + const sets = getSubsetsFromQueryContext(query, context); + return getAverageScaledScore(sets); } }; diff --git a/js/utils/query.js b/js/utils/query.js index cf7ef14..ff5f418 100644 --- a/js/utils/query.js +++ b/js/utils/query.js @@ -10,6 +10,7 @@ import { matrixMultiply, unique } from './math'; +/** @typedef {import("../IntersectionSet").default} IntersectionSet */ const queryColumnRegEx = /([^ []*(?:[[(]{1}[^\])]+[\])]{1})*)/g; const queryColumnAttributeRegEx = /[[(]{1}[^\])]+[\])]{1}/g; diff --git a/js/utils/scoring.js b/js/utils/scoring.js index 7ecfef4..47bd715 100644 --- a/js/utils/scoring.js +++ b/js/utils/scoring.js @@ -1,3 +1,8 @@ +import { + sum +} from './math'; +/** @typedef {import("../IntersectionSet").default} IntersectionSet */ + /** * Returns the percentage position (between -100-100) of score between minScore and maxScore. * @param {number} score @@ -14,3 +19,14 @@ export function getScaledScoreFromMinMax(score, minScore, maxScore) { if (!range) return 0; return Math.round((score / range) * 100); } + +/** + * Returns the average scaledScore for the specified sets. + * @param {IntersectionSet[]} sets + * @returns {number} + */ +export function getAverageScaledScore(sets) { + const count = sets?.length; + if (!count) return 0; + return Math.round(sum(sets, 'averageScaledScore') / count); +} From 26cd9d3136ce15c466651c5785f0c5f2b653d21e Mon Sep 17 00:00:00 2001 From: "AzureAD\\DanGhost" Date: Fri, 1 May 2026 16:31:32 +0100 Subject: [PATCH 35/35] Amended the lifecycle so it correctly resets sets based on the intersecting models of the set being reset - see https://github.com/adaptlearning/adapt-contrib-scoring/pull/30/changes#r3173433396. Tidied some jsdoc comments. --- js/Lifecycle.js | 7 +++---- js/ScoringSet.js | 2 +- js/TotalSets.js | 23 +++-------------------- 3 files changed, 7 insertions(+), 25 deletions(-) diff --git a/js/Lifecycle.js b/js/Lifecycle.js index 2850cb5..8a32943 100644 --- a/js/Lifecycle.js +++ b/js/Lifecycle.js @@ -2,6 +2,7 @@ import Data from 'core/js/data'; import Adapt from 'core/js/adapt'; import AdaptModelSet from './AdaptModelSet'; import { + filterSetsByIntersectingModels, filterSetsByIntersectingModelId, filterSetsByLocalModelId, filterSetsByModelId, @@ -166,13 +167,11 @@ export default class Lifecycle extends Backbone.Controller { } /** - * Adds sets to the reset phase which are on the set.modelId. + * Adds sets to the reset phase which intersect the set models. * @param {IntersectionSet} set */ onScoringSetReset(set) { - // TODO: determine if we should restart the sets on this model id only or all descendant sets as well - if (!set.model) return; - const sets = filterSetsByModelId(getAllSets(), set.modelId); + const sets = filterSetsByIntersectingModels(getAllSets(), set.models); this.restart(sets); } diff --git a/js/ScoringSet.js b/js/ScoringSet.js index 77e7334..334aaa9 100644 --- a/js/ScoringSet.js +++ b/js/ScoringSet.js @@ -259,7 +259,7 @@ export default class ScoringSet extends LifecycleSet { */ get isFailed() { if (!this.hasPassmark) return null; - return this.isComplete && this.isPassed === false; + return this.isComplete && !this.isPassed; } /** diff --git a/js/TotalSets.js b/js/TotalSets.js index a3fb0d9..1ef93a3 100644 --- a/js/TotalSets.js +++ b/js/TotalSets.js @@ -54,7 +54,6 @@ export default class TotalSets extends ScoringSet { /** * Returns all models from sets marked with `_isScoreIncluded` or `_isCompletionRequired`, filtered and intersected where appropriate. * @override - * @returns {Backbone.Model[]} */ get models() { const allScoringSets = Adapt.scoring.sets.filter(({ isScoreIncluded }) => isScoreIncluded); @@ -95,7 +94,6 @@ export default class TotalSets extends ScoringSet { /** * Returns the minimum score of all `_isScoreIncluded` subsets. * @override - * @returns {number} */ get minScore() { return sum(this.scoringSets, 'minScore'); @@ -104,7 +102,6 @@ export default class TotalSets extends ScoringSet { /** * Returns the maximum score of all `_isScoreIncluded` subsets. * @override - * @returns {number} */ get maxScore() { return sum(this.scoringSets, 'maxScore'); @@ -113,7 +110,6 @@ export default class TotalSets extends ScoringSet { /** * Returns the score of all `_isScoreIncluded` subsets. * @override - * @returns {number} */ get score() { return sum(this.scoringSets, 'score'); @@ -122,26 +118,17 @@ export default class TotalSets extends ScoringSet { /** * Returns the average scaledScore across all `_isScoreIncluded` subsets. * @override - * @returns {number} */ get averageScaledScore() { return getAverageScaledScore(this.scoringSets); } - /** - * Returns the number of correctly answered available questions. - * @override - * @returns {number} - */ + /** @override */ get correctness() { return sum(this.scoringSets, 'correctness'); } - /** - * Returns the number of available questions. - * @override - * @returns {number} - */ + /** @override */ get maxCorrectness() { return sum(this.scoringSets, 'maxCorrectness'); } @@ -157,7 +144,6 @@ export default class TotalSets extends ScoringSet { /** * Returns whether all registered sets marked with `_isCompletionRequired` are completed. * @override - * @returns {boolean} */ get isComplete() { const completionSets = this.completionSets; @@ -169,7 +155,6 @@ export default class TotalSets extends ScoringSet { * If passmark is disabled, don't evaluate. * If _passmark._requiresPassedSubsets then all scoring subsets have to be passed. * @override - * @returns {boolean|null} */ get isPassed() { if (!this.hasPassmark) return null; @@ -185,7 +170,6 @@ export default class TotalSets extends ScoringSet { * Returns whether any registered sets marked with `_isScoreIncluded` are failed and cannot be reset. * If passmark is disabled, don't evaluate. * @override - * @returns {boolean|null} */ get isFailed() { if (!this.hasPassmark) return null; @@ -195,10 +179,9 @@ export default class TotalSets extends ScoringSet { /** * Returns whether any registered sets marked with `_isScoreIncluded` can be reset. * @override - * @returns {boolean} */ get canReset() { - return this.scoringSets.some(set => set?.canReset); + return this.scoringSets.some(set => set.canReset); } /** @override */