760 lines
28 KiB
Python
760 lines
28 KiB
Python
"""
|
|
Suíte de Testes Automatizados para Conversão de Artigo JSON para Markdown.
|
|
|
|
Cobre 100% dos Requisitos Funcionais (FR-001 a FR-018), Requisitos Não Funcionais (RNF-001 a RNF-006),
|
|
Critérios de Aceite (CA-001 a CA-013), Casos de Teste do PRD (CT-001 a CT-012),
|
|
Matriz de Erros (12 condições), Testes Unitários de Prioridade/Normalização,
|
|
Testes de Integração de Arquivo e Testes E2E de Pipeline via subprocess.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from scripts.convert_article_to_markdown import (
|
|
assemble_markdown_document,
|
|
clean_body_images,
|
|
convert_article,
|
|
convert_html_to_markdown,
|
|
normalize_date,
|
|
normalize_list,
|
|
normalize_scalar,
|
|
parse_arguments,
|
|
remove_duplicate_initial_h1,
|
|
resolve_article_body,
|
|
resolve_article_metadata,
|
|
validate_url,
|
|
)
|
|
|
|
FIXTURES_DIR = Path(__file__).parent / "fixtures" / "markdown_conversion"
|
|
SCRIPT_PATH = Path(__file__).parent.parent / "scripts" / "convert_article_to_markdown.py"
|
|
|
|
|
|
# ==============================================================================
|
|
# 1. Testes Unitários de Normalização e Sanitização Escalar
|
|
# ==============================================================================
|
|
|
|
|
|
def test_normalize_scalar_complex_html_entities():
|
|
"""Valida decodificação de entidades HTML nomeadas e numéricas."""
|
|
assert (
|
|
normalize_scalar("River & Boca "Superclásico"")
|
|
== 'River & Boca "Superclásico"'
|
|
)
|
|
assert normalize_scalar("Preço: R$ 50,00 €") == "Preço: R$ 50,00 €"
|
|
|
|
|
|
def test_normalize_scalar_whitespace_collapsing():
|
|
"""Valida colapso de tabs, quebras de linha e espaços múltiplos em um único espaço."""
|
|
assert (
|
|
normalize_scalar(" Texto com \t\t múltiplos \n\n espaços ")
|
|
== "Texto com múltiplos espaços"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"placeholder",
|
|
[
|
|
"null",
|
|
"Null",
|
|
"NULL",
|
|
"none",
|
|
"None",
|
|
"NONE",
|
|
"n/a",
|
|
"N/A",
|
|
"N/a",
|
|
"unknown",
|
|
"Unknown",
|
|
"UNKNOWN",
|
|
"[no-author]",
|
|
"[No-Author]",
|
|
"[NO-AUTHOR]",
|
|
"no-author",
|
|
"No-Author",
|
|
"NO-AUTHOR",
|
|
],
|
|
)
|
|
def test_normalize_scalar_placeholders_discarded(placeholder: str):
|
|
"""Garante que todos os placeholders documentados no PRD sejam descartados (retornando None)."""
|
|
assert normalize_scalar(placeholder) is None
|
|
assert normalize_scalar(f" {placeholder} ") is None
|
|
|
|
|
|
def test_normalize_scalar_non_string_types():
|
|
"""Valida que entradas não string retornem None de forma segura."""
|
|
assert normalize_scalar(None) is None
|
|
assert normalize_scalar(12345) is None
|
|
assert normalize_scalar(["lista"]) is None
|
|
assert normalize_scalar({"chave": "valor"}) is None
|
|
|
|
|
|
# ==============================================================================
|
|
# 2. Testes Unitários de Normalização de Listas
|
|
# ==============================================================================
|
|
|
|
|
|
def test_normalize_list_semicolon_and_comma_split():
|
|
"""Testa divisão por ponto e vírgula na string e vírgulas em elementos de lista para tags/categorias."""
|
|
raw_str = "Futebol; Copa Libertadores; Conmebol; Notícias de Hoje"
|
|
expected_str = ["Futebol", "Copa Libertadores", "Conmebol", "Notícias de Hoje"]
|
|
assert normalize_list(raw_str) == expected_str
|
|
|
|
raw_list = ["Futebol", "Copa Libertadores, Conmebol", "Notícias de Hoje"]
|
|
expected_list = ["Futebol", "Copa Libertadores", "Conmebol", "Notícias de Hoje"]
|
|
assert normalize_list(raw_list) == expected_list
|
|
|
|
|
|
def test_normalize_list_author_url_filtering():
|
|
"""Garante que URLs em campos de autor sejam estritamente descartadas."""
|
|
raw_authors = [
|
|
"Ernesto Provitilo",
|
|
"https://twitter.com/eprovitilo",
|
|
"http://www.instagram.com/reporter",
|
|
"www.tycsports.com/autor",
|
|
"Juan Pablo Varsky",
|
|
]
|
|
result = normalize_list(raw_authors, is_author=True)
|
|
assert result == ["Ernesto Provitilo", "Juan Pablo Varsky"]
|
|
|
|
|
|
def test_normalize_list_deduplication_preserves_case_and_order():
|
|
"""Testa deduplicação case-insensitive preservando a grafia e ordem da primeira ocorrência."""
|
|
items = ["River Plate", "Boca Juniors", "river plate", "RIVER PLATE", "boca juniors", "Racing"]
|
|
assert normalize_list(items) == ["River Plate", "Boca Juniors", "Racing"]
|
|
|
|
|
|
def test_normalize_list_empty_and_invalid():
|
|
"""Testa comportamento com listas vazias, nulas ou contendo apenas placeholders."""
|
|
assert normalize_list([]) == []
|
|
assert normalize_list(None) == []
|
|
assert normalize_list(["n/a", "unknown", "[no-author]", " "]) == []
|
|
|
|
|
|
# ==============================================================================
|
|
# 3. Testes Unitários de Parsing de Datas
|
|
# ==============================================================================
|
|
|
|
|
|
def test_normalize_date_iso_8601_variants():
|
|
"""Valida parsing de datas ISO 8601 em múltiplos formatos e fusos."""
|
|
assert normalize_date("2026-08-20T00:36:33-03:00") == "2026-08-20T00:36:33-03:00"
|
|
assert normalize_date("2026-08-20T03:36:33+00:00") == "2026-08-20T03:36:33+00:00"
|
|
# Data pura sem hora
|
|
assert normalize_date("2026-08-20") == "2026-08-20"
|
|
|
|
|
|
def test_normalize_date_rfc_2822_variants():
|
|
"""Valida parsing de datas no formato RFC 2822 (usado em feeds RSS e cabeçalhos HTTP)."""
|
|
d1 = normalize_date("Thu, 20 Aug 2026 03:27:26 GMT")
|
|
assert d1 is not None and "2026-08-20" in d1
|
|
|
|
d2 = normalize_date("Wed, 19 Aug 2026 21:00:00 -0300")
|
|
assert d2 is not None and "2026-08-19" in d2
|
|
|
|
|
|
def test_normalize_date_invalid_and_placeholders():
|
|
"""Garante que datas inválidas ou placeholders retornem None sem lançar exceção não tratada."""
|
|
assert normalize_date("data-invalida") is None
|
|
assert normalize_date("2026/99/99") is None
|
|
assert normalize_date("n/a") is None
|
|
assert normalize_date(None) is None
|
|
assert normalize_date(123456789) is None
|
|
|
|
|
|
# ==============================================================================
|
|
# 4. Testes Unitários de Validação de URLs
|
|
# ==============================================================================
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"valid_url",
|
|
[
|
|
"https://www.tycsports.com/river-plate/los-puntajes.html",
|
|
"http://globoesporte.globo.com/futebol/times/flamengo",
|
|
"https://sub.dominio.co.uk:8080/path/to/resource?param=1&query=test#hash",
|
|
"https://example.com/noticia-com-acentos-%C3%A1%C3%A9%C3%AD",
|
|
],
|
|
)
|
|
def test_validate_url_valid_schemes(valid_url: str):
|
|
"""Garante aceitação de URLs absolutas com esquema HTTP e HTTPS válidos."""
|
|
assert validate_url(valid_url) == valid_url
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"invalid_url",
|
|
[
|
|
"ftp://ftp.is.co.za/rfc/rfc1808.txt",
|
|
"file:///C:/Users/test/file.txt",
|
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA",
|
|
"javascript:alert('xss')",
|
|
"/caminho/relativo/artigo.html",
|
|
"http://",
|
|
"https://",
|
|
"",
|
|
" ",
|
|
None,
|
|
],
|
|
)
|
|
def test_validate_url_invalid_schemes(invalid_url: str | None):
|
|
"""Garante rejeição de esquemas não permitidos, URLs relativas e strings vazias."""
|
|
assert validate_url(invalid_url) is None
|
|
|
|
|
|
# ==============================================================================
|
|
# 5. Testes Unitários de Conversão HTML para Markdown
|
|
# ==============================================================================
|
|
|
|
|
|
def test_convert_html_to_markdown_rich_formatting():
|
|
"""Valida conversão de elementos HTML estruturados para Markdown com títulos ATX."""
|
|
html_raw = (
|
|
"<div>"
|
|
"<h1>Título H1</h1>"
|
|
"<h2>Subtítulo H2</h2>"
|
|
"<h3>Seção H3</h3>"
|
|
"<p>Parágrafo com <b>negrito</b>, <strong>forte</strong>, <i>itálico</i> e <em>ênfase</em>.</p>"
|
|
"<blockquote>Uma citação memorável.</blockquote>"
|
|
"<ul><li>Item 1</li><li>Item 2</li></ul>"
|
|
"<p>Link para o <a href='https://example.com/fonte'>portal oficial</a>.</p>"
|
|
"<code>codigo_inline()</code>"
|
|
"<script>alert('remover');</script>"
|
|
"<style>.esconder { display: none; }</style>"
|
|
"</div>"
|
|
)
|
|
md = convert_html_to_markdown(html_raw)
|
|
|
|
assert "# Título H1" in md
|
|
assert "## Subtítulo H2" in md
|
|
assert "### Seção H3" in md
|
|
assert "**negrito**" in md or "__negrito__" in md
|
|
assert "> Uma citação memorável." in md
|
|
assert "* Item 1" in md or "- Item 1" in md
|
|
assert "[portal oficial](https://example.com/fonte)" in md
|
|
assert "`codigo_inline()`" in md
|
|
assert "alert('remover')" not in md
|
|
assert "display: none" not in md
|
|
|
|
|
|
def test_convert_html_to_markdown_empty_or_whitespace():
|
|
"""Testa conversão de HTML vazio retornando string vazia."""
|
|
assert convert_html_to_markdown("") == ""
|
|
assert convert_html_to_markdown(" \n\t ") == ""
|
|
assert convert_html_to_markdown(None) == ""
|
|
|
|
|
|
# ==============================================================================
|
|
# 6. Testes de Isolamento Estrito do Extrator e Fallback Interno
|
|
# ==============================================================================
|
|
|
|
|
|
def test_resolve_article_body_trafilatura_primary_and_fallback():
|
|
"""Testa prioridade trafilatura.markdown sobre trafilatura.text."""
|
|
# Primário
|
|
art1 = {
|
|
"selected_extractor": "trafilatura",
|
|
"trafilatura": {"markdown": "Corpo primário Trafilatura", "text": "Texto secundário"},
|
|
}
|
|
assert resolve_article_body(art1) == "Corpo primário Trafilatura"
|
|
|
|
# Fallback
|
|
art2 = {
|
|
"selected_extractor": "trafilatura",
|
|
"trafilatura": {"markdown": None, "text": "Texto secundário Trafilatura"},
|
|
}
|
|
assert resolve_article_body(art2) == "Texto secundário Trafilatura"
|
|
|
|
|
|
def test_resolve_article_body_newspaper4k_primary_and_fallback():
|
|
"""Testa prioridade newspaper4k.article_html sobre newspaper4k.text."""
|
|
# Primário HTML -> MD
|
|
art1 = {
|
|
"selected_extractor": "newspaper4k",
|
|
"newspaper4k": {
|
|
"article_html": "<p>Artigo em <b>HTML</b></p>",
|
|
"text": "Artigo em texto puro",
|
|
},
|
|
}
|
|
assert "**HTML**" in resolve_article_body(art1)
|
|
|
|
# Fallback
|
|
art2 = {
|
|
"selected_extractor": "newspaper4k",
|
|
"newspaper4k": {"article_html": None, "text": "Artigo em texto puro Newspaper"},
|
|
}
|
|
assert resolve_article_body(art2) == "Artigo em texto puro Newspaper"
|
|
|
|
|
|
def test_resolve_article_body_readability_primary_and_fallback():
|
|
"""Testa prioridade readability.cleaned_html sobre readability.cleaned_text."""
|
|
# Primário HTML -> MD
|
|
art1 = {
|
|
"selected_extractor": "readability",
|
|
"readability": {
|
|
"cleaned_html": "<p>Conteúdo <i>Readability</i></p>",
|
|
"cleaned_text": "Texto puro Readability",
|
|
},
|
|
}
|
|
body = resolve_article_body(art1)
|
|
assert "*Readability*" in body or "_Readability_" in body
|
|
|
|
# Fallback
|
|
art2 = {
|
|
"selected_extractor": "readability",
|
|
"readability": {"cleaned_html": "", "cleaned_text": "Texto puro Readability Fallback"},
|
|
}
|
|
assert resolve_article_body(art2) == "Texto puro Readability Fallback"
|
|
|
|
|
|
@pytest.mark.parametrize("extractor", ["trafilatura", "newspaper4k", "readability"])
|
|
def test_resolve_article_body_strict_isolation_all_extractors(extractor: str):
|
|
"""Garante que a ausência de corpo no extrator selecionado NUNCA faça fallback para outro extrator."""
|
|
article = {
|
|
"selected_extractor": extractor,
|
|
"trafilatura": {"markdown": "Texto Trafilatura", "text": "Texto Trafilatura"},
|
|
"newspaper4k": {"article_html": "<p>Texto Newspaper</p>", "text": "Texto Newspaper"},
|
|
"readability": {
|
|
"cleaned_html": "<p>Texto Readability</p>",
|
|
"cleaned_text": "Texto Readability",
|
|
},
|
|
}
|
|
# Esvazia o corpo do extrator selecionado
|
|
if extractor == "trafilatura":
|
|
article["trafilatura"] = {"markdown": None, "text": ""}
|
|
elif extractor == "newspaper4k":
|
|
article["newspaper4k"] = {"article_html": "", "text": None}
|
|
elif extractor == "readability":
|
|
article["readability"] = {"cleaned_html": None, "cleaned_text": ""}
|
|
|
|
with pytest.raises(ValueError, match="Corpo do extrator selecionado.*vazio|indisponível"):
|
|
resolve_article_body(article)
|
|
|
|
|
|
def test_resolve_article_body_invalid_selected_extractor():
|
|
"""Garante erro ao receber selected_extractor ausente ou não reconhecido."""
|
|
with pytest.raises(ValueError, match="selected_extractor inválido ou ausente"):
|
|
resolve_article_body({"selected_extractor": "extrator_desconhecido"})
|
|
|
|
with pytest.raises(ValueError, match="selected_extractor inválido ou ausente"):
|
|
resolve_article_body({"selected_extractor": None})
|
|
|
|
|
|
# ==============================================================================
|
|
# 7. Testes da Matriz Determinística de Resolução de Metadados
|
|
# ==============================================================================
|
|
|
|
|
|
def test_metadata_priority_title_all_fallbacks():
|
|
"""Valida a cadeia de fallback completa para o campo TÍTULO (6 níveis)."""
|
|
# 1. Do selecionado
|
|
art1 = {"selected_extractor": "trafilatura", "trafilatura": {"title": "Título Selecionado"}}
|
|
assert (
|
|
resolve_article_metadata({**art1, "crawled_url": "https://e.com"})["title"]
|
|
== "Título Selecionado"
|
|
)
|
|
|
|
# 2. input_meta.titulo
|
|
art2 = {
|
|
"selected_extractor": "trafilatura",
|
|
"input_meta": {"titulo": "Título Input Meta"},
|
|
"crawled_url": "https://e.com",
|
|
}
|
|
assert resolve_article_metadata(art2)["title"] == "Título Input Meta"
|
|
|
|
# 3. page_title
|
|
art3 = {
|
|
"selected_extractor": "trafilatura",
|
|
"page_title": "Título Page Title",
|
|
"crawled_url": "https://e.com",
|
|
}
|
|
assert resolve_article_metadata(art3)["title"] == "Título Page Title"
|
|
|
|
# 4. newspaper4k.title
|
|
art4 = {
|
|
"selected_extractor": "trafilatura",
|
|
"newspaper4k": {"title": "Título Newspaper"},
|
|
"crawled_url": "https://e.com",
|
|
}
|
|
assert resolve_article_metadata(art4)["title"] == "Título Newspaper"
|
|
|
|
# 5. readability.title
|
|
art5 = {
|
|
"selected_extractor": "trafilatura",
|
|
"readability": {"title": "Título Readability"},
|
|
"crawled_url": "https://e.com",
|
|
}
|
|
assert resolve_article_metadata(art5)["title"] == "Título Readability"
|
|
|
|
|
|
def test_metadata_priority_original_url_all_fallbacks():
|
|
"""Valida a cadeia de fallback completa para a URL ORIGINAL (5 níveis)."""
|
|
# 1. input_meta.url
|
|
art1 = {
|
|
"selected_extractor": "trafilatura",
|
|
"trafilatura": {"title": "T"},
|
|
"input_meta": {"url": "https://example.com/input-meta"},
|
|
"crawled_url": "https://example.com/crawled",
|
|
}
|
|
assert resolve_article_metadata(art1)["original_url"] == "https://example.com/input-meta"
|
|
|
|
# 2. crawled_url
|
|
art2 = {
|
|
"selected_extractor": "trafilatura",
|
|
"trafilatura": {"title": "T"},
|
|
"crawled_url": "https://example.com/crawled",
|
|
}
|
|
assert resolve_article_metadata(art2)["original_url"] == "https://example.com/crawled"
|
|
|
|
# 3. Canonical do selecionado
|
|
art3 = {
|
|
"selected_extractor": "trafilatura",
|
|
"trafilatura": {"title": "T", "canonical_url": "https://example.com/canonical-trafilatura"},
|
|
}
|
|
assert (
|
|
resolve_article_metadata(art3)["original_url"]
|
|
== "https://example.com/canonical-trafilatura"
|
|
)
|
|
|
|
# 4. Canonical do newspaper4k
|
|
art4 = {
|
|
"selected_extractor": "readability",
|
|
"readability": {"title": "T"},
|
|
"newspaper4k": {"canonical_link": "https://example.com/canonical-newspaper"},
|
|
}
|
|
assert (
|
|
resolve_article_metadata(art4)["original_url"] == "https://example.com/canonical-newspaper"
|
|
)
|
|
|
|
|
|
def test_metadata_priority_subtitle_omitted_when_equal_to_title():
|
|
"""Garante que subtítulo idêntico ao título seja automaticamente omitido (None)."""
|
|
art = {
|
|
"selected_extractor": "trafilatura",
|
|
"trafilatura": {
|
|
"title": "Grande Vitória no Clássico",
|
|
"description": " grande vitória no clássico ",
|
|
},
|
|
"crawled_url": "https://example.com/noticia",
|
|
}
|
|
meta = resolve_article_metadata(art)
|
|
assert meta["title"] == "Grande Vitória no Clássico"
|
|
assert meta["subtitle"] is None
|
|
|
|
|
|
def test_metadata_priority_first_valid_source_no_cross_merging():
|
|
"""Garante que listas de autores/tags usem apenas a primeira fonte válida, sem merge cruzado."""
|
|
art = {
|
|
"selected_extractor": "readability",
|
|
"crawled_url": "https://example.com/noticia",
|
|
"readability": {"title": "Título", "author": "Carlos Bilardo"},
|
|
"newspaper4k": {"authors": ["Juan Pérez", "María Gómez"]},
|
|
}
|
|
meta = resolve_article_metadata(art)
|
|
# Deve pegar o autor de Readability (selecionado), sem misturar com Newspaper4k
|
|
assert meta["authors"] == ["Carlos Bilardo"]
|
|
|
|
|
|
# ==============================================================================
|
|
# 8. Testes de Higienização de Cabeçalhos e Imagens no Corpo
|
|
# ==============================================================================
|
|
|
|
|
|
def test_remove_duplicate_initial_h1_exact_and_variations():
|
|
"""Testa remoção de H1 inicial coincidente com título com variações de espaços e caixa."""
|
|
title = "River Plate Conquista a Copa"
|
|
|
|
# H1 inicial igual
|
|
body1 = "# river plate conquista a copa\n\nPrimeiro parágrafo do artigo."
|
|
assert remove_duplicate_initial_h1(body1, title).strip() == "Primeiro parágrafo do artigo."
|
|
|
|
# H1 inicial diferente (deve ser preservado)
|
|
body2 = "# Outro Título Diferente\n\nPrimeiro parágrafo."
|
|
assert remove_duplicate_initial_h1(body2, title) == body2
|
|
|
|
# Sem H1 inicial
|
|
body3 = "Parágrafo sem nenhum título H1 inicial."
|
|
assert remove_duplicate_initial_h1(body3, title) == body3
|
|
|
|
|
|
def test_clean_body_images_removes_invalid_and_deduplicates():
|
|
"""Valida descarte de data:, relativos e deduplicação mantendo a primeira ocorrência."""
|
|
body = (
|
|
"\n\n"
|
|
"\n\n"
|
|
"\n\n"
|
|
"\n\n"
|
|
""
|
|
)
|
|
cleaned = clean_body_images(body)
|
|
|
|
assert cleaned.count("https://example.com/img1.jpg") == 1
|
|
assert "https://example.com/img2.webp" in cleaned
|
|
assert "/imagem.png" not in cleaned
|
|
assert "data:image" not in cleaned
|
|
|
|
|
|
# ==============================================================================
|
|
# 9. Testes de Montagem e Formatação do Documento Markdown
|
|
# ==============================================================================
|
|
|
|
|
|
def test_assemble_markdown_document_full_and_minimal():
|
|
"""Testa montagem com todos os campos e apenas com campos obrigatórios."""
|
|
# Artigo Mínimo (apenas Título e URL Original)
|
|
min_meta: dict[str, Any] = {
|
|
"title": "Título Mínimo",
|
|
"original_url": "https://example.com/minimo",
|
|
"subtitle": None,
|
|
"authors": [],
|
|
"publish_date": None,
|
|
"site_name": None,
|
|
"categories": [],
|
|
"tags": [],
|
|
"keywords": [],
|
|
"language": None,
|
|
"top_image": None,
|
|
}
|
|
doc_min = assemble_markdown_document(min_meta, "Corpo do texto simples.")
|
|
|
|
assert doc_min.startswith("# Título Mínimo\n\n")
|
|
assert "**Fonte original:** [https://example.com/minimo](https://example.com/minimo)" in doc_min
|
|
assert "**Autor:**" not in doc_min
|
|
assert "**Site:**" not in doc_min
|
|
assert "![Imagem principal]" not in doc_min
|
|
assert "\n\n---\n\nCorpo do texto simples.\n" in doc_min
|
|
assert doc_min.endswith("\n")
|
|
assert not doc_min.endswith("\n\n")
|
|
|
|
|
|
# ==============================================================================
|
|
# 10. Golden Test Fixtures (Conformidade 100% Byte a Byte)
|
|
# ==============================================================================
|
|
|
|
|
|
def test_golden_fixtures_byte_level_precision(tmp_path):
|
|
"""Garante correspondência exata byte a byte para Trafilatura, Newspaper4k e Readability."""
|
|
for extractor in ["trafilatura", "newspaper4k", "readability"]:
|
|
input_json = FIXTURES_DIR / f"valid_{extractor}.json"
|
|
expected_md = (FIXTURES_DIR / f"valid_{extractor}.md").read_text(encoding="utf-8")
|
|
output_md = tmp_path / f"valid_{extractor}.md"
|
|
|
|
res = subprocess.run(
|
|
[sys.executable, str(SCRIPT_PATH), "-i", str(input_json), "-o", str(output_md)],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert res.returncode == 0, f"Erro no extrator {extractor}: {res.stderr}"
|
|
generated_md = output_md.read_text(encoding="utf-8")
|
|
assert generated_md == expected_md, f"Divergência byte a byte na fixture {extractor}"
|
|
|
|
|
|
def test_conversion_determinism_sha256_repeatability(tmp_path):
|
|
"""Garante que múltiplas execuções no mesmo arquivo produzam hashes SHA-256 idênticos."""
|
|
input_json = FIXTURES_DIR / "valid_trafilatura.json"
|
|
hashes = set()
|
|
|
|
for i in range(5):
|
|
out_md = tmp_path / f"deterministic_{i}.md"
|
|
res = subprocess.run(
|
|
[sys.executable, str(SCRIPT_PATH), "-i", str(input_json), "-o", str(out_md)],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert res.returncode == 0
|
|
content_bytes = out_md.read_bytes()
|
|
hashes.add(hashlib.sha256(content_bytes).hexdigest())
|
|
|
|
assert len(hashes) == 1, "A conversão não foi 100% determinística entre execuções repetidas."
|
|
|
|
|
|
# ==============================================================================
|
|
# 11. Testes de Integração CLI, Validação e Códigos de Saída
|
|
# ==============================================================================
|
|
|
|
|
|
def test_cli_exit_codes_and_error_handling(tmp_path):
|
|
"""Testa toda a matriz de códigos de saída da CLI (0, 1, 2)."""
|
|
# Código 2: Sintaxe / Argumentos Faltantes
|
|
res_no_args = subprocess.run([sys.executable, str(SCRIPT_PATH)], capture_output=True, text=True)
|
|
assert res_no_args.returncode == 2
|
|
|
|
# Código 1: Arquivo Inexistente
|
|
res_missing_file = subprocess.run(
|
|
[sys.executable, str(SCRIPT_PATH), "-i", "arquivo_que_nao_existe_xyz.json"],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert res_missing_file.returncode == 1
|
|
assert "não encontrado" in res_missing_file.stderr.lower()
|
|
|
|
# Código 1: Rejeição de Batch com chave 'articles'
|
|
res_batch = subprocess.run(
|
|
[sys.executable, str(SCRIPT_PATH), "-i", str(FIXTURES_DIR / "batch_articles_invalid.json")],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert res_batch.returncode == 1
|
|
assert "articles" in res_batch.stderr.lower()
|
|
|
|
# Código 1: JSON Corrompido
|
|
res_corrupt = subprocess.run(
|
|
[sys.executable, str(SCRIPT_PATH), "-i", str(FIXTURES_DIR / "corrupt_json_invalid.json")],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert res_corrupt.returncode == 1
|
|
assert "json" in res_corrupt.stderr.lower()
|
|
|
|
|
|
def test_cli_json_root_must_be_object(tmp_path):
|
|
"""Garante encerramento com código 1 caso a raiz do JSON seja lista, número ou string."""
|
|
for invalid_root in [["item1", "item2"], 12345, "string simples"]:
|
|
bad_json = tmp_path / "bad_root.json"
|
|
bad_json.write_text(json.dumps(invalid_root), encoding="utf-8")
|
|
|
|
res = subprocess.run(
|
|
[sys.executable, str(SCRIPT_PATH), "-i", str(bad_json)],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert res.returncode == 1
|
|
assert "objeto" in res.stderr.lower() or "dict" in res.stderr.lower()
|
|
|
|
|
|
def test_cli_no_stdout_pollution_and_atomic_preservation(tmp_path):
|
|
"""Garante que stdout permaneça limpo e gravação atômica preserve arquivos preexistentes em falha."""
|
|
target_md = tmp_path / "target_document.md"
|
|
target_md.write_text("Versão Original Preservada", encoding="utf-8")
|
|
|
|
# Executa conversão bem-sucedida
|
|
res_ok = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SCRIPT_PATH),
|
|
"-i",
|
|
str(FIXTURES_DIR / "valid_trafilatura.json"),
|
|
"-o",
|
|
str(target_md),
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert res_ok.returncode == 0
|
|
assert res_ok.stdout == "" # Não polui stdout
|
|
assert "[INFO]" in res_ok.stderr
|
|
|
|
# Executa falha direcionada ao mesmo target
|
|
res_fail = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SCRIPT_PATH),
|
|
"-i",
|
|
str(FIXTURES_DIR / "missing_body_invalid.json"),
|
|
"-o",
|
|
str(target_md),
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert res_fail.returncode == 1
|
|
# O arquivo target deve manter o conteúdo do sucesso anterior, não foi apagado/corrompido
|
|
assert "# Los puntajes de River" in target_md.read_text(encoding="utf-8")
|
|
|
|
# Verifica que não há arquivos temporários .tmp no diretório
|
|
tmp_files = list(tmp_path.glob("*.tmp"))
|
|
assert len(tmp_files) == 0
|
|
|
|
|
|
def test_cli_default_output_naming(tmp_path):
|
|
"""Garante geração automática de <input_stem>.md quando -o não é informado."""
|
|
sample_file = tmp_path / "meu_artigo_editorial.json"
|
|
sample_file.write_text(
|
|
json.dumps(
|
|
{
|
|
"selected_extractor": "trafilatura",
|
|
"crawled_url": "https://example.com/editorial",
|
|
"trafilatura": {"title": "Editorial do Dia", "markdown": "Texto do editorial."},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
res = subprocess.run(
|
|
[sys.executable, str(SCRIPT_PATH), "-i", str(sample_file)],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert res.returncode == 0
|
|
expected_md = tmp_path / "meu_artigo_editorial.md"
|
|
assert expected_md.exists()
|
|
assert "# Editorial do Dia" in expected_md.read_text(encoding="utf-8")
|
|
|
|
|
|
# ==============================================================================
|
|
# 12. Teste E2E de Pipeline Real (Artigo Autêntico)
|
|
# ==============================================================================
|
|
|
|
|
|
def test_e2e_pipeline_with_real_extracted_selected_json(tmp_path):
|
|
"""Valida a conversão E2E de um artigo real extraído do arquivo out/river_plate_extracted_selected.json."""
|
|
sample_source = Path(__file__).parent.parent / "out" / "river_plate_extracted_selected.json"
|
|
if not sample_source.exists():
|
|
pytest.skip(
|
|
"Arquivo out/river_plate_extracted_selected.json não encontrado para teste de integração real."
|
|
)
|
|
|
|
data = json.loads(sample_source.read_text(encoding="utf-8"))
|
|
assert "articles" in data and len(data["articles"]) > 0
|
|
|
|
first_article = data["articles"][0]
|
|
input_json = tmp_path / "river_first_article.json"
|
|
input_json.write_text(json.dumps(first_article, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
|
|
output_md = tmp_path / "river_first_article.md"
|
|
res = subprocess.run(
|
|
[sys.executable, str(SCRIPT_PATH), "-i", str(input_json), "-o", str(output_md)],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert res.returncode == 0, f"Erro na conversão E2E real: {res.stderr}"
|
|
assert output_md.exists()
|
|
|
|
md_content = output_md.read_text(encoding="utf-8")
|
|
assert md_content.startswith("# ")
|
|
assert "**Fonte original:** [" in md_content
|
|
assert "\n\n---\n\n" in md_content
|
|
assert len(md_content.splitlines()) > 10
|
|
|
|
|
|
def test_convert_article_api_direct(tmp_path):
|
|
"""Testa a chamada direta da função convert_article em código Python."""
|
|
in_file = tmp_path / "direct.json"
|
|
in_file.write_text(
|
|
json.dumps(
|
|
{
|
|
"selected_extractor": "trafilatura",
|
|
"crawled_url": "https://example.com/direct",
|
|
"trafilatura": {"title": "Título Direto", "markdown": "Conteúdo direto."},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
out_file = tmp_path / "direct.md"
|
|
result = convert_article(in_file, out_file)
|
|
assert result == out_file
|
|
assert out_file.exists()
|
|
assert "# Título Direto" in out_file.read_text(encoding="utf-8")
|
|
|
|
|
|
def test_parse_arguments_api_direct():
|
|
"""Testa a chamada direta do parse_arguments."""
|
|
args = parse_arguments(["-i", "input_test.json", "-o", "output_test.md"])
|
|
assert args.input == Path("input_test.json")
|
|
assert args.output == Path("output_test.md")
|