231 lines
6.7 KiB
JavaScript
231 lines
6.7 KiB
JavaScript
/**
|
|
* 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;
|