// @flow import { every, filter, get, includes, join, map, maxBy, pipe, reverse, some, split, toLower, trim, zip } from 'lodash/fp'; import FuzzyMatching from 'fuzzy-matching'; import type { Question, BasicQuestion, TemplateQuestion, PartialCorrection, AnswerCorrection, AcceptedAnswers, Answer, Config } from './types'; const reverseString = pipe(split(''), reverse, join('')); function checkFuzzyAnswer(maxTypos: number, fm: FuzzyMatching, userAnswer: string): boolean { if (!userAnswer || userAnswer.length === 0) { return false; } // Find a valid answer resembling userAnswer return !!fm.get(userAnswer, {maxChanges: maxTypos}).value; } function containsAnswer(config: Config, allowedAnswer: string, givenAnswer: string): boolean { // Find the allowed answer in the given answer if (!includes(allowedAnswer, givenAnswer)) { // If not present return false; } // Get the non-space characters surrounding the answer and make sure that there are not too many. const limit = config.answerBoundaryLimit; const [first = '', second = ''] = givenAnswer.split(allowedAnswer); const indexOfSpaceInFirst = reverseString(first).indexOf(' '); const indexOfSpaceInSecond = second.indexOf(' '); return ( (first.length <= limit || (indexOfSpaceInFirst !== -1 && indexOfSpaceInFirst <= limit)) && (second.length <= limit || (indexOfSpaceInSecond !== -1 && indexOfSpaceInSecond <= limit)) ); } function isTextCorrect( config: Config, _allowedAnswers: Answer, answerWithCase: string, _maxTypos: ?number, noFuzzy: boolean ): boolean { const allowedAnswers = _allowedAnswers.map(trim); const fm = new FuzzyMatching(allowedAnswers); const maxTypos = _maxTypos === 0 ? _maxTypos : _maxTypos || config.maxTypos; const answer = toLower(answerWithCase); if (noFuzzy) { return some(allowedAnswer => toLower(allowedAnswer) === answer, allowedAnswers); } return ( checkFuzzyAnswer(maxTypos, fm, answer) || (maxTypos !== 0 && some(allowedAnswer => containsAnswer(config, toLower(allowedAnswer), answer), allowedAnswers)) ); } function matchAnswerForBasic( config: Config, question: BasicQuestion, givenAnswer: Answer ): Array> { if (question.content.answers.length === 0) { return []; } const isCorrect = isTextCorrect( config, question.content.answers.map(answers => answers[0]), givenAnswer[0], question.content.maxTypos, false ); return [[{answer: givenAnswer[0], isCorrect}]]; } function matchAnswerForTemplate( config: Config, question: TemplateQuestion, givenAnswer: Answer ): Array> { if (question.content.answers.length === 0) { return []; } const result = givenAnswer.map((answer, index) => ({ answer, isCorrect: question.content.answers.some(allowedAnswer => isTextCorrect( config, [allowedAnswer[index]], toLower(answer), get(['content', 'choices', `${index}`, 'type'], question) === 'text' ? question.content.maxTypos : 0, get(['content', 'choices', `${index}`, 'type'], question) !== 'text' ) ) })); const missingAnswers = question.content.answers[0] .slice(result.length) .map(() => ({answer: undefined, isCorrect: false})); return [result.concat(missingAnswers)]; } function matchAnswerForUnorderedItems( allowedAnswers: AcceptedAnswers, givenAnswer: Answer ): Array> { const lowerGivenAnswer = map(toLower, givenAnswer); return allowedAnswers.map((allowedAnswer): Array => { const lowerAllowedAnswer = map(toLower, allowedAnswer); const givenAnswersMap = map( answer => ({ answer, isCorrect: includes(toLower(answer), lowerAllowedAnswer) }), givenAnswer ); if (lowerAllowedAnswer.some(answer => !includes(answer, lowerGivenAnswer))) { return givenAnswersMap.concat([{answer: undefined, isCorrect: false}]); } return givenAnswersMap; }); } function matchAnswerForOrderedItems( allowedAnswers: AcceptedAnswers, givenAnswer: Answer ): Array> { return map((allowedAnswer): Array => { return map(([givenAnswerPart, allowedAnswerPart]): PartialCorrection => { return { answer: givenAnswerPart, isCorrect: toLower(givenAnswerPart) === toLower(allowedAnswerPart) }; }, zip(givenAnswer, allowedAnswer)); }, allowedAnswers); } const findBestMatch = maxBy(correction => filter('isCorrect', correction).length); function matchGivenAnswerToQuestion( config: Config, question: Question, givenAnswer: Answer ): Array> { const allowedAnswers = question.content.answers.map(answer => answer.map(trim)); switch (question.type) { case 'basic': { return matchAnswerForBasic(config, question, givenAnswer); } case 'template': { return matchAnswerForTemplate(config, question, givenAnswer); } case 'qcm': { return matchAnswerForUnorderedItems(allowedAnswers, givenAnswer); } case 'qcmGraphic': { return matchAnswerForUnorderedItems(allowedAnswers, givenAnswer); } case 'qcmDrag': { return question.content.matchOrder ? matchAnswerForOrderedItems(allowedAnswers, givenAnswer) : matchAnswerForUnorderedItems(allowedAnswers, givenAnswer); } case 'slider': { return matchAnswerForOrderedItems(allowedAnswers, givenAnswer); } default: return [[]]; } } export default function checkAnswerCorrectness( config: Config, question: Question, givenAnswer: Answer ): AnswerCorrection { const matches = matchGivenAnswerToQuestion(config, question, givenAnswer.map(trim)); if (matches.length === 0) { return { isCorrect: false, corrections: [] }; } const bestMatch: Array = findBestMatch(matches); return { isCorrect: every('isCorrect', bestMatch), corrections: filter(item => item.answer !== undefined, bestMatch) }; }