remate: modo corto para el post que solo ha perdido el alt
Sustituir la imagen destacada en Ghost BORRA feature_image_alt: el campo va atado a la imagen, no al post. El vigilante volvía a considerar candidato al post y, como no distinguía casos, iba a relanzar el remate COMPLETO para rellenar 191 caracteres: revisión editorial otra vez, el enlazador sobre un artículo que ya tenía sus cuatro 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 en que la cuota ya se había agotado una vez. solo_falta_alt() distingue el caso y build_prompt() sirve un encargo de 2.248 caracteres en vez de 7.237, que además PROHÍBE tocar texto, slug, metas, excerpt, tags y enlaces: ahí cualquier cambio es una regresión, no una mejora. El modo va en el nombre del log y en la cabecera de Telegram, porque al revisar por qué cambió un post lo primero que quieres saber es cuál corrió. Escribe el alt por el MISMO camino que el modo largo (seo_finish prep → Read de la imagen → seo_finish apply), así que no hay una segunda forma de escribirlo que pueda derivar de la primera. Once tests, y los que importan son los del falso positivo: un post recién creado NO debe entrar por el corto (se quedaría sin remate y parecería hecho), ni uno con el remate interrumpido a medias (meta_title sin meta_description). Verificado por el camino real sobre un borrador canario en el estado exacto —imagen y metas puestas, sin alt—: eligió el modo alt, escribió 189/191 caracteres describiendo la imagen, y de doce campos comparados NO tocó ninguno; el cuerpo quedó literalmente idéntico. Canario borrado después. Y hub-en/hub-es con los dos artículos publicados hoy (Socorro 1964 y Cash-Landrum ES), que seo_estado cazó fuera del índice a los minutos de publicarse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
#!/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)
|
||||
Reference in New Issue
Block a user