merge(eda): suite de aceptacion de los 16 capitulos (29 passed, rescatado de ejecutor con auth caida)
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
"""Batería de tests de ACEPTACIÓN del AutomaticEDA — "que cada AEDA salga como queremos".
|
||||
|
||||
Esta suite es la red de seguridad del subsistema EDA del grupo `eda`: garantiza
|
||||
que CADA capítulo de un informe AutomaticEDA sale poblado y con su contenido
|
||||
esencial, que la feature de capítulos sueltos (``only_chapters``) resuelve sus
|
||||
dependencias de cómputo, que los capítulos opcionales devuelven None cuando no
|
||||
aplican, que el informe de carpeta multi-tabla detecta la FK, y que el Markdown
|
||||
trae el apéndice completo (matriz de asociación entera + describe con
|
||||
skew/kurtosis). A diferencia de los tests unitarios de cada capítulo, aquí se
|
||||
ejercita el pipeline END-TO-END sobre un dataset sintético determinista que
|
||||
activa todos los capítulos a la vez.
|
||||
|
||||
Determinismo: el dataset se genera con ``seed`` fijo y el pipeline corre sin LLM
|
||||
(``profile_level='standard'``), de modo que el manifest y el Markdown son
|
||||
reproducibles entre corridas. Un único render `standard` se reutiliza vía un
|
||||
fixture de scope module para no repetir el cómputo caro.
|
||||
|
||||
dict-no-throw: los pipelines del grupo `eda` nunca lanzan; aquí se asserta sobre
|
||||
``status == 'ok'`` y luego sobre el contenido concreto del manifest / Markdown.
|
||||
|
||||
Honestidad (DoD): los asserts comprueban CONTENIDO real (texto esencial de cada
|
||||
capítulo), no solo el heading. Si un capítulo dejara de emitir su contenido (un
|
||||
cambio rompiera la distribución numérica, el Isolation Forest, la matriz de
|
||||
correlación completa, …), el test correspondiente FALLA nombrando el capítulo y
|
||||
el fragmento ausente — no se ablanda para que pase.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_FUNCTIONS = os.path.abspath(os.path.join(_HERE, "..", "..")) # python/functions
|
||||
if _FUNCTIONS not in sys.path:
|
||||
sys.path.insert(0, _FUNCTIONS)
|
||||
|
||||
from datascience.automatic_eda import CHAPTER_ORDER # noqa: E402
|
||||
from datascience.generate_synthetic_eda_folder import ( # noqa: E402
|
||||
generate_synthetic_eda_folder,
|
||||
)
|
||||
from datascience.generate_synthetic_eda_table import ( # noqa: E402
|
||||
generate_synthetic_eda_table,
|
||||
)
|
||||
from pipelines.render_automatic_eda import render_automatic_eda # noqa: E402
|
||||
from pipelines.render_automatic_eda_folder import ( # noqa: E402
|
||||
render_automatic_eda_folder,
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Parámetros deterministas del fixture de oro.
|
||||
# --------------------------------------------------------------------------- #
|
||||
SEED = 42
|
||||
N_ROWS = 800
|
||||
TABLE = "synthetic"
|
||||
|
||||
# El capítulo `analisis_llm` SOLO se computa con run_llm=True; en el preset
|
||||
# `standard` (sin LLM, lo que esta suite usa) no debe aparecer. Por eso los
|
||||
# capítulos esperados en un informe `standard` son todos los de CHAPTER_ORDER
|
||||
# MENOS analisis_llm. CHAPTER_ORDER es la fuente de verdad de los 16 capítulos
|
||||
# del motor (portada … glosario).
|
||||
LLM_ONLY_CHAPTERS = {"analisis_llm"}
|
||||
EXPECTED_STANDARD = [c for c in CHAPTER_ORDER if c not in LLM_ONLY_CHAPTERS]
|
||||
|
||||
|
||||
def _pdf_text(path):
|
||||
"""Texto del PDF vía pdftotext, o None si la herramienta no está disponible."""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["pdftotext", "-layout", path, "-"],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
return out.stdout if out.returncode == 0 else None
|
||||
except Exception: # noqa: BLE001 — la verificación principal es sobre el MD.
|
||||
return None
|
||||
|
||||
|
||||
def _manifest_chapters(result):
|
||||
"""Set de ids de capítulo presentes en el manifest del resultado."""
|
||||
with open(result["manifest_path"], encoding="utf-8") as fh:
|
||||
return set((json.load(fh).get("chapters") or {}).keys())
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fixtures de scope module: el dataset sintético se genera UNA vez y el render
|
||||
# `standard` se computa UNA vez; todos los tests de contenido lo reutilizan.
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.fixture(scope="module")
|
||||
def synth_db(tmp_path_factory):
|
||||
"""Tabla sintética determinista que activa los 16 capítulos del motor."""
|
||||
d = tmp_path_factory.mktemp("aeda_accept_synth")
|
||||
db = str(d / "synthetic.duckdb")
|
||||
g = generate_synthetic_eda_table(db, TABLE, n_rows=N_ROWS, seed=SEED)
|
||||
assert g["status"] == "ok", g.get("error")
|
||||
return {"db": db, "table": TABLE, "gen": g}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def standard_run(synth_db, tmp_path_factory):
|
||||
"""Render AutomaticEDA `standard` (sin LLM) sobre el dataset sintético.
|
||||
|
||||
Devuelve el dict del pipeline más el manifest cargado, el texto del Markdown
|
||||
y el del PDF (si pdftotext está). Reutilizado por la mayoría de los tests.
|
||||
"""
|
||||
out = str(tmp_path_factory.mktemp("aeda_accept_std"))
|
||||
r = render_automatic_eda(
|
||||
synth_db["db"], synth_db["table"],
|
||||
profile_level="standard", out_dir=out, basename="synth_std",
|
||||
)
|
||||
assert r["status"] == "ok", r.get("error")
|
||||
with open(r["manifest_path"], encoding="utf-8") as fh:
|
||||
manifest = json.load(fh)
|
||||
md = open(r["aeda_md_path"], encoding="utf-8").read()
|
||||
return {
|
||||
"r": r,
|
||||
"manifest": manifest,
|
||||
"chapters": manifest.get("chapters") or {},
|
||||
"md": md,
|
||||
"pdf_text": _pdf_text(r["pdf_path"]),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def minimal_db(tmp_path_factory):
|
||||
"""Tabla mínima SIN texto libre, SIN fecha y SIN lat/lon.
|
||||
|
||||
Sirve para comprobar que text_distr / timeseries / geospatial devuelven None
|
||||
(no aparecen en el manifest) y el EDA no peta. Solo numéricas continuas +
|
||||
una categórica de baja cardinalidad.
|
||||
"""
|
||||
import random
|
||||
|
||||
import duckdb
|
||||
|
||||
d = tmp_path_factory.mktemp("aeda_accept_min")
|
||||
db = str(d / "minimal.duckdb")
|
||||
con = duckdb.connect(db)
|
||||
con.execute("CREATE TABLE minimal (a DOUBLE, b DOUBLE, c INTEGER, grp VARCHAR)")
|
||||
random.seed(7)
|
||||
rows = [
|
||||
(round(random.gauss(10, 2), 3), round(random.gauss(50, 5), 3),
|
||||
random.randint(1, 100), ["x", "y", "z"][i % 3])
|
||||
for i in range(120)
|
||||
]
|
||||
con.executemany("INSERT INTO minimal VALUES (?,?,?,?)", rows)
|
||||
con.close()
|
||||
return {"db": db, "table": "minimal"}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 1) COBERTURA DE CAPÍTULOS (golden) — el manifest standard trae los 15
|
||||
# capítulos no-LLM esperados, ninguno falta, y analisis_llm NO sale sin LLM.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_standard_cubre_todos_los_capitulos_esperados(standard_run):
|
||||
chapters = set(standard_run["chapters"].keys())
|
||||
expected = set(EXPECTED_STANDARD)
|
||||
missing = expected - chapters
|
||||
assert not missing, (
|
||||
"capítulos esperados ausentes del manifest standard: "
|
||||
f"{sorted(missing)} (presentes: {sorted(chapters)})"
|
||||
)
|
||||
# analisis_llm requiere run_llm=True: en standard NO debe aparecer.
|
||||
assert "analisis_llm" not in chapters, (
|
||||
"analisis_llm apareció sin LLM: el preset standard no debería computarlo"
|
||||
)
|
||||
|
||||
|
||||
def test_manifest_top_level_es_valido(standard_run):
|
||||
"""El manifest declara el motor y un dict de capítulos con metadatos por id."""
|
||||
man = standard_run["manifest"]
|
||||
assert man.get("engine") == "AutomaticEDA"
|
||||
assert man.get("engine_version")
|
||||
chapters = standard_run["chapters"]
|
||||
# Cada capítulo trae version + nº de páginas/slides (formato del manifest).
|
||||
for cid, meta in chapters.items():
|
||||
assert meta.get("version"), f"capítulo {cid} sin version en el manifest"
|
||||
assert (meta.get("n_pages") or 0) > 0, f"capítulo {cid} con 0 páginas"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 2) CONTENIDO CLAVE POR CAPÍTULO (acceptance) — cada capítulo trae su contenido
|
||||
# ESENCIAL en el Markdown, no solo el heading. Un fragmento ausente nombra el
|
||||
# capítulo y el texto que falta.
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fragmentos de texto ESTABLE que cada capítulo emite en el Markdown del dataset
|
||||
# sintético. No son números frágiles: son etiquetas/estructura del capítulo más
|
||||
# nombres de columna del fixture. Si un capítulo deja de poblar su contenido, su
|
||||
# fragmento desaparece y el test falla nombrándolo.
|
||||
CHAPTER_NEEDLES = {
|
||||
"portada": ["800 filas", "19 columnas"],
|
||||
"overview": ["Primeras filas (df.head)", "Diccionario de columnas",
|
||||
"customer_id", "signup_date"],
|
||||
"num_distr": ["Distribuciones numéricas", "vallas Tukey", "income"],
|
||||
"cat_distr": ["Distribuciones categóricas", "Entropía", "Top categorías",
|
||||
"country"],
|
||||
"text_distr": ["Texto libre (NLP)", "TTR", "Términos más frecuentes",
|
||||
"Idioma dominante"],
|
||||
"calidad": ["Cómo se calcula la calidad", "Calidad global"],
|
||||
"missingness": ["Datos faltantes", "Celdas faltantes (global)",
|
||||
"Faltantes por columna"],
|
||||
"outliers": ["Valores atípicos por columna", "Filas atípicas (multivariante)",
|
||||
"Isolation Forest", "Filas analizadas"],
|
||||
"correlacion": ["Matriz de asociación", "Pares más correlacionados"],
|
||||
"relaciones": ["Candidatas a clave primaria", "customer_id"],
|
||||
"modelos": ["PCA — varianza explicada", "Segmentación (KMeans)"],
|
||||
"timeseries": ["Series temporales", "Columna de fecha", "signup_date"],
|
||||
"geospatial": ["Análisis geoespacial", "Extensión geográfica", "Centroide"],
|
||||
"agregacion": ["Agregación por grupos", "Agrupado por"],
|
||||
"glosario": ["Glosario de términos",
|
||||
"### Isolation Forest (anomalías multivariantes)",
|
||||
"### PCA (componentes principales)"],
|
||||
}
|
||||
|
||||
|
||||
def test_needles_cubren_exactamente_los_capitulos_standard():
|
||||
"""Guard de mantenimiento: las needles cubren los mismos 15 capítulos no-LLM.
|
||||
|
||||
Si alguien añade un capítulo nuevo a CHAPTER_ORDER, este test recuerda que
|
||||
hay que documentar su contenido esencial aquí (o marcarlo como LLM-only)."""
|
||||
assert set(CHAPTER_NEEDLES.keys()) == set(EXPECTED_STANDARD), (
|
||||
"CHAPTER_NEEDLES desincronizado con los capítulos esperados de standard: "
|
||||
f"falta needles para {set(EXPECTED_STANDARD) - set(CHAPTER_NEEDLES)}, "
|
||||
f"sobra {set(CHAPTER_NEEDLES) - set(EXPECTED_STANDARD)}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("chapter_id", list(CHAPTER_NEEDLES.keys()))
|
||||
def test_capitulo_trae_su_contenido_esencial(standard_run, chapter_id):
|
||||
md = standard_run["md"]
|
||||
# Pre-condición: el capítulo está en el manifest (cobertura). Si no, es un
|
||||
# fallo de cobertura, no de contenido — se reporta como tal.
|
||||
assert chapter_id in standard_run["chapters"], (
|
||||
f"capítulo {chapter_id} ausente del manifest (fallo de cobertura)"
|
||||
)
|
||||
for needle in CHAPTER_NEEDLES[chapter_id]:
|
||||
assert needle in md, (
|
||||
f"capítulo '{chapter_id}': falta su contenido esencial en el Markdown "
|
||||
f"— fragmento ausente: {needle!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_outliers_isolation_forest_poblado_no_degradado(standard_run):
|
||||
"""El bloque multivariante (Isolation Forest) sale con datos, no degradado."""
|
||||
md = standard_run["md"]
|
||||
assert "Anomalías multivariantes" in md
|
||||
assert "Filas analizadas" in md, "el Isolation Forest no trae su tabla poblada"
|
||||
assert "No se pudo analizar la anomalía multivariante" not in md, (
|
||||
"el bloque multivariante salió degradado en el informe completo"
|
||||
)
|
||||
# El perfil trae el bloque de modelos con los outliers multivariantes.
|
||||
models = (standard_run["r"]["profile"] or {}).get("models") or {}
|
||||
assert models.get("outliers") is not None, "profile['models']['outliers'] vacío"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 3) CAPÍTULOS SUELTOS CON DEPS RESUELTAS (acceptance de only_chapters) — pedir
|
||||
# un capítulo suelto lo deja POBLADO porque la resolución de dependencias
|
||||
# activa el cómputo que necesita, aunque el caller no lo pidiera.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_only_outliers_isolation_forest_poblado(synth_db, tmp_path):
|
||||
"""only=['outliers'] sin run_models explícito → IsolationForest poblado."""
|
||||
out = str(tmp_path / "only_out")
|
||||
r = render_automatic_eda(
|
||||
synth_db["db"], synth_db["table"],
|
||||
only_chapters=["outliers"], out_dir=out, basename="only_outliers",
|
||||
)
|
||||
assert r["status"] == "ok", r.get("error")
|
||||
# Documento = portada + outliers + glosario, nada más.
|
||||
assert _manifest_chapters(r) == {"portada", "outliers", "glosario"}
|
||||
md = open(r["aeda_md_path"], encoding="utf-8").read()
|
||||
assert "Filas atípicas (multivariante)" in md
|
||||
assert "Filas analizadas" in md, "Isolation Forest sin tabla poblada"
|
||||
assert "No se pudo analizar la anomalía multivariante" not in md, (
|
||||
"el multivariante salió degradado pese a resolver las deps"
|
||||
)
|
||||
# La resolución activó run_models → el perfil trae el bloque de modelos.
|
||||
assert ((r["profile"] or {}).get("models") or {}).get("outliers") is not None
|
||||
|
||||
|
||||
def test_only_timeseries_rango_temporal_presente(synth_db, tmp_path):
|
||||
"""only=['timeseries'] → rango temporal poblado (run_series resuelto)."""
|
||||
out = str(tmp_path / "only_ts")
|
||||
r = render_automatic_eda(
|
||||
synth_db["db"], synth_db["table"],
|
||||
only_chapters=["timeseries"], out_dir=out, basename="only_ts",
|
||||
)
|
||||
assert r["status"] == "ok", r.get("error")
|
||||
assert "timeseries" in _manifest_chapters(r)
|
||||
md = open(r["aeda_md_path"], encoding="utf-8").read()
|
||||
assert "Columna de fecha" in md
|
||||
assert "signup_date" in md, "la serie no nombra su columna de fecha"
|
||||
# run_series resuelto por deps → el perfil trae el análisis de serie.
|
||||
assert (r["profile"] or {}).get("series") is not None, (
|
||||
"only=['timeseries'] debe activar run_series por dependencias"
|
||||
)
|
||||
|
||||
|
||||
def test_only_correlacion_scatters_presentes(synth_db, tmp_path):
|
||||
"""only=['correlacion'] → matriz + scatters de los pares fuertes."""
|
||||
out = str(tmp_path / "only_corr")
|
||||
r = render_automatic_eda(
|
||||
synth_db["db"], synth_db["table"],
|
||||
only_chapters=["correlacion"], out_dir=out, basename="only_corr",
|
||||
)
|
||||
assert r["status"] == "ok", r.get("error")
|
||||
assert _manifest_chapters(r) == {"portada", "correlacion", "glosario"}
|
||||
md = open(r["aeda_md_path"], encoding="utf-8").read()
|
||||
assert "Matriz de asociación" in md
|
||||
assert "Relaciones más fuertes (scatter)" in md, "faltan los scatters"
|
||||
assert "Dispersión de" in md, "no se emitió ninguna figura de dispersión"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 4) NONE CUANDO NO APLICA — sobre una tabla sin texto largo, sin fecha y sin
|
||||
# lat/lon, text_distr / timeseries / geospatial NO aparecen y el EDA no peta.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_capitulos_opcionales_ausentes_cuando_no_aplican(minimal_db, tmp_path):
|
||||
out = str(tmp_path / "minimal_out")
|
||||
r = render_automatic_eda(
|
||||
minimal_db["db"], minimal_db["table"],
|
||||
profile_level="standard", out_dir=out, basename="minimal",
|
||||
)
|
||||
assert r["status"] == "ok", r.get("error")
|
||||
chapters = _manifest_chapters(r)
|
||||
for absent in ("text_distr", "timeseries", "geospatial"):
|
||||
assert absent not in chapters, (
|
||||
f"capítulo {absent} apareció en una tabla que no lo justifica "
|
||||
f"(presentes: {sorted(chapters)})"
|
||||
)
|
||||
# El documento sigue siendo válido: portada + glosario + capítulos que sí
|
||||
# aplican (overview/num_distr/correlacion al menos).
|
||||
assert {"portada", "glosario", "overview", "num_distr"} <= chapters
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 5) FOLDER MULTI-TABLA (acceptance) — el informe de carpeta perfila las N tablas
|
||||
# y el capítulo de relaciones detecta la FK por containment.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_folder_multitabla_con_fk_detectada(tmp_path):
|
||||
fdir = str(tmp_path / "folder")
|
||||
g = generate_synthetic_eda_folder(fdir, n_rows=300, seed=SEED)
|
||||
assert g["status"] == "ok", g.get("error")
|
||||
|
||||
out = str(tmp_path / "fout")
|
||||
rf = render_automatic_eda_folder(fdir, out_dir=out, basename="folder")
|
||||
assert rf["status"] == "ok", rf.get("error")
|
||||
|
||||
# Las 3 tablas se perfilaron.
|
||||
assert rf["n_tables"] == 3, f"esperadas 3 tablas, vistas {rf['n_tables']}"
|
||||
|
||||
# El manifest base trae el capítulo de relaciones inter-tabla.
|
||||
with open(rf["manifest_path"], encoding="utf-8") as fh:
|
||||
chapters = set((json.load(fh).get("chapters") or {}).keys())
|
||||
assert "relaciones" in chapters, (
|
||||
f"el documento de carpeta no incluye el capítulo de relaciones: {chapters}"
|
||||
)
|
||||
|
||||
# El Markdown nombra las 3 tablas y declara la FK detectada por containment.
|
||||
md = open(rf["md_path"], encoding="utf-8").read()
|
||||
for tbl in ("customers", "orders", "reviews"):
|
||||
assert tbl in md, f"la tabla {tbl} no aparece en el informe de carpeta"
|
||||
assert "FK candidatas" in md, "no se declaran las FK candidatas"
|
||||
assert "orders.customer_id" in md and "customers.customer_id" in md, (
|
||||
"la FK orders→customers no se detectó por containment"
|
||||
)
|
||||
assert "reviews.customer_id" in md, "la FK reviews→customers no se detectó"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 6) MD COMPLETITUD (regresión) — el Markdown trae el apéndice con la matriz de
|
||||
# asociación COMPLETA (todos los pares, no solo el top) y el describe con
|
||||
# skew/kurtosis de todas las numéricas. Protege un fix ya mergeado.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_md_apendice_matriz_correlacion_completa(standard_run):
|
||||
md = standard_run["md"]
|
||||
assert "Matriz de asociación — todos los pares" in md, (
|
||||
"falta el apéndice con la matriz de asociación completa"
|
||||
)
|
||||
# Un par num-num de correlación BAJA que el top del capítulo NUNCA mostraría:
|
||||
# su presencia prueba que el apéndice lista TODOS los pares, no solo el top.
|
||||
assert "income ↔ longitude" in md, (
|
||||
"el apéndice no contiene los pares de baja correlación: no es la matriz "
|
||||
"completa, solo el top-k del capítulo"
|
||||
)
|
||||
|
||||
|
||||
def test_md_apendice_describe_con_skew_kurtosis(standard_run):
|
||||
md = standard_run["md"]
|
||||
assert "Estadísticos numéricos completos (describe)" in md, (
|
||||
"falta el apéndice describe completo"
|
||||
)
|
||||
# La cabecera del describe del apéndice lleva las columnas skew y kurtosis
|
||||
# (subcadena única de ese header). Sin ellas el describe está incompleto.
|
||||
assert "| skew | kurtosis |" in md, (
|
||||
"el describe del apéndice no trae las columnas skew/kurtosis"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 7) LAS 3 SALIDAS NO-VACÍAS — PDF con páginas, PPTX con slides, MD con un mínimo
|
||||
# de caracteres, y los tres archivos en disco. Manifest válido.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_tres_salidas_no_vacias(standard_run):
|
||||
r = standard_run["r"]
|
||||
assert r["pdf_path"] and os.path.exists(r["pdf_path"])
|
||||
assert r["pptx_path"] and os.path.exists(r["pptx_path"])
|
||||
assert r["aeda_md_path"] and os.path.exists(r["aeda_md_path"])
|
||||
assert (r["n_pages"] or 0) > 0, "el PDF no tiene páginas"
|
||||
assert (r["n_slides"] or 0) > 0, "el PPTX no tiene slides"
|
||||
# El informe completo es grande: un mínimo holgado protege contra un MD vacío
|
||||
# o truncado sin atarse a un tamaño exacto.
|
||||
assert (r["md_chars"] or 0) > 10000, f"MD demasiado corto: {r['md_chars']} chars"
|
||||
assert r["manifest_path"] and os.path.exists(r["manifest_path"])
|
||||
|
||||
|
||||
def test_pdf_texto_extraible_con_contenido(standard_run):
|
||||
"""Si pdftotext está disponible, el PDF debe traer texto real (no solo
|
||||
imágenes): la portada nombra el dataset y su forma. Si no está la
|
||||
herramienta, el test se omite (no es un fallo del EDA)."""
|
||||
txt = standard_run["pdf_text"]
|
||||
if txt is None:
|
||||
pytest.skip("pdftotext no disponible")
|
||||
assert len(txt) > 5000, "el PDF apenas tiene texto extraíble"
|
||||
assert "Portada" in txt or "synthetic" in txt, (
|
||||
"el texto del PDF no contiene la portada esperada"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# DETERMINISMO — dos renders del MISMO dataset producen el MISMO manifest
|
||||
# (mismos capítulos y mismos n_pages/n_slides por capítulo). El generated_at
|
||||
# difiere por timestamp, por eso se compara el dict de capítulos, no el archivo.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_render_es_determinista(synth_db, tmp_path):
|
||||
out1 = str(tmp_path / "det1")
|
||||
out2 = str(tmp_path / "det2")
|
||||
r1 = render_automatic_eda(synth_db["db"], synth_db["table"],
|
||||
profile_level="standard", out_dir=out1, basename="d1")
|
||||
r2 = render_automatic_eda(synth_db["db"], synth_db["table"],
|
||||
profile_level="standard", out_dir=out2, basename="d2")
|
||||
assert r1["status"] == "ok" and r2["status"] == "ok"
|
||||
c1 = json.load(open(r1["manifest_path"], encoding="utf-8")).get("chapters")
|
||||
c2 = json.load(open(r2["manifest_path"], encoding="utf-8")).get("chapters")
|
||||
assert c1 == c2, "el manifest no es determinista entre dos renders del mismo dataset"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# SLOW (opcional, skippeable) — informe `full` con narrativa LLM. Requiere red /
|
||||
# credenciales y NO es determinista, por eso está apagado salvo opt-in explícito
|
||||
# vía la variable de entorno EDA_ACCEPT_LLM=1. Se omite con skipif (no con un
|
||||
# marker custom) para no depender de registro de marks en la config del repo.
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("EDA_ACCEPT_LLM") != "1",
|
||||
reason="full+LLM es lento/no determinista; exporta EDA_ACCEPT_LLM=1 para correrlo",
|
||||
)
|
||||
def test_full_incluye_capitulo_analisis_llm(synth_db, tmp_path):
|
||||
out = str(tmp_path / "full")
|
||||
r = render_automatic_eda(synth_db["db"], synth_db["table"],
|
||||
profile_level="full", out_dir=out, basename="full")
|
||||
assert r["status"] == "ok", r.get("error")
|
||||
assert "analisis_llm" in _manifest_chapters(r), (
|
||||
"el preset full debe incluir el capítulo de análisis LLM"
|
||||
)
|
||||
Reference in New Issue
Block a user