mcp-quiz-server
Version:
🧠AI-Powered Quiz Management via Model Context Protocol (MCP) - Create, manage, and take quizzes directly from VS Code, Claude, and other AI agents.
510 lines (509 loc) • 20.5 kB
JavaScript
"use strict";
/**
* @fileoverview Quiz Validation Domain Service
* @version 1.0.0
* @since 2025-07-29
* @lastUpdated 2025-07-29
* @module QuizValidationService Domain Service
* @description Pure domain service for quiz and question validation business rules,
* free from infrastructure concerns and framework dependencies.
* @contributors Claude Code Agent
* @dependencies Quiz, Question domain entities
* @requirements REQ-ARCH-001 (Clean Architecture Domain Layer)
* @testCoverage Unit tests for validation rules and business logic
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.QuizValidationService = void 0;
/**
* Quiz Validation Domain Service
*
* @description Encapsulates all business logic for validating quizzes and questions
* according to educational standards, accessibility guidelines, and
* business rules. Provides comprehensive validation reporting.
*
* @example
* ```typescript
* const validator = new QuizValidationService();
* const result = validator.validateQuiz(quiz, {
* strictMode: true,
* educationalStandards: true,
* accessibilityCheck: true
* });
* ```
*
* @since 2025-07-29
* @author Claude Code Agent
* @requirements REQ-ARCH-001 (Clean Architecture Domain Layer)
*/
class QuizValidationService {
/**
* Validate a complete quiz with all questions
*/
validateQuiz(quiz, options = {}) {
const errors = [];
const warnings = [];
// Basic quiz validation
this.validateQuizBasics(quiz, errors, warnings);
// Question validation
this.validateQuestions(quiz.questions, errors, warnings, options);
// Educational standards validation
if (options.educationalStandards) {
this.validateEducationalStandards(quiz, errors, warnings);
}
// Accessibility validation
if (options.accessibilityCheck) {
this.validateAccessibility(quiz, errors, warnings);
}
// Content quality validation
if (options.contentQualityCheck) {
this.validateContentQuality(quiz, errors, warnings);
}
// Strict mode additional checks
if (options.strictMode) {
this.validateStrictMode(quiz, errors, warnings);
}
const score = this.calculateQualityScore(quiz, errors, warnings);
return {
isValid: errors.filter(e => e.severity === 'critical').length === 0,
errors,
warnings,
score,
};
}
/**
* Validate basic quiz properties
*/
validateQuizBasics(quiz, errors, warnings) {
// Title validation
if (!quiz.title.trim()) {
errors.push({
code: 'QUIZ_TITLE_EMPTY',
message: 'Quiz title cannot be empty',
field: 'title',
severity: 'critical',
});
}
if (quiz.title.length < 3) {
warnings.push({
code: 'QUIZ_TITLE_SHORT',
message: 'Quiz title is very short',
field: 'title',
recommendation: 'Consider a more descriptive title (3+ characters)',
});
}
if (quiz.title.length > 100) {
warnings.push({
code: 'QUIZ_TITLE_LONG',
message: 'Quiz title is very long',
field: 'title',
recommendation: 'Consider shortening the title for better display',
});
}
// Question count validation
if (quiz.questionCount === 0) {
errors.push({
code: 'QUIZ_NO_QUESTIONS',
message: 'Quiz must have at least one question',
field: 'questions',
severity: 'critical',
});
}
if (quiz.questionCount === 1) {
warnings.push({
code: 'QUIZ_SINGLE_QUESTION',
message: 'Quiz has only one question',
field: 'questions',
recommendation: 'Consider adding more questions for better assessment',
});
}
if (quiz.questionCount > 50) {
warnings.push({
code: 'QUIZ_TOO_MANY_QUESTIONS',
message: 'Quiz has many questions which may cause user fatigue',
field: 'questions',
recommendation: 'Consider breaking into multiple shorter quizzes',
});
}
// Time limit validation
if (quiz.isTimeLimited && quiz.timeLimit < 1) {
errors.push({
code: 'QUIZ_INVALID_TIME_LIMIT',
message: 'Time limit must be at least 1 minute',
field: 'timeLimit',
severity: 'major',
});
}
if (quiz.isTimeLimited && quiz.timeLimit < quiz.questionCount) {
warnings.push({
code: 'QUIZ_TIME_LIMIT_SHORT',
message: 'Time limit may be too short (less than 1 minute per question)',
field: 'timeLimit',
recommendation: 'Consider allowing more time per question',
});
}
// Category validation
if (!quiz.category.trim()) {
warnings.push({
code: 'QUIZ_NO_CATEGORY',
message: 'Quiz has no category assigned',
field: 'category',
recommendation: 'Assign a category for better organization',
});
}
}
/**
* Validate all questions in the quiz
*/
validateQuestions(questions, errors, warnings, options) {
const questionTexts = new Set();
const questionOrders = new Set();
questions.forEach((question, index) => {
// Individual question validation
this.validateSingleQuestion(question, errors, warnings, index + 1);
// Check for duplicate question text
const normalizedText = question.questionText.toLowerCase().trim();
if (questionTexts.has(normalizedText)) {
errors.push({
code: 'QUESTION_DUPLICATE',
message: `Question ${index + 1} has duplicate text`,
field: `questions[${index}].questionText`,
severity: 'major',
});
}
questionTexts.add(normalizedText);
// Check for duplicate order numbers
if (questionOrders.has(question.order)) {
warnings.push({
code: 'QUESTION_DUPLICATE_ORDER',
message: `Question ${index + 1} has duplicate order number`,
field: `questions[${index}].order`,
recommendation: 'Ensure each question has a unique order number',
});
}
questionOrders.add(question.order);
});
this.validateQuestionSequence(questions, errors, warnings);
}
/**
* Validate a single question
*/
validateSingleQuestion(question, errors, warnings, questionNumber) {
const prefix = `Question ${questionNumber}`;
// Question text validation
if (question.questionText.length < 5) {
warnings.push({
code: 'QUESTION_TEXT_SHORT',
message: `${prefix}: Question text is very short`,
recommendation: 'Consider making the question more specific',
});
}
if (question.questionText.length > 500) {
warnings.push({
code: 'QUESTION_TEXT_LONG',
message: `${prefix}: Question text is very long`,
recommendation: 'Consider breaking into multiple questions or simplifying',
});
}
// Options validation
if (question.optionCount < 2) {
errors.push({
code: 'QUESTION_INSUFFICIENT_OPTIONS',
message: `${prefix}: Must have at least 2 options`,
severity: 'critical',
});
}
if (question.isMultipleChoice && question.optionCount < 3) {
warnings.push({
code: 'QUESTION_FEW_OPTIONS',
message: `${prefix}: Multiple choice questions work better with 3+ options`,
recommendation: 'Add more plausible distractors',
});
}
if (question.optionCount > 6) {
warnings.push({
code: 'QUESTION_TOO_MANY_OPTIONS',
message: `${prefix}: Too many options may confuse users`,
recommendation: 'Consider reducing to 4-5 options',
});
}
// Validate option lengths
const optionLengths = question.options.map(opt => opt.length);
const avgLength = optionLengths.reduce((sum, len) => sum + len, 0) / optionLengths.length;
const maxLength = Math.max(...optionLengths);
const minLength = Math.min(...optionLengths);
if (maxLength - minLength > avgLength) {
warnings.push({
code: 'QUESTION_UNBALANCED_OPTIONS',
message: `${prefix}: Option lengths vary significantly`,
recommendation: 'Try to make options similar in length',
});
}
// Correct answer validation
if (!question.isValidConfiguration()) {
errors.push({
code: 'QUESTION_INVALID_ANSWER',
message: `${prefix}: Correct answer must be one of the provided options`,
severity: 'critical',
});
}
// Points validation
if (question.points === 0) {
warnings.push({
code: 'QUESTION_ZERO_POINTS',
message: `${prefix}: Question awards zero points`,
recommendation: 'Consider assigning points for scoring',
});
}
if (question.points > 10) {
warnings.push({
code: 'QUESTION_HIGH_POINTS',
message: `${prefix}: Question has unusually high point value`,
recommendation: 'Consider if this weighting is intentional',
});
}
}
/**
* Validate question sequence and ordering
*/
validateQuestionSequence(questions, errors, warnings) {
const orders = questions.map(q => q.order).sort((a, b) => a - b);
// Check for gaps in sequence
for (let i = 0; i < orders.length - 1; i++) {
if (orders[i + 1] - orders[i] > 1) {
warnings.push({
code: 'QUESTION_SEQUENCE_GAP',
message: 'Gap in question order sequence',
recommendation: 'Consider using consecutive order numbers',
});
break;
}
}
// Check if orders start from 1
if (orders[0] !== 1) {
warnings.push({
code: 'QUESTION_SEQUENCE_START',
message: 'Question ordering does not start from 1',
recommendation: 'Consider starting question order from 1',
});
}
}
/**
* Validate against educational standards
*/
validateEducationalStandards(quiz, errors, warnings) {
// Bloom's taxonomy level distribution
const difficultyDistribution = this.analyzeDifficultyDistribution(quiz.questions);
if (difficultyDistribution.easy > 0.8) {
warnings.push({
code: 'QUIZ_TOO_EASY',
message: 'Quiz may be too easy (80%+ easy questions)',
recommendation: 'Include more challenging questions for better assessment',
});
}
if (difficultyDistribution.hard > 0.6) {
warnings.push({
code: 'QUIZ_TOO_HARD',
message: 'Quiz may be too difficult (60%+ hard questions)',
recommendation: 'Include easier questions to support learning progression',
});
}
// Check for explanations
const questionsWithExplanations = quiz.questions.filter(q => q.hasExplanation).length;
const explanationRate = questionsWithExplanations / quiz.questionCount;
if (explanationRate < 0.5) {
warnings.push({
code: 'QUIZ_FEW_EXPLANATIONS',
message: 'Less than 50% of questions have explanations',
recommendation: 'Add explanations to help with learning',
});
}
// Minimum time per question recommendation
if (quiz.isTimeLimited) {
const timePerQuestion = quiz.timeLimit / quiz.questionCount;
if (timePerQuestion < 0.5) {
warnings.push({
code: 'QUIZ_INSUFFICIENT_TIME',
message: 'Less than 30 seconds per question on average',
recommendation: 'Allow more time for thoughtful responses',
});
}
}
}
/**
* Validate accessibility guidelines
*/
validateAccessibility(quiz, errors, warnings) {
// Check for potentially problematic content
quiz.questions.forEach((question, index) => {
const text = `${question.questionText} ${question.options.join(' ')}`.toLowerCase();
// Color-only instructions
if (this.containsColorOnlyInstructions(text)) {
warnings.push({
code: 'ACCESSIBILITY_COLOR_ONLY',
message: `Question ${index + 1}: May rely on color alone for meaning`,
recommendation: 'Add text labels or patterns alongside color',
});
}
// Very long questions without breaks
if (question.questionText.length > 300 && !question.questionText.includes('.')) {
warnings.push({
code: 'ACCESSIBILITY_LONG_TEXT',
message: `Question ${index + 1}: Very long text without sentence breaks`,
recommendation: 'Break long questions into shorter sentences',
});
}
// Check for clear language
if (this.hasComplexLanguage(question.questionText)) {
warnings.push({
code: 'ACCESSIBILITY_COMPLEX_LANGUAGE',
message: `Question ${index + 1}: May use complex language`,
recommendation: 'Consider simplifying language for broader accessibility',
});
}
});
}
/**
* Validate content quality
*/
validateContentQuality(quiz, errors, warnings) {
quiz.questions.forEach((question, index) => {
// Check for obvious/trivial questions
if (this.isTrivialQuestion(question)) {
warnings.push({
code: 'CONTENT_TRIVIAL_QUESTION',
message: `Question ${index + 1}: May be too obvious or trivial`,
recommendation: 'Consider making the question more thought-provoking',
});
}
// Check for ambiguous questions
if (this.isAmbiguousQuestion(question)) {
warnings.push({
code: 'CONTENT_AMBIGUOUS_QUESTION',
message: `Question ${index + 1}: May be ambiguous`,
recommendation: 'Clarify the question to avoid confusion',
});
}
// Check for grammar/spelling (basic check)
if (this.hasGrammarIssues(question.questionText)) {
warnings.push({
code: 'CONTENT_GRAMMAR_ISSUES',
message: `Question ${index + 1}: May have grammar or spelling issues`,
recommendation: 'Review and proofread the question text',
});
}
});
}
/**
* Additional validation for strict mode
*/
validateStrictMode(quiz, errors, warnings) {
// All questions must have explanations
const questionsWithoutExplanations = quiz.questions.filter(q => !q.hasExplanation);
if (questionsWithoutExplanations.length > 0) {
errors.push({
code: 'STRICT_MISSING_EXPLANATIONS',
message: 'Strict mode requires all questions to have explanations',
severity: 'major',
});
}
// Minimum number of questions
if (quiz.questionCount < 5) {
errors.push({
code: 'STRICT_INSUFFICIENT_QUESTIONS',
message: 'Strict mode requires at least 5 questions',
severity: 'major',
});
}
// All questions must have unique points
const pointValues = quiz.questions.map(q => q.points);
const uniquePoints = new Set(pointValues);
if (uniquePoints.size === 1 && quiz.questionCount > 3) {
warnings.push({
code: 'STRICT_UNIFORM_POINTS',
message: 'All questions have the same point value',
recommendation: 'Consider varying points based on difficulty',
});
}
}
/**
* Calculate overall quality score (0-100)
*/
calculateQualityScore(quiz, errors, warnings) {
let score = 100;
// Deduct points for errors
errors.forEach(error => {
switch (error.severity) {
case 'critical':
score -= 25;
break;
case 'major':
score -= 15;
break;
case 'minor':
score -= 5;
break;
}
});
// Deduct points for warnings
score -= warnings.length * 2;
// Bonus points for good practices
const explanationRate = quiz.questions.filter(q => q.hasExplanation).length / quiz.questionCount;
score += Math.round(explanationRate * 10);
if (quiz.questionCount >= 5 && quiz.questionCount <= 20) {
score += 5; // Good question count
}
return Math.max(0, Math.min(100, score));
}
/**
* Helper methods for content analysis
*/
analyzeDifficultyDistribution(questions) {
const total = questions.length;
const counts = { easy: 0, medium: 0, hard: 0 };
questions.forEach(q => {
counts[q.difficulty]++;
});
return {
easy: counts.easy / total,
medium: counts.medium / total,
hard: counts.hard / total,
};
}
containsColorOnlyInstructions(text) {
const colorWords = ['red', 'green', 'blue', 'yellow', 'orange', 'purple', 'pink'];
const colorPattern = new RegExp(`\\b(${colorWords.join('|')})\\b`, 'i');
return colorPattern.test(text) && /choose|select|click/.test(text);
}
hasComplexLanguage(text) {
// Simple heuristic: sentences with more than 20 words or complex words
const sentences = text.split(/[.!?]+/);
return sentences.some(sentence => {
const words = sentence.trim().split(/\s+/);
return words.length > 20 || words.some(word => word.length > 12);
});
}
isTrivialQuestion(question) {
const text = question.questionText.toLowerCase();
const trivialPatterns = [
/what is \d+ \+ \d+/,
/true or false/i,
/what color is/i,
/how many letters/i,
];
return trivialPatterns.some(pattern => pattern.test(text));
}
isAmbiguousQuestion(question) {
const text = question.questionText.toLowerCase();
const ambiguousWords = ['sometimes', 'usually', 'often', 'might', 'could', 'may'];
return (ambiguousWords.some(word => text.includes(word)) &&
!text.includes('always') &&
!text.includes('never'));
}
hasGrammarIssues(text) {
// Very basic grammar check - in production, you'd use a proper grammar checker
return (/\s{2,}/.test(text) || // Multiple spaces
/^[a-z]/.test(text) || // Doesn't start with capital
!/[.!?]$/.test(text.trim())); // Doesn't end with punctuation
}
}
exports.QuizValidationService = QuizValidationService;