diff --git a/backend/tests/domain/services/test_pdf_rules.py b/backend/tests/domain/services/test_pdf_rules.py
new file mode 100644
index 0000000..fc2e49e
--- /dev/null
+++ b/backend/tests/domain/services/test_pdf_rules.py
@@ -0,0 +1,232 @@
+from datetime import datetime
+from dateutil.relativedelta import relativedelta
+
+import pytest
+
+from src.domain.services.calculador_pontuacao import CalculadorPontuacao
+from src.domain.value_objects.criterios_pontuacao import CRITERIOS
+from src.domain.value_objects.periodo import Periodo
+from src.domain.entities.consultor import (
+ Consultoria,
+ CoordenacaoCapes,
+ Inscricao,
+ AvaliacaoComissao,
+ Participacao,
+)
+
+
+PDF_BASE_TETO = {
+ "CA": (200, 450),
+ "CAJ": (150, 370),
+ "CAJ_MP": (120, 315),
+ "CAM": (100, 280),
+ "PPG_COORD": (0, 0),
+ "CONS_ATIVO": (150, 230),
+ "CONS_HIST": (100, 230),
+ "CONS_FALECIDO": (100, 230),
+ "INSC_AUTOR": (10, 20),
+ "INSC_INST_AUTOR": (20, 50),
+ "AVAL_COMIS_PREMIO": (30, 60),
+ "AVAL_COMIS_GP": (40, 80),
+ "COORD_COMIS_PREMIO": (40, 100),
+ "COORD_COMIS_GP": (50, 120),
+ "BOL_BPQ_NIVEL": (30, 60),
+ "PREMIACAO_GP_AUTOR": (100, 300),
+ "PREMIACAO_AUTOR": (50, 150),
+ "MENCAO_AUTOR": (30, 90),
+ "EVENTO": (1, 5),
+ "PROJ": (10, 30),
+ "IDIOMA_BILINGUE": (0, 0),
+ "IDIOMA_MULTILINGUE": (0, 0),
+ "TITULACAO_MESTRE": (0, 0),
+ "TITULACAO_DOUTOR": (0, 0),
+ "TITULACAO_POS_DOUTOR": (0, 0),
+ "ORIENT_POS_DOC": (0, 0),
+ "ORIENT_POS_DOC_PREM": (0, 0),
+ "ORIENT_TESE": (0, 0),
+ "ORIENT_TESE_PREM": (0, 0),
+ "ORIENT_DISS": (0, 0),
+ "ORIENT_DISS_PREM": (0, 0),
+ "CO_ORIENT_POS_DOC": (0, 0),
+ "CO_ORIENT_POS_DOC_PREM": (0, 0),
+ "CO_ORIENT_TESE": (0, 0),
+ "CO_ORIENT_TESE_PREM": (0, 0),
+ "CO_ORIENT_DISS": (0, 0),
+ "CO_ORIENT_DISS_PREM": (0, 0),
+ "MB_BANCA_POS_DOC": (0, 0),
+ "MB_BANCA_POS_DOC_PREM": (0, 0),
+ "MB_BANCA_TESE": (0, 0),
+ "MB_BANCA_TESE_PREM": (0, 0),
+ "MB_BANCA_DISS": (0, 0),
+ "MB_BANCA_DISS_PREM": (0, 0),
+}
+
+PDF_TEMPO = {
+ "CA": (10, 100),
+ "CAJ": (8, 80),
+ "CAJ_MP": (6, 60),
+ "CAM": (5, 50),
+ "PPG_COORD": (0, 0),
+ "CONS_ATIVO": (5, 50),
+ "CONS_HIST": (5, 50),
+ "CONS_FALECIDO": (5, 50),
+}
+
+PDF_BONUS = {
+ "CA": {"atualidade": 30, "retorno": 20},
+ "CAJ": {"atualidade": 20, "retorno": 15},
+ "CAJ_MP": {"atualidade": 15, "retorno": 10},
+ "CAM": {"atualidade": 20, "retorno": 10},
+ "PPG_COORD": {"atualidade": 15, "retorno": 10, "continuidade": 15},
+ "CONS_ATIVO": {"atualidade": 20, "retorno": 15, "continuidade": 20},
+ "CONS_HIST": {"retorno": 20, "continuidade": 20},
+ "CONS_FALECIDO": {"continuidade": 20},
+}
+
+PDF_RECORRENCIA = {
+ "INSC_AUTOR": {"por_participacao": 2, "teto_participacao": 10},
+ "INSC_INST_AUTOR": {"por_participacao": 5, "teto_participacao": 10},
+ "AVAL_COMIS_PREMIO": {"por_ano": 2, "teto_ano": 15},
+ "AVAL_COMIS_GP": {"por_ano": 3, "teto_ano": 20},
+ "COORD_COMIS_PREMIO": {"por_ano": 4, "teto_ano": 20},
+ "COORD_COMIS_GP": {"por_ano": 6, "teto_ano": 20},
+ "EVENTO": {"por_participacao": 1, "teto_participacao": 10},
+ "PROJ": {"por_participacao": 2, "teto_participacao": 10},
+}
+
+
+def periodo_anos(anos: int, ativo: bool = False) -> Periodo:
+ inicio = datetime.now() - relativedelta(years=anos)
+ fim = None if ativo else datetime.now()
+ return Periodo(inicio=inicio, fim=fim)
+
+
+@pytest.mark.parametrize("codigo,base_teto", PDF_BASE_TETO.items())
+def test_pdf_base_teto(codigo, base_teto):
+ criterio = CRITERIOS.get(codigo)
+ assert criterio is not None
+ base, teto = base_teto
+ assert criterio.base == base
+ assert criterio.teto == teto
+
+
+def test_pdf_criterios_cobrem_43_codigos():
+ assert set(CRITERIOS.keys()) == set(PDF_BASE_TETO.keys())
+ assert len(CRITERIOS) == 43
+
+
+@pytest.mark.parametrize("codigo,tempo", PDF_TEMPO.items())
+def test_pdf_regras_tempo(codigo, tempo):
+ criterio = CRITERIOS[codigo]
+ multiplicador, teto_tempo = tempo
+ assert criterio.pontua_tempo is True
+ assert criterio.multiplicador_tempo == multiplicador
+ assert criterio.teto_tempo == teto_tempo
+
+
+@pytest.mark.parametrize("codigo", sorted(set(PDF_BASE_TETO) - set(PDF_TEMPO)))
+def test_pdf_codigos_sem_tempo(codigo):
+ criterio = CRITERIOS[codigo]
+ assert criterio.pontua_tempo is False
+ assert criterio.multiplicador_tempo == 0
+ assert criterio.teto_tempo == 0
+
+
+@pytest.mark.parametrize("codigo,bonus", PDF_BONUS.items())
+def test_pdf_bonus_configurados(codigo, bonus):
+ criterio = CRITERIOS[codigo]
+ assert criterio.bonus_atualidade == bonus.get("atualidade", 0)
+ assert criterio.bonus_retorno == bonus.get("retorno", 0)
+ assert criterio.bonus_continuidade_8anos == bonus.get("continuidade", 0)
+
+
+@pytest.mark.parametrize("codigo,rec", PDF_RECORRENCIA.items())
+def test_pdf_regras_recorrencia(codigo, rec):
+ criterio = CRITERIOS[codigo]
+ assert criterio.bonus_recorrencia_anual == rec.get("por_ano", 0)
+ assert criterio.teto_recorrencia == rec.get("teto_ano", 0)
+ assert criterio.bonus_recorrencia_participacao == rec.get("por_participacao", 0)
+ assert criterio.teto_recorrencia_participacao == rec.get("teto_participacao", 0)
+
+
+def test_calculo_tempo_e_bonus_ca():
+ periodo_historico = Periodo(
+ inicio=datetime.now() - relativedelta(years=12),
+ fim=datetime.now() - relativedelta(years=2),
+ )
+ periodo_ativo = periodo_anos(2, ativo=True)
+ coords = [
+ CoordenacaoCapes(
+ codigo="CA",
+ tipo="Coordenador de Area",
+ area_avaliacao="AREA",
+ periodo=periodo_historico,
+ ),
+ CoordenacaoCapes(
+ codigo="CA",
+ tipo="Coordenador de Area",
+ area_avaliacao="AREA",
+ periodo=periodo_ativo,
+ ),
+ ]
+ resultado = CalculadorPontuacao.calcular_bloco_a(coords)
+ atuacao = resultado.atuacoes[0]
+ assert atuacao.tempo == 100
+ assert atuacao.bonus == 50
+
+
+def test_calculo_bonus_consultoria_completo():
+ consultoria = Consultoria(
+ codigo="CONS_ATIVO",
+ situacao="Ativo",
+ periodo=periodo_anos(9, ativo=True),
+ periodos=[periodo_anos(9, ativo=True)],
+ anos_consecutivos=9,
+ retornos=1,
+ )
+ resultado = CalculadorPontuacao.calcular_bloco_b(consultoria)
+ atuacao = resultado.atuacoes[0]
+ assert atuacao.bonus == 55
+
+
+def test_calculo_recorrencia_inscricao_autor_bonus():
+ inscricoes = [
+ Inscricao(codigo="INSC_AUTOR", tipo="Autor", premio="PCT", ano=2020),
+ Inscricao(codigo="INSC_AUTOR", tipo="Autor", premio="PCT", ano=2021),
+ Inscricao(codigo="INSC_AUTOR", tipo="Autor", premio="PCT", ano=2022),
+ Inscricao(codigo="INSC_AUTOR", tipo="Autor", premio="PCT", ano=2023),
+ Inscricao(codigo="INSC_AUTOR", tipo="Autor", premio="PCT", ano=2024),
+ ]
+ resultado = CalculadorPontuacao.calcular_bloco_c(inscricoes, [], [], [], [])
+ atuacao = next(a for a in resultado.atuacoes if a.codigo == "INSC_AUTOR")
+ assert atuacao.bonus == 10
+
+
+def test_calculo_recorrencia_avaliacao_gp_bonus():
+ avaliacoes = [
+ AvaliacaoComissao(codigo="AVAL_COMIS_GP", tipo="Membro", premio="GP", ano=2021),
+ AvaliacaoComissao(codigo="AVAL_COMIS_GP", tipo="Membro", premio="GP", ano=2022),
+ AvaliacaoComissao(codigo="AVAL_COMIS_GP", tipo="Membro", premio="GP", ano=2023),
+ ]
+ resultado = CalculadorPontuacao.calcular_bloco_c([], avaliacoes, [], [], [])
+ atuacao = next(a for a in resultado.atuacoes if a.codigo == "AVAL_COMIS_GP")
+ assert atuacao.bonus == 9
+
+
+def test_calculo_recorrencia_evento_bonus():
+ eventos = [
+ Participacao(codigo="EVENTO", tipo="Evento", ano=2020),
+ Participacao(codigo="EVENTO", tipo="Evento", ano=2021),
+ Participacao(codigo="EVENTO", tipo="Evento", ano=2022),
+ Participacao(codigo="EVENTO", tipo="Evento", ano=2023),
+ Participacao(codigo="EVENTO", tipo="Evento", ano=2024),
+ Participacao(codigo="EVENTO", tipo="Evento", ano=2025),
+ Participacao(codigo="EVENTO", tipo="Evento", ano=2026),
+ Participacao(codigo="EVENTO", tipo="Evento", ano=2027),
+ Participacao(codigo="EVENTO", tipo="Evento", ano=2028),
+ Participacao(codigo="EVENTO", tipo="Evento", ano=2029),
+ Participacao(codigo="EVENTO", tipo="Evento", ano=2030),
+ ]
+ resultado = CalculadorPontuacao.calcular_bloco_d([], eventos)
+ atuacao = next(a for a in resultado.atuacoes if a.codigo == "EVENTO")
+ assert atuacao.bonus == 10
diff --git a/backend/tests/infrastructure/test_pdf_selos.py b/backend/tests/infrastructure/test_pdf_selos.py
new file mode 100644
index 0000000..a6bac46
--- /dev/null
+++ b/backend/tests/infrastructure/test_pdf_selos.py
@@ -0,0 +1,53 @@
+from src.infrastructure.ranking_store import SELOS_DISPONIVEIS
+
+
+PDF_SELOS = {
+ "CA",
+ "CAJ",
+ "CAJ_MP",
+ "CAM",
+ "PRESID_CAMARA",
+ "CONS_ATIVO",
+ "AVAL_COMIS",
+ "COORD_COMIS",
+ "AUTOR_GP",
+ "AUTOR_PREMIO",
+ "AUTOR_MENCAO",
+ "ORIENT_GP",
+ "ORIENT_PREMIO",
+ "ORIENT_MENCAO",
+ "COORIENT_GP",
+ "COORIENT_PREMIO",
+ "COORIENT_MENCAO",
+ "ORIENT_POS_DOC",
+ "ORIENT_POS_DOC_PREM",
+ "ORIENT_TESE",
+ "ORIENT_TESE_PREM",
+ "ORIENT_DISS",
+ "ORIENT_DISS_PREM",
+ "CO_ORIENT_POS_DOC",
+ "CO_ORIENT_POS_DOC_PREM",
+ "CO_ORIENT_TESE",
+ "CO_ORIENT_TESE_PREM",
+ "CO_ORIENT_DISS",
+ "CO_ORIENT_DISS_PREM",
+ "MB_BANCA_POS_DOC",
+ "MB_BANCA_POS_DOC_PREM",
+ "MB_BANCA_TESE",
+ "MB_BANCA_TESE_PREM",
+ "MB_BANCA_DISS",
+ "MB_BANCA_DISS_PREM",
+ "IDIOMA_BILINGUE",
+ "IDIOMA_MULTILINGUE",
+ "TITULACAO_MESTRE",
+ "TITULACAO_DOUTOR",
+ "TITULACAO_POS_DOUTOR",
+ "BOL_BPQ_NIVEL",
+ "PPG_COORD",
+ "EVENTO",
+ "PROJ",
+}
+
+
+def test_pdf_selos_disponiveis():
+ assert set(SELOS_DISPONIVEIS) == PDF_SELOS
diff --git a/frontend/package.json b/frontend/package.json
index 274b6de..e92abf3 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -6,7 +6,8 @@
"scripts": {
"dev": "vite",
"build": "vite build",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "test": "vitest"
},
"dependencies": {
"react": "^19.2.0",
@@ -17,6 +18,10 @@
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
- "vite": "^5.0.0"
+ "@testing-library/jest-dom": "^6.2.0",
+ "@testing-library/react": "^14.1.2",
+ "jsdom": "^24.0.0",
+ "vite": "^5.0.0",
+ "vitest": "^1.6.0"
}
}
diff --git a/frontend/src/components/__tests__/FiltroSelos.test.jsx b/frontend/src/components/__tests__/FiltroSelos.test.jsx
new file mode 100644
index 0000000..02bb5b5
--- /dev/null
+++ b/frontend/src/components/__tests__/FiltroSelos.test.jsx
@@ -0,0 +1,27 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import FiltroSelos from '../FiltroSelos';
+
+describe('FiltroSelos', () => {
+ it('applies selected seals', () => {
+ const handleChange = vi.fn();
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: /Filtrar por selos/i }));
+
+ const checkbox = screen.getByLabelText(/Coord\./i);
+ fireEvent.click(checkbox);
+
+ fireEvent.click(screen.getByRole('button', { name: /Aplicar/i }));
+
+ expect(handleChange).toHaveBeenCalledWith(['CA']);
+ });
+
+ it('clears filters from the trigger when active', () => {
+ const handleChange = vi.fn();
+ render();
+
+ fireEvent.click(screen.getByTitle('Limpar filtros'));
+
+ expect(handleChange).toHaveBeenCalledWith([]);
+ });
+});
diff --git a/frontend/src/components/__tests__/Header.test.jsx b/frontend/src/components/__tests__/Header.test.jsx
new file mode 100644
index 0000000..620a23c
--- /dev/null
+++ b/frontend/src/components/__tests__/Header.test.jsx
@@ -0,0 +1,19 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import Header from '../Header';
+
+describe('Header', () => {
+ it('renders the title and total count', () => {
+ render();
+
+ expect(screen.getByText('Ranking de Consultores CAPES')).toBeInTheDocument();
+ expect(screen.getByText(/Total:\s+1\.000 consultores/)).toBeInTheDocument();
+ });
+
+ it('opens the criteria modal when a block is clicked', () => {
+ render();
+
+ fireEvent.click(screen.getByText('A - Coordenacao CAPES'));
+
+ expect(screen.getByText(/Coordena/)).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/setupTests.js b/frontend/src/setupTests.js
new file mode 100644
index 0000000..7b0828b
--- /dev/null
+++ b/frontend/src/setupTests.js
@@ -0,0 +1 @@
+import '@testing-library/jest-dom';
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index 3f4c31b..e4ccb80 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -3,6 +3,11 @@ import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
+ test: {
+ environment: 'jsdom',
+ setupFiles: './src/setupTests.js',
+ globals: true,
+ },
server: {
host: '0.0.0.0',
port: 5173,