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';
|
} from './learningHub/cmsRenderer.js';
|
||||||
import { createCmsController } from './learningHub/cmsController.js';
|
import { createCmsController } from './learningHub/cmsController.js';
|
||||||
import { renderCategoryPills, renderEmptySearchMessage, renderFeed, renderSearchHeader } from './learningHub/feedRenderer.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 { createSlideController } from './learningHub/slideController.js';
|
||||||
import { buildViewerMeta, renderPresentationCard, renderProgressList } from './learningHub/viewerRenderer.js';
|
import { buildViewerMeta, renderPresentationCard, renderProgressList } from './learningHub/viewerRenderer.js';
|
||||||
|
|
||||||
|
|
@ -36,6 +36,12 @@ import { buildViewerMeta, renderPresentationCard, renderProgressList } from './l
|
||||||
refreshUserCategories: function() { loadCategories(); },
|
refreshUserCategories: function() { loadCategories(); },
|
||||||
showToast: function(message, type) { showToast(message, type); }
|
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 ────────────────────────────────
|
// ── Load when tab activated ────────────────────────────────
|
||||||
document.addEventListener('tabChanged', function(e) {
|
document.addEventListener('tabChanged', function(e) {
|
||||||
|
|
@ -86,7 +92,7 @@ import { buildViewerMeta, renderPresentationCard, renderProgressList } from './l
|
||||||
|
|
||||||
// Submit quiz
|
// Submit quiz
|
||||||
if (e.target.closest('#lh-submit-quiz')) {
|
if (e.target.closest('#lh-submit-quiz')) {
|
||||||
submitQuiz();
|
quiz.submitQuiz();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -326,7 +332,7 @@ import { buildViewerMeta, renderPresentationCard, renderProgressList } from './l
|
||||||
if (quizSection) {
|
if (quizSection) {
|
||||||
if (item.questions && item.questions.length > 0) {
|
if (item.questions && item.questions.length > 0) {
|
||||||
quizSection.classList.remove('hidden');
|
quizSection.classList.remove('hidden');
|
||||||
renderQuiz(item.questions);
|
quiz.renderQuiz(item.questions);
|
||||||
} else {
|
} else {
|
||||||
quizSection.classList.add('hidden');
|
quizSection.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
@ -340,7 +346,7 @@ import { buildViewerMeta, renderPresentationCard, renderProgressList } from './l
|
||||||
if (quizSection) {
|
if (quizSection) {
|
||||||
if (item.questions && item.questions.length > 0) {
|
if (item.questions && item.questions.length > 0) {
|
||||||
quizSection.classList.remove('hidden');
|
quizSection.classList.remove('hidden');
|
||||||
renderQuiz(item.questions);
|
quiz.renderQuiz(item.questions);
|
||||||
} else {
|
} else {
|
||||||
quizSection.classList.add('hidden');
|
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) {
|
function setActiveCatPill(slug) {
|
||||||
document.querySelectorAll('.lh-cat-pill').forEach(function(p) {
|
document.querySelectorAll('.lh-cat-pill').forEach(function(p) {
|
||||||
p.classList.toggle('active', p.dataset.slug === slug);
|
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 cmsRendererModule = read('public/js/learningHub/cmsRenderer.js');
|
||||||
const editorModule = read('public/js/learningHub/tiptapEditor.js');
|
const editorModule = read('public/js/learningHub/tiptapEditor.js');
|
||||||
const feedRendererModule = read('public/js/learningHub/feedRenderer.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 quizRendererModule = read('public/js/learningHub/quizRenderer.js');
|
||||||
const sanitizeModule = read('public/js/learningHub/sanitize.js');
|
const sanitizeModule = read('public/js/learningHub/sanitize.js');
|
||||||
const slideControllerModule = read('public/js/learningHub/slideController.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', () => {
|
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 renderQuizQuestions\(questions\)/);
|
||||||
assert.match(quizRendererModule, /export function renderQuizResultExplanations\(results\)/);
|
assert.match(quizRendererModule, /export function renderQuizResultExplanations\(results\)/);
|
||||||
assert.match(quizRendererModule, /sanitizeHtml\(q\.question_text\)/);
|
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/);
|
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', () => {
|
test('Learning Hub viewer renderers are isolated from API and slide loading', () => {
|
||||||
assert.match(source, /from '\.\/learningHub\/viewerRenderer\.js'/);
|
assert.match(source, /from '\.\/learningHub\/viewerRenderer\.js'/);
|
||||||
assert.match(viewerRendererModule, /export function buildViewerMeta\(item\)/);
|
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