379 lines
18 KiB
JavaScript
379 lines
18 KiB
JavaScript
/**
|
|
* cuestionarios/js/quiz.js
|
|
* Lógica del cuestionario TAI con fórmula de corrección AGE.
|
|
* Fórmula: Nota = (Aciertos - Fallos/3) / TotalPreguntas * 10
|
|
*/
|
|
|
|
// ── Estado ──────────────────────────────────────────────────
|
|
let preguntas = [];
|
|
let supuestosData = {}; // { "Supuesto I": [...], "Supuesto II": [...] }
|
|
let faseActual = 'test'; // 'test' | 'supuesto'
|
|
let indice = 0;
|
|
let aciertos = 0;
|
|
let fallos = 0;
|
|
let respondida = false;
|
|
let preguntasFalladas = []; // { pregunta, elegida }
|
|
|
|
// ── Mapa de temas y palabras clave ──────────────────────────
|
|
const TEMAS_KW = [
|
|
{ id:'const', label:'Constitución Española', link:'../curso.html?bloque=1', kw:['constitución','constitucional','rey ','cortes generales','senado','congreso','diputad','tribunal constitucional','defensor del pueblo','artículo 62','artículo 63','título i','título ii','artículo 1 ','capítulo'] },
|
|
{ id:'p39', label:'Procedimiento Adm. (Ley 39/2015)', link:'../curso.html?bloque=1', kw:['ley 39','procedimiento administrativo','recurso de alzada','silencio administrativo','notificación','expediente administrativo','recurso potestativo','recurso extraordinario'] },
|
|
{ id:'p40', label:'Régimen Jurídico (Ley 40/2015)', link:'../curso.html?bloque=1', kw:['ley 40','órgano colegiado','delegación de competencia','avocación','administración general del estado','convenio interadministrativo'] },
|
|
{ id:'lcsp', label:'Contratación Pública (LCSP)', link:'../curso.html?bloque=1', kw:['contratos del sector público','lcsp','licitación','adjudicación','pliego','contrato menor','concesión de servicios','poder adjudicador'] },
|
|
{ id:'trebep', label:'Función Pública (TREBEP)', link:'../curso.html?bloque=1', kw:['trebep','funcionario','empleado público','oposición','provisión de puestos','situaciones administrativas','excedencia','régimen disciplinario','carrera profesional'] },
|
|
{ id:'hac', label:'Hacienda Pública / Presupuestos', link:'../curso.html?bloque=1', kw:['presupuesto','hacienda pública','igae','tribunal de cuentas','crédito presupuestario','gasto público','control financiero','intervención general'] },
|
|
{ id:'html', label:'HTML / CSS / JavaScript', link:'../curso.html?bloque=3', kw:['html','css','javascript','dom','<input','etiqueta','formulario web','diseño web','responsive','selector css'] },
|
|
{ id:'bd', label:'Bases de Datos / SQL', link:'../curso.html?bloque=3', kw:['sql','select ','join','base de datos','normalización','índice','relacional','trigger','procedimiento almacenado','sgbd','tabla '] },
|
|
{ id:'prog', label:'Programación / Algoritmos', link:'../curso.html?bloque=3', kw:['algoritmo','lenguaje de programación','java ','python','c# ','compilad','interpreta','orientado a objetos','array','recursiv','metodología ágil','scrum','git'] },
|
|
{ id:'redes', label:'Redes y Comunicaciones', link:'../curso.html?bloque=4', kw:['tcp/ip','protocolo','router','switch','vlan','dirección ip','máscara de red','ethernet','arp','dns','dhcp','ospf','bgp','mpls'] },
|
|
{ id:'seg', label:'Seguridad Informática', link:'../curso.html?bloque=4', kw:['cifrado','criptografía','firma digital','certificado digital','ssl','tls','vpn','firewall','ciberincidente','autenticación','hash','ransomware','ids ','ips '] },
|
|
{ id:'so', label:'Sistemas Operativos / Hardware', link:'../curso.html?bloque=2', kw:['sistema operativo','linux','windows server','proceso','kernel','sistema de ficheros','raid','virtualización','contenedor','cpu','procesador','memoria ram'] },
|
|
];
|
|
|
|
function detectarTema(txt) {
|
|
const t = txt.toLowerCase();
|
|
for (const tema of TEMAS_KW) {
|
|
if (tema.kw.some(k => t.includes(k))) return tema;
|
|
}
|
|
return { id:'otro', label:'Otros / Material general', link:'../curso.html' };
|
|
}
|
|
|
|
// ── Elementos DOM ────────────────────────────────────────────
|
|
const selExamen = document.getElementById('sel-examen');
|
|
const btnIniciar = document.getElementById('btn-iniciar');
|
|
const btnSiguiente = document.getElementById('btn-siguiente');
|
|
const seccionQuiz = document.getElementById('seccion-quiz');
|
|
const seccionFinal = document.getElementById('seccion-final');
|
|
const seccionEmpty = document.getElementById('seccion-empty');
|
|
const seccionSupSel = document.getElementById('seccion-supuesto-sel');
|
|
const supCardsWrap = document.getElementById('sup-cards');
|
|
const contextoPanel = document.getElementById('contexto-pregunta');
|
|
const examPdfsPanel = document.getElementById('exam-pdfs');
|
|
|
|
// ── Mapa de PDFs por examen ───────────────────────────────────
|
|
const EXAM_PDFS = {
|
|
'data/TAI_2019.json': [
|
|
{ label: 'Cuestionario oficial', icon: 'fa-file-alt', url: 'pdfs/cues_1er_ejer_TAI-L_oep19_154AB89SD658.pdf' },
|
|
{ label: 'Plantilla definitiva', icon: 'fa-check-square', url: 'pdfs/Plantilla_defTAI-L1ejer_154AB89SD658.pdf' },
|
|
{ label: 'Plantilla provisional', icon: 'fa-clipboard', url: 'pdfs/plant_prov_1er_ejer_TAI-L_oep19_154AB89SD658.pdf' },
|
|
{ label: 'Material adicional (supuesto)', icon: 'fa-book-open', url: 'pdfs/07TAIL_154AB89SD658.pdf' },
|
|
],
|
|
'data/TAI_2023.json': [
|
|
{ label: 'Cuestionario oficial', icon: 'fa-file-alt', url: 'pdfs/Cuestionario TAI-L_2023.pdf' },
|
|
{ label: 'Plantilla de respuestas', icon: 'fa-check-square', url: 'pdfs/PlantillaRespuestas TAI-L_2023.pdf' },
|
|
],
|
|
'data/TAI_2024A.json': [
|
|
{ label: 'Cuestionario oficial', icon: 'fa-file-alt', url: 'pdfs/Cuestionario_TAI_LI_2024_A_M8L91VL1CL_154AB89SD658.pdf' },
|
|
{ label: 'Plantilla de respuestas', icon: 'fa-check-square', url: 'pdfs/Plantilla_Respuestas_PROV_TAI_LI_2024_A_6J5MFQ8OEN_154AB89SD658.pdf' },
|
|
{ label: 'Material supuesto II', icon: 'fa-book-open', url: 'data/tai_2024A_supuesto2.md' },
|
|
],
|
|
'data/TAI_2024B.json': [
|
|
{ label: 'Cuestionario oficial', icon: 'fa-file-alt', url: 'pdfs/Cuestionario_TAI_LI_2024_B_DNFGFEK45R_154AB89SD658.pdf' },
|
|
{ label: 'Plantilla de respuestas', icon: 'fa-check-square', url: 'pdfs/Plantilla_Respuestas_PROV_TAI_LI_2024_B_JQE95HBC1R_154AB89SD658.pdf' },
|
|
],
|
|
};
|
|
|
|
const elPreguntaNum = document.getElementById('pregunta-num');
|
|
const elPreguntaTxt = document.getElementById('pregunta-txt');
|
|
const elOpciones = document.getElementById('opciones');
|
|
const elFeedback = document.getElementById('feedback');
|
|
|
|
const elAciertos = document.getElementById('val-aciertos');
|
|
const elFallos = document.getElementById('val-fallos');
|
|
const elProgreso = document.getElementById('val-progreso');
|
|
const elNota = document.getElementById('val-nota');
|
|
|
|
// ── Eventos ──────────────────────────────────────────────────
|
|
btnIniciar.addEventListener('click', iniciarExamen);
|
|
btnSiguiente.addEventListener('click', siguiente);
|
|
|
|
selExamen.addEventListener('change', () => {
|
|
btnIniciar.disabled = !selExamen.value;
|
|
renderExamPdfs(selExamen.value);
|
|
});
|
|
|
|
function renderExamPdfs(url) {
|
|
const pdfs = EXAM_PDFS[url];
|
|
if (!pdfs || !pdfs.length) { examPdfsPanel.style.display = 'none'; return; }
|
|
examPdfsPanel.innerHTML =
|
|
'<span class="exam-pdfs-label"><i class="fas fa-file-pdf"></i> Documentos INAP:</span>' +
|
|
pdfs.map(p =>
|
|
`<a href="${encodeURI(p.url)}" target="_blank" rel="noopener" class="exam-pdf-link">` +
|
|
`<i class="fas ${p.icon}"></i> ${escHtml(p.label)}</a>`
|
|
).join('');
|
|
examPdfsPanel.style.display = 'flex';
|
|
}
|
|
|
|
// ── Funciones ────────────────────────────────────────────────
|
|
async function iniciarExamen() {
|
|
const url = selExamen.value;
|
|
if (!url) return;
|
|
|
|
btnIniciar.disabled = true;
|
|
btnIniciar.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Cargando…';
|
|
|
|
try {
|
|
const datos = await fetch(url).then(r => r.json());
|
|
const todas = Array.isArray(datos) ? datos : (datos.preguntas || []);
|
|
// Separar preguntas tipo test de supuestos prácticos
|
|
const testQs = todas.filter(p => !p.contexto?.supuesto);
|
|
const supuestoQs = todas.filter(p => p.contexto?.supuesto);
|
|
preguntas = mezclar(testQs);
|
|
supuestosData = {};
|
|
for (const p of supuestoQs) {
|
|
const nombre = p.contexto.supuesto;
|
|
if (!supuestosData[nombre]) supuestosData[nombre] = [];
|
|
supuestosData[nombre].push(p);
|
|
}
|
|
} catch (e) {
|
|
alert('Error cargando el examen. Comprueba la ruta del fichero.');
|
|
btnIniciar.disabled = false;
|
|
btnIniciar.innerHTML = '<i class="fas fa-play"></i> Iniciar';
|
|
return;
|
|
}
|
|
|
|
faseActual = 'test';
|
|
indice = 0;
|
|
aciertos = 0;
|
|
fallos = 0;
|
|
respondida = false;
|
|
preguntasFalladas = [];
|
|
|
|
mostrarSolo('quiz');
|
|
actualizarMarcador();
|
|
mostrarPregunta();
|
|
|
|
btnIniciar.disabled = false;
|
|
btnIniciar.innerHTML = '<i class="fas fa-redo"></i> Reiniciar';
|
|
}
|
|
|
|
function mostrarSolo(seccion) {
|
|
seccionEmpty.style.display = seccion === 'empty' ? 'block' : 'none';
|
|
seccionQuiz.style.display = seccion === 'quiz' ? 'block' : 'none';
|
|
seccionFinal.style.display = seccion === 'final' ? 'block' : 'none';
|
|
seccionSupSel.style.display = seccion === 'supsel' ? 'block' : 'none';
|
|
}
|
|
|
|
function mostrarSelectorSupuesto() {
|
|
supCardsWrap.innerHTML = '';
|
|
for (const [nombre, pregs] of Object.entries(supuestosData)) {
|
|
const ctx = pregs[0]?.contexto || {};
|
|
const ref = ctx.referencia_diagrama || ctx.referencia || null;
|
|
const esImg = ref && /\.(png|jpg|jpeg|gif|webp)$/i.test(ref);
|
|
const esPdf = ref && /\.pdf$/i.test(ref);
|
|
|
|
const card = document.createElement('div');
|
|
card.className = 'sup-card';
|
|
card.innerHTML = `
|
|
<div class="sup-card-header">
|
|
<i class="fas fa-file-code"></i>
|
|
<strong>${escHtml(nombre)}</strong>
|
|
<span class="sup-card-num">${pregs.length} preguntas</span>
|
|
</div>
|
|
<p class="sup-card-desc">${escHtml(ctx.descripcion || '')}</p>
|
|
${esImg ? `<div class="sup-material"><img src="data/${ref}" alt="Material ${escHtml(nombre)}" style="max-width:100%;border-radius:6px;margin:.5rem 0"></div>` : ''}
|
|
${esPdf ? `<div class="sup-material"><a href="data/${ref}" target="_blank" rel="noopener" class="btn btn-outline" style="font-size:.85rem"><i class="fas fa-file-pdf"></i> Ver enunciado (PDF)</a></div>` : ''}
|
|
<button class="btn btn-primary" style="margin-top:.75rem" onclick="iniciarSupuesto('${nombre.replace(/'/g, "\\'")}')"><i class="fas fa-play"></i> Practicar este supuesto</button>
|
|
`;
|
|
supCardsWrap.appendChild(card);
|
|
}
|
|
mostrarSolo('supsel');
|
|
}
|
|
|
|
function iniciarSupuesto(nombre) {
|
|
preguntas = supuestosData[nombre] || [];
|
|
faseActual = 'supuesto';
|
|
indice = 0;
|
|
aciertos = 0;
|
|
fallos = 0;
|
|
respondida = false;
|
|
preguntasFalladas = [];
|
|
mostrarSolo('quiz');
|
|
actualizarMarcador();
|
|
mostrarPregunta();
|
|
}
|
|
|
|
function mostrarPregunta() {
|
|
respondida = false;
|
|
btnSiguiente.style.display = 'none';
|
|
elFeedback.className = 'question-feedback';
|
|
elFeedback.textContent = '';
|
|
|
|
if (indice >= preguntas.length) {
|
|
if (faseActual === 'test' && Object.keys(supuestosData).length > 0) {
|
|
mostrarSelectorSupuesto();
|
|
} else {
|
|
finalizarExamen();
|
|
}
|
|
return;
|
|
}
|
|
|
|
const p = preguntas[indice];
|
|
|
|
elPreguntaNum.textContent = `Pregunta ${indice + 1} de ${preguntas.length}`;
|
|
elPreguntaTxt.textContent = p.pregunta;
|
|
|
|
// Mostrar contexto si estamos en un supuesto práctico
|
|
if (faseActual === 'supuesto' && p.contexto?.descripcion) {
|
|
contextoPanel.style.display = 'block';
|
|
contextoPanel.innerHTML = `<i class="fas fa-info-circle"></i> <strong>Contexto:</strong> ${escHtml(p.contexto.descripcion)}`;
|
|
} else {
|
|
contextoPanel.style.display = 'none';
|
|
}
|
|
|
|
elOpciones.innerHTML = '';
|
|
for (const [letra, texto] of Object.entries(p.opciones)) {
|
|
const li = document.createElement('li');
|
|
li.className = 'options-list__item';
|
|
|
|
const label = document.createElement('label');
|
|
label.className = 'option-label';
|
|
label.innerHTML = `
|
|
<input type="radio" name="resp" value="${letra}">
|
|
<span class="option-letter">${letra.toUpperCase()})</span>
|
|
<span>${escHtml(texto)}</span>`;
|
|
|
|
label.querySelector('input').addEventListener('change', () => {
|
|
if (!respondida) comprobar(p);
|
|
});
|
|
|
|
li.appendChild(label);
|
|
elOpciones.appendChild(li);
|
|
}
|
|
|
|
actualizarMarcador();
|
|
}
|
|
|
|
function comprobar(p) {
|
|
respondida = true;
|
|
const marcada = document.querySelector('input[name="resp"]:checked');
|
|
if (!marcada) return;
|
|
|
|
// Deshabilitar todos los radio
|
|
document.querySelectorAll('input[name="resp"]').forEach(r => r.disabled = true);
|
|
|
|
// Marcar opciones
|
|
document.querySelectorAll('.option-label').forEach(label => {
|
|
const val = label.querySelector('input').value;
|
|
if (val === p.correcta) label.classList.add('correct');
|
|
else if (val === marcada.value) label.classList.add('incorrect');
|
|
});
|
|
|
|
if (marcada.value === p.correcta) {
|
|
aciertos++;
|
|
elFeedback.textContent = '✔ ¡Correcto!';
|
|
elFeedback.className = 'question-feedback show ok';
|
|
} else {
|
|
fallos++; preguntasFalladas.push({ pregunta: p, elegida: marcada.value }); elFeedback.textContent = `✘ Incorrecto. La respuesta correcta era la ${p.correcta.toUpperCase()})`;
|
|
elFeedback.className = 'question-feedback show ko';
|
|
}
|
|
|
|
actualizarMarcador();
|
|
btnSiguiente.style.display = 'inline-flex';
|
|
btnSiguiente.focus();
|
|
}
|
|
|
|
function siguiente() {
|
|
indice++;
|
|
mostrarPregunta();
|
|
// mostrarPregunta() detecta si el índice superó el total y actúa
|
|
}
|
|
|
|
function actualizarMarcador() {
|
|
const contestadas = aciertos + fallos;
|
|
const puntosNetos = aciertos - fallos / 3;
|
|
const nota = contestadas > 0
|
|
? Math.max(0, (puntosNetos / preguntas.length) * 10).toFixed(2)
|
|
: '—';
|
|
|
|
elAciertos.textContent = aciertos;
|
|
elFallos.textContent = fallos;
|
|
elProgreso.textContent = `${contestadas} / ${preguntas.length}`;
|
|
elNota.textContent = nota;
|
|
}
|
|
|
|
function finalizarExamen() {
|
|
mostrarSolo('final');
|
|
|
|
const total = preguntas.length;
|
|
const puntosNetos = aciertos - fallos / 3;
|
|
const nota = Math.max(0, (puntosNetos / total) * 10).toFixed(2);
|
|
const sinRespuesta = total - aciertos - fallos;
|
|
|
|
document.getElementById('final-nota').textContent = nota;
|
|
document.getElementById('final-aciertos').textContent = aciertos;
|
|
document.getElementById('final-fallos').textContent = fallos;
|
|
document.getElementById('final-sin').textContent = sinRespuesta;
|
|
document.getElementById('final-total').textContent = total;
|
|
|
|
const notaNum = parseFloat(nota);
|
|
const color = notaNum >= 5 ? 'var(--success)' : notaNum >= 4 ? 'var(--warning)' : 'var(--error)';
|
|
document.getElementById('final-nota').style.color = color;
|
|
|
|
renderRepaso();
|
|
}
|
|
|
|
function renderRepaso() {
|
|
const wrap = document.getElementById('repaso-wrap');
|
|
if (!wrap) return;
|
|
|
|
if (preguntasFalladas.length === 0) {
|
|
wrap.innerHTML = '<p class="repaso-perfecto"><i class="fas fa-star"></i> ¡Sin fallos! Dominas todo el temario de este examen.</p>';
|
|
wrap.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
// Agrupar fallos por tema detectado
|
|
const grupos = {};
|
|
for (const { pregunta, elegida } of preguntasFalladas) {
|
|
const tema = detectarTema(pregunta.pregunta);
|
|
if (!grupos[tema.id]) grupos[tema.id] = { tema, items: [] };
|
|
grupos[tema.id].items.push({ pregunta, elegida });
|
|
}
|
|
|
|
const n = preguntasFalladas.length;
|
|
wrap.innerHTML = `
|
|
<h3 class="repaso-titulo"><i class="fas fa-exclamation-triangle"></i> Necesitas repasar</h3>
|
|
<p class="repaso-intro">Has fallado <strong>${n}</strong> pregunta${n > 1 ? 's' : ''}. Estos son los temas donde debes reforzar:</p>
|
|
${Object.values(grupos).map(g => `
|
|
<details class="repaso-grupo" open>
|
|
<summary class="repaso-tema">
|
|
<span class="repaso-tema-name"><i class="fas fa-book"></i> ${escHtml(g.tema.label)}</span>
|
|
<span class="repaso-badge">${g.items.length} fallo${g.items.length > 1 ? 's' : ''}</span>
|
|
</summary>
|
|
<ul class="repaso-lista">
|
|
${g.items.map(({ pregunta: p, elegida }) => `
|
|
<li class="repaso-item">
|
|
<p class="repaso-q">${escHtml(p.pregunta.length > 140 ? p.pregunta.slice(0,140)+'…' : p.pregunta)}</p>
|
|
<div class="repaso-answers">
|
|
<span class="repaso-ans ko"><i class="fas fa-times"></i> Tu resp.: ${elegida.toUpperCase()}) ${escHtml((p.opciones[elegida]||'').length > 70 ? p.opciones[elegida].slice(0,70)+'…' : (p.opciones[elegida]||''))}</span>
|
|
<span class="repaso-ans ok"><i class="fas fa-check"></i> Correcta: ${p.correcta.toUpperCase()}) ${escHtml((p.opciones[p.correcta]||'').length > 70 ? p.opciones[p.correcta].slice(0,70)+'…' : (p.opciones[p.correcta]||''))}</span>
|
|
</div>
|
|
</li>
|
|
`).join('')}
|
|
</ul>
|
|
<a href="${g.tema.link}" class="repaso-link-tema"><i class="fas fa-book-open"></i> Estudiar este tema</a>
|
|
</details>
|
|
`).join('')}
|
|
`;
|
|
wrap.style.display = 'block';
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────
|
|
function mezclar(arr) {
|
|
const a = [...arr];
|
|
for (let i = a.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[a[i], a[j]] = [a[j], a[i]];
|
|
}
|
|
return a;
|
|
}
|
|
|
|
function escHtml(str) {
|
|
return str
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|