diff --git a/public/js/learningHub.js b/public/js/learningHub.js
index 0d00972..0466070 100644
--- a/public/js/learningHub.js
+++ b/public/js/learningHub.js
@@ -16,7 +16,7 @@ import {
} from './learningHub/cmsRenderer.js';
import { createCmsController } from './learningHub/cmsController.js';
import { renderCategoryPills, renderEmptySearchMessage, renderFeed, renderSearchHeader } from './learningHub/feedRenderer.js';
-import { renderQuizQuestions, renderQuizResultExplanations } from './learningHub/quizRenderer.js';
+import { createQuizController } from './learningHub/quizController.js';
import { createSlideController } from './learningHub/slideController.js';
import { buildViewerMeta, renderPresentationCard, renderProgressList } from './learningHub/viewerRenderer.js';
@@ -36,6 +36,12 @@ import { buildViewerMeta, renderPresentationCard, renderProgressList } from './l
refreshUserCategories: function() { loadCategories(); },
showToast: function(message, type) { showToast(message, type); }
});
+ var quiz = createQuizController({
+ getCurrentContent: function() { return currentContent; },
+ showLoading: function(message) { showLoading(message); },
+ hideLoading: function() { hideLoading(); },
+ showToast: function(message, type) { showToast(message, type); }
+ });
// ── Load when tab activated ────────────────────────────────
document.addEventListener('tabChanged', function(e) {
@@ -86,7 +92,7 @@ import { buildViewerMeta, renderPresentationCard, renderProgressList } from './l
// Submit quiz
if (e.target.closest('#lh-submit-quiz')) {
- submitQuiz();
+ quiz.submitQuiz();
return;
}
@@ -326,7 +332,7 @@ import { buildViewerMeta, renderPresentationCard, renderProgressList } from './l
if (quizSection) {
if (item.questions && item.questions.length > 0) {
quizSection.classList.remove('hidden');
- renderQuiz(item.questions);
+ quiz.renderQuiz(item.questions);
} else {
quizSection.classList.add('hidden');
}
@@ -340,7 +346,7 @@ import { buildViewerMeta, renderPresentationCard, renderProgressList } from './l
if (quizSection) {
if (item.questions && item.questions.length > 0) {
quizSection.classList.remove('hidden');
- renderQuiz(item.questions);
+ quiz.renderQuiz(item.questions);
} else {
quizSection.classList.add('hidden');
}
@@ -357,90 +363,6 @@ import { buildViewerMeta, renderPresentationCard, renderProgressList } from './l
}
}
- function renderQuiz(questions) {
- var container = document.getElementById('lh-quiz-questions');
- var countEl = document.getElementById('lh-quiz-count');
- if (!container) return;
- if (countEl) countEl.textContent = questions.length + ' question' + (questions.length !== 1 ? 's' : '');
-
- container.innerHTML = renderQuizQuestions(questions);
- }
-
- function submitQuiz() {
- if (!currentContent || !currentContent.questions) return;
-
- var answers = [];
- currentContent.questions.forEach(function(q) {
- if (q.question_type === 'multi') {
- var checkedEls = document.querySelectorAll('input[name="lh-qm-' + q.id + '"]:checked');
- var optionIds = [];
- checkedEls.forEach(function(el) { optionIds.push(parseInt(el.value)); });
- answers.push({ questionId: q.id, optionIds: optionIds });
- } else {
- var selected = document.querySelector('input[name="lh-q-' + q.id + '"]:checked');
- answers.push({ questionId: q.id, optionId: selected ? parseInt(selected.value) : null });
- }
- });
-
- showLoading('Submitting quiz...');
-
- sendJson('/api/learning/submit-quiz', 'POST', { contentId: currentContent.id, answers: answers })
- .then(function(data) {
- hideLoading();
- if (!data.success) { showToast(data.error || 'Submit failed', 'error'); return; }
- showQuizResults(data);
- })
- .catch(function() { hideLoading(); showToast('Submit failed', 'error'); });
- }
-
- function showQuizResults(data) {
- var resultsEl = document.getElementById('lh-quiz-results');
- var scoreEl = document.getElementById('lh-quiz-score');
- var explEl = document.getElementById('lh-quiz-explanations');
- if (!resultsEl) return;
-
- resultsEl.classList.remove('hidden');
- var pct = data.percentage;
- var color = pct >= 80 ? 'var(--green)' : pct >= 50 ? 'var(--amber)' : 'var(--red)';
- if (scoreEl) {
- scoreEl.textContent = data.score + '/' + data.total + ' (' + pct + '%)';
- scoreEl.style.background = color;
- scoreEl.style.color = 'white';
- }
-
- if (explEl) {
- explEl.innerHTML = renderQuizResultExplanations(data.results);
- }
-
- // Highlight correct/incorrect in quiz
- data.results.forEach(function(r) {
- var qDiv = document.querySelector('.lh-quiz-q[data-qid="' + r.questionId + '"]');
- if (!qDiv) return;
- var isMulti = r.questionType === 'multi';
- qDiv.querySelectorAll('.lh-quiz-option').forEach(function(label) {
- var input = label.querySelector('input');
- if (!input) return;
- var optId = parseInt(input.value);
- if (isMulti) {
- var correctIds = r.correctOptionIds || [];
- var selectedIds = r.selectedOptionIds || [];
- if (correctIds.indexOf(optId) !== -1) label.classList.add('lh-opt-correct');
- if (selectedIds.indexOf(optId) !== -1 && correctIds.indexOf(optId) === -1) label.classList.add('lh-opt-wrong');
- } else {
- if (optId === r.correctOptionId) label.classList.add('lh-opt-correct');
- if (optId === r.selectedOptionId && !r.isCorrect) label.classList.add('lh-opt-wrong');
- }
- input.disabled = true;
- });
- });
-
- // Disable submit button
- var submitBtn = document.getElementById('lh-submit-quiz');
- if (submitBtn) { submitBtn.disabled = true; submitBtn.innerHTML = ' Submitted'; }
-
- resultsEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
- }
-
function setActiveCatPill(slug) {
document.querySelectorAll('.lh-cat-pill').forEach(function(p) {
p.classList.toggle('active', p.dataset.slug === slug);
diff --git a/public/js/learningHub/quizController.js b/public/js/learningHub/quizController.js
new file mode 100644
index 0000000..8080a47
--- /dev/null
+++ b/public/js/learningHub/quizController.js
@@ -0,0 +1,100 @@
+import { sendJson } from './api.js';
+import { renderQuizQuestions, renderQuizResultExplanations } from './quizRenderer.js';
+
+export function createQuizController(deps) {
+ function renderQuiz(questions) {
+ var container = document.getElementById('lh-quiz-questions');
+ var countEl = document.getElementById('lh-quiz-count');
+ if (!container) return;
+ if (countEl) countEl.textContent = questions.length + ' question' + (questions.length !== 1 ? 's' : '');
+
+ container.innerHTML = renderQuizQuestions(questions);
+ }
+
+ function submitQuiz() {
+ var currentContent = deps.getCurrentContent();
+ if (!currentContent || !currentContent.questions) return;
+
+ var answers = buildQuizAnswers(currentContent.questions);
+ deps.showLoading('Submitting quiz...');
+
+ sendJson('/api/learning/submit-quiz', 'POST', { contentId: currentContent.id, answers: answers })
+ .then(function(data) {
+ deps.hideLoading();
+ if (!data.success) { deps.showToast(data.error || 'Submit failed', 'error'); return; }
+ showQuizResults(data);
+ })
+ .catch(function() { deps.hideLoading(); deps.showToast('Submit failed', 'error'); });
+ }
+
+ function showQuizResults(data) {
+ var resultsEl = document.getElementById('lh-quiz-results');
+ var scoreEl = document.getElementById('lh-quiz-score');
+ var explEl = document.getElementById('lh-quiz-explanations');
+ if (!resultsEl) return;
+
+ resultsEl.classList.remove('hidden');
+ var pct = data.percentage;
+ var color = pct >= 80 ? 'var(--green)' : pct >= 50 ? 'var(--amber)' : 'var(--red)';
+ if (scoreEl) {
+ scoreEl.textContent = data.score + '/' + data.total + ' (' + pct + '%)';
+ scoreEl.style.background = color;
+ scoreEl.style.color = 'white';
+ }
+
+ if (explEl) explEl.innerHTML = renderQuizResultExplanations(data.results);
+ applyQuizResultHighlights(data.results || []);
+
+ var submitBtn = document.getElementById('lh-submit-quiz');
+ if (submitBtn) { submitBtn.disabled = true; submitBtn.innerHTML = ' Submitted'; }
+
+ resultsEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+
+ return {
+ renderQuiz: renderQuiz,
+ submitQuiz: submitQuiz
+ };
+}
+
+export function buildQuizAnswers(questions, root) {
+ root = root || document;
+ var answers = [];
+
+ (questions || []).forEach(function(q) {
+ if (q.question_type === 'multi') {
+ var checkedEls = root.querySelectorAll('input[name="lh-qm-' + q.id + '"]:checked');
+ var optionIds = [];
+ checkedEls.forEach(function(el) { optionIds.push(parseInt(el.value)); });
+ answers.push({ questionId: q.id, optionIds: optionIds });
+ } else {
+ var selected = root.querySelector('input[name="lh-q-' + q.id + '"]:checked');
+ answers.push({ questionId: q.id, optionId: selected ? parseInt(selected.value) : null });
+ }
+ });
+
+ return answers;
+}
+
+function applyQuizResultHighlights(results) {
+ results.forEach(function(r) {
+ var qDiv = document.querySelector('.lh-quiz-q[data-qid="' + r.questionId + '"]');
+ if (!qDiv) return;
+ var isMulti = r.questionType === 'multi';
+ qDiv.querySelectorAll('.lh-quiz-option').forEach(function(label) {
+ var input = label.querySelector('input');
+ if (!input) return;
+ var optId = parseInt(input.value);
+ if (isMulti) {
+ var correctIds = r.correctOptionIds || [];
+ var selectedIds = r.selectedOptionIds || [];
+ if (correctIds.indexOf(optId) !== -1) label.classList.add('lh-opt-correct');
+ if (selectedIds.indexOf(optId) !== -1 && correctIds.indexOf(optId) === -1) label.classList.add('lh-opt-wrong');
+ } else {
+ if (optId === r.correctOptionId) label.classList.add('lh-opt-correct');
+ if (optId === r.selectedOptionId && !r.isCorrect) label.classList.add('lh-opt-wrong');
+ }
+ input.disabled = true;
+ });
+ });
+}
diff --git a/test/learning-hub-editor.test.js b/test/learning-hub-editor.test.js
index c0d07af..bf344aa 100644
--- a/test/learning-hub-editor.test.js
+++ b/test/learning-hub-editor.test.js
@@ -13,6 +13,7 @@ const cmsControllerModule = read('public/js/learningHub/cmsController.js');
const cmsRendererModule = read('public/js/learningHub/cmsRenderer.js');
const editorModule = read('public/js/learningHub/tiptapEditor.js');
const feedRendererModule = read('public/js/learningHub/feedRenderer.js');
+const quizControllerModule = read('public/js/learningHub/quizController.js');
const quizRendererModule = read('public/js/learningHub/quizRenderer.js');
const sanitizeModule = read('public/js/learningHub/sanitize.js');
const slideControllerModule = read('public/js/learningHub/slideController.js');
@@ -127,7 +128,7 @@ test('Learning Hub slide controller owns preview and PPTX flow', () => {
});
test('Learning Hub quiz renderers are isolated from API and submit flow', () => {
- assert.match(source, /from '\.\/learningHub\/quizRenderer\.js'/);
+ assert.match(quizControllerModule, /from '\.\/quizRenderer\.js'/);
assert.match(quizRendererModule, /export function renderQuizQuestions\(questions\)/);
assert.match(quizRendererModule, /export function renderQuizResultExplanations\(results\)/);
assert.match(quizRendererModule, /sanitizeHtml\(q\.question_text\)/);
@@ -135,6 +136,19 @@ test('Learning Hub quiz renderers are isolated from API and submit flow', () =>
assert.doesNotMatch(quizRendererModule, /fetch\(|getJson|sendJson|deleteJson|submitQuiz|showLoading|currentContent/);
});
+test('Learning Hub quiz controller owns submission and answer tracking', () => {
+ assert.match(source, /from '\.\/learningHub\/quizController\.js'/);
+ assert.match(source, /var quiz = createQuizController\(/);
+ assert.match(source, /quiz\.submitQuiz\(\)/);
+ assert.match(source, /quiz\.renderQuiz\(item\.questions\)/);
+ assert.match(quizControllerModule, /export function createQuizController\(deps\)/);
+ assert.match(quizControllerModule, /export function buildQuizAnswers\(questions, root\)/);
+ assert.match(quizControllerModule, /sendJson\('\/api\/learning\/submit-quiz', 'POST', \{ contentId: currentContent\.id, answers: answers \}\)/);
+ assert.match(quizControllerModule, /optionIds: optionIds/);
+ assert.match(quizControllerModule, /optionId: selected \? parseInt\(selected\.value\) : null/);
+ assert.doesNotMatch(source, /function submitQuiz\(\)|function showQuizResults\(|function renderQuiz\(questions\)/);
+});
+
test('Learning Hub viewer renderers are isolated from API and slide loading', () => {
assert.match(source, /from '\.\/learningHub\/viewerRenderer\.js'/);
assert.match(viewerRendererModule, /export function buildViewerMeta\(item\)/);
diff --git a/test/learning-hub-quiz-controller.test.js b/test/learning-hub-quiz-controller.test.js
new file mode 100644
index 0000000..01da255
--- /dev/null
+++ b/test/learning-hub-quiz-controller.test.js
@@ -0,0 +1,55 @@
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const path = require('node:path');
+const { pathToFileURL } = require('node:url');
+
+async function loadQuizController() {
+ return import(pathToFileURL(path.join(__dirname, '..', 'public/js/learningHub/quizController.js')).href);
+}
+
+function makeRoot(singleSelections, multiSelections) {
+ return {
+ querySelector(selector) {
+ var match = selector.match(/input\[name="lh-q-(\d+)"\]:checked/);
+ if (!match) return null;
+ var value = singleSelections[match[1]];
+ return value == null ? null : { value: String(value) };
+ },
+ querySelectorAll(selector) {
+ var match = selector.match(/input\[name="lh-qm-(\d+)"\]:checked/);
+ if (!match) return [];
+ return (multiSelections[match[1]] || []).map(function(value) { return { value: String(value) }; });
+ }
+ };
+}
+
+test('Learning Hub quiz answers preserve single, unanswered, and multi-select payloads', async () => {
+ const { buildQuizAnswers } = await loadQuizController();
+ const questions = [
+ { id: 11, question_type: 'mcq' },
+ { id: 12, question_type: 'true_false' },
+ { id: 13, question_type: 'multi' },
+ { id: 14, question_type: 'multi' }
+ ];
+ const root = makeRoot({ 11: 101 }, { 13: [301, 303] });
+
+ assert.deepEqual(buildQuizAnswers(questions, root), [
+ { questionId: 11, optionId: 101 },
+ { questionId: 12, optionId: null },
+ { questionId: 13, optionIds: [301, 303] },
+ { questionId: 14, optionIds: [] }
+ ]);
+});
+
+test('Learning Hub quiz controller keeps result highlighting semantics', async () => {
+ const fs = require('node:fs');
+ const source = fs.readFileSync(path.join(__dirname, '..', 'public/js/learningHub/quizController.js'), 'utf8');
+
+ assert.match(source, /function applyQuizResultHighlights\(results\)/);
+ assert.match(source, /var correctIds = r\.correctOptionIds \|\| \[\]/);
+ assert.match(source, /var selectedIds = r\.selectedOptionIds \|\| \[\]/);
+ assert.match(source, /selectedIds\.indexOf\(optId\) !== -1 && correctIds\.indexOf\(optId\) === -1/);
+ assert.match(source, /optId === r\.correctOptionId/);
+ assert.match(source, /optId === r\.selectedOptionId && !r\.isCorrect/);
+ assert.match(source, /input\.disabled = true/);
+});