From dc62cc880d08fb83bf01dcb1a6776ee00aa84c70 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 8 May 2026 16:38:51 +0200 Subject: [PATCH] extract learning hub quiz controller --- public/js/learningHub.js | 98 +++------------------ public/js/learningHub/quizController.js | 100 ++++++++++++++++++++++ test/learning-hub-editor.test.js | 16 +++- test/learning-hub-quiz-controller.test.js | 55 ++++++++++++ 4 files changed, 180 insertions(+), 89 deletions(-) create mode 100644 public/js/learningHub/quizController.js create mode 100644 test/learning-hub-quiz-controller.test.js 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/); +});