"""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