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 ) ;