@ou-imdt/utils
Version:
Utility library for interactive media development
495 lines (446 loc) • 17.4 kB
JavaScript
import { default as Base, defaultState } from '../class/Base.js';
export const attempts = Symbol('attempts');
/**
* compares values to answer, generates mark data and creates attempts
* @param {object} options
* @param {boolean} [options.allowNoAttempt] - marked where no attempt if true, unmarked if not
*/
export default class AssessorModule extends Base {
static get [defaultState]() {
return {
[attempts]: [],
initialValues: [],
correctValues: [],
alternateValues: [],
attemptsAllowed: 999, // infinity? TODO rename allowedAttempts?
disregardInvalidAttempts: true,
validateNoAttempt: false, // default/initial values
validateUnchangedAttempt: true, // unchanged values
validateIncompleteAttempt: true, // unattempted values
validateInconsistentAttempt: true, // unexpected values
markMap: null,
typeOptions: {},
// contextsAsNestedAttempts: false,
// contextAsAlternateValues: false,
// getters, no setters...
attemptNumber: null,
attemptsRemaining: null,
latestAttempt: null,
attempts: null
}
}
constructor() {
super();
}
get answer() {
return this.answer;
}
get attemptNumber() {
return this.attempts.length;
}
get attempts() {
// return [...this[attempts]];
return (this.disregardInvalidAttempts ? this.validAttempts : [...this[attempts]]);
}
get validAttempts() {
return this[attempts].filter(attempt => attempt.isValid);
}
get latestAttempt() {
return this[attempts][this[attempts].length - 1] ?? null;
}
get latestValidAttempt() {
return this.validAttempts[this.validAttempts.length - 1] ?? null;
}
get attemptsRemaining() {
return this.attemptsAllowed - this.attemptNumber;
}
// get state() {
// const { correctValues, alternateValues } = this;
// const { attemptNumber, attemptsRemaining, latestAttempt, attempts: attempts } = this;
// return {
// // correctValues,
// // alternateValues,
// ...super.state,
// attemptNumber,
// attemptsRemaining,
// latestAttempt,
// attempts
// }
// }
makeAttempt(values) {
const { correctValues, initialValues } = this;
const attempt = this._createAttempt(values, {
initialValues: this.latestAttempt?.values ?? initialValues,
correctValues
});
console.log('make attempt..........', attempt);
this[attempts].push(attempt);
return attempt;
}
// values = [], answer = [], alternates = [], options = []
// [{ values, answer, alternates, options }, ...]
// override options for new contexts
_getAttemptOptions({ correctValues, ...overrides }) { // correctValues?
const { typeOptions, ...globalOptions } = this;
const merged = { ...globalOptions };
const hasContextOptions = (correctValues instanceof Object) && !Array.isArray(correctValues);
if (hasContextOptions) {
const { type, values, ...rest } = correctValues;
Object.assign(merged, typeOptions[type], { correctValues: values, ...rest });
} else {
merged.correctValues = correctValues;
}
Object.assign(merged, overrides);
return merged;
}
_createAttempt(values, overrides) {
if (!Array.isArray(values)) return null; // TODO normalise single [values]?
const options = this._getAttemptOptions(overrides);
const { initialValues, correctValues, alternateValues } = options;
const nodes = values.map((el, index) => { // TODO subnodes?
return this._createAttempt(el, { // upack...
initialValues: initialValues?.[index],
correctValues: correctValues?.[index]
});
});
const marks = new calculableArray(...nodes.map((el, index) => {
if (el !== null) return el.marks;
const alternates = (alternateValues === 'context') ? correctValues : alternateValues?.[index];
return this._mark(values[index], correctValues?.[index], alternates);
}));
const { expected, ...markMap } = {
expected: ['*', [null]],
unexpected: -2,
attempted: [0, 1, -2],
unattempted: -1,
correct: 1,
incorrect: [0, -1, -2],
null: null,
...options.markMap
};
const totals = Object.freeze({
expected: marks.calculateTotal(...expected, correctValues), // answer as attempt may have unexpected/unattempted
...marks.getTotals(markMap)
});
// helpers - order matters!
const isCorrect = (totals.correct === totals.attempted && totals.correct === totals.expected);
const isIncorrect = (totals.correct === 0);
const isPartiallyCorrect = (!isCorrect && !isIncorrect);
const isUnattempted = (totals.attempted === 0);
const isIncomplete = (totals.unattempted > 0);
const isInconsistent = (totals.unexpected > 0);
const isUnchanged = (JSON.stringify(values) === JSON.stringify(initialValues));
const validationResult = {
unattempted: (!options.validateNoAttempt && isUnattempted),
unchanged: (!options.validateUnchangedAttempt && isUnchanged),
incomplete: (!options.validateIncompleteAttempt && isIncomplete),
inconsistent: (!options.validateInconsistentAttempt && isInconsistent)
};
const isValid = !Object.values(validationResult).includes(true);
const status = (!isValid ? 'invalid' : isCorrect ? 'correct' : isIncorrect ? 'incorrect' : 'partially-correct');
const attempt = {
number: this.attemptNumber + 1,
initialValues,
values,
marks,
totals,
nodes,
status,
isCorrect,
isIncorrect,
isPartiallyCorrect,
isUnattempted,
isIncomplete,
isInconsistent,
isUnchanged,
isValid
};
// console.log('new attempt', attempt);
return Object.freeze(attempt);
}
_mark(value = null, answer = null, alternates = null) {
// console.log('mark value:', { value, answer, alternates });
// no attempt/answer - nothing to mark/mark against
if (value === null && answer === null) {
return null;
}
// unattempted - nothing to mark
// if (value === null || typeof value === 'undefined') {
if (value === null) {
return -1;
}
// unexpected - nothing to mark against
// if (answer === null || typeof answer === 'undefined') {
if (answer === null) {
return -2;
}
// correct - straight comparison (1:1)
if (value === answer) {
return 1;
}
// correct - as alternate
if (Array.isArray(alternates)) {
return alternates.includes(value) ? 1 : 0;
}
// incorrect
return 0;
}
reset() {
this[attempts].splice(0);
}
}
// TODO merge back to attempt... worth keeping separate??
export class calculableArray extends Array {
constructor() {
// super(...arguments); // single param numbers set length
super();
this.push(...arguments);
}
getTotals(map) {
const entries = Object.entries(map);
// const data = map.reduce((result, [ref, include, exclude]) => {
const data = entries.reduce((result, [key, value]) => {
const [include, exclude] = (Array.isArray(value) && Array.isArray(value[1]) ? value : [value]);
const total = this.calculateTotal(include, exclude);
result[key] = total;
return result;
}, {});
return data;
}
/**
* recursively calculates the occurences of value
* @param {object[]} target
* @param {*} value
* @param {*} exception
* @returns {number} - sum of occurences
*/
calculateTotal(value = '*', exclude = [], target = this) {
if (Array.isArray(target)) {
return target.reduce((total, el) => total += this.calculateTotal(value, exclude, el), 0);
} else {
if (exclude.includes(target)) return 0;
if (value === '*') return 1;
if ((Array.isArray(value) ? value : [value]).includes(target)) return 1;
// if (Array.isArray(value) && value.includes(target)) return 1;
// if (target === value) return 1;
return 0;
// return (value === '*' && !exceptions.includes(target) || target === value) ? 1 : 0;
}
}
}
// /**
// * holds attempt data/logic
// * @param {object} options
// * @param {boolean} [options.number] - the attempt number
// * @param {boolean} [options.validateNoAttempt] - validates no attempt if true, invalid if not
// * @param {boolean} [options.validateIncompleteAttempt] - validates incomplete attempt if true, invalid if false
// * @param {boolean} [options.validateInconsistentAttempt] - validates attempt that contains unexpected values if true, invalid if false
// * marks - mark array with convenience methods to get totals
// */
// export class Attempt {
// constructor({
// number = 0, // needed? (would be wrong for invalid)
// initialValues = null,
// values = null,
// answer = null,
// alternates = null,
// markMap = null,
// contextsAsNestedAttempts = false,
// // markUnattemptedAsIncorrect = false,
// // markUnexpectedAsIncorrect = true,
// validateNoAttempt = false, // no attempt/unchanged from initial values
// validateUnchangedAttempt = true, // unchanged from previous attempt/values
// validateIncompleteAttempt = true, // has unattempted values
// validateInconsistentAttempt = true, // has unexpected values
// }) {
// this.options = Object.freeze({
// number,
// initialValues,
// values,
// answer,
// alternates,
// markMap,
// contextsAsNestedAttempts,
// // markUnattemptedAsIncorrect,
// // markUnexpectedAsIncorrect,
// validateNoAttempt,
// validateIncompleteAttempt,
// validateInconsistentAttempt,
// validateUnchangedAttempt
// });
// this.values = values; // freeze?
// this.marks = new calculableArray(...Attempt.mark(values, answer, alternates));
// // this.marks = new calculableArray(...this._mark(values, answer, alternates));
// this.totals = Object.freeze({
// expected: this.marks.calculateTotal('*', [null], answer), // answer as attempt may have unexpected/unattempted
// ...this.marks.getTotals(this.markMap)
// });
// this.nodes = !contextsAsNestedAttempts ? null : values.map((el, index) => { // TODO subnodes?
// // freeze?
// return new Attempt({
// ...this.options,
// initialValues: initialValues?.[index],
// // values: values?.[index],
// values: el,
// answer: answer?.[index]
// });
// });
// // helpers - order matters!
// this.isCorrect = (this.totals.correct === this.totals.attempted === this.totals.expected);
// this.isIncorrect = (this.totals.correct === 0);
// this.isPartiallyCorrect = (!this.isCorrect && !this.isIncorrect);
// this.isUnattempted = (this.totals.attempted === 0);
// this.isIncomplete = (this.totals.unattempted > 0);
// this.isUnexpected = (this.totals.unexpected > 0);
// this.isUnchanged = (JSON.stringify(values) === JSON.stringify(initialValues));
// this.validationResult = {
// unattempted: (!validateNoAttempt && this.isUnattempted),
// unchanged: (!validateUnchangedAttempt && this.isUnchanged),
// incomplete: (!validateIncompleteAttempt && this.isIncomplete),
// unexpected: (!validateInconsistentAttempt && this.isUnexpected)
// };
// this.isValid = !Object.values(this.validationResult).includes(true);
// // this.status = (this.isValid ? (this.isCorrect ? 'correct' : this.isIncorrect ? 'incorrect' : 'partially-correct') : 'invalid');
// this.status = (() => {
// if (!this.isValid) return 'invalid';
// if (this.isCorrect) return 'correct';
// if (this.isIncorrect) return 'incorrect';
// return 'partially-correct';
// })();
// console.log('new attempt', this);
// }
// get markMap() {
// return {
// expected: ['*', [null]], // not used... should be
// unexpected: -2,
// attempted: [0, 1],
// unattempted: -1,
// correct: 1,
// incorrect: [0, -1, -2],
// null: null,
// ...this.options.markMap
// };
// // const { markUnattemptedAsIncorrect, markUnexpectedAsIncorrect } = this.options;
// // const incorrectIncludes = [0, -1, -2].filter(value => { // eslint-disable-line
// // if (value === -1 && !markUnattemptedAsIncorrect) return false;
// // if (value === -2 && !markUnexpectedAsIncorrect) return false;
// // return true;
// // });
// // return [
// // ['expected', '*', [null]], // not used... should be
// // ['unexpected', -2],
// // ['attempted', [0, 1]],
// // ['unattempted', -1],
// // ['correct', 1],
// // ['incorrect', incorrectIncludes],
// // ['null', null]
// // ];
// }
// get isCorrect() {
// const { attempted, expected, correct } = this.totals;
// return attempted === expected && correct === expected;
// }
// get isIncorrect() {
// return this.totals.correct === 0;
// }
// get isPartiallyCorrect() {
// return !this.isCorrect && !this.isIncorrect;
// }
// get isUnattempted() {
// return this.totals.attempted === 0;
// }
// get isIncomplete() {
// return this.totals.unattempted > 0;
// }
// get isUnexpected() {
// return this.totals.unexpected > 0;
// }
// get isUnchanged() {
// const { initialValues, values } = this.options;
// return (JSON.stringify(values) === JSON.stringify(initialValues));
// }
// get validationResult() {
// const { validateNoAttempt, validateIncompleteAttempt, validateInconsistentAttempt, validateUnchangedAttempt } = this.options;
// const { isUnattempted, isIncomplete, isUnexpected, isUnchanged } = this;
// return {
// unattempted: (!validateNoAttempt && isUnattempted),
// unchanged: (!validateUnchangedAttempt && isUnchanged),
// incomplete: (!validateIncompleteAttempt && isIncomplete),
// unexpected: (!validateInconsistentAttempt && isUnexpected)
// };
// }
// get isValid() {
// // console.log(Object.values(this.validationResult).includes(true), this.validationResult);
// return !Object.values(this.validationResult).includes(true);
// }
// get status() {
// if (this.isValid) {
// if (this.isCorrect) return 'correct';
// if (this.isIncorrect) return 'incorrect';
// return 'partially-correct';
// }
// return 'invalid';
// }
// /**
// * Compares attempt to answer and returns result formatted relative to attempt
// * @param {object[]} values - an array of values and/or nested arrays of values
// * @param {object[]} answer - an array of values and/or nested arrays of values
// * @param {object} [alternates] - an array of alternate answers, structured relative to answer
// * @returns {object[]|number|null}
// */
// static mark(values = null, answer = null, alternates = null) {
// // _mark(values = null, answer = null, alternates = null) {
// // console.log('mark attempt:', attempt, answer, alternates);
// // validate...
// // alternates incorrectly formatted
// if (!Array.isArray(alternates) && alternates !== null) {
// throw new Error('alternates must be array or null', alternates);
// }
// // attempt incorrectly formatted
// if (typeof values === 'object' && !Array.isArray(values) && values !== null) {
// throw new Error('attempt must be array or primitive', values);
// }
// // answer incorrectly formatted
// if (typeof answer === 'object' && !Array.isArray(answer) && answer !== null) {
// throw new Error('answer must be array or primitive', answer);
// }
// // answer incorrectly formatted relative to attempt
// // if (Array.isArray(answer) && !Array.isArray(attempt) || Array.isArray(attempt) && !Array.isArray(answer)) {
// if ([Array.isArray(answer), Array.isArray(values)].some((el, _, arr) => el !== arr[0])) {
// throw new Error('answer/attempt must both be array where either is', values, answer);
// }
// // TODO map the answer and tag additional attempts on the end as unexpected (-2)
// // mark...
// // attempt is array, start new context
// if (Array.isArray(values)) {
// return values.map((attempt, index) => {
// return Attempt.mark(attempt, answer?.[index], alternates?.[index]);
// // return this._mark(attempt, answer?.[index], alternates?.[index]);
// });
// }
// // no attempt/answer - nothing to mark/mark against so just ignore
// if (values === null && answer === null) {
// return null;
// }
// // unattempted - nothing to mark
// // if (attempt === null || typeof attempt === 'undefined') {
// if (values === null) {
// return -1;
// }
// // unexpected - nothing to mark against
// // if (answer === null || typeof answer === 'undefined') {
// if (answer === null) {
// return -2;
// }
// // correct - straight comparison (1:1)
// if (values === answer) {
// return 1;
// }
// // correct - as alternate
// if (Array.isArray(alternates)) {
// return alternates.includes(values) ? 1 : 0;
// }
// // incorrect
// return 0;
// }
// }