Initial: Intelligrade Lite — client-side digital exam platform MVP
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.venv/
|
||||
.verdict
|
||||
+13
@@ -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>
|
||||
Generated
+1158
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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();
|
||||
@@ -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
@@ -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%; }
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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')}`;
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"grade.test.js","sourceRoot":"","sources":["grade.test.ts"],"names":[],"mappings":"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"}
|
||||
@@ -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!');
|
||||
@@ -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/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user