Files
TextNLPClassifierApp/tests/tools/test_convert_article_to_markdown.py
T

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.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 = (
"![Legenda 1](https://example.com/img1.jpg)\n\n"
"![Legenda Relativa](/imagem.png)\n\n"
"![Legenda Data](data:image/png;base64,AAAA)\n\n"
"![Legenda 1 Repetida](https://example.com/img1.jpg)\n\n"
"![Legenda 2](https://example.com/img2.webp)"
)
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")