449 lines
17 KiB
JavaScript
Raw Normal View History

2025-12-11 09:09:57 -06:00
(function (global) {
2025-12-11 17:00:53 -06:00
"use strict";
2025-12-11 09:09:57 -06:00
2025-12-11 17:00:53 -06:00
/**
* QuizManager: Clase principal para gestionar cuestionarios basados en un archivo Excel.
*/
class QuizManager {
2025-12-11 09:09:57 -06:00
/**
2025-12-11 17:00:53 -06:00
* @constructor
* @param {Object} config - Configuración del cuestionario.
* @param {string} config.excelFileUrl - URL del archivo Excel que contiene las preguntas.
* @param {Object} [config.mandatoryFields] - Campos obligatorios en el archivo Excel para el cuestionario.
* @param {string} [config.mandatoryFields.question="pregunta"] - Nombre de la columna para el texto de la pregunta.
* @param {string} [config.mandatoryFields.correctOption="opcion_c"] - Nombre de la columna que identifica la respuesta correcta.
* @param {string} [config.mandatoryFields.correctFeedback="retroalimentacion_correcta"] - Nombre de la columna para la retroalimentación en respuestas correctas.
* @param {string} [config.mandatoryFields.incorrectFeedback="retroalimentacion_incorrecta"] - Nombre de la columna para la retroalimentación en respuestas incorrectas.
* @param {string} [config.optionPrefix="opcion"] - Prefijo utilizado para identificar las columnas de las opciones de respuesta.
* @param {boolean} [config.randomizeQuestions=false] - Indica si las preguntas deben presentarse en orden aleatorio.
* @param {boolean} [config.randomizeOptions=false] - Indica si las opciones dentro de una pregunta deben ordenarse aleatoriamente.
* @param {number} [config.passingScore=80] - Puntaje mínimo necesario para aprobar el cuestionario, expresado en porcentaje.
* @param {number} [config.defaultWeight=1] - Ponderación por defecto asignada a cada pregunta, si no se especifica en el archivo Excel.
* @param {number} [config.maxQuestions=Infinity] - Número máximo de preguntas a incluir en el cuestionario. Si no se especifica, se incluyen todas las preguntas.
* @param {number} [config.maxAttempts=Infinity] - Número máximo de intentos permitidos para completar el cuestionario. Por defecto, es infinito.
* @license Licencia Comercial Propietaria
* @version 1.0.0
* @author Salvador Martínez
* @copyright Este software está protegido por derechos de autor y es propiedad de E360 Digital Solutions S.A. de C.V.
* El uso está restringido a los términos y condiciones establecidos en el contrato de licencia.
* Para obtener una licencia y acceder a las características completas y soporte, contáctenos en licencias@espacio360.com.mx
2025-12-11 09:09:57 -06:00
*/
2025-12-11 17:00:53 -06:00
constructor(config) {
this.config = {
excelFileUrl: config.excelFileUrl,
mandatoryFields: config.mandatoryFields || {
question: "pregunta",
correctOption: "opcion_c",
correctAnswers: "correctas",
correctFeedback: "retroalimentacion_correcta",
incorrectFeedback: "retroalimentacion_incorrecta",
},
optionPrefix: config.optionPrefix || "opcion",
randomizeQuestions: config.randomizeQuestions || false,
randomizeOptions: config.randomizeOptions || false,
passingScore: config.passingScore || 80,
defaultWeight: config.defaultWeight || 1,
maxAttempts: config.maxAttempts || Infinity,
maxQuestions: config.maxQuestions || Infinity,
};
this.questions = []; // Contendrá las preguntas seleccionadas para el cuestionario.
this.currentQuestionIndex = 0; // Índice de la pregunta actual en el cuestionario.
this.correctAnswersCount = 0; // Contador de respuestas correctas.
this.incorrectAnswersCount = 0; // Contador de respuestas incorrectas.
this.totalScore = 0; // Puntaje acumulado del cuestionario.
this.attempts = 0; // Contador de intentos realizados.
}
2025-12-11 09:09:57 -06:00
2025-12-11 17:00:53 -06:00
/**
* Carga las preguntas desde el archivo Excel especificado en la configuración.
* @async
* @returns {Promise<void>}
*/
async loadQuestionsFromExcel() {
const rawData = await QuizManager.readExcelFile(this.config.excelFileUrl);
2025-12-11 09:09:57 -06:00
2025-12-11 17:00:53 -06:00
const sheetName = Object.keys(rawData)[0];
const sheetData = rawData[sheetName];
2025-12-11 09:09:57 -06:00
2025-12-11 17:00:53 -06:00
if (!Array.isArray(sheetData)) {
console.error("The selected sheet does not contain an array of data.");
return;
}
2025-12-11 09:09:57 -06:00
2025-12-11 17:00:53 -06:00
let questions = QuizManager.processQuestions(sheetData, this.config);
2025-12-11 09:09:57 -06:00
2025-12-11 17:00:53 -06:00
if (this.config.randomizeQuestions) {
questions = QuizManager.shuffle(questions);
}
2025-12-11 09:09:57 -06:00
2025-12-11 17:00:53 -06:00
// Validar maxQuestions solo si fue definido explícitamente
const totalQuestions = questions.length;
if (this.config.maxQuestions !== Infinity && this.config.maxQuestions > totalQuestions) {
throw new Error(`El número máximo de preguntas especificado (${this.config.maxQuestions}) excede el total disponible (${totalQuestions}).`);
}
2025-12-11 09:09:57 -06:00
2025-12-11 17:00:53 -06:00
// Seleccionar las preguntas según maxQuestions
this.questions = this.config.maxQuestions === Infinity ? questions : questions.slice(0, this.config.maxQuestions);
}
2025-12-11 09:09:57 -06:00
2025-12-11 17:00:53 -06:00
/**
* Lee un archivo Excel y lo convierte a un objeto JSON.
* @static
* @async
* @param {string} url - URL del archivo Excel.
* @returns {Promise<Object>} Objeto donde las hojas del Excel son claves y sus datos son arrays de objetos.
*/
static async readExcelFile(url) {
try {
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
const data = new Uint8Array(arrayBuffer);
const workbook = XLSX.read(data, { type: "array" });
const result = {};
workbook.SheetNames.forEach((sheetName) => {
const sheet = workbook.Sheets[sheetName];
result[sheetName] = XLSX.utils.sheet_to_json(sheet);
});
return result;
} catch (error) {
console.error("Error reading Excel file:", error);
throw error;
}
}
2025-12-11 09:09:57 -06:00
2025-12-11 17:00:53 -06:00
/**
* Procesa los datos de una hoja del Excel para generar las preguntas.
* @static
* @param {Array} rawData - Datos de una hoja en formato JSON.
* @param {Object} config - Configuración para procesar preguntas.
* @returns {Array} Array de preguntas procesadas.
*/
static processQuestions(rawData, config) {
return rawData
.map((row) => {
const fields = config.mandatoryFields;
2025-12-11 09:09:57 -06:00
2025-12-11 17:00:53 -06:00
if (!row[fields.question]) {
console.error(`Missing mandatory field: '${fields.question}'`);
return null;
}
if (!row[fields.correctAnswers] && !row[fields.correctOption]) {
console.error(`Missing correct answers field: need either '${fields.correctAnswers}' or '${fields.correctOption}'`);
return null;
}
let options = Object.keys(row)
.filter((key) => key.startsWith(config.optionPrefix))
.map((key, index) => {
let isCorrect = false;
if (row[fields.correctAnswers]) {
const correctIndices = row[fields.correctAnswers]
.toString()
.split(',')
.map(num => parseInt(num.trim()) - 1);
isCorrect = correctIndices.includes(index);
} else if (row[fields.correctOption]) {
isCorrect = key === fields.correctOption;
}
return {
id: key,
text: row[key]?.trim(),
isCorrect: isCorrect,
};
});
const specialOptionsTexts = config.specialOptions || [];
let specialOptions = [];
let regularOptions = [];
options.forEach((option) => {
if (specialOptionsTexts.some((text) => option.text.toLowerCase().includes(text.toLowerCase()))) {
specialOptions.push(option);
} else {
regularOptions.push(option);
2025-12-11 09:09:57 -06:00
}
2025-12-11 17:00:53 -06:00
});
if (config.randomizeOptions) {
regularOptions = QuizManager.shuffle(regularOptions);
}
options = [...regularOptions, ...specialOptions];
const additionalInfo = Object.keys(row)
.filter((key) => !Object.values(fields).includes(key) && !key.startsWith(config.optionPrefix))
.reduce((info, key) => {
// Convertir a cadena antes de aplicar .trim() y manejar valores nulos/indefinidos
const value = row[key] !== null && row[key] !== undefined ? String(row[key]).trim() : "";
info[key] = value;
return info;
}, {});
const weight = parseFloat(row["ponderacion"]) || config.defaultWeight;
return {
question: row[fields.question].trim(),
options,
correctFeedback: row[fields.correctFeedback]?.trim() || "",
incorrectFeedback: row[fields.incorrectFeedback]?.trim() || "",
additionalInfo,
weight,
};
})
.filter(Boolean);
}
2025-12-11 09:09:57 -06:00
2025-12-11 17:00:53 -06:00
/**
* Baraja un array usando el algoritmo Fisher-Yates.
* @static
* @param {Array} array - Array a barajar.
* @returns {Array} Array barajado.
*/
static shuffle(array) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
2025-12-11 09:09:57 -06:00
2025-12-11 17:00:53 -06:00
/**
* Obtiene la pregunta actual.
* @returns {Object} La pregunta actual.
*/
getCurrentQuestion() {
return this.questions[this.currentQuestionIndex];
}
2025-12-11 09:09:57 -06:00
2025-12-11 17:00:53 -06:00
/**
* Avanza a la siguiente pregunta y la retorna.
* @returns {Object|null} La siguiente pregunta o `null` si no hay más.
*/
getNextQuestion() {
if (this.currentQuestionIndex < this.questions.length - 1) {
this.currentQuestionIndex++;
return this.questions[this.currentQuestionIndex];
}
return null;
}
/**
* Registra la respuesta del usuario a una pregunta específica por su ID.
* @param {number} questionId - El ID de la pregunta a responder.
* @param {boolean} isCorrect - Indica si la respuesta fue correcta.
*/
answerQuestionById(questionId, isCorrect) {
if (questionId < 0 || questionId >= this.questions.length) {
console.error(`Invalid question ID: ${questionId}`);
return;
}
const question = this.questions[questionId];
if (!question.answered) {
question.answered = true; // Marcar la pregunta como respondida
question.isAnswerCorrect = isCorrect; //Guarda el resultado de la respuesta
if (isCorrect) {
this.correctAnswersCount++;
this.totalScore += question.weight;
} else {
this.incorrectAnswersCount++;
2025-12-11 09:09:57 -06:00
}
2025-12-11 17:00:53 -06:00
} else {
console.warn(`Question ID ${questionId} has already been answered.`);
}
}
2025-12-11 09:09:57 -06:00
2025-12-11 17:00:53 -06:00
/**
* Registra la respuesta del usuario a la pregunta actual.
* @param {boolean} isCorrect - Indica si la respuesta fue correcta.
*/
answerCurrentQuestion(isCorrect) {
this.answerQuestionById(this.currentQuestionIndex, isCorrect);
}
2025-12-11 09:09:57 -06:00
2025-12-11 17:00:53 -06:00
/**
* Registra la respuesta del usuario a la pregunta por su indice.
* @param {boolean} isCorrect - Indica si la respuesta fue correcta.
*/
answerCurrentQuestionById(index, isCorrect) {
this.answerQuestionById(index, isCorrect);
}
2025-12-11 09:09:57 -06:00
2025-12-11 17:00:53 -06:00
/**
* Verifica si hay más preguntas disponibles.
* @returns {boolean} `true` si hay más preguntas, `false` de lo contrario.
*/
hasMoreQuestions() {
// Contamos cuántas preguntas han sido respondidas
const answeredQuestions = this.questions.filter((q) => q.answered).length;
2025-12-11 09:09:57 -06:00
2025-12-11 17:00:53 -06:00
// Si el número de preguntas respondidas es menor que el total, hay más preguntas
return answeredQuestions < this.questions.length;
}
2025-12-11 09:09:57 -06:00
2025-12-11 17:00:53 -06:00
/**
* Genera un resumen del estado actual del cuestionario.
* @returns {Object} Resumen del cuestionario.
*/
getSummary() {
const totalQuestions = this.questions.length;
const maxScore = this.questions.reduce((sum, q) => sum + q.weight, 0);
const scorePercentage = Math.round((this.totalScore * 100) / maxScore);
const passed = scorePercentage >= this.config.passingScore;
return {
correct: this.correctAnswersCount,
incorrect: this.incorrectAnswersCount,
total: totalQuestions,
score: scorePercentage,
maxScore,
passed,
attempts: this.attempts,
maxAttempts: this.config.maxAttempts,
};
}
2025-12-11 09:09:57 -06:00
2025-12-11 17:00:53 -06:00
/**
* Genera un resumen del estado de un conjunto de preguntas.
* @param {Set} questionSet - Conjunto de preguntas del cuestionario.
* @returns {Object} Resumen del cuestionario.
*/
generateSummaryFromSet(questionSet) {
// Convertir el Set a un Array para procesarlo
const questions = Array.from(questionSet);
// Variables para calcular el resumen
const totalQuestions = questions.length;
const maxScore = questions.reduce((sum, q) => sum + (q.weight || 1), 0);
const correctAnswersCount = questions.filter((q) => q.isAnswerCorrect).length;
const incorrectAnswersCount = totalQuestions - correctAnswersCount;
const totalScore = questions.reduce((sum, q) => (q.isAnswerCorrect ? sum + (q.weight || 1) : sum), 0);
const scorePercentage = maxScore > 0 ? Math.round((totalScore * 100) / maxScore) : 0;
const passed = scorePercentage >= this.config.passingScore;
// Generar el resumen
return {
correct: correctAnswersCount,
incorrect: incorrectAnswersCount,
total: totalQuestions,
score: scorePercentage,
maxScore,
passed,
attempts: this.attempts,
maxAttempts: this.config.maxAttempts,
};
}
/**
* Devuelve los datos necesarios para renderizar la pregunta actual.
* @returns {Object|null} Datos de la pregunta actual o `null` si no existe.
*/
getRenderData() {
const currentQuestion = this.getCurrentQuestion();
if (!currentQuestion) return null;
return {
text: currentQuestion.question,
options: currentQuestion.options.map((option) => ({
id: option.id,
text: option.text,
isCorrect: option.isCorrect,
})),
correctFeedback: currentQuestion.correctFeedback,
incorrectFeedback: currentQuestion.incorrectFeedback,
additionalInfo: currentQuestion.additionalInfo,
};
}
2025-12-11 09:09:57 -06:00
2025-12-11 17:00:53 -06:00
/**
* Obtiene una pregunta específica por ID.
* @param {number} id - Índice de la pregunta.
* @returns {Object|null} Pregunta correspondiente o `null` si el índice no es válido.
*/
getQuestionById(id) {
if (id < 0 || id >= this.questions.length) {
console.error(`Invalid question ID: ${id}`);
return null;
}
return this.questions[id];
}
2025-12-11 09:09:57 -06:00
2025-12-11 17:00:53 -06:00
/**
* Genera los datos necesarios para renderizar una pregunta específica por ID.
* @param {number} id - Índice de la pregunta.
* @returns {Object|null} Datos de renderizado o `null` si el ID no es válido.
*/
getRenderDataById(id) {
const question = this.getQuestionById(id);
if (!question) return null;
return {
text: question.question,
options: question.options.map((option) => ({
id: option.id,
text: option.text,
isCorrect: option.isCorrect,
})),
correctFeedback: question.correctFeedback,
incorrectFeedback: question.incorrectFeedback,
additionalInfo: question.additionalInfo,
};
}
2025-12-11 09:09:57 -06:00
2025-12-11 17:00:53 -06:00
/**
* Devuelve los datos necesarios para renderizar todas las preguntas.
* @returns {Array} Array de objetos con los datos de todas las preguntas.
*/
getAllRenderData() {
return this.questions.map((question) => ({
text: question.question,
options: question.options.map((option) => ({
id: option.id,
text: option.text,
isCorrect: option.isCorrect,
})),
correctFeedback: question.correctFeedback,
incorrectFeedback: question.incorrectFeedback,
additionalInfo: question.additionalInfo,
}));
}
/**
* Incrementa el número de intentos del cuestionario.
*/
incrementAttempts() {
this.attempts++;
}
2025-12-11 09:09:57 -06:00
2025-12-11 17:00:53 -06:00
/**
* Obtiene el número de intentos actuales.
* @returns {number} Número de intentos.
*/
getAttempts() {
return this.attempts;
}
/**
* Establece el número de intentos desde una fuente externa (LMS, etc.).
* @param {number} attempts - Número inicial de intentos.
*/
setAttempts(attempts) {
if (isNaN(attempts) || attempts < 0) {
console.error("El valor de intentos no es válido.");
return;
}
this.attempts = attempts;
}
2025-12-11 09:09:57 -06:00
2025-12-11 17:00:53 -06:00
/**
* Verifica si el número máximo de intentos se ha alcanzado.
* @returns {boolean} `true` si se alcanzó el máximo de intentos, `false` de lo contrario.
*/
hasReachedMaxAttempts() {
return this.attempts >= this.config.maxAttempts;
2025-12-11 09:09:57 -06:00
}
2025-12-11 17:00:53 -06:00
}
2025-12-11 09:09:57 -06:00
2025-12-11 17:00:53 -06:00
// Exponer la clase al ámbito global
global.QuizManager = QuizManager;
})(window);