/** * Base class for all lesson modules * All lesson modules should extend this class */ class LessonModule { constructor(config) { this.config = config; this.lessonKey = config.lessonKey; } /** * Validate an answer for a specific question * @param {string} questionId - The question identifier * @param {any} answer - The participant's answer * @returns {Object} { isCorrect, pointsAwarded, feedback } */ async validateAnswer(questionId, answer) { const step = this.config.steps.find(s => s.id === questionId); if (!step || step.type !== 'question') { throw new Error(`Question ${questionId} not found`); } return this._validateQuestionType(step, answer); } /** * Internal validation based on question type */ _validateQuestionType(step, answer) { switch (step.questionType) { case 'single_choice': return this._validateSingleChoice(step, answer); case 'multiple_choice': return this._validateMultipleChoice(step, answer); case 'free_text': return this._validateFreeText(step, answer); default: throw new Error(`Unknown question type: ${step.questionType}`); } } _validateSingleChoice(step, answer) { const selectedOption = step.options.find(opt => opt.id === answer); if (!selectedOption) { return { isCorrect: false, pointsAwarded: 0, feedback: step.feedback?.incorrect || 'Incorrect answer' }; } return { isCorrect: selectedOption.isCorrect, pointsAwarded: selectedOption.isCorrect ? selectedOption.points : 0, feedback: selectedOption.isCorrect ? (step.feedback?.correct || 'Correct!') : (step.feedback?.incorrect || 'Incorrect answer') }; } _validateMultipleChoice(step, answers) { // answers should be an array of option IDs if (!Array.isArray(answers)) { return { isCorrect: false, pointsAwarded: 0, feedback: step.feedback?.incorrect || 'Invalid answer format' }; } const correctOptions = step.options.filter(opt => opt.isCorrect).map(opt => opt.id); const selectedCorrect = answers.filter(a => correctOptions.includes(a)); const selectedIncorrect = answers.filter(a => !correctOptions.includes(a)); // Calculate points const pointsAwarded = selectedCorrect.reduce((sum, id) => { const option = step.options.find(opt => opt.id === id); return sum + (option?.points || 0); }, 0); const isFullyCorrect = selectedCorrect.length === correctOptions.length && selectedIncorrect.length === 0; const isPartiallyCorrect = selectedCorrect.length > 0 && !isFullyCorrect; let feedback = step.feedback?.incorrect || 'Incorrect answer'; if (isFullyCorrect) { feedback = step.feedback?.correct || 'Correct!'; } else if (isPartiallyCorrect) { feedback = step.feedback?.partial || step.feedback?.correct || 'Partially correct'; } return { isCorrect: isFullyCorrect, isPartial: isPartiallyCorrect, pointsAwarded, feedback }; } _validateFreeText(step, answer) { if (!answer || typeof answer !== 'string') { return { isCorrect: false, pointsAwarded: 0, feedback: step.feedback?.incorrect || 'Answer is required' }; } if (!step.validationRules || step.validationRules.length === 0) { // No validation rules, accept any non-empty answer const points = answer.trim().length > 0 ? step.maxPoints : 0; return { isCorrect: points > 0, pointsAwarded: points, feedback: points > 0 ? (step.feedback?.correct || 'Answer received') : (step.feedback?.incorrect || 'Answer is too short') }; } let passedRules = 0; const totalRules = step.validationRules.length; for (const rule of step.validationRules) { if (this._checkValidationRule(rule, answer)) { passedRules++; } } const scorePercentage = passedRules / totalRules; const pointsAwarded = Math.round(step.maxPoints * scorePercentage); const isCorrect = scorePercentage >= 0.7; // 70% threshold return { isCorrect, pointsAwarded, feedback: isCorrect ? (step.feedback?.correct || 'Good answer!') : (step.feedback?.incorrect || 'Please review your answer') }; } _checkValidationRule(rule, answer) { const lowerAnswer = (answer || '').toLowerCase(); switch (rule.type) { case 'contains_keywords': const matches = rule.keywords.filter(keyword => lowerAnswer.includes(keyword.toLowerCase()) ).length; return matches >= (rule.minMatches || 1); case 'min_length': return answer.length >= rule.value; case 'max_length': return answer.length <= rule.value; case 'regex': return new RegExp(rule.pattern, rule.flags || 'i').test(answer); default: return false; } } /** * Get interactive component data for a step * Can be overridden by subclasses for dynamic content */ async getInteractiveData(stepId) { const step = this.config.steps.find(s => s.id === stepId); if (!step || step.type !== 'interactive') { throw new Error(`Interactive step ${stepId} not found`); } return { component: step.interactiveComponent, props: step.componentProps || {} }; } /** * Get lesson content for rendering (without answers) */ getContent() { return { lessonKey: this.lessonKey, title: this.config.title, description: this.config.description, difficultyLevel: this.config.difficultyLevel, estimatedDuration: this.config.estimatedDuration, steps: this.config.steps.map(step => ({ id: step.id, type: step.type, title: step.title, content: step.content, // For question steps, don't send correct answers ...(step.type === 'question' && { questionType: step.questionType, question: step.question, maxPoints: step.maxPoints, options: step.options?.map(opt => ({ id: opt.id, text: opt.text // isCorrect and points are intentionally omitted })) }), // For interactive steps, send component info ...(step.type === 'interactive' && { interactiveComponent: step.interactiveComponent, componentProps: step.componentProps }) })), scoring: { maxTotalPoints: this.config.scoring?.maxTotalPoints || 100, passingScore: this.config.scoring?.passingScore || 70 } }; } /** * Get full configuration (for debugging/admin) */ getFullConfig() { return this.config; } } module.exports = LessonModule;