aida-api / testes /conftest.py
AIDAON's picture
Claude Opus 5.5
Valida dimensoes da imagem e versiona a suite de testes
5340e92
Raw History Blame Contribute Delete
9.78 kB
"""Fixtures compartilhadas da suíte do AIDA.
Duas decisões guiam este arquivo:
**Nada aqui carrega modelo.** Subir o pipeline real levaria dezenas de segundos
por sessão e exigiria os `.joblib` treinados no disco — a suíte deixaria de
rodar em qualquer máquina limpa e em CI. Onde o teste precisa de um pipeline,
usamos um dublê com o mesmo contrato.
**Nada aqui escreve fora do `tmp_path`.** Os módulos de cache e de artefatos
resolvem seus diretórios na importação (`from ... import CACHE_ANALISES_DIR`),
então a redireção acontece por `monkeypatch.setattr` no módulo já importado —
trocar a variável em `aida.configuracao` depois do import não teria efeito.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
RAIZ = Path(__file__).resolve().parents[1]
if str(RAIZ) not in sys.path:
sys.path.insert(0, str(RAIZ))
# --------------------------------------------------------------------------- #
# Imagens sintéticas
# --------------------------------------------------------------------------- #
@pytest.fixture
def criar_imagem(tmp_path):
"""Gera um JPEG pequeno com conteúdo real (não uma cor sólida).
Cor sólida quebraria os testes dos métodos forenses de um jeito enganoso:
gradiente e ruído residual ficariam identicamente nulos, e a normalização
por percentis não teria o que separar.
"""
import numpy as np
from PIL import Image
def _criar(nome: str = "amostra.jpg", lado: int = 96, semente: int = 7) -> Path:
rng = np.random.default_rng(semente)
eixo = np.linspace(0, 255, lado, dtype=np.float64)
base = np.stack(
[
np.tile(eixo, (lado, 1)),
np.tile(eixo.reshape(-1, 1), (1, lado)),
np.full((lado, lado), 128.0),
],
axis=-1,
)
base += rng.normal(0, 12, base.shape)
arr = np.clip(base, 0, 255).astype("uint8")
destino = tmp_path / nome
Image.fromarray(arr).save(destino, quality=88)
return destino
return _criar
# --------------------------------------------------------------------------- #
# Cache isolado
# --------------------------------------------------------------------------- #
@pytest.fixture
def cache(tmp_path, monkeypatch):
"""`aida.inferencia.cache_analise` apontando para um diretório descartável."""
from aida.inferencia import cache_analise
pasta = tmp_path / "cache_analises"
indice = pasta / "por_conteudo"
pasta.mkdir(parents=True, exist_ok=True)
indice.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(cache_analise, "CACHE_ANALISES_DIR", pasta)
monkeypatch.setattr(cache_analise, "CACHE_ANALISES_INDICE_DIR", indice)
# O TTL vem do ambiente; um valor herdado da máquina do desenvolvedor
# tornaria o teste de expiração não determinístico.
monkeypatch.delenv("AIDA_CACHE_TTL_H", raising=False)
return cache_analise
# --------------------------------------------------------------------------- #
# Payload de análise
# --------------------------------------------------------------------------- #
@pytest.fixture
def payload_analise():
"""Resposta do pipeline, no formato que `site_api/app.py` devolve.
Os valores imitam um caso INCONCLUSIVO de propósito: é o estado que as
integrações mais esquecem de tratar, e o que a interface tem mais chance de
exibir errado.
"""
from aida.configuracao import CONFIG
def _payload(id_analise: str = "ANALISE-0001", **sobrescritas):
base = {
"id_analise": id_analise,
"nome_imagem": f"{id_analise}.jpg",
"nome_imagem_original": "foto-do-usuario.jpg",
"caminho_imagem": "/tmp/servidor/interno/foto.jpg",
"data_hora": "2026-08-16 10:00:00",
"duracao_s": 1.42,
"resultado": "INCONCLUSIVO",
"inconclusivo": True,
"fora_de_dominio": False,
"probabilidade_ia": 0.4123,
"probabilidade_real": 0.5877,
"probabilidade_ia_exibicao": 0.48,
"probabilidade_real_exibicao": 0.52,
"probabilidade_ia_conteudo": 0.44,
"limiar": 0.4816,
"limiar_exibicao": 0.5,
"banda_inconclusiva": [0.30, 0.66],
"banda_inconclusiva_exibicao": [0.32, 0.68],
"confianca": "baixa",
"motivos": ["a probabilidade caiu dentro da banda de abstenção"],
"titulo": "Resultado inconclusivo",
"explicacao": "Os módulos não convergiram.",
"ressalva": "Este resultado é indício, não prova.",
"limitacoes": ["Imagem pouco nítida."],
"avisos_qualidade": ["Compressão alta."],
"avisos_sistema": [],
"scores_modulos": {"forense": 0.31, "clip": 0.55},
"fusao": {"metodo": "media", "calibrado": False, "desacordo": 0.24},
"qualidade": {"qualidade_score": 0.62, "qualidade_nitidez": 0.4},
"proveniencia": {
"status": "NO_PROVENANCE_FOUND",
"c2pa_presente": False,
"declaracao_ia": False,
"softwares": [],
"evidencias": [],
"atribuicao": {"gerador": "desconhecido"},
"metadados_presentes": {"exif": True, "xmp": False, "iptc": False, "campos": 50},
},
"atribuicao_gerador": {"gerador": "desconhecido", "confianca": 0.0},
"divergencia": None,
"evidencias": {
"arquivos": {
"gradiente": f"/disco/servidor/{id_analise}_gradiente.png",
"espectro_magnitude": f"/disco/servidor/{id_analise}_espectro_magnitude.png",
},
"patches_suspeitos": [],
"perfil_radial": [1.0, 0.8, 0.6],
"perfil_angular": [0.2, 0.3],
},
"metadados": {
"arquivo": f"{id_analise}.jpg",
"extensao": ".jpg",
"formato_pil": "JPEG",
"modo": "RGB",
"bytes": 53333,
"largura": 576,
"altura": 1024,
"pixels": 589824,
"bits_por_pixel": 0.72,
"jpeg_qualidade_estimada": 88.0,
"progressivo": 0,
"subamostragem": 2,
"tem_exif": 0,
"qtd_exif": 0,
"tem_icc": 0,
},
"hash_sha256": "a" * 64,
"modelo_utilizado": "media",
"modo_rapido": False,
"versao_modelo": CONFIG.execucao.versao_modelo,
"principais_metricas": {f"caracteristica_{i}": float(i) for i in range(853)},
}
base.update(sobrescritas)
return base
return _payload
# --------------------------------------------------------------------------- #
# Aplicação Flask com pipeline dublê
# --------------------------------------------------------------------------- #
@pytest.fixture
def cliente_api(tmp_path, monkeypatch, cache, payload_analise):
"""Cliente de teste da API, sem carregar modelo nenhum.
`site_api/app.py` instancia o pipeline na importação. O dublê é injetado no
módulo de origem **antes** do import, que é o único ponto em que a
substituição ainda é vista pelo `from ... import` do app.
"""
import importlib
from aida.inferencia import pipeline as modulo_pipeline
class PipelineDuble:
"""Mesmo contrato que `site_api/app.py` consome do pipeline real."""
def __init__(self, *_args, **kwargs):
self.modulos = type("Modulos", (), {"disponiveis": ("forense",)})()
self.ensemble = None
self.modo_rapido = bool(kwargs.get("modo_rapido"))
self.usar_proveniencia = True
self.dominio = None
self.avisos = []
self.politica = type(
"Politica",
(),
{
"para_dict": staticmethod(lambda: {"limiar": 0.4816}),
"versao_modelo": "duble-1",
},
)()
monkeypatch.setattr(modulo_pipeline, "PipelineAIDA", PipelineDuble)
modulo_app = importlib.import_module("site_api.app")
modulo_app = importlib.reload(modulo_app)
# Depois do reload, o app tem as suas próprias referências às pastas e à
# função de análise; é nele que a substituição precisa acontecer.
explicabilidade = tmp_path / "explicabilidade"
explicabilidade.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(modulo_app, "EXPLICABILIDADE_DIR", explicabilidade)
monkeypatch.setattr(modulo_app, "cache_analise", cache)
analises_feitas = []
def analisar_duble(caminho_imagem, id_analise=None, **_kwargs):
analises_feitas.append(id_analise)
resposta = payload_analise(id_analise or "SEM-ID")
# Os PNGs de evidência existem de verdade no diretório temporário: sem
# isso, `GET /evidencia/...` responderia 404 e o teste de integração
# passaria a medir só a montagem da URL.
for nome in resposta["evidencias"]["arquivos"]:
destino = explicabilidade / f"{id_analise}_{nome}.png"
destino.write_bytes(b"\x89PNG\r\n\x1a\n" + b"0" * 32)
resposta["evidencias"]["arquivos"][nome] = str(destino)
return resposta
monkeypatch.setattr(modulo_app, "analisar_imagem", analisar_duble)
modulo_app.app.config["TESTING"] = True
with modulo_app.app.test_client() as cliente:
cliente.modulo = modulo_app
cliente.analises_feitas = analises_feitas
yield cliente