extract learning hub quiz controller
This commit is contained in:
parent
d97d92f8f2
commit
dc62cc880d
4 changed files with 180 additions and 89 deletions
|
|
@ -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);
|
||||
|
|
|
|||
100
public/js/learningHub/quizController.js
Normal file
100
public/js/learningHub/quizController.js
Normal 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;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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\)/);
|
||||
|
|
|
|||
55
test/learning-hub-quiz-controller.test.js
Normal file
55
test/learning-hub-quiz-controller.test.js
Normal 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/);
|
||||
});
|
||||
Loading…
Reference in a new issue