GradUP es una plataforma web de capacitaciones (LMS): catálogo de cursos, rutas, contenido con video incrustado desde fuera, evaluaciones y certificado. Es un monolito modular en Java 21 y Spring Boot 3, con interfaz en Thymeleaf (htmx y Alpine), PostgreSQL y migraciones Flyway. El nombre del artefacto en los paquetes sigue siendo com.portal / portal-treinamentos; el producto público es GradUP.
Este caso técnico muestra cómo se sortea el examen por intento, qué cubre de verdad “no repetir” y qué todavía falta. Los números se comprobaron en el repositorio en la versión del 29/10/2026 (commit f349c11). La suite de Maven no se volvió a ejecutar en esta redacción — decisión del autor para este frente; los resultados citados provienen del informe de QA del 10/07/2026 versionado en el proyecto y de la lectura del código.
Transparencia: GradUP fue diseñado y lo desarrolla Jeremias Cardoso, autor de este blog. El estado público es “En implementación”. El texto usa el tono de comprobación de capacitación (incluidos requisitos de registro en el espíritu de las NRs) solo donde el código y los ADR sostienen la afirmación. No confundir este LMS con el repositorio Empresa GradUP, que es otro proyecto (orquestación).
Nota de actualización (02/11/2026): este post se reescribió como caso técnico. La versión del 19/09/2026 (ajustada el 23/10) describía el motor de exámenes de forma general y todavía generaba ambigüedad sobre “sin repetición”. Esta versión separa lo que vale dentro de un intento de lo que vale entre intentos, con números y límites comprobados en el repositorio.
El problema: examen fijo y clave de respuestas que circula
En la capacitación corporativa y en las capacitaciones ligadas a normas de seguridad laboral, el examen necesita servir de evidencia: quién lo hizo, cuándo, con qué corte, con qué preguntas. Si el examen es siempre el mismo bloque en el mismo orden, el alumno memoriza la clave de respuestas en el segundo intento, y dos colegas comparan respuestas. El valor probatorio cae.
Hasta la épica del 08/07/2026, en GradUP el examen de un curso era el banco entero, en orden fijo de position, con nota de corte constante del 70% en el código. El ADR-0010 registró el cambio: banco más grande que el aplicado, mezcla de dificultad, sorteo por intento, conjunto persistido y corte congelado en el momento del examen — impulsores explícitos de integridad, antiadulteración y mantenibilidad.
El sistema en números
Medición local el 02/11/2026 sobre el commit f349c11.
| Medida | Valor |
|---|---|
Java en src/main | 437 archivos; cerca de 31,8 mil líneas físicas (~27,9 mil no vacías) |
Java en src/test | 199 archivos; cerca de 33,3 mil líneas físicas |
Anotaciones @Test / @ParameterizedTest | 1.595 (conteo estático en HEAD; suite no reejecutada en esta redacción) |
ExamSortitionTest | 16 pruebas en el archivo del dominio |
Plantillas/SQL en resources | 145 archivos; cerca de 14,1 mil líneas |
| Historial en Git | 163 commits |
QA 10/07/2026 (commit 63ad21f, informe en el repo) | mvn test: 722 run, 0 fail, 105 skip; batería dinámica del alcance: 26/26 PASS |
Monolito modular: lo que queda en el servidor
GradUP nace para alojamiento propio o base de datos gestionada (Neon aparece en las guías). El video no pasa por el servidor de la aplicación: incrustación externa (YouTube/Vimeo), decisión del ADR-0004, para no pagar streaming en el mismo proceso del LMS. La UI se renderiza en el servidor; no hay una SPA desacoplada como núcleo. Eso reduce la complejidad del front y empuja la regla de negocio — incluida la del examen — hacia Java testeable.
Los módulos de catálogo, empresa, IAM, billing, assessment y reporting conviven en el mismo despliegue. El examen del alumno pasa por QuizService y por el dominio ExamSortition. La configuración por curso queda en quiz_settings (cuántas preguntas aplicar, mezcla BAIXO/MÉDIO/ALTO, porcentaje de corte). El conjunto sorteado y las respuestas van a quiz_attempt_questions; el corte usado en ese intento queda en quiz_attempts.pass_percent.
El instructor importa el banco por Excel, revisa los cuasiduplicados y configura el examen; el alumno hace el intento; la empresa y analytics leen el passed ya persistido sin recalcular el corte antiguo. Esa separación explica por qué cambiar el porcentaje después no reabre veredictos: el hecho del intento ya quedó grabado.
El motor ExamSortition
ExamSortition es una función pura: recibe el banco, la configuración y un RandomGenerator, y devuelve el subconjunto. Sin Spring y sin E/S. En producción la fuente es SecureRandom; en las pruebas, semilla fija.
Reglas que el código documenta y ejecuta:
- Aplicar todas (conteo nulo o ≥ tamaño del banco): devuelve el banco en orden de
position, sin barajar — comportamiento anterior preservado. - Cuotas por mayor resto sobre el número de aplicadas y la mezcla porcentual; la suma de las cuotas es siempre N.
- Falta en una franja → la franja vecina completa el déficit; no bloquea el examen.
- Sin reposición de pregunta en el mismo intento.
- Sin enunciado repetido en el mismo intento: clave normalizada (sin acento, sin distinción de mayúsculas, espacios colapsados). Si, después de excluir los cuasiduplicados, no hay enunciados únicos suficientes, el examen sale más corto de lo pedido — fallback honesto.
- El conjunto final se baraja para que la dificultad no se filtre por el orden.
El archivo tiene 258 líneas. Las 16 pruebas de ExamSortitionTest cubren cuotas, enunciado duplicado por acento/mayúsculas y el caso en que el examen se encoge en lugar de repetir la pregunta.
Qué significa “no repetir” — y qué no significa
Esta es la distinción que la versión antigua del post dejaba ambigua.
Dentro del mismo intento
- La misma pregunta (mismo id) no aparece dos veces.
- Dos ids con el mismo enunciado normalizado tampoco: el guard los trata como la misma pregunta.
- Las opciones en la pantalla del alumno salen en el orden registrado (
optionA…optionEen la plantilla); lo que se baraja es el conjunto de preguntas, no las letras de cada ítem.
Entre intentos
- El sorteo no excluye el historial del intento anterior. Una pregunta (o un enunciado) que cayó en el intento 1 puede caer de nuevo en el intento 2.
- Lo que cambia es la composición sorteada a partir del banco y de la mezcla, no una garantía de examen inédito.
- Decir “reduce la probabilidad de repetición” es honesto; decir “sin repetición en cada intento” sería marketing falso.
El QA del 10/07 midió exactamente el lado “dentro del intento”: sorteos A y B con tres preguntas, claves normalizadas distintas, y el par deliberado “capital do Brasil” con mayúsculas/acento diferentes nunca apareció dos veces en el mismo examen. Eso no prueba exclusión entre intentos — y el producto no promete eso en el dominio.
Sesión, intento y el riesgo de volver a sortear
El conjunto nace en el GET del examen, vive en la sesión HTTP y solo se vuelve intento persistido en el SUBMIT. Abandonar la pantalla no consume intento. F5 en el GET mantiene el conjunto. Un POST sin conjunto en la sesión vuelve a sortear sin corregir y sin consumir intento.
El ADR-0010 registra un riesgo residual aceptado: si la sesión muere antes del envío (caducidad, reinicio, otro navegador), el siguiente GET sortea un conjunto nuevo sin costo de intento. Un alumno paciente puede volver a sortear hasta que le guste el conjunto. La alternativa (crear el intento en el GET) rompería la semántica de “N intentos”. El gatillo de mitigación en el ADR es abuso observado → persistir el conjunto pendiente fuera de la sesión.
La instancia única es la premisa (ADR-0006). Una multiinstancia exigiría sesión compartida u otro puente.
Certificado, corte congelado y NR
La nota de corte configurable no es retroactiva: los intentos antiguos mantienen el passed de la época. El certificado imprime la nota y el corte del mismo intento ancla (preferencia por la mejor aprobada). Si el certificado sale por conclusión y el alumno solo tiene reprobaciones, la decisión de producto R4 (08/07/2026) es omitir la nota en el PDF — no imprimir una nota por debajo del corte en un documento oficial.
Los datos por pregunta ya existen en quiz_attempt_questions (conjunto, respuesta, acierto congelado). La UI de drill-down para el auditor todavía estaba registrada como fast-follow en los puntos de atención: evidencia en la base ≠ pantalla lista.
Para capacitaciones en el espíritu de las NRs, lo que el código sostiene hoy es: examen sorteado a partir de un banco mayor, corte y veredicto congelados por intento, certificado coherente con el ancla. Lo que no sostiene: afirmar conformidad normativa automática, cubrir la parte práctica presencial, o garantizar que el auditor ya tiene la pantalla completa de inspección por intento.
Otros límites honestos
- Generación de preguntas por IA (cuando está activada): solo opción múltiple A–D, grado predeterminado MÉDIO; el formulario manual y el Excel ya aceptan un modelo más rico — brecha documentada.
- Tope de 99 preguntas por curso (ADR-0009).
- Rate limit en registro/inicio de sesión: 5 intentos / 60 s por IP (pruebas de filtro).
- Facturación y Asaas existen en el código; este caso no profundiza el cobro ni afirma clientes de pago.
- Estado: En implementación. Sin alegación de flota de empresas en producción en este texto.
Incidentes y decisiones que se volvieron regla
- Examen = banco entero → ADR-0010 y
ExamSortition(08/07/2026). - Cuasiduplicado por acento → guard de enunciado + examen más corto si hace falta; insignia “Possível duplicada” en la importación.
- Volver a sortear sin costo si la sesión muere → riesgo aceptado con gatillo de mitigación.
- Corte no retroactivo y omite la nota cuando solo hay reprobación en la emisión por conclusión.
- Datos de auditoría antes de la UI — persistir el hecho de la NR aunque no esté la pantalla del auditor en el mismo release.
Pruebas (lectura, sin reejecución en este frente)
En HEAD f349c11 hay 1.595 anotaciones de prueba. El informe del 10/07/2026, en el commit 63ad21f, registró mvn test con 722 run / 0 fail / 105 skip y batería dinámica 26/26 en el alcance de aquel QA (sorteo, invitación QR, certificado de empresa, etc.). Esta redacción no repitió la suite: usa el informe versionado y la lectura estática del código. Quien vaya a republicar con un número de ejecución “de hoy” necesita volver a ejecutar y actualizar el extracto. La misma lógica de compuertas que demuestran vale aquí: un número sin evidencia que se pueda señalar no cierra la tarea.
Lo que este caso no prueba
- Que GradUP esté homologado para cualquier NR específica, ni que sustituya la parte práctica presencial exigida en varias normas de riesgo. El código sostiene el registro del examen y un certificado coherente con el intento; la aplicación presencial sigue a cargo del proveedor de capacitación.
- Que “examen inédito en cada intento” sea una regla del producto — no lo es. Entre intentos, las preguntas y los enunciados pueden volver.
- Que la UI de auditoría por intento ya esté completa para el inspector o el auditor externo. Los datos están; la pantalla de drill-down era fast-follow en el registro de julio.
- Que la suite con 1.595 anotaciones
@Testesté en verde en este instante en la máquina del autor. Esta redacción no la volvió a ejecutar. - Resultados de negocio (alumnos activos, ingresos, conversión de trial, empresas de pago).
- Paridad total entre la generación por IA y el alta manual de preguntas: el generador todavía nace más pobre que el formulario y el Excel.
GradUP convive, en el portafolio del autor, con sistemas de operación como SIGCOT y el simulador de maniobras. El eje común no es “un LMS más”: es registrar lo que ocurrió de un modo que se pueda auditar después — SI y maniobra de un lado, intento de examen del otro.
Lecciones
- Separe “en el mismo examen” de “en el siguiente intento”. Mezclar los dos se vuelve contradicción pública.
- Un fallback honesto (examen más corto) es mejor que una pregunta repetida.
- El dato de auditoría puede nacer antes que la pantalla. El hecho de la NR en la base no espera el wireframe.
- El riesgo residual escrito en el ADR evita la sorpresa cuando el alumno vuelve a sortear la sesión.
- Un informe de QA antiguo es evidencia histórica, no un sustituto de una ejecución nueva — declare cuándo no se volvió a ejecutar.
- El nombre del paquete ≠ el nombre del producto.
com.portalen el artefacto y “GradUP” en la marca pueden convivir; lo que no puede ocurrir es mezclar este LMS con el repositorio de Empresa GradUP.
Fuentes
- Código, los ADR, puntos de atención, informe de QA e historial: repositorio Gradup (
Portal-Treinamentos) en el commitf349c11(29/10/2026), leído por el autor; extracto con fragmentos, método del conteo y límites, verificado el 2026-11-02. Suite de Maven no reejecutada en esta redacción. - ADR-0010 — Examen sorteado por intento (08/07/2026), en el repositorio del producto.
- Informe QA de Gradup del 10/07/2026 (
docs/qa-reports/qa-2026-07-10-1149.md), commit63ad21f.



