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 }; /**