#!/usr/bin/env python3 """Tests de remate_watch.py — modo completo frente a modo alt. Cicatriz del 2026-07-30: al SUSTITUIR la imagen destacada de un post ya rematado, Ghost borra `feature_image_alt` (va atado a la imagen, no al post) y el vigilante volvía a considerarlo candidato. Como no distinguía casos, iba a relanzar el remate COMPLETO para rellenar un campo de 191 caracteres: revisión editorial otra vez, el enlazador sobre un artículo que ya tenía sus 4 enlaces, posible cambio de slug —que rompe la pareja de hreflang recién declarada— y otra sesión entera del plan, el mismo día que la cuota ya se había agotado. Lo peligroso de los dos lados: · si el modo corto NO salta, se rehace todo y se pisa trabajo bueno · si el modo corto salta de más, un post nuevo se queda SIN remate python3 -m pytest test_remate_watch.py -q python3 test_remate_watch.py # sin pytest """ import os import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import remate_watch as R def post(**kw): base = {"id": "id-1", "slug": "un-caso-1964", "title": "Un caso", "status": "draft", "feature_image": "https://x/img.webp", "meta_title": None, "meta_description": None, "feature_image_alt": None} base.update(kw) return base RECIEN = post() # sin nada REMATADO = post(meta_title="mt", meta_description="md", feature_image_alt="alt") SIN_ALT = post(meta_title="mt", meta_description="md") # le cambiaron la imagen # ─────────────────── a quién considera candidato ─────────────────── def test_el_post_que_perdio_el_alt_sigue_siendo_candidato(): """No basta con dejar de rehacerlo todo: el alt hay que reponerlo, o el post se queda sin texto alternativo para siempre y nadie se entera.""" assert R.is_candidate(SIN_ALT) def test_un_post_completo_no_es_candidato(): assert not R.is_candidate(REMATADO) def test_un_borrador_sin_imagen_no_es_candidato(): """El disparo es la imagen: sin ella no hay nada que describir ni el artículo está listo.""" assert not R.is_candidate(post(feature_image=None)) # ─────────────────── qué modo elige ─────────────────── def test_solo_falta_alt_cuando_las_metas_ya_estan(): assert R.solo_falta_alt(SIN_ALT) def test_un_post_recien_creado_NO_va_por_el_modo_corto(): """El falso positivo caro: el modo alt tiene PROHIBIDO tocar metas, slug y enlaces. Si saltara aquí, el post se quedaría sin remate de verdad y parecería hecho.""" assert not R.solo_falta_alt(RECIEN) def test_con_media_meta_tampoco_va_por_el_corto(): """Un remate interrumpido a medias tiene meta_title y no meta_description. Eso NO es «solo falta el alt»: hay que rematarlo entero.""" assert not R.solo_falta_alt(post(meta_title="mt")) assert not R.solo_falta_alt(post(meta_description="md")) def test_sin_imagen_no_hay_modo_alt(): assert not R.solo_falta_alt(post(meta_title="mt", meta_description="md", feature_image=None)) # ─────────────────── que el prompt sea el que toca ─────────────────── def test_el_modo_alt_usa_la_plantilla_corta_y_prohibe_lo_demas(): p = R.build_prompt("en", SIN_ALT) assert "modo ALT" in p assert "191" in p # el límite de Ghost sigue dicho for prohibido in ("el slug", "las metas", "los enlaces internos"): assert prohibido in p, prohibido assert "seo_link.py" not in p, "el modo corto no debe invitar a enlazar" def test_el_modo_completo_sigue_llevando_el_checklist_entero(): p = R.build_prompt("en", RECIEN) assert "METAS + ALT" in p assert "seo_link.py" in p or "enlaces internos" in p assert "modo ALT" not in p def test_los_dos_prompts_prohiben_publicar(): """La regla que no se negocia: publicar es decisión de Jose.""" for p in (R.build_prompt("en", SIN_ALT), R.build_prompt("en", RECIEN)): assert "DRAFT" in p and "Publicar o programar" in p def test_el_prompt_corto_no_deja_variables_sin_sustituir(): p = R.build_prompt("es", SIN_ALT) assert "$" not in p, [l for l in p.splitlines() if "$" in l] assert "un-caso-1964" in p if __name__ == "__main__": fallos = 0 for nombre, fn in sorted(globals().items()): if not nombre.startswith("test_"): continue try: fn() print(f" ✓ {nombre}") except AssertionError as exc: fallos += 1 print(f" ✗ {nombre}: {exc}") print(f"\n{'✓ todo pasa' if not fallos else f'✗ {fallos} fallo(s)'}") sys.exit(1 if fallos else 0)