feat(runtime): implement single-article consolidation runtime and modularize codebase
This commit is contained in:
@@ -0,0 +1,759 @@
|
||||
"""
|
||||
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.parent / "fixtures" / "markdown_conversion"
|
||||
SCRIPT_PATH = Path(__file__).parent.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.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")
|
||||
Reference in New Issue
Block a user