/**
* File : activities/text/Evaluator.js
* Created : 14/04/2015
* By : Francesc Busquets <francesc@gmail.com>
*
* JClic.js
* An HTML5 player of JClic activities
* https://projectestac.github.io/jclic.js
*
* @source https://github.com/projectestac/jclic.js
*
* @license EUPL-1.2
* @licstart
* (c) 2000-2020 Educational Telematic Network of Catalonia (XTEC)
*
* Licensed under the EUPL, Version 1.1 or -as soon they will be approved by
* the European Commission- subsequent versions of the EUPL (the "Licence");
* You may not use this work except in compliance with the Licence.
*
* You may obtain a copy of the Licence at:
* https://joinup.ec.europa.eu/software/page/eupl
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the Licence is distributed on an "AS IS" basis, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* Licence for the specific language governing permissions and limitations
* under the Licence.
* @licend
* @module
*/
/* global window */
import { log, attrForEach, getBoolean, setAttr, getAttr } from '../../Utils.js';
/**
* This class and its derivatives {@link module:activities/text/Evaluator.BasicEvaluator BasicEvaluator} and
* {@link module:activities/text/Evaluator.ComplexEvaluator ComplexEvaluator} are used to evaluate the answers written by the final users
* in text activities.
*/
export class Evaluator {
/**
* Evaluator constructor
* @param {string} className - The class name of this evaluator.
*/
constructor(className) {
this.className = className;
this.collator = (window.Intl && window.Intl.Collator) ?
new window.Intl.Collator() :
{ compare: (a, b) => this.checkCase ? a === b : a.toUpperCase() === b.toUpperCase() };
}
/**
* Factory constructor that returns a specific type of {@link module:activities/text/Evaluator.Evaluator Evaluator} based on the `class`
* attribute declared in the $xml element.
* @param {external:jQuery} $xml - The XML element to be parsed.
* @returns {module:activities/text/Evaluator.Evaluator}
*/
static getEvaluator($xml) {
let ev = null;
if ($xml) {
const className = $xml.attr('class');
const cl = Evaluator.CLASSES[className];
if (cl) {
ev = new cl(className);
ev.setProperties($xml);
} else
log('error', `Unknown evaluator class: "${className}"`);
}
return ev;
}
/**
* Loads the object settings from a specific JQuery XML element
* @param {external:jQuery} $xml - The jQuery XML element to parse
*/
setProperties($xml) {
attrForEach($xml.get(0).attributes, (name, value) => {
switch (name) {
case 'class':
this.className = value;
break;
case 'checkCase':
case 'checkAccents':
case 'checkPunctuation':
case 'checkDoubleSpaces':
case 'detail':
this[name] = getBoolean(value);
break;
case 'checkSteps':
case 'checkScope':
this[name] = Number(value);
break;
}
});
return this;
}
/**
* Builds a new Evaluator, based on the properties specified in a data object
* @param {object} data - The data object to be parsed
* @returns {module:activities/text/Evaluator.Evaluator}
*/
static factory(data) {
const cl = Evaluator.CLASSES[data.className];
if (cl) {
const result = new cl(data.className);
return setAttr(result, data, [
'className',
'checkCase', 'checkAccents', 'checkPunctuation', 'checkDoubleSpaces', 'detail',
'checkSteps', 'checkScope',
]);
}
return null;
}
/**
* Gets a object with the basic attributes needed to rebuild this instance excluding functions,
* parent references, constants and also attributes retaining the default value.
* The resulting object is commonly usued to serialize elements in JSON format.
* @returns {object} - The resulting object, with minimal attrributes
*/
getAttributes() {
return getAttr(this, [
'className',
'checkCase', 'checkAccents', 'checkPunctuation', 'checkDoubleSpaces', 'detail',
'checkSteps', 'checkScope',
]);
}
/**
* Initializes this evaluator
* @param {string[]} _locales - An array of valid locales, to be used by Intl.Collator
*/
init(_locales) {
this.initiated = true;
}
/**
* Checks the given text against a set of valid matches
* @param {string} text - The text to be checked
* @param {string|string[]} match - The valid expression or expressions with which to compare.
* @returns {boolean} - `true` if the checked expression is valid, `false` otherwise.
*/
checkText(text, match) {
if (match instanceof Array)
return match.some(m => this._checkText(text, m));
else if (match)
return this._checkText(text, match);
else
return false;
}
/**
* Abstract method to be implemented in subclasses.
* Performs the validation of a string against a single match.
* @param {string} _text - The text to be checked
* @param {string} _match - A valid expression with which to compare.
* @returns {boolean} - `true` when the two expressions can be considered equivalent.
*/
_checkText(_text, _match) {
return false;
}
/**
* Evaluates the given text against a set of valid matches, returning an array of flags useful
* to indicate where the mistakes are located.
* @param {string} text - The text to be checked
* @param {string|string[]} match - The valid expression or expressions with which to compare.
* @returns {number[]} - An array of flags (one number for character) indicating whether each
* position is erroneous or ok.
*/
evalText(text, match) {
if (!(match instanceof Array))
match = [match];
return this._evalText(text, match);
}
/**
* Abstract method to be implemented in subclasses.
* Performs the evaluation of a string against an array of valid matches, returning an array of
* flags useful to indicate where the mistakes are located.
* @param {string} _text - The text to be checked
* @param {string} _match - A valid expression with which to compare.
* @returns {number[]} - An array of flags (one number for character) indicating whether each
* position is erroneous or OK.
*/
_evalText(_text, _match) {
return [];
}
/**
* Checks if the given array of flags (usually returned by `evalText`) can be considered as a
* valid or erroneous answer.
* @param {number[]} flags
* @returns {boolean} - `true` when there is at least one flag and all flags are 0 (meaning no error).
*/
isOk(flags) {
return flags && flags.length > 0 && !flags.some(f => f !== 0);
}
}
Object.assign(Evaluator.prototype, {
/**
* The type of evaluator.
* @name module:activities/text/Evaluator.Evaluator#className
* @type {string} */
className: null,
/**
* Whether this evaluator has been initialized or not.
* @name module:activities/text/Evaluator.Evaluator#initiated
* @type {boolean} */
initiated: false,
/**
* The Intl.Collator object used to compare strings, when available.
* @name module:activities/text/Evaluator.Evaluator#collator
* @type {external:Collator} */
collator: null,
/**
* Whether uppercase and lowercase expressions must be considered equivalent or not.
* @name module:activities/text/Evaluator.Evaluator#checkcase
* @type {boolean} */
checkCase: false,
});
/**
* A basic evaluator that just compares texts, without looking for possible coincidences of text
* fragments once erroneous characters removed.
* @extends module:activities/text/Evaluator.Evaluator
*/
export class BasicEvaluator extends Evaluator {
/**
* BasicEvaluator constructor
* @param {string} className - The class name of this evaluator.
*/
constructor(className) {
super(className);
}
/**
* Initializes the {@link module:activities/text/Evaluator.Evaluator#collator collator}.
* @override
* @param {string[]} locales - An array of valid locales to be used by the Inlt.Collator object
*/
init(locales) {
// Call `init` method on ancestor
super.init([locales]);
// Get canonical locales
if (window.Intl && window.Intl.Collator) {
this.collator = new window.Intl.Collator(locales, {
sensitivity: this.checkAccents ? this.checkCase ? 'case' : 'accent' : 'base',
ignorePunctuation: !this.checkPunctuation
});
}
}
/**
* Performs the validation of a string against a single match.
* @override
* @param {string} text - The text to be checked
* @param {string} match - A valid expression with which to compare.
* @returns {boolean} - `true` when the two expressions can be considered equivalent.
*/
_checkText(text, match) {
return this.collator.compare(this.getClearedText(text), this.getClearedText(match)) === 0;
}
/**
* Performs the evaluation of a string against an array of valid matches, returning an array of
* flags useful to indicate where the mistakes are located.
* In BasicEvaluator, all characters are just marked as 1 (error) or 0 (OK). See
* {@link module:activities/text/Evaluator.ComplexEvaluator ComplexEvaluator} for more detailed analysis of answers.
* @override
* @param {string} text - The text to be checked
* @param {string} match - A valid expression with which to compare.
* @returns {number[]} - An array of flags (one number for character) indicating whether each
* position is erroneous or OK.
*/
_evalText(text, match) {
return Array(text.length).fill(this._checkText(text, match[0]) ? 0 : 1);
}
/**
* Removes double spaces and erroneous characters from a given text expression.
* @param {string} src - The text to be processed.
* @param {boolean[]} skipped - An array of boolean indicating which characters should be removed
* from the string.
* @returns {string}
*/
getClearedText(src, skipped) {
if (this.checkPunctuation && this.checkDoubleSpaces)
return src;
if (!skipped)
skipped = Array(src.length).fill(false);
let sb = '';
for (let i = 0, wasSpace = false; i < src.length; i++) {
const ch = src.charAt(i);
if (this.PUNCTUATION.indexOf(ch) >= 0 && !this.checkPunctuation) {
if (!wasSpace)
sb += ' ';
else
skipped[i] = true;
wasSpace = true;
} else if (ch === ' ') {
if (this.checkDoubleSpaces || !wasSpace)
sb += ch;
else
skipped[i] = true;
wasSpace = true;
} else {
wasSpace = false;
sb += ch;
}
}
return sb;
}
}
Object.assign(BasicEvaluator.prototype, {
/**
* Whether accented letters must be considered equivalent or not.
* @name module:activities/text/Evaluator.BasicEvaluator#checkAccents
* @type {boolean} */
checkAccents: true,
/**
* Whether to check or not dots, commas and other punctuation marks when comparing texts.
* @name module:activities/text/Evaluator.BasicEvaluator#checkPunctuation
* @type {boolean} */
checkPunctuation: true,
/**
* Whether to check or not the extra spaces added between words.
* @name module:activities/text/Evaluator.BasicEvaluator#checkDoubleSpaces
* @type {boolean} */
checkDoubleSpaces: false,
/**
* String containing all the characters considered as punctuation marks (currently ".,;:")
* @name module:activities/text/Evaluator.BasicEvaluator#PUNCTUATION
* @type {string} */
PUNCTUATION: '.,;:',
});
/**
* ComplexEvaluator acts like {@link module:activities/text/Evaluator.BasicEvaluator BasicEvaluator}, but providing feedback about
* the location of mistakes on the user's answer.
* @extends module:activities/text/Evaluator.BasicEvaluator
*/
export class ComplexEvaluator extends BasicEvaluator {
/**
* ComplexEvaluator constructor
* @param {string} className - The class name of this evaluator.
*/
constructor(className) {
super(className);
}
/**
* Performs the evaluation of a string against an array of valid matches, returning an array of
* flags useful to indicate where the mistakes are located.
* In BasicEvaluator, all characters are just marked as 1 (error) or 0 (OK). See
* {@link module:activities/text/Evaluator.ComplexEvaluator ComplexEvaluator} for more detailed analysis of answers.
* @override
* @param {string} text - The text to be checked
* @param {string} match - A valid expression with which to compare.
* @returns {number[]} - An array of flags (one number for character) indicating whether each
* position is erroneous or OK.
*/
_evalText(text, match) {
if (!this.detail)
return super._evalText(text, match);
const
skipped = Array(text.length).fill(false),
sText = this.getClearedText(text, skipped),
numChecks = Array(match.length),
flags = Array(match.length),
returnFlags = Array(text.length);
let
maxCheck = -1,
maxCheckIndex = -1;
for (let i = 0; i < match.length; i++) {
flags[i] = Array(text.length).fill(0);
const ok = this.compareSegment(sText, sText.length, match[i], match[i].length, flags[i], false);
numChecks[i] = this.countFlagsOk(flags[i]);
if (ok) {
maxCheckIndex = i;
maxCheck = numChecks[i];
}
}
if (maxCheckIndex === -1) {
for (let i = 0; i < match.length; i++) {
if (numChecks[i] > maxCheck) {
maxCheck = numChecks[i];
maxCheckIndex = i;
}
}
}
for (let i = 0, k = 0; i < text.length; i++)
returnFlags[i] = skipped[i] ? 0 : flags[maxCheckIndex][k++];
return returnFlags;
}
/**
* Counts the number of flags on the provided array that are zero.
* @param {number[]} flags
* @returns {number}
*/
countFlagsOk(flags) {
return flags.reduce((n, v) => v == 0 ? ++n : n, 0);
}
/**
* Compares two segments of text.
* This function should make recursive calls.
* @param {string} src - Text to be compared
* @param {number} ls - Offset in `src` where to start the comparison
* @param {string} ok - Text to match against.
* @param {number} lok - Offset in `ok` where to start the comparison.
* @param {number[]} attr - Array of integers that will be filled with information about the
* validity or error of each character in `src`.
* @param {boolean} iterate - When `true`, the segment will be iterated looking for other
* coincident fragments.
* @returns {boolean} - `true` if the comparison was valid.
*/
compareSegment(src, ls, ok, lok, attr, iterate) {
let
is = 0,
iok = 0,
lastIs = 0,
lastiok = true,
result = true,
chs = '',
chok = '';
if (ls === 0 || lok === 0 || src === null || ok === null)
return false;
for (; is < ls; is++, iok++) {
chs = src.charAt(is);
lastIs = is;
if (iok >= 0 && iok < lok)
chok = ok.charAt(iok);
else
chok = 0;
if (this.collator.compare(chs, chok) === 0) {
attr[is] = 0;
lastiok = true;
} else {
result = false;
attr[is] = 1;
if (!iterate && lastiok && chok !== 0 && this.checkSteps > 0 && this.checkScope > 0) {
const
lbloc = 2 * this.checkSteps + 1,
itcoinc = [];
let i = 0, j = 0;
for (; j < lbloc; j++) {
itcoinc[j] = 0;
i = iok + Math.floor((j + 1) / 2) * ((j & 1) !== 0 ? 1 : -1);
if (i >= lok)
continue;
const is2 = i < 0 ? is - i : is;
if (is2 >= ls)
continue;
const
ls2 = Math.min(ls - is2, this.checkScope),
iok2 = i < 0 ? 0 : i,
lok2 = Math.min(lok - iok2, this.checkScope),
flags2 = Array(src.length - is2).fill(0),
result2 = this.compareSegment(src.substring(is2), ls2, ok.substring(iok2), lok2, flags2, true);
itcoinc[j] = this.countFlagsOk(flags2);
if (result2)
break;
}
if (j === lbloc) {
let jmax = this.checkSteps;
for (j = 0; j < lbloc; j++)
if (itcoinc[j] > itcoinc[jmax])
jmax = j;
i = iok + Math.floor((jmax + 1) / 2) * ((jmax & 1) !== 0 ? 1 : -1);
}
iok = i;
lastiok = false;
}
}
}
if (iok !== lok) {
result = false;
attr[lastIs] = 1;
}
return result;
}
}
Object.assign(ComplexEvaluator.prototype, {
/**
* Whether to detail or not the location of errors found on the analyzed text.
* @name module:activities/text/Evaluator.ComplexEvaluator#detail
* @type {boolean} */
detail: true,
/**
* Number of times to repeat the evaluation process if an error is found, eliminating in each
* cycle the extra characters that caused the error.
* @name module:activities/text/Evaluator.ComplexEvaluator#checkSteps
* @type {number} */
checkSteps: 3,
/**
* When an eror is detected in the analyzed expression, this variable indicates the number of
* characters the checking pointer will be moved forward and back looking for a coincident
* expression.
*
* For example, comparing the answer "_one lardzy dog_" with the correct answer "_one lazy dog_"
* will detect an error at position 6 (an "r" instead of "z"). If `checkSteps` is set to 2 or
* greater, the "_zy dog_" expression at position 8 will be found and evaluated as valid, while
* a value of 1 or less will not found any coincident expression beyond the error position, thus
* evaluating all the remaining sentence as erroneous.
* @name module:activities/text/Evaluator.ComplexEvaluator#checkScope
* @type {number} */
checkScope: 6,
});
// List of known Evaluator classes
Evaluator.CLASSES = {
'@BasicEvaluator': BasicEvaluator,
'@ComplexEvaluator': ComplexEvaluator
};
export default Evaluator;