UNPKG

@ou-imdt/utils

Version:

Utility library for interactive media development

495 lines (446 loc) 17.4 kB
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; // } // }