Initial: Intelligrade Lite — client-side digital exam platform MVP

This commit is contained in:
Bun Bun
2026-06-20 18:24:02 +00:00
commit 9ddf67148c
14 changed files with 3038 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
*.log
.venv/
.verdict
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Intelligrade Lite — Digital Exams</title>
<link rel="stylesheet" href="./src/style.css">
</head>
<body>
<div id="app"></div>
<script type="module" src="./src/main.ts"></script>
</body>
</html>
+1158
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
{
"name": "intelligrade-lite",
"version": "1.0.0",
"description": "Client-side digital exam creation and grading tool",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "node tests/grade.test.js"
},
"devDependencies": {
"typescript": "~5.7.2",
"vite": "^6.0.0"
}
}
+801
View File
@@ -0,0 +1,801 @@
import { View, Exam, Submission } from './types';
import { saveExams, loadExams, saveSubmissions, loadSubmissions, generateId, exportToJSON, importFromJSON } from './store';
import { autoGradeSubmission, formatTime } from './utils';
// App state
let currentView: View = 'dashboard';
let editingExamId: string | null = null;
let takingExamId: string | null = null;
let gradingSubmissionId: string | null = null;
let viewingResultsExamId: string | null = null;
// DOM
const app = document.getElementById('app')!;
function render() {
app.innerHTML = '';
switch (currentView) {
case 'dashboard': renderDashboard(); break;
case 'editor': renderEditor(); break;
case 'take': renderTake(); break;
case 'submissions': renderSubmissions(); break;
case 'grade': renderGrade(); break;
case 'results': renderResults(); break;
case 'export': renderExport(); break;
}
}
function navigate(view: View, params?: { examId?: string; submissionId?: string }) {
currentView = view;
editingExamId = params?.examId ?? null;
takingExamId = params?.examId ?? null;
gradingSubmissionId = params?.submissionId ?? null;
viewingResultsExamId = params?.examId ?? null;
render();
}
function renderNav(): string {
return `
<nav class="nav">
<div class="nav-brand" onclick="navigate('dashboard')">Intelligrade Lite</div>
<div class="nav-links">
<button class="nav-btn ${currentView === 'dashboard' ? 'active' : ''}" onclick="navigate('dashboard')">Dashboard</button>
<button class="nav-btn ${currentView === 'export' ? 'active' : ''}" onclick="navigate('export')">Import / Export</button>
</div>
</nav>
`;
}
// Make navigate available globally for inline handlers
(window as any).navigate = navigate;
function renderDashboard() {
const exams = loadExams();
const submissions = loadSubmissions();
app.innerHTML = `
${renderNav()}
<div class="container">
<div class="hero">
<h1>Digital Exams</h1>
<p>Create, conduct, and grade exams — all in your browser. No server. No data leaves your device.</p>
<button class="btn btn-primary" onclick="createNewExam()">+ Create New Exam</button>
</div>
<div class="stats">
<div class="stat-card">
<div class="stat-value">${exams.length}</div>
<div class="stat-label">Exams</div>
</div>
<div class="stat-card">
<div class="stat-value">${submissions.length}</div>
<div class="stat-label">Submissions</div>
</div>
<div class="stat-card">
<div class="stat-value">${submissions.filter(s => s.graded).length}</div>
<div class="stat-label">Graded</div>
</div>
</div>
<h2>Your Exams</h2>
${exams.length === 0 ? `
<div class="empty-state">
<p>No exams yet. Create your first exam to get started.</p>
</div>
` : `
<div class="exam-grid">
${exams.map(exam => `
<div class="exam-card">
<div class="exam-card-header">
<h3>${escapeHtml(exam.title)}</h3>
<span class="badge">${exam.questions.length} questions</span>
</div>
<p class="exam-subject">${escapeHtml(exam.subject)}</p>
<p class="exam-desc">${escapeHtml(exam.description)}</p>
<div class="exam-meta">
<span>⏱ ${exam.timeLimit > 0 ? exam.timeLimit + ' min' : 'No limit'}</span>
<span>🔀 ${exam.shuffleQuestions ? 'Shuffled' : 'Fixed order'}</span>
</div>
<div class="exam-actions">
<button class="btn btn-sm" onclick="editExam('${exam.id}')">Edit</button>
<button class="btn btn-sm btn-accent" onclick="takeExam('${exam.id}')">Take Exam</button>
<button class="btn btn-sm" onclick="viewResults('${exam.id}')">Results</button>
<button class="btn btn-sm btn-danger" onclick="deleteExam('${exam.id}')">Delete</button>
</div>
</div>
`).join('')}
</div>
`}
<h2>Recent Submissions</h2>
${submissions.length === 0 ? `
<div class="empty-state">
<p>No submissions yet. Take an exam to see results here.</p>
</div>
` : `
<div class="submission-list">
${submissions.slice(0, 10).map(sub => {
const exam = exams.find(e => e.id === sub.examId);
return `
<div class="submission-row">
<span class="sub-student">${escapeHtml(sub.studentName)}</span>
<span class="sub-exam">${escapeHtml(exam?.title ?? 'Unknown exam')}</span>
<span class="sub-score">${sub.percentage}%</span>
<span class="sub-status ${sub.graded ? 'graded' : 'pending'}">${sub.graded ? 'Graded' : 'Pending'}</span>
<button class="btn btn-sm" onclick="gradeSubmission('${sub.id}')">${sub.graded ? 'Review' : 'Grade'}</button>
</div>
`;
}).join('')}
</div>
`}
</div>
`;
}
function escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
(window as any).createNewExam = () => {
editingExamId = null;
navigate('editor');
};
(window as any).editExam = (id: string) => navigate('editor', { examId: id });
(window as any).takeExam = (id: string) => navigate('take', { examId: id });
(window as any).viewResults = (id: string) => navigate('results', { examId: id });
(window as any).gradeSubmission = (id: string) => navigate('grade', { submissionId: id });
(window as any).deleteExam = (id: string) => {
if (!confirm('Delete this exam and all its submissions?')) return;
const exams = loadExams().filter(e => e.id !== id);
const submissions = loadSubmissions().filter(s => s.examId !== id);
saveExams(exams);
saveSubmissions(submissions);
render();
};
function renderEditor() {
const exams = loadExams();
const exam = editingExamId ? exams.find(e => e.id === editingExamId) : null;
const questions = exam?.questions ?? [];
app.innerHTML = `
${renderNav()}
<div class="container">
<div class="editor-header">
<button class="btn btn-back" onclick="navigate('dashboard')">← Back</button>
<h1>${exam ? 'Edit Exam' : 'New Exam'}</h1>
</div>
<div class="editor-form">
<div class="form-group">
<label>Title</label>
<input type="text" id="exam-title" value="${escapeHtml(exam?.title ?? '')}" placeholder="e.g., Biology Midterm">
</div>
<div class="form-row">
<div class="form-group">
<label>Subject</label>
<input type="text" id="exam-subject" value="${escapeHtml(exam?.subject ?? '')}" placeholder="e.g., Biology">
</div>
<div class="form-group">
<label>Time Limit (minutes, 0 = none)</label>
<input type="number" id="exam-time" value="${exam?.timeLimit ?? 60}">
</div>
</div>
<div class="form-group">
<label>Description</label>
<textarea id="exam-desc" rows="2" placeholder="Brief description of the exam">${escapeHtml(exam?.description ?? '')}</textarea>
</div>
<div class="form-checkboxes">
<label><input type="checkbox" id="exam-shuffle" ${exam?.shuffleQuestions ? 'checked' : ''}> Shuffle questions</label>
<label><input type="checkbox" id="exam-retake" ${exam?.allowRetake ? 'checked' : ''}> Allow retake</label>
<label><input type="checkbox" id="exam-showresults" ${exam?.showResults !== false ? 'checked' : ''}> Show results immediately</label>
</div>
</div>
<h2>Questions (${questions.length})</h2>
<div id="questions-container">
${questions.map((q, i) => renderQuestionEditor(q, i)).join('')}
</div>
<div class="add-question-bar">
<button class="btn" onclick="addQuestion('multiple_choice')">+ Multiple Choice</button>
<button class="btn" onclick="addQuestion('true_false')">+ True/False</button>
<button class="btn" onclick="addQuestion('short_answer')">+ Short Answer</button>
<button class="btn" onclick="addQuestion('essay')">+ Essay</button>
<button class="btn" onclick="addQuestion('fill_blank')">+ Fill in Blank</button>
</div>
<div class="editor-actions">
<button class="btn btn-primary btn-large" onclick="saveExam()">Save Exam</button>
</div>
</div>
`;
}
function renderQuestionEditor(q: any, index: number): string {
const optionsHtml = q.options?.map((opt: string, i: number) => `
<div class="option-row">
<input type="radio" name="correct-${q.id}" value="${i}" ${q.correctAnswer === String(i) ? 'checked' : ''}>
<input type="text" class="option-text" data-qid="${q.id}" data-idx="${i}" value="${escapeHtml(opt)}" placeholder="Option ${i + 1}">
<button class="btn btn-icon" onclick="removeOption('${q.id}', ${i})">×</button>
</div>
`).join('') ?? '';
const optionsEditor = q.type === 'multiple_choice' ? `
<div class="options-list" id="options-${q.id}">
${optionsHtml}
<button class="btn btn-sm" onclick="addOption('${q.id}')">+ Add Option</button>
</div>
` : '';
const correctAnswerInput = q.type === 'fill_blank' ? `
<div class="form-group">
<label>Correct Answer(s) — comma separated</label>
<input type="text" id="correct-${q.id}" value="${escapeHtml(Array.isArray(q.correctAnswer) ? q.correctAnswer.join(', ') : (q.correctAnswer ?? ''))}" placeholder="e.g., mitochondria, Mitochondria">
<label><input type="checkbox" id="case-${q.id}" ${q.caseSensitive ? 'checked' : ''}> Case sensitive</label>
</div>
` : q.type === 'true_false' ? `
<div class="form-group">
<label>Correct Answer</label>
<select id="correct-${q.id}">
<option value="true" ${q.correctAnswer === 'true' ? 'selected' : ''}>True</option>
<option value="false" ${q.correctAnswer === 'false' ? 'selected' : ''}>False</option>
</select>
</div>
` : '';
return `
<div class="question-editor" data-qid="${q.id}">
<div class="question-header">
<span class="question-number">${index + 1}</span>
<span class="question-type-badge">${q.type.replace('_', ' ')}</span>
<button class="btn btn-icon btn-danger" onclick="removeQuestion('${q.id}')">×</button>
</div>
<div class="form-row">
<div class="form-group flex-grow">
<label>Question Text</label>
<textarea id="text-${q.id}" rows="2" placeholder="Enter your question">${escapeHtml(q.text)}</textarea>
</div>
<div class="form-group" style="width: 100px;">
<label>Points</label>
<input type="number" id="points-${q.id}" value="${q.points}" min="0">
</div>
</div>
${optionsEditor}
${correctAnswerInput}
</div>
`;
}
(window as any).addQuestion = (type: string) => {
const container = document.getElementById('questions-container')!;
const qid = generateId();
const newQ = {
id: qid,
type,
text: '',
points: type === 'essay' ? 10 : (type === 'short_answer' ? 5 : 1),
options: type === 'multiple_choice' ? ['', ''] : undefined,
correctAnswer: type === 'true_false' ? 'true' : undefined,
};
const div = document.createElement('div');
div.innerHTML = renderQuestionEditor(newQ, container.children.length);
container.appendChild(div.firstElementChild!);
};
(window as any).addOption = (qid: string) => {
const list = document.getElementById(`options-${qid}`)!;
const idx = list.querySelectorAll('.option-row').length;
const row = document.createElement('div');
row.className = 'option-row';
row.innerHTML = `
<input type="radio" name="correct-${qid}" value="${idx}">
<input type="text" class="option-text" data-qid="${qid}" data-idx="${idx}" placeholder="Option ${idx + 1}">
<button class="btn btn-icon" onclick="removeOption('${qid}', ${idx})">×</button>
`;
list.insertBefore(row, list.lastElementChild);
};
(window as any).removeOption = (qid: string, idx: number) => {
const list = document.getElementById(`options-${qid}`)!;
const rows = list.querySelectorAll('.option-row');
if (rows[idx]) rows[idx].remove();
};
(window as any).removeQuestion = (qid: string) => {
const el = document.querySelector(`[data-qid="${qid}"]`);
el?.remove();
// Renumber
document.querySelectorAll('.question-editor').forEach((el, i) => {
el.querySelector('.question-number')!.textContent = String(i + 1);
});
};
(window as any).saveExam = () => {
const title = (document.getElementById('exam-title') as HTMLInputElement).value.trim();
if (!title) { alert('Title is required'); return; }
const exam: Exam = {
id: editingExamId ?? generateId(),
title,
subject: (document.getElementById('exam-subject') as HTMLInputElement).value.trim(),
description: (document.getElementById('exam-desc') as HTMLTextAreaElement).value.trim(),
timeLimit: parseInt((document.getElementById('exam-time') as HTMLInputElement).value) || 0,
shuffleQuestions: (document.getElementById('exam-shuffle') as HTMLInputElement).checked,
allowRetake: (document.getElementById('exam-retake') as HTMLInputElement).checked,
showResults: (document.getElementById('exam-showresults') as HTMLInputElement).checked,
questions: [],
createdAt: editingExamId ? loadExams().find(e => e.id === editingExamId)!.createdAt : new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
// Collect questions from DOM
document.querySelectorAll('.question-editor').forEach(el => {
const qid = el.getAttribute('data-qid')!;
const type = el.querySelector('.question-type-badge')!.textContent!.replace(' ', '_') as any;
const text = (el.querySelector(`#text-${qid}`) as HTMLTextAreaElement).value.trim();
const points = parseInt((el.querySelector(`#points-${qid}`) as HTMLInputElement).value) || 0;
const q: any = { id: qid, type, text, points };
if (type === 'multiple_choice') {
const options: string[] = [];
let correctAnswer = '';
el.querySelectorAll('.option-row').forEach((row, i) => {
const optText = (row.querySelector('.option-text') as HTMLInputElement)?.value.trim() ?? '';
if (optText) options.push(optText);
const radio = row.querySelector('input[type="radio"]') as HTMLInputElement;
if (radio?.checked) correctAnswer = String(i);
});
q.options = options;
q.correctAnswer = correctAnswer;
} else if (type === 'true_false') {
q.correctAnswer = (el.querySelector(`#correct-${qid}`) as HTMLSelectElement)?.value;
} else if (type === 'fill_blank') {
const raw = (el.querySelector(`#correct-${qid}`) as HTMLInputElement)?.value.trim() ?? '';
q.correctAnswer = raw ? raw.split(',').map((s: string) => s.trim()).filter(Boolean) : [];
q.caseSensitive = (el.querySelector(`#case-${qid}`) as HTMLInputElement)?.checked;
}
exam.questions.push(q);
});
const exams = loadExams().filter(e => e.id !== exam.id);
exams.push(exam);
saveExams(exams);
navigate('dashboard');
};
function renderTake() {
const exams = loadExams();
const exam = exams.find(e => e.id === takingExamId);
if (!exam) {
app.innerHTML = `<div class="container"><p>Exam not found. <button onclick="navigate('dashboard')">Back</button></p></div>`;
return;
}
const questions = exam.shuffleQuestions
? [...exam.questions].sort(() => Math.random() - 0.5)
: exam.questions;
app.innerHTML = `
${renderNav()}
<div class="container">
<div class="take-header">
<h1>${escapeHtml(exam.title)}</h1>
<div class="take-meta">
<span>${exam.questions.length} questions</span>
<span>${exam.questions.reduce((a, q) => a + q.points, 0)} points</span>
${exam.timeLimit > 0 ? `<span id="timer" class="timer">⏱ ${formatTime(exam.timeLimit * 60)}</span>` : ''}
</div>
</div>
<div class="student-form">
<div class="form-group">
<label>Student Name</label>
<input type="text" id="student-name" placeholder="Enter your name" required>
</div>
</div>
<div class="questions-list">
${questions.map((q, i) => renderQuestionTake(q, i)).join('')}
</div>
<div class="take-actions">
<button class="btn btn-primary btn-large" onclick="submitExam()">Submit Exam</button>
</div>
</div>
`;
// Start timer
if (exam.timeLimit > 0) {
let remaining = exam.timeLimit * 60;
const timerEl = document.getElementById('timer')!;
const interval = setInterval(() => {
remaining--;
timerEl.textContent = `${formatTime(remaining)}`;
if (remaining <= 0) {
clearInterval(interval);
alert('Time is up! Submitting your exam.');
(window as any).submitExam();
}
}, 1000);
(window as any)._examTimer = interval;
}
}
function renderQuestionTake(q: any, index: number): string {
const inputName = `q-${q.id}`;
switch (q.type) {
case 'multiple_choice':
return `
<div class="question-take">
<div class="question-text">${index + 1}. ${escapeHtml(q.text)} <span class="points">(${q.points} pts)</span></div>
<div class="options-take">
${q.options?.map((opt: string, i: number) => `
<label class="option-label">
<input type="radio" name="${inputName}" value="${i}">
<span>${escapeHtml(opt)}</span>
</label>
`).join('') ?? ''}
</div>
</div>
`;
case 'true_false':
return `
<div class="question-take">
<div class="question-text">${index + 1}. ${escapeHtml(q.text)} <span class="points">(${q.points} pts)</span></div>
<div class="options-take">
<label class="option-label">
<input type="radio" name="${inputName}" value="true">
<span>True</span>
</label>
<label class="option-label">
<input type="radio" name="${inputName}" value="false">
<span>False</span>
</label>
</div>
</div>
`;
case 'fill_blank':
return `
<div class="question-take">
<div class="question-text">${index + 1}. ${escapeHtml(q.text)} <span class="points">(${q.points} pts)</span></div>
<input type="text" name="${inputName}" class="answer-input" placeholder="Your answer">
</div>
`;
case 'short_answer':
return `
<div class="question-take">
<div class="question-text">${index + 1}. ${escapeHtml(q.text)} <span class="points">(${q.points} pts)</span></div>
<textarea name="${inputName}" class="answer-textarea" rows="3" placeholder="Your answer"></textarea>
</div>
`;
case 'essay':
return `
<div class="question-take">
<div class="question-text">${index + 1}. ${escapeHtml(q.text)} <span class="points">(${q.points} pts)</span></div>
<textarea name="${inputName}" class="answer-textarea" rows="8" placeholder="Write your essay here..."></textarea>
</div>
`;
default:
return '';
}
}
(window as any).submitExam = () => {
const studentName = (document.getElementById('student-name') as HTMLInputElement)?.value.trim();
if (!studentName) { alert('Please enter your name'); return; }
const exams = loadExams();
const exam = exams.find(e => e.id === takingExamId);
if (!exam) return;
const answers: any[] = [];
exam.questions.forEach(q => {
const inputName = `q-${q.id}`;
if (q.type === 'multiple_choice' || q.type === 'true_false') {
const selected = document.querySelector(`input[name="${inputName}"]:checked`) as HTMLInputElement;
if (selected) answers.push({ questionId: q.id, value: selected.value });
} else {
const el = document.querySelector(`[name="${inputName}"]`) as HTMLInputElement | HTMLTextAreaElement;
if (el) answers.push({ questionId: q.id, value: el.value });
}
});
if ((window as any)._examTimer) clearInterval((window as any)._examTimer);
const submission: Submission = {
id: generateId(),
examId: exam.id,
studentName,
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
answers,
scores: {},
totalScore: 0,
maxScore: 0,
percentage: 0,
graded: false,
};
const questionsMap = new Map(exam.questions.map(q => [q.id, q]));
const graded = autoGradeSubmission(submission, questionsMap);
const submissions = loadSubmissions();
submissions.push(graded);
saveSubmissions(submissions);
if (exam.showResults) {
alert(`Submitted! You scored ${graded.percentage}% (${graded.totalScore}/${graded.maxScore})`);
} else {
alert('Submitted! Your teacher will review your answers.');
}
navigate('dashboard');
};
function renderGrade() {
const submissions = loadSubmissions();
const submission = submissions.find(s => s.id === gradingSubmissionId);
const exams = loadExams();
const exam = submission ? exams.find(e => e.id === submission.examId) : null;
if (!submission || !exam) {
app.innerHTML = `<div class="container"><p>Submission not found. <button onclick="navigate('dashboard')">Back</button></p></div>`;
return;
}
app.innerHTML = `
${renderNav()}
<div class="container">
<div class="editor-header">
<button class="btn btn-back" onclick="navigate('dashboard')">← Back</button>
<h1>Grade: ${escapeHtml(submission.studentName)}</h1>
<p class="grade-exam-title">${escapeHtml(exam.title)}</p>
</div>
<div class="grade-summary">
<div class="grade-score">Current: ${submission.totalScore} / ${submission.maxScore} = ${submission.percentage}%</div>
</div>
<div class="questions-grade">
${exam.questions.map(q => {
const answer = submission.answers.find(a => a.questionId === q.id);
const score = submission.scores[q.id] ?? 0;
const needsManual = q.type === 'short_answer' || q.type === 'essay';
const autoGraded = !needsManual && submission.scores[q.id] !== undefined;
return `
<div class="question-grade">
<div class="question-text">${escapeHtml(q.text)} <span class="points">(${q.points} pts)</span></div>
<div class="answer-display">
<strong>Answer:</strong> ${escapeHtml(String(answer?.value ?? 'No answer'))}
</div>
${q.correctAnswer ? `<div class="correct-answer"><strong>Expected:</strong> ${escapeHtml(Array.isArray(q.correctAnswer) ? q.correctAnswer.join(' or ') : q.correctAnswer)}</div>` : ''}
<div class="grade-input">
<label>Score:</label>
<input type="number" id="score-${q.id}" value="${score}" min="0" max="${q.points}" step="0.5"
onchange="updateScore('${q.id}', ${q.points})">
<span class="auto-badge ${autoGraded ? 'auto' : (needsManual ? 'manual' : '')}">
${autoGraded ? 'Auto-graded' : (needsManual ? 'Manual' : '')}
</span>
</div>
</div>
`;
}).join('')}
</div>
<div class="editor-actions">
<button class="btn btn-primary btn-large" onclick="saveGrades()">Save Grades</button>
</div>
</div>
`;
}
(window as any).updateScore = (qid: string, maxPoints: number) => {
const el = document.getElementById(`score-${qid}`) as HTMLInputElement;
let val = parseFloat(el.value);
if (val < 0) val = 0;
if (val > maxPoints) val = maxPoints;
el.value = String(val);
};
(window as any).saveGrades = () => {
const submissions = loadSubmissions();
const submission = submissions.find(s => s.id === gradingSubmissionId);
if (!submission) return;
const exams = loadExams();
const exam = exams.find(e => e.id === submission.examId);
if (!exam) return;
let totalScore = 0;
const scores: Record<string, number> = {};
exam.questions.forEach(q => {
const el = document.getElementById(`score-${q.id}`) as HTMLInputElement;
const score = parseFloat(el.value) || 0;
scores[q.id] = score;
totalScore += score;
});
const maxScore = exam.questions.reduce((a, q) => a + q.points, 0);
const percentage = maxScore > 0 ? Math.round((totalScore / maxScore) * 100) : 0;
const idx = submissions.findIndex(s => s.id === gradingSubmissionId);
submissions[idx] = {
...submission,
scores,
totalScore,
maxScore,
percentage,
graded: true,
gradedAt: new Date().toISOString(),
};
saveSubmissions(submissions);
alert('Grades saved!');
navigate('dashboard');
};
function renderResults() {
const exams = loadExams();
const exam = exams.find(e => e.id === viewingResultsExamId);
const submissions = loadSubmissions().filter(s => s.examId === viewingResultsExamId);
if (!exam) {
app.innerHTML = `<div class="container"><p>Exam not found. <button onclick="navigate('dashboard')">Back</button></p></div>`;
return;
}
const avgScore = submissions.length > 0
? Math.round(submissions.reduce((a, s) => a + s.percentage, 0) / submissions.length)
: 0;
const gradedCount = submissions.filter(s => s.graded).length;
app.innerHTML = `
${renderNav()}
<div class="container">
<div class="editor-header">
<button class="btn btn-back" onclick="navigate('dashboard')">← Back</button>
<h1>Results: ${escapeHtml(exam.title)}</h1>
</div>
<div class="stats">
<div class="stat-card">
<div class="stat-value">${submissions.length}</div>
<div class="stat-label">Submissions</div>
</div>
<div class="stat-card">
<div class="stat-value">${gradedCount}</div>
<div class="stat-label">Graded</div>
</div>
<div class="stat-card">
<div class="stat-value">${avgScore}%</div>
<div class="stat-label">Average</div>
</div>
<div class="stat-card">
<div class="stat-value">${submissions.length > 0 ? Math.max(...submissions.map(s => s.percentage)) : 0}%</div>
<div class="stat-label">Highest</div>
</div>
</div>
<h2>Submissions</h2>
${submissions.length === 0 ? '<p class="empty-state">No submissions yet.</p>' : `
<div class="submission-list">
${submissions.map(sub => `
<div class="submission-row">
<span class="sub-student">${escapeHtml(sub.studentName)}</span>
<span class="sub-score">${sub.percentage}%</span>
<span class="sub-status ${sub.graded ? 'graded' : 'pending'}">${sub.graded ? 'Graded' : 'Pending'}</span>
<span class="sub-date">${new Date(sub.completedAt).toLocaleDateString()}</span>
<button class="btn btn-sm" onclick="gradeSubmission('${sub.id}')">${sub.graded ? 'Review' : 'Grade'}</button>
</div>
`).join('')}
</div>
`}
<h2>Question Breakdown</h2>
<div class="question-breakdown">
${exam.questions.map(q => {
const questionSubmissions = submissions.map(sub => {
const answer = sub.answers.find(a => a.questionId === q.id);
return { score: sub.scores[q.id] ?? 0, answer: answer?.value ?? '' };
});
const avgQScore = questionSubmissions.length > 0
? Math.round((questionSubmissions.reduce((a, s) => a + s.score, 0) / (questionSubmissions.length * q.points)) * 100)
: 0;
return `
<div class="breakdown-row">
<span class="breakdown-text">${escapeHtml(q.text.substring(0, 60))}${q.text.length > 60 ? '...' : ''}</span>
<div class="breakdown-bar">
<div class="breakdown-fill" style="width: ${avgQScore}%"></div>
</div>
<span class="breakdown-score">${avgQScore}% avg</span>
</div>
`;
}).join('')}
</div>
</div>
`;
}
function renderSubmissions() {
navigate('dashboard');
}
function renderExport() {
app.innerHTML = `
${renderNav()}
<div class="container">
<div class="editor-header">
<button class="btn btn-back" onclick="navigate('dashboard')">← Back</button>
<h1>Import / Export</h1>
</div>
<div class="export-section">
<h2>Export Exams</h2>
<p>Download all your exams as a JSON file.</p>
<button class="btn btn-primary" onclick="exportExams()">Export Exams</button>
</div>
<div class="export-section">
<h2>Export Submissions</h2>
<p>Download all submissions as a JSON file.</p>
<button class="btn btn-primary" onclick="exportSubmissions()">Export Submissions</button>
</div>
<div class="export-section">
<h2>Import Exams</h2>
<p>Upload a previously exported exam file.</p>
<input type="file" id="import-file" accept=".json" onchange="importExams()">
</div>
</div>
`;
}
(window as any).exportExams = () => {
exportToJSON(loadExams(), `intelligrade-exams-${new Date().toISOString().slice(0, 10)}.json`);
};
(window as any).exportSubmissions = () => {
exportToJSON(loadSubmissions(), `intelligrade-submissions-${new Date().toISOString().slice(0, 10)}.json`);
};
(window as any).importExams = async () => {
const input = document.getElementById('import-file') as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
try {
const data = await importFromJSON(file) as Exam[];
if (!Array.isArray(data)) { alert('Invalid file format'); return; }
const existing = loadExams();
const merged = [...existing];
for (const exam of data) {
if (!existing.find(e => e.id === exam.id)) {
exam.id = generateId(); // Reassign IDs to avoid conflicts
exam.createdAt = new Date().toISOString();
merged.push(exam);
}
}
saveExams(merged);
alert(`Imported ${data.length} exams`);
input.value = '';
navigate('dashboard');
} catch (e) {
alert('Failed to import: ' + (e as Error).message);
}
};
// Initial render
render();
+53
View File
@@ -0,0 +1,53 @@
import { Exam, Submission } from './types';
const STORAGE_KEYS = {
exams: 'intelligrade_exams',
submissions: 'intelligrade_submissions',
};
export function saveExams(exams: Exam[]): void {
localStorage.setItem(STORAGE_KEYS.exams, JSON.stringify(exams));
}
export function loadExams(): Exam[] {
const raw = localStorage.getItem(STORAGE_KEYS.exams);
return raw ? JSON.parse(raw) : [];
}
export function saveSubmissions(submissions: Submission[]): void {
localStorage.setItem(STORAGE_KEYS.submissions, JSON.stringify(submissions));
}
export function loadSubmissions(): Submission[] {
const raw = localStorage.getItem(STORAGE_KEYS.submissions);
return raw ? JSON.parse(raw) : [];
}
export function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}
export function exportToJSON(data: unknown, filename: string): void {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
export function importFromJSON(file: File): Promise<unknown> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
try {
resolve(JSON.parse(reader.result as string));
} catch (e) {
reject(e);
}
};
reader.onerror = reject;
reader.readAsText(file);
});
}
+626
View File
@@ -0,0 +1,626 @@
/* Intelligrade Lite — Digital Exam Platform Styles */
:root {
--primary: #2563eb;
--primary-dark: #1d4ed8;
--accent: #0ea5e9;
--danger: #dc2626;
--success: #16a34a;
--warning: #ca8a04;
--bg: #f8fafc;
--card: #ffffff;
--text: #1e293b;
--text-muted: #64748b;
--border: #e2e8f0;
--radius: 8px;
--shadow: 0 1px 3px rgba(0,0,0,0.1);
--shadow-lg: 0 4px 12px rgba(0,0,0,0.1);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
}
/* Navigation */
.nav {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 2rem;
background: var(--card);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 100;
}
.nav-brand {
font-size: 1.25rem;
font-weight: 700;
color: var(--primary);
cursor: pointer;
}
.nav-links { display: flex; gap: 0.5rem; }
.nav-btn {
padding: 0.5rem 1rem;
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
border-radius: var(--radius);
font-size: 0.875rem;
transition: all 0.2s;
}
.nav-btn:hover, .nav-btn.active {
background: var(--bg);
color: var(--primary);
}
/* Container */
.container {
max-width: 960px;
margin: 0 auto;
padding: 2rem 1rem;
}
/* Hero */
.hero {
text-align: center;
padding: 3rem 1rem;
margin-bottom: 2rem;
}
.hero h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
color: var(--text);
}
.hero p {
color: var(--text-muted);
font-size: 1.1rem;
max-width: 600px;
margin: 0 auto 1.5rem;
}
/* Buttons */
.btn {
padding: 0.5rem 1rem;
border: 1px solid var(--border);
background: var(--card);
color: var(--text);
border-radius: var(--radius);
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.btn:hover { background: var(--bg); }
.btn-primary {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.btn-primary:hover { background: var(--primary-dark); }
.btn-accent {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.btn-accent:hover { background: #0284c7; }
.btn-danger {
background: var(--danger);
color: white;
border-color: var(--danger);
}
.btn-danger:hover { background: #b91c1c; }
.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
.btn-large { padding: 0.75rem 1.5rem; font-size: 1rem; }
.btn-back { margin-bottom: 1rem; }
.btn-icon {
width: 28px;
height: 28px;
padding: 0;
justify-content: center;
font-size: 1rem;
}
/* Stats */
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: var(--card);
padding: 1.5rem;
border-radius: var(--radius);
box-shadow: var(--shadow);
text-align: center;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: var(--primary);
}
.stat-label {
font-size: 0.875rem;
color: var(--text-muted);
margin-top: 0.25rem;
}
/* Exam Grid */
.exam-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.exam-card {
background: var(--card);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.5rem;
display: flex;
flex-direction: column;
}
.exam-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.5rem;
}
.exam-card-header h3 {
font-size: 1.1rem;
font-weight: 600;
}
.badge {
background: var(--bg);
color: var(--text-muted);
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
}
.exam-subject {
color: var(--primary);
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.25rem;
}
.exam-desc {
color: var(--text-muted);
font-size: 0.875rem;
margin-bottom: 1rem;
flex-grow: 1;
}
.exam-meta {
display: flex;
gap: 1rem;
font-size: 0.75rem;
color: var(--text-muted);
margin-bottom: 1rem;
}
.exam-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
/* Submission List */
.submission-list {
background: var(--card);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden;
}
.submission-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.875rem 1rem;
border-bottom: 1px solid var(--border);
gap: 1rem;
}
.submission-row:last-child { border-bottom: none; }
.sub-student { font-weight: 500; min-width: 120px; }
.sub-exam { color: var(--text-muted); font-size: 0.875rem; flex-grow: 1; }
.sub-score { font-weight: 600; color: var(--primary); }
.sub-status {
font-size: 0.75rem;
padding: 0.125rem 0.5rem;
border-radius: 4px;
background: var(--bg);
color: var(--text-muted);
}
.sub-status.graded { background: #dcfce7; color: var(--success); }
.sub-status.pending { background: #fef9c3; color: var(--warning); }
.sub-date { font-size: 0.75rem; color: var(--text-muted); }
/* Empty State */
.empty-state {
text-align: center;
padding: 3rem;
color: var(--text-muted);
background: var(--card);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
/* Editor */
.editor-header {
margin-bottom: 1.5rem;
}
.editor-header h1 {
font-size: 1.5rem;
margin-top: 0.5rem;
}
.editor-form {
background: var(--card);
padding: 1.5rem;
border-radius: var(--radius);
box-shadow: var(--shadow);
margin-bottom: 1.5rem;
}
.form-group { margin-bottom: 1rem; }
.form-group label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.375rem;
color: var(--text);
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 0.625rem;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 0.875rem;
background: var(--bg);
color: var(--text);
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: var(--primary);
}
.form-row {
display: flex;
gap: 1rem;
}
.form-row .form-group { flex: 1; }
.flex-grow { flex-grow: 1; }
.form-checkboxes {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
}
.form-checkboxes label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
cursor: pointer;
}
/* Question Editor */
.question-editor {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem;
margin-bottom: 1rem;
}
.question-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.question-number {
background: var(--primary);
color: white;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 0.75rem;
font-weight: 600;
}
.question-type-badge {
background: var(--card);
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
text-transform: capitalize;
color: var(--text-muted);
flex-grow: 1;
}
.options-list { margin: 0.5rem 0; }
.option-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.option-row input[type="radio"] { margin-right: 0.25rem; }
.option-text { flex-grow: 1; }
.add-question-bar {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 1.5rem;
}
.editor-actions {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 1rem;
}
/* Take Exam */
.take-header {
background: var(--card);
padding: 1.5rem;
border-radius: var(--radius);
box-shadow: var(--shadow);
margin-bottom: 1.5rem;
}
.take-header h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
.take-meta {
display: flex;
gap: 1rem;
color: var(--text-muted);
font-size: 0.875rem;
}
.timer {
font-weight: 600;
color: var(--danger);
}
.student-form {
background: var(--card);
padding: 1rem;
border-radius: var(--radius);
box-shadow: var(--shadow);
margin-bottom: 1rem;
}
.questions-list { margin-bottom: 1rem; }
.question-take {
background: var(--card);
padding: 1.5rem;
border-radius: var(--radius);
box-shadow: var(--shadow);
margin-bottom: 1rem;
}
.question-text {
font-weight: 500;
margin-bottom: 1rem;
font-size: 1rem;
}
.question-text .points {
color: var(--text-muted);
font-weight: 400;
font-size: 0.875rem;
}
.options-take { display: flex; flex-direction: column; gap: 0.5rem; }
.option-label {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
transition: all 0.2s;
}
.option-label:hover { background: var(--bg); }
.option-label input { margin-right: 0.5rem; }
.answer-input,
.answer-textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 0.875rem;
background: var(--bg);
}
.answer-textarea { resize: vertical; min-height: 100px; }
.take-actions {
display: flex;
justify-content: center;
padding: 1rem 0;
}
/* Grade */
.grade-exam-title {
color: var(--text-muted);
font-size: 0.875rem;
}
.grade-summary {
background: var(--card);
padding: 1rem;
border-radius: var(--radius);
box-shadow: var(--shadow);
margin-bottom: 1rem;
text-align: center;
}
.grade-score {
font-size: 1.25rem;
font-weight: 600;
}
.questions-grade { margin-bottom: 1rem; }
.question-grade {
background: var(--card);
padding: 1rem;
border-radius: var(--radius);
box-shadow: var(--shadow);
margin-bottom: 1rem;
}
.answer-display {
background: var(--bg);
padding: 0.75rem;
border-radius: var(--radius);
margin: 0.5rem 0;
font-size: 0.875rem;
}
.correct-answer {
color: var(--success);
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
.grade-input {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
}
.grade-input input {
width: 80px;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: var(--radius);
}
.auto-badge {
font-size: 0.75rem;
padding: 0.125rem 0.5rem;
border-radius: 4px;
background: var(--bg);
color: var(--text-muted);
}
.auto-badge.auto { background: #dcfce7; color: var(--success); }
.auto-badge.manual { background: #fef9c3; color: var(--warning); }
/* Results */
.question-breakdown { margin-top: 1rem; }
.breakdown-row {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 0;
border-bottom: 1px solid var(--border);
}
.breakdown-text {
flex: 0 0 200px;
font-size: 0.875rem;
}
.breakdown-bar {
flex-grow: 1;
height: 8px;
background: var(--bg);
border-radius: 4px;
overflow: hidden;
}
.breakdown-fill {
height: 100%;
background: var(--primary);
border-radius: 4px;
transition: width 0.3s;
}
.breakdown-score {
font-size: 0.875rem;
color: var(--text-muted);
min-width: 60px;
text-align: right;
}
/* Export */
.export-section {
background: var(--card);
padding: 1.5rem;
border-radius: var(--radius);
box-shadow: var(--shadow);
margin-bottom: 1rem;
}
.export-section h2 { font-size: 1.1rem; margin-bottom: 0.5rem; }
.export-section p { color: var(--text-muted); font-size: 0.875rem; margin-bottom: 1rem; }
/* Responsive */
@media (max-width: 640px) {
.nav { padding: 1rem; }
.container { padding: 1rem; }
.hero { padding: 2rem 1rem; }
.hero h1 { font-size: 1.75rem; }
.exam-grid { grid-template-columns: 1fr; }
.form-row { flex-direction: column; gap: 0; }
.submission-row { flex-direction: column; align-items: flex-start; gap: 0.25rem; }
.breakdown-row { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
.breakdown-text { flex: none; }
.breakdown-bar { width: 100%; }
}
+49
View File
@@ -0,0 +1,49 @@
// Core types for the exam platform
export type QuestionType = 'multiple_choice' | 'true_false' | 'short_answer' | 'essay' | 'fill_blank';
export interface Question {
id: string;
type: QuestionType;
text: string;
points: number;
options?: string[]; // for multiple_choice
correctAnswer?: string | string[]; // for auto-grading
caseSensitive?: boolean; // for fill_blank
}
export interface Exam {
id: string;
title: string;
description: string;
subject: string;
timeLimit: number; // minutes, 0 = no limit
shuffleQuestions: boolean;
allowRetake: boolean;
showResults: boolean;
questions: Question[];
createdAt: string;
updatedAt: string;
}
export interface Answer {
questionId: string;
value: string | string[];
}
export interface Submission {
id: string;
examId: string;
studentName: string;
startedAt: string;
completedAt: string;
answers: Answer[];
scores: Record<string, number>; // questionId -> score
totalScore: number;
maxScore: number;
percentage: number;
graded: boolean;
gradedAt?: string;
}
export type View = 'dashboard' | 'editor' | 'take' | 'submissions' | 'grade' | 'results' | 'export';
+86
View File
@@ -0,0 +1,86 @@
import { Question, Answer, Submission } from './types';
export function gradeQuestion(question: Question, answer: Answer): number {
const answerValue = answer.value;
switch (question.type) {
case 'multiple_choice':
if (typeof answerValue === 'string' && question.correctAnswer) {
return answerValue === question.correctAnswer ? question.points : 0;
}
return 0;
case 'true_false':
if (typeof answerValue === 'string' && question.correctAnswer) {
return answerValue === question.correctAnswer ? question.points : 0;
}
return 0;
case 'fill_blank': {
if (typeof answerValue !== 'string' || !question.correctAnswer) return 0;
const correct = Array.isArray(question.correctAnswer)
? question.correctAnswer
: [question.correctAnswer];
const submitted = question.caseSensitive ? answerValue.trim() : answerValue.trim().toLowerCase();
for (const c of correct) {
const expected = question.caseSensitive ? c.trim() : c.trim().toLowerCase();
if (submitted === expected) return question.points;
}
return 0;
}
case 'short_answer':
case 'essay':
// Manual grading required
return -1;
default:
return 0;
}
}
export function autoGradeSubmission(submission: Submission, questions: Map<string, Question>): Submission {
const scores: Record<string, number> = { ...submission.scores };
let totalScore = 0;
let needsGrading = false;
let maxScore = 0;
for (const q of questions.values()) {
maxScore += q.points;
const answer = submission.answers.find(a => a.questionId === q.id);
if (answer) {
const score = gradeQuestion(q, answer);
if (score === -1) {
needsGrading = true;
scores[q.id] = scores[q.id] ?? 0;
} else {
scores[q.id] = score;
totalScore += score;
}
} else {
scores[q.id] = 0;
}
}
// Add up any manual scores already set
for (const [_qid, score] of Object.entries(scores)) {
if (score !== -1 && !totalScore) {
totalScore += score;
}
}
return {
...submission,
scores,
totalScore: Object.values(scores).reduce((a, b) => a + b, 0),
maxScore,
percentage: maxScore > 0 ? Math.round((Object.values(scores).reduce((a, b) => a + b, 0) / maxScore) * 100) : 0,
graded: !needsGrading,
};
}
export function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
+95
View File
@@ -0,0 +1,95 @@
import assert from 'node:assert';
import test from 'node:test';
// Test grading utilities
function gradeQuestion(question, answer) {
const answerValue = answer.value;
switch (question.type) {
case 'multiple_choice':
if (typeof answerValue === 'string' && question.correctAnswer) {
return answerValue === question.correctAnswer ? question.points : 0;
}
return 0;
case 'true_false':
if (typeof answerValue === 'string' && question.correctAnswer) {
return answerValue === question.correctAnswer ? question.points : 0;
}
return 0;
case 'fill_blank': {
if (typeof answerValue !== 'string' || !question.correctAnswer)
return 0;
const correct = Array.isArray(question.correctAnswer)
? question.correctAnswer
: [question.correctAnswer];
const submitted = question.caseSensitive ? answerValue.trim() : answerValue.trim().toLowerCase();
for (const c of correct) {
const expected = question.caseSensitive ? c.trim() : c.trim().toLowerCase();
if (submitted === expected)
return question.points;
}
return 0;
}
case 'short_answer':
case 'essay':
return -1;
default:
return 0;
}
}
test('grade multiple choice - correct', () => {
const q = { type: 'multiple_choice', points: 2, correctAnswer: '1', options: ['A', 'B', 'C'] };
const a = { questionId: '1', value: '1' };
assert.strictEqual(gradeQuestion(q, a), 2);
});
test('grade multiple choice - incorrect', () => {
const q = { type: 'multiple_choice', points: 2, correctAnswer: '1', options: ['A', 'B', 'C'] };
const a = { questionId: '1', value: '0' };
assert.strictEqual(gradeQuestion(q, a), 0);
});
test('grade true/false - correct', () => {
const q = { type: 'true_false', points: 1, correctAnswer: 'true' };
const a = { questionId: '1', value: 'true' };
assert.strictEqual(gradeQuestion(q, a), 1);
});
test('grade fill_blank - exact match', () => {
const q = { type: 'fill_blank', points: 3, correctAnswer: ['mitochondria'], caseSensitive: false };
const a = { questionId: '1', value: 'mitochondria' };
assert.strictEqual(gradeQuestion(q, a), 3);
});
test('grade fill_blank - case insensitive', () => {
const q = { type: 'fill_blank', points: 3, correctAnswer: ['mitochondria'], caseSensitive: false };
const a = { questionId: '1', value: 'Mitochondria' };
assert.strictEqual(gradeQuestion(q, a), 3);
});
test('grade fill_blank - multiple acceptable', () => {
const q = { type: 'fill_blank', points: 3, correctAnswer: ['mitochondria', 'Mitochondrion'], caseSensitive: false };
const a = { questionId: '1', value: 'Mitochondrion' };
assert.strictEqual(gradeQuestion(q, a), 3);
});
test('grade fill_blank - no match', () => {
const q = { type: 'fill_blank', points: 3, correctAnswer: ['mitochondria'], caseSensitive: false };
const a = { questionId: '1', value: 'nucleus' };
assert.strictEqual(gradeQuestion(q, a), 0);
});
test('grade essay - requires manual', () => {
const q = { type: 'essay', points: 10 };
const a = { questionId: '1', value: 'An essay...' };
assert.strictEqual(gradeQuestion(q, a), -1);
});
test('grade short_answer - requires manual', () => {
const q = { type: 'short_answer', points: 5 };
const a = { questionId: '1', value: 'Short answer' };
assert.strictEqual(gradeQuestion(q, a), -1);
});
// Test store utilities
function generateId() {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}
test('generateId produces unique strings', () => {
const id1 = generateId();
const id2 = generateId();
assert.strictEqual(typeof id1, 'string');
assert.strictEqual(id1.length > 0, true);
assert.notStrictEqual(id1, id2);
});
console.log('All tests passed!');
//# sourceMappingURL=grade.test.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"grade.test.js","sourceRoot":"","sources":["grade.test.ts"],"names":[],"mappings":"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"}
+105
View File
@@ -0,0 +1,105 @@
import assert from 'node:assert';
import test from 'node:test';
// Test grading utilities
function gradeQuestion(question: any, answer: any): number {
const answerValue = answer.value;
switch (question.type) {
case 'multiple_choice':
if (typeof answerValue === 'string' && question.correctAnswer) {
return answerValue === question.correctAnswer ? question.points : 0;
}
return 0;
case 'true_false':
if (typeof answerValue === 'string' && question.correctAnswer) {
return answerValue === question.correctAnswer ? question.points : 0;
}
return 0;
case 'fill_blank': {
if (typeof answerValue !== 'string' || !question.correctAnswer) return 0;
const correct = Array.isArray(question.correctAnswer)
? question.correctAnswer
: [question.correctAnswer];
const submitted = question.caseSensitive ? answerValue.trim() : answerValue.trim().toLowerCase();
for (const c of correct) {
const expected = question.caseSensitive ? c.trim() : c.trim().toLowerCase();
if (submitted === expected) return question.points;
}
return 0;
}
case 'short_answer':
case 'essay':
return -1;
default:
return 0;
}
}
test('grade multiple choice - correct', () => {
const q = { type: 'multiple_choice', points: 2, correctAnswer: '1', options: ['A', 'B', 'C'] };
const a = { questionId: '1', value: '1' };
assert.strictEqual(gradeQuestion(q, a), 2);
});
test('grade multiple choice - incorrect', () => {
const q = { type: 'multiple_choice', points: 2, correctAnswer: '1', options: ['A', 'B', 'C'] };
const a = { questionId: '1', value: '0' };
assert.strictEqual(gradeQuestion(q, a), 0);
});
test('grade true/false - correct', () => {
const q = { type: 'true_false', points: 1, correctAnswer: 'true' };
const a = { questionId: '1', value: 'true' };
assert.strictEqual(gradeQuestion(q, a), 1);
});
test('grade fill_blank - exact match', () => {
const q = { type: 'fill_blank', points: 3, correctAnswer: ['mitochondria'], caseSensitive: false };
const a = { questionId: '1', value: 'mitochondria' };
assert.strictEqual(gradeQuestion(q, a), 3);
});
test('grade fill_blank - case insensitive', () => {
const q = { type: 'fill_blank', points: 3, correctAnswer: ['mitochondria'], caseSensitive: false };
const a = { questionId: '1', value: 'Mitochondria' };
assert.strictEqual(gradeQuestion(q, a), 3);
});
test('grade fill_blank - multiple acceptable', () => {
const q = { type: 'fill_blank', points: 3, correctAnswer: ['mitochondria', 'Mitochondrion'], caseSensitive: false };
const a = { questionId: '1', value: 'Mitochondrion' };
assert.strictEqual(gradeQuestion(q, a), 3);
});
test('grade fill_blank - no match', () => {
const q = { type: 'fill_blank', points: 3, correctAnswer: ['mitochondria'], caseSensitive: false };
const a = { questionId: '1', value: 'nucleus' };
assert.strictEqual(gradeQuestion(q, a), 0);
});
test('grade essay - requires manual', () => {
const q = { type: 'essay', points: 10 };
const a = { questionId: '1', value: 'An essay...' };
assert.strictEqual(gradeQuestion(q, a), -1);
});
test('grade short_answer - requires manual', () => {
const q = { type: 'short_answer', points: 5 };
const a = { questionId: '1', value: 'Short answer' };
assert.strictEqual(gradeQuestion(q, a), -1);
});
// Test store utilities
function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}
test('generateId produces unique strings', () => {
const id1 = generateId();
const id2 = generateId();
assert.strictEqual(typeof id1, 'string');
assert.strictEqual(id1.length > 0, true);
assert.notStrictEqual(id1, id2);
});
console.log('All tests passed!');
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
export default defineConfig({
build: {
outDir: 'dist',
emptyOutDir: true,
},
server: {
port: 3000,
},
})