Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion docs/workPlan.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,35 @@ Garantir que a função principal `run_etl` orquestra corretamente as chamadas a

---

## 6. Atualização e Correção dos Testes (Setembro 2025)

**Objetivo:** Atualizar a suíte de testes para refletir a nova arquitetura do pipeline AutoSINAPI, garantindo que todos os testes passem e que a cobertura do código seja mantida ou ampliada.

### Situação Atual dos Testes

Após uma refatoração significativa do pipeline de ETL, a suíte de testes encontra-se parcialmente quebrada. Os principais problemas são:

- **`tests/test_file_input.py` e `tests/test_pipeline.py`**: Falham devido à remoção da função `run_etl` e a mudanças na lógica interna do pipeline. As chamadas diretas à função foram substituídas por uma classe `Pipeline`, e os testes precisam ser adaptados para instanciar e mockar essa classe corretamente.
- **`tests/core/test_database.py`**: Apresenta falhas relacionadas a mudanças na assinatura de métodos (ex: `save_data` agora exige um parâmetro `policy`) e a mensagens de erro que foram atualizadas.
- **`tests/core/test_processor.py`**: Contém falhas devido à remoção de métodos privados que eram testados diretamente e a mudanças na assinatura de métodos públicos como `process_composicao_itens`.

### Situação Desejada

- **Todos os testes passando**: A suíte de testes deve ser executada sem falhas.
- **Cobertura de código**: A cobertura de testes deve ser mantida ou ampliada para abranger a nova arquitetura.
- **Manutenibilidade**: Os testes devem ser fáceis de entender e manter.

### Plano de Ação Detalhado

| Arquivo | Ação Corretiva |
| --- | --- |
| **`tests/core/test_database.py`** | **1. Corrigir `test_save_data_failure`**: Atualizar a mensagem de erro esperada no `pytest.raises` para refletir a nova mensagem da exceção `DatabaseError`.<br>**2. Corrigir `test_save_data_success` e `test_save_data_failure`**: Adicionar o argumento `policy` na chamada do método `save_data`. |
| **`tests/core/test_processor.py`** | **1. Corrigir `test_process_composicao_itens`**: Ajustar a forma como o arquivo Excel de teste é criado, garantindo que o cabeçalho e o nome da planilha (`Analítico`) estejam corretos para que o `Processor` possa lê-lo. |
| **`tests/test_pipeline.py`** | **1. Ajustar `mock_pipeline` fixture**: <br> - Modificar o mock de `process_composicao_itens` para que o `parent_composicoes_details` retornado contenha a coluna `codigo`. <br> - Garantir que o mock de `_unzip_file` retorne um caminho que contenha um arquivo "Referência" simulado. <br> **2. Atualizar `caplog`**: Corrigir as mensagens de erro esperadas nas asserções dos testes de falha (`test_run_etl_download_error`, `test_run_etl_processing_error`, `test_run_etl_database_error`). |
| **`tests/test_file_input.py`** | **1. Ajustar `mock_pipeline` fixture**: Aplicar as mesmas correções do `test_pipeline.py` para garantir a consistência dos mocks. <br> **2. Corrigir `test_direct_file_input`**: Garantir que o método `save_data` seja chamado, corrigindo o `KeyError` no pipeline. <br> **3. Atualizar `caplog`**: Corrigir a mensagem de erro esperada no teste `test_invalid_input_file`. |

---

## Plano de Trabalho Sugerido

### 1. Configuração do Ambiente de Teste
Expand Down Expand Up @@ -313,4 +342,4 @@ Para garantir manutenibilidade e compreensão, documente cada teste com:

> **Dica:** Mantenha a documentação dos testes sempre atualizada conforme novas funcionalidades forem adicionadas ao sistema.

---
---
37 changes: 21 additions & 16 deletions tests/core/test_database.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
"""
import pytest
import pandas as pd
from unittest.mock import Mock, patch
from sqlalchemy import create_engine
from unittest.mock import Mock, patch, MagicMock
from sqlalchemy.exc import SQLAlchemyError
from autosinapi.core.database import Database
from autosinapi.exceptions import DatabaseError
Expand All @@ -23,12 +22,12 @@ def db_config():
@pytest.fixture
def database(db_config):
"""Fixture que cria uma instância do Database com engine mockada."""
with patch('sqlalchemy.create_engine') as mock_create_engine:
mock_engine = Mock()
with patch('autosinapi.core.database.create_engine') as mock_create_engine:
mock_engine = MagicMock()
mock_create_engine.return_value = mock_engine
db = Database(db_config)
db._engine = mock_engine
yield db
yield db, mock_engine

@pytest.fixture
def sample_df():
Expand All @@ -41,31 +40,37 @@ def sample_df():

def test_connect_success(db_config):
"""Testa conexão bem-sucedida com o banco."""
with patch('sqlalchemy.create_engine') as mock_create_engine:
mock_engine = Mock()
with patch('autosinapi.core.database.create_engine') as mock_create_engine:
mock_engine = MagicMock()
mock_create_engine.return_value = mock_engine
db = Database(db_config)
assert db._engine is not None
mock_create_engine.assert_called_once()

def test_connect_failure(db_config):
"""Testa falha na conexão com o banco."""
with patch('sqlalchemy.create_engine') as mock_create_engine:
with patch('autosinapi.core.database.create_engine') as mock_create_engine:
mock_create_engine.side_effect = SQLAlchemyError("Connection failed")
with pytest.raises(DatabaseError, match="Erro ao conectar"):
Database(db_config)

def test_save_data_success(database, sample_df):
"""Testa salvamento bem-sucedido de dados."""
mock_conn = Mock()
database._engine.connect.return_value.__enter__.return_value = mock_conn
database.save_data(sample_df, 'test_table')
mock_conn.execute.assert_called()
db, mock_engine = database
mock_conn = MagicMock()
mock_engine.connect.return_value.__enter__.return_value = mock_conn

db.save_data(sample_df, 'test_table', policy='append')

assert mock_conn.execute.call_count > 0

@pytest.mark.filterwarnings("ignore:pandas only supports SQLAlchemy")
def test_save_data_failure(database, sample_df):
"""Testa falha no salvamento de dados."""
mock_conn = Mock()
db, mock_engine = database
mock_conn = MagicMock()
mock_conn.execute.side_effect = SQLAlchemyError("Insert failed")
database._engine.connect.return_value.__enter__.return_value = mock_conn
with pytest.raises(DatabaseError, match="Erro ao salvar dados"):
database.save_data(sample_df, 'test_table')
mock_engine.connect.return_value.__enter__.return_value = mock_conn

with pytest.raises(DatabaseError, match="Erro ao inserir dados"):
db.save_data(sample_df, 'test_table', policy='append')
190 changes: 28 additions & 162 deletions tests/core/test_processor.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,11 @@
def test_read_xlsx_planilhas_reais():
"""Testa se o parser lê corretamente todas as planilhas relevantes dos arquivos xlsx reais do SINAPI."""
import os
from autosinapi.core.processor import read_sinapi_file
arquivos_planilhas = [
('tools/downloads/2025_07/SINAPI-2025-07-formato-xlsx/SINAPI_mao_de_obra_2025_07.xlsx', None),
('tools/downloads/2025_07/SINAPI-2025-07-formato-xlsx/SINAPI_Referência_2025_07.xlsx', None),
('tools/downloads/2025_07/SINAPI-2025-07-formato-xlsx/SINAPI_Manutenções_2025_07.xlsx', None),
]
for arquivo, planilha in arquivos_planilhas:
assert os.path.exists(arquivo), f"Arquivo não encontrado: {arquivo}"
import pandas as pd
xls = pd.ExcelFile(arquivo)
for sheet in xls.sheet_names:
df = read_sinapi_file(arquivo, sheet_name=sheet, dtype=str)
assert isinstance(df, pd.DataFrame)
# O parser deve conseguir ler todas as planilhas não vazias
if df.shape[0] > 0:
print(f"Arquivo {os.path.basename(arquivo)} - Planilha '{sheet}': {df.shape[0]} linhas, {df.shape[1]} colunas")
"""
Testes unitários para o módulo processor.py
"""
import pytest
from unittest.mock import Mock
import pandas as pd
import numpy as np
import logging
from unittest.mock import patch, MagicMock
from autosinapi.core.processor import Processor
from autosinapi.exceptions import ProcessingError

Expand All @@ -43,14 +24,12 @@ def processor():
@pytest.fixture
def sample_insumos_df():
"""Fixture que cria um DataFrame de exemplo para insumos."""
df = pd.DataFrame({
return pd.DataFrame({
'CODIGO': ['1234', '5678', '9012'],
'DESCRICAO': ['AREIA MEDIA', 'CIMENTO PORTLAND', 'TIJOLO CERAMICO'],
'UNIDADE': ['M3', 'KG', 'UN'],
'PRECO_MEDIANO': [120.50, 0.89, 1.25]
})
df.index = range(3) # Garante índices sequenciais
return df

@pytest.fixture
def sample_composicoes_df():
Expand All @@ -66,152 +45,39 @@ def sample_composicoes_df():
'CUSTO_TOTAL': [89.90, 45.75, 32.80]
})

def test_clean_data_remove_empty(processor):
"""Testa se a limpeza remove linhas e colunas vazias."""
df = pd.DataFrame({
'A': [1, np.nan, 3],
'B': [np.nan, np.nan, np.nan],
'C': ['x', 'y', 'z']
})
processor.logger.debug(f"Test clean_data_remove_empty - input columns: {list(df.columns)}")
result = processor._clean_data(df)
processor.logger.debug(f"Test clean_data_remove_empty - output columns: {list(result.columns)}")
assert 'B' not in result.columns
assert len(result) == 3
assert result['A'].isna().sum() == 1

def test_clean_data_normalize_columns(processor):
def test_normalize_cols(processor):
"""Testa a normalização dos nomes das colunas."""
df = pd.DataFrame({
'Código do Item': [1, 2, 3],
'Descrição': ['a', 'b', 'c'],
'Preço Unitário': [10, 20, 30]
})
processor.logger.debug(f"Test clean_data_normalize_columns - input columns: {list(df.columns)}")
result = processor._clean_data(df)
processor.logger.debug(f"Test clean_data_normalize_columns - output columns: {list(result.columns)}")
# Após normalização, os nomes devem ser compatíveis com o DataModel
# Aceita 'codigo' (catálogo) ou 'item_codigo' (estrutura)
assert 'descricao' in result.columns
assert 'preco_mediano' in result.columns
assert any(col in result.columns for col in ['codigo', 'item_codigo'])

def test_clean_data_normalize_text(processor):
"""Testa a normalização de textos."""
df = pd.DataFrame({
'DESCRICAO': ['Areia Média ', 'CIMENTO portland', 'Tijolo Cerâmico']
})
processor.logger.debug(f"Test clean_data_normalize_text - input: {df['DESCRICAO'].tolist()}")
result = processor._clean_data(df)
processor.logger.debug(f"Test clean_data_normalize_text - output: {result['descricao'].tolist()}")
# Agora as descrições devem estar encapsuladas por aspas duplas e manter acentuação
assert all(x.startswith('"') and x.endswith('"') for x in result['descricao'])

def test_transform_insumos(processor, sample_insumos_df):
"""Testa transformação de dados de insumos."""
result = processor._transform_insumos(sample_insumos_df)
assert 'CODIGO_INSUMO' in result.columns
assert 'DESCRICAO_INSUMO' in result.columns
assert 'PRECO_MEDIANO' in result.columns
assert result['PRECO_MEDIANO'].dtype in ['float64', 'float32']

def test_transform_composicoes(processor, sample_composicoes_df):
"""Testa transformação de dados de composições."""
result = processor._transform_composicoes(sample_composicoes_df)
assert 'CODIGO' in result.columns
result = processor._normalize_cols(df)
assert 'CODIGO_DO_ITEM' in result.columns
assert 'DESCRICAO' in result.columns
assert 'CUSTO_TOTAL' in result.columns
assert result['CUSTO_TOTAL'].dtype in ['float64', 'float32']

def test_validate_data_empty_df(processor):
"""Testa validação com DataFrame vazio."""
df = pd.DataFrame()

with pytest.raises(ProcessingError, match="DataFrame está vazio"):
processor._validate_data(df)

def test_validate_data_invalid_codes(processor, sample_insumos_df):
"""Testa validação de códigos inválidos."""
# Cria uma cópia para não afetar o fixture
df = sample_insumos_df.copy()
df.loc[0, 'CODIGO'] = 'ABC' # Código inválido
# Ajusta para compatibilidade com o novo mapeamento
df = df.rename(columns={'CODIGO': 'codigo', 'DESCRICAO': 'descricao', 'UNIDADE': 'unidade', 'PRECO_MEDIANO': 'preco_mediano'})
result = processor._validate_data(df)
# Só deve restar linhas com código numérico
assert all(result['codigo'].str.isnumeric())

def test_validate_data_negative_prices(processor, sample_insumos_df):
"""Testa validação de preços negativos."""
# Cria uma cópia para não afetar o fixture
df = sample_insumos_df.copy()
df.loc[0, 'PRECO_MEDIANO'] = -10.0
df = df.rename(columns={'CODIGO': 'codigo', 'DESCRICAO': 'descricao', 'UNIDADE': 'unidade', 'PRECO_MEDIANO': 'preco_mediano'})
result = processor._validate_data(df)
# Se houver linhas, o preço negativo deve ser None
if not result.empty:
assert result['preco_mediano'].isnull().iloc[0]
assert 'PRECO_UNITARIO' in result.columns

def test_validate_insumos_code_length(processor):
"""Testa validação do tamanho dos códigos de insumos."""
def test_process_composicao_itens(processor, tmp_path):
"""Testa o processamento da estrutura das composições."""
# Cria um arquivo XLSX de teste
test_file = tmp_path / "test_sinapi.xlsx"
df = pd.DataFrame({
'CODIGO_INSUMO': ['123', '1234', '12345'], # Primeiro código inválido
'DESCRICAO_INSUMO': ['A', 'B', 'C']
'CODIGO_DA_COMPOSICAO': ['87453', '87453'],
'TIPO_ITEM': ['INSUMO', 'COMPOSICAO'],
'CODIGO_DO_ITEM': ['1234', '5678'],
'COEFICIENTE': ['1,0', '2,5'],
'DESCRICAO': ['INSUMO A', 'COMPOSICAO B'],
'UNIDADE': ['UN', 'M2']
})
result = processor._validate_insumos(df)
# Aceita códigos com 4 ou mais dígitos
assert len(result) == 2
assert set(result['CODIGO_INSUMO']) == {'1234', '12345'}

def test_validate_composicoes_code_length(processor):
"""Testa validação do tamanho dos códigos de composições."""
df = pd.DataFrame({
'codigo': ['1234', '12345', '123456'], # Primeiro código inválido
'descricao': ['A', 'B', 'C']
})
result = processor._validate_composicoes(df)
# Aceita códigos com exatamente 6 dígitos
assert all(result['codigo'].str.len() == 6)
assert set(result['codigo']) == {'123456'}


def test_process_composicao_itens(tmp_path):
"""Testa o processamento da estrutura das composições e inserção na tabela composicao_itens."""
import pandas as pd
from sqlalchemy.engine import create_engine, Connection, Engine
from sqlalchemy import text
# Cria DataFrame simulado
df = pd.DataFrame({
'CÓDIGO DA COMPOSIÇÃO': [1001, 1001, 1002],
'CÓDIGO DO ITEM': [2001, 2002, 2003],
'TIPO ITEM': ['INSUMO', 'COMPOSICAO', 'INSUMO'],
'COEFICIENTE': ['1,5', '2.0', '0,75']
})
# Salva como xlsx temporário
xlsx_path = tmp_path / 'analitico.xlsx'
with pd.ExcelWriter(xlsx_path) as writer:
df.to_excel(writer, index=False, sheet_name='Analítico')

# Cria engine SQLite em memória para teste
engine = create_engine('sqlite:///:memory:')

# Cria tabela composicao_itens
with engine.connect() as conn:
conn.execute(text('''CREATE TABLE composicao_itens (
composicao_pai_codigo INTEGER,
item_codigo INTEGER,
tipo_item TEXT,
coeficiente REAL
)'''))
conn.commit()

# Processa os dados
processor = Processor({'year': 2025, 'month': 8, 'type': 'REFERENCIA'})
processor.process_composicao_itens(str(xlsx_path), engine)

# Verifica se os dados foram inseridos corretamente
result = pd.read_sql('SELECT * FROM composicao_itens ORDER BY composicao_pai_codigo', engine)
assert len(result) == 3
assert set(result['tipo_item']) == {'INSUMO', 'COMPOSICAO'}


# Adiciona linha de cabeçalho e outras linhas para simular o arquivo real
writer = pd.ExcelWriter(test_file, engine='xlsxwriter')
df.to_excel(writer, index=False, header=True, sheet_name='Analítico', startrow=9)
writer.close()

result = processor.process_composicao_itens(str(test_file))

assert 'composicao_insumos' in result
assert 'composicao_subcomposicoes' in result
assert len(result['composicao_insumos']) == 1
assert len(result['composicao_subcomposicoes']) == 1
assert result['composicao_insumos'].iloc[0]['insumo_filho_codigo'] == 1234
Loading
Loading