O GradUP é uma plataforma web de treinamentos (LMS): catálogo de cursos, trilhas, conteúdo com vídeo embutido de fora, avaliações e certificado. É um monólito modular em Java 21 e Spring Boot 3, com interface em Thymeleaf (htmx e Alpine), PostgreSQL e migrações Flyway. O nome do artefato nos pacotes continua com.portal / portal-treinamentos; o produto público é GradUP.
Este caso técnico mostra como a prova é sorteada por tentativa, o que “não repetir” cobre de verdade e o que ainda falta. Os números foram conferidos no repositório na versão de 29/10/2026 (commit f349c11). A suíte Maven não foi reexecutada nesta redação — decisão do autor para esta frente; os resultados citados vêm do relatório de QA de 10/07/2026 versionado no projeto e da leitura do código.
Transparência: o GradUP foi projetado e é desenvolvido por Jeremias Cardoso, autor deste blog. O status público é “Em implementação”. O texto usa o tom de comprovação de treinamento (incluindo requisitos de registro no espírito das NRs) só onde o código e os ADRs sustentam a afirmação. Não confundir este LMS com o repositório Empresa GradUP, que é outro projeto (orquestração).
Nota de atualização (02/11/2026): este post foi reescrito como caso técnico. A versão de 19/09/2026 (ajustada em 23/10) descrevia o motor de provas de forma geral e ainda gerava ambiguidade sobre “sem repetição”. Esta versão separa o que vale dentro de uma tentativa do que vale entre tentativas, com números e limites conferidos no repositório.
O problema: prova fixa e gabarito que circula
Em capacitação corporativa e em treinamentos ligados a normas de segurança do trabalho, a prova precisa servir de evidência: quem fez, quando, com qual corte, com quais questões. Se a prova é sempre o mesmo bloco na mesma ordem, o aluno memoriza o gabarito na segunda tentativa, e dois colegas comparam respostas. O valor probatório cai.
Até o épico de 08/07/2026, no GradUP a prova de um curso era o banco inteiro, em ordem fixa de position, com nota de corte constante de 70% no código. O ADR-0010 registrou a mudança: banco maior do que o aplicado, mix de dificuldade, sorteio por tentativa, conjunto persistido e corte congelado no momento da prova — drivers explícitos de integridade, anti-adulteração e manutenibilidade.
O sistema em números
Medição local em 02/11/2026 sobre o commit f349c11.
| Medida | Valor |
|---|---|
Java em src/main | 437 arquivos; cerca de 31,8 mil linhas físicas (~27,9 mil não vazias) |
Java em src/test | 199 arquivos; cerca de 33,3 mil linhas físicas |
Anotações @Test / @ParameterizedTest | 1.595 (contagem estática no HEAD; suíte não reexecutada nesta redação) |
ExamSortitionTest | 16 testes no arquivo do domínio |
Templates/SQL em resources | 145 arquivos; cerca de 14,1 mil linhas |
| Histórico no Git | 163 commits |
QA 10/07/2026 (commit 63ad21f, relatório no repo) | mvn test: 722 run, 0 fail, 105 skip; bateria dinâmica do escopo: 26/26 PASS |
Monólito modular: o que fica no servidor
O GradUP nasce para hospedagem própria ou banco gerenciado (Neon aparece nos guias). Vídeo não passa pelo servidor da aplicação: embed externo (YouTube/Vimeo), decisão do ADR-0004, para não pagar streaming no mesmo processo do LMS. A UI é renderizada no servidor; não há SPA desacoplada como núcleo. Isso reduz complexidade de front e empurra a regra de negócio — inclusive a da prova — para Java testável.
Módulos de catálogo, empresa, IAM, billing, assessment e reporting convivem no mesmo deploy. A prova do aluno passa por QuizService e pelo domínio ExamSortition. A configuração por curso fica em quiz_settings (quantas questões aplicar, mix BAIXO/MÉDIO/ALTO, percentual de corte). O conjunto sorteado e as respostas vão para quiz_attempt_questions; o corte usado naquela tentativa fica em quiz_attempts.pass_percent.
Instrutor importa banco por Excel, revisa quase-duplicatas e configura a prova; aluno faz a tentativa; empresa e analytics leem o passed já persistido sem recalcular o corte antigo. Essa separação explica por que mudar o percentual depois não reabre vereditos: o fato da tentativa já foi gravado.
O motor ExamSortition
ExamSortition é função pura: recebe o banco, a configuração e um RandomGenerator, devolve o subconjunto. Sem Spring e sem I/O. Em produção a fonte é SecureRandom; nos testes, seed fixa.
Regras que o código documenta e executa:
- Aplicar todas (contagem nula ou ≥ tamanho do banco): devolve o banco em ordem de
position, sem embaralhar — comportamento anterior preservado. - Cotas por maior-resto sobre o número de aplicadas e o mix percentual; a soma das cotas é sempre N.
- Falta numa faixa → faixa vizinha completa o déficit; não bloqueia a prova.
- Sem reposição de questão na mesma tentativa.
- Sem enunciado repetido na mesma tentativa: chave normalizada (sem acento, sem caixa, espaços colapsados). Se, depois de excluir quase-duplicatas, não houver enunciados únicos suficientes, a prova sai menor do que o pedido — fallback honesto.
- O conjunto final é embaralhado para a dificuldade não vazar pela ordem.
O arquivo tem 258 linhas. Os 16 testes de ExamSortitionTest cobrem cotas, enunciado duplicado por acento/caixa e o caso em que a prova encolhe em vez de repetir pergunta.
O que “não repetir” significa — e o que não significa
Esta é a distinção que a versão antiga do post deixava ambígua.
Dentro da mesma tentativa
- A mesma questão (mesmo id) não aparece duas vezes.
- Dois ids com o mesmo enunciado normalizado também não: o guard trata como a mesma pergunta.
- As alternativas na tela do aluno saem na ordem cadastrada (
optionA…optionEno template); o que embaralha é o conjunto de questões, não as letras de cada item.
Entre tentativas
- O sorteio não exclui o histórico da tentativa anterior. Uma questão (ou um enunciado) que caiu na tentativa 1 pode cair de novo na tentativa 2.
- O que muda é a composição sorteada a partir do banco e do mix, não uma garantia de prova inédita.
- Dizer “reduz a chance de repetição” é honesto; dizer “sem repetição a cada tentativa” seria marketing falso.
O QA de 10/07 mediu exatamente o lado “dentro da tentativa”: sorteios A e B com três questões, chaves normalizadas distintas, e o par proposital “capital do Brasil” com caixa/acento diferentes nunca apareceu duas vezes na mesma prova. Isso não prova exclusão entre tentativas — e o produto não promete isso no domínio.
Sessão, tentativa e o risco de re-rolar
O conjunto nasce no GET da prova, vive na sessão HTTP e só vira tentativa persistida no SUBMIT. Abandonar a tela não consome tentativa. F5 no GET mantém o conjunto. POST sem conjunto na sessão re-sorteia sem corrigir e sem consumir tentativa.
O ADR-0010 registra um risco residual aceito: se a sessão morrer antes do submit (expiração, restart, outro navegador), o próximo GET sorteia um conjunto novo sem custo de tentativa. Um aluno paciente pode re-rolar até gostar do conjunto. A alternativa (criar tentativa no GET) quebraria a semântica de “N tentativas”. O gatilho de mitigação no ADR é abuso observado → persistir o conjunto pendente fora da sessão.
Instância única é premissa (ADR-0006). Multi-instância exigiria sessão compartilhada ou outro bridge.
Certificado, corte congelado e NR
Nota de corte configurável não é retroativa: tentativas antigas mantêm o passed da época. O certificado imprime nota e corte da mesma tentativa-âncora (preferência pela melhor aprovada). Se o certificado sai por conclusão e o aluno só tem reprovações, a decisão de produto R4 (08/07/2026) é omitir a nota no PDF — não imprimir nota abaixo do corte em documento oficial.
Os dados por questão já existem em quiz_attempt_questions (conjunto, resposta, acerto congelado). A UI de drill-down para o auditor ainda estava registrada como fast-follow nos pontos de atenção: evidência no banco ≠ tela pronta.
Para treinamentos no espírito das NRs, o que o código sustenta hoje é: prova sorteada a partir de banco maior, corte e veredito congelados por tentativa, certificado coerente com a âncora. O que não sustenta: afirmar conformidade normativa automática, cobrir parte prática presencial, ou garantir que o auditor já tem a tela completa de inspeção por tentativa.
Outros limites honestos
- Geração de questões por IA (quando ligada): só múltipla escolha A–D, grau default MÉDIO; o formulário manual e o Excel já aceitam modelo mais rico — gap documentado.
- Teto de 99 questões por curso (ADR-0009).
- Rate limit em cadastro/login: 5 tentativas / 60 s por IP (testes de filtro).
- Billing e Asaas existem no código; este caso não aprofunda cobrança nem afirma clientes pagantes.
- Status: Em implementação. Sem alegação de frota de empresas em produção neste texto.
Incidentes e decisões que viraram regra
- Prova = banco inteiro → ADR-0010 e
ExamSortition(08/07/2026). - Quase-duplicata por acento → guard de enunciado + prova menor se preciso; badge “Possível duplicada” na importação.
- Re-rolagem sem custo se a sessão morrer → risco aceito com gatilho de mitigação.
- Corte não retroativo e omite nota quando só há reprovação na emissão por conclusão.
- Dados de auditoria antes da UI — persistir o fato NR mesmo sem a tela do auditor no mesmo release.
Testes (leitura, sem reexecução nesta frente)
No HEAD f349c11 há 1.595 anotações de teste. O relatório de 10/07/2026, no commit 63ad21f, registrou mvn test com 722 run / 0 fail / 105 skip e bateria dinâmica 26/26 no escopo daquele QA (sorteio, convite QR, certificado empresa, etc.). Esta redação não repetiu a suíte: usa o relatório versionado e a leitura estática do código. Quem for republicar com número de execução “hoje” precisa rodar de novo e atualizar o extrato. A mesma lógica de portões que provam vale aqui: número sem evidência apontável não fecha a tarefa.
O que este caso não prova
- Que o GradUP está homologado para qualquer NR específica, nem que substitui a parte prática presencial exigida em várias normas de risco. O código sustenta registro de prova e certificado coerente com a tentativa; a aplicação presencial continua com o provedor de treinamento.
- Que “prova inédita a cada tentativa” é regra do produto — não é. Entre tentativas, questões e enunciados podem voltar.
- Que a UI de auditoria por tentativa já está completa para o fiscal ou o auditor externo. Os dados estão; a tela de drill-down era fast-follow no registro de julho.
- Que a suíte com 1.595 anotações
@Testestá verde neste instante na máquina do autor. Esta redação não a reexecutou. - Resultados de negócio (alunos ativos, receita, conversão de trial, empresas pagantes).
- Paridade total entre geração por IA e cadastro manual de questões: o gerador ainda nasce mais pobre que o formulário e o Excel.
O GradUP convive, no portfólio do autor, com sistemas de operação como o SIGCOT e o simulador de manobras. O eixo comum não é “mais um LMS”: é registrar o que aconteceu de um jeito que dá para auditar depois — SI e manobra de um lado, tentativa de prova do outro.
Lições
- Separe “na mesma prova” de “na próxima tentativa”. Misturar os dois vira contradição pública.
- Fallback honesto (prova menor) é melhor que pergunta repetida.
- Dado de auditoria pode nascer antes da tela. O fato NR no banco não espera o wireframe.
- Risco residual escrito no ADR evita surpresa quando o aluno re-rola a sessão.
- Relatório de QA antigo é evidência histórica, não substituto de execução nova — declare quando não rodou de novo.
- Nome de pacote ≠ nome de produto.
com.portalno artefato e “GradUP” na marca podem conviver; o que não pode é misturar este LMS com o repositório da Empresa GradUP.
Fontes
- Código, ADRs, pontos de atenção, relatório de QA e histórico: repositório Gradup (
Portal-Treinamentos) no commitf349c11(29/10/2026), lido pelo autor; extrato com trechos, método da contagem e limites, verificado em 2026-11-02. Suíte Maven não reexecutada nesta redação. - ADR-0010 — Prova sorteada por tentativa (08/07/2026), no repositório do produto.
- Relatório QA Gradup 10/07/2026 (
docs/qa-reports/qa-2026-07-10-1149.md), commit63ad21f.



