extract learning hub quiz controller

This commit is contained in:
Daniel 2026-05-08 16:38:51 +02:00
parent d97d92f8f2
commit dc62cc880d
4 changed files with 180 additions and 89 deletions

View file

@ -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 = '<i class="fas fa-check"></i> 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);

View file

@ -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 = '<i class="fas fa-check"></i> 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;
});
});
}

View file

@ -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\)/);

View file

@ -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/);
});