se agregan las plantillas de aceptado/rechazado y su renderizador
plantilla/ con los dos HTML de correo y ejemplos.json (6 escenarios ficticios); plantillas.py renderiza el subconjunto de Mustache que usan (variables con escape de HTML, secciones e invertidas), en modo estricto: un dato que falta da error en vez de un correo con huecos. tools/previsualizar_plantillas.py genera las vistas previas y envía correos de prueba (--enviar). En la plantilla de rechazo el RUC, la fila Comprobante y la tabla de guías son opcionales, para el rechazo de un correo sin comprobante. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
96dca78162
commit
53f203e832
7 files changed
+1012
No files matched your search
@@ -0,0 +1,179 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
import plantillas
|
||||
from plantillas import PlantillaError, renderizar
|
||||
|
||||
EJEMPLOS = os.path.join(plantillas.CARPETA_PLANTILLAS, "ejemplos.json")
|
||||
|
||||
|
||||
def _ejemplos() -> dict:
|
||||
with open(EJEMPLOS, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Motor de plantillas
|
||||
# ============================================================================
|
||||
def test_variable_con_escape_de_html():
|
||||
assert renderizar("{{x}}", {"x": 'A & B <i> "c" \'d\''}) == "A & B <i> "c" 'd'"
|
||||
|
||||
|
||||
def test_variable_none_queda_vacia_y_numeros_se_convierten():
|
||||
assert renderizar("[{{a}}][{{b}}]", {"a": None, "b": 12.5}) == "[][12.5]"
|
||||
|
||||
|
||||
def test_variable_faltante_lanza_error():
|
||||
with pytest.raises(PlantillaError, match="Falta 'x'"):
|
||||
renderizar("{{x}}", {})
|
||||
|
||||
|
||||
def test_seccion_faltante_tambien_lanza_error():
|
||||
with pytest.raises(PlantillaError, match="Falta 'opcional'"):
|
||||
renderizar("{{#opcional}}x{{/opcional}}", {})
|
||||
with pytest.raises(PlantillaError, match="Falta 'opcional'"):
|
||||
renderizar("{{^opcional}}x{{/opcional}}", {})
|
||||
|
||||
|
||||
def test_seccion_de_lista_se_repite_y_ve_el_contexto_externo():
|
||||
texto = "{{titulo}}:{{#items}}[{{titulo}}-{{n}}]{{/items}}"
|
||||
assert renderizar(texto, {"titulo": "T", "items": [{"n": 1}, {"n": 2}]}) == "T:[T-1][T-2]"
|
||||
|
||||
|
||||
def test_lista_vacia_no_muestra_nada_y_la_invertida_si():
|
||||
texto = "{{#items}}x{{/items}}{{^items}}vacío{{/items}}"
|
||||
assert renderizar(texto, {"items": []}) == "vacío"
|
||||
assert renderizar(texto, {"items": [{}]}) == "x"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("valor, visible", [(True, True), (False, False), (None, False), ("", False), ("a", True), (0, False)])
|
||||
def test_seccion_booleana(valor, visible):
|
||||
texto = "{{#f}}SI{{/f}}{{^f}}NO{{/f}}"
|
||||
assert renderizar(texto, {"f": valor}) == ("SI" if visible else "NO")
|
||||
|
||||
|
||||
def test_seccion_con_texto_muestra_una_vez_y_la_variable_sigue_disponible():
|
||||
"""Caso de ``fecha_estimada_pago``: la sección usa un texto como condición y lo imprime dentro."""
|
||||
texto = "{{#pago}}Pago: {{pago}}{{/pago}}"
|
||||
assert renderizar(texto, {"pago": "18/11/2026"}) == "Pago: 18/11/2026"
|
||||
assert renderizar(texto, {"pago": None}) == ""
|
||||
|
||||
|
||||
def test_secciones_con_el_mismo_nombre_seguidas_en_una_linea():
|
||||
"""Caso de ``conforme``: ``{{#conforme}}..{{/conforme}}{{^conforme}}..{{/conforme}}``."""
|
||||
texto = "{{#guias}}<{{#ok}}Conforme{{/ok}}{{^ok}}No coincide{{/ok}}>{{/guias}}"
|
||||
assert renderizar(texto, {"guias": [{"ok": True}, {"ok": False}]}) == "<Conforme><No coincide>"
|
||||
|
||||
|
||||
def test_secciones_anidadas():
|
||||
texto = "{{#obs}}{{t}}({{#filas}}{{k}}={{v}};{{/filas}}){{/obs}}"
|
||||
contexto = {"obs": [{"t": "A", "filas": [{"k": "x", "v": 1}, {"k": "y", "v": 2}]}, {"t": "B", "filas": []}]}
|
||||
assert renderizar(texto, contexto) == "A(x=1;y=2;)B()"
|
||||
|
||||
|
||||
def test_seccion_sin_cerrar_o_cierre_sin_abrir():
|
||||
with pytest.raises(PlantillaError, match="no se cierra"):
|
||||
renderizar("{{#a}}x", {"a": True})
|
||||
with pytest.raises(PlantillaError, match="no corresponde"):
|
||||
renderizar("x{{/a}}", {"a": True})
|
||||
with pytest.raises(PlantillaError, match="no corresponde"):
|
||||
renderizar("{{#a}}{{#b}}x{{/a}}{{/b}}", {"a": True, "b": True})
|
||||
|
||||
|
||||
def test_llaves_sueltas_de_css_no_se_confunden_con_etiquetas():
|
||||
assert renderizar("a{color:red;}{{x}}", {"x": "1"}) == "a{color:red;}1"
|
||||
|
||||
|
||||
def test_nombres_usados():
|
||||
assert plantillas.nombres_usados("{{a}}{{#b}}{{c}}{{/b}}{{^d}}x{{/d}}") == {"a", "b", "c", "d"}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Plantillas reales con los ejemplos de plantilla/ejemplos.json
|
||||
# ============================================================================
|
||||
ESCENARIOS = sorted(_ejemplos())
|
||||
|
||||
|
||||
def _claves(obj) -> set[str]:
|
||||
if isinstance(obj, dict):
|
||||
return set(obj) | {c for v in obj.values() for c in _claves(v)}
|
||||
if isinstance(obj, list):
|
||||
return {c for v in obj for c in _claves(v)}
|
||||
return set()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("escenario", ESCENARIOS)
|
||||
def test_todos_los_escenarios_se_renderizan_completos(escenario):
|
||||
ejemplo = _ejemplos()[escenario]
|
||||
html = plantillas.renderizar_plantilla(ejemplo["plantilla"], ejemplo["contexto"])
|
||||
|
||||
assert "{{" not in html and "}}" not in html # nada sin resolver
|
||||
assert ejemplo["contexto"]["codigo_seguimiento"] in html
|
||||
assert html.lstrip().startswith("<!DOCTYPE html>")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("nombre", ["aceptado", "rechazado"])
|
||||
def test_los_ejemplos_cubren_las_variables_de_la_plantilla(nombre):
|
||||
"""Si la plantilla pide algo nuevo (o un ejemplo sobra), el desfase se nota aquí."""
|
||||
usados = plantillas.nombres_usados(plantillas.cargar_plantilla(nombre))
|
||||
ejemplos = [e["contexto"] for e in _ejemplos().values() if e["plantilla"] == nombre]
|
||||
|
||||
assert ejemplos, f"No hay ejemplos para la plantilla {nombre}"
|
||||
cubiertos = set().union(*(_claves(c) for c in ejemplos)) # el ejemplo sin guías no trae las claves de cada guía
|
||||
assert usados <= cubiertos, f"Ningún ejemplo trae: {sorted(usados - cubiertos)}"
|
||||
for contexto in ejemplos:
|
||||
sobran = _claves(contexto) - usados
|
||||
assert not sobran, f"Sobran en el ejemplo: {sorted(sobran)}"
|
||||
|
||||
|
||||
def test_razon_social_con_caracteres_especiales_se_escapa():
|
||||
ejemplo = _ejemplos()["rechazado_una_observacion"]
|
||||
html = plantillas.renderizar_plantilla("rechazado", ejemplo["contexto"])
|
||||
|
||||
assert "<Lima>" in html and ""EL RÁPIDO"" in html
|
||||
assert "<Lima>" not in html
|
||||
|
||||
|
||||
def test_aceptado_muestra_pago_estimado_solo_si_hay_fecha():
|
||||
ejemplos = _ejemplos()
|
||||
con = plantillas.renderizar_plantilla("aceptado", ejemplos["aceptado_con_pago"]["contexto"])
|
||||
sin = plantillas.renderizar_plantilla("aceptado", ejemplos["aceptado_sin_pago"]["contexto"])
|
||||
|
||||
assert "Pago estimado" in con and "18/11/2026" in con
|
||||
assert "Pago estimado" not in sin
|
||||
|
||||
|
||||
def test_rechazado_numera_las_observaciones_y_marca_las_guias():
|
||||
html = plantillas.renderizar_plantilla("rechazado", _ejemplos()["rechazado_dos_observaciones"]["contexto"])
|
||||
|
||||
assert "OBSERVACIÓN 1" in html and "OBSERVACIÓN 2" in html
|
||||
assert "OBSERVACIÓN 3" not in html
|
||||
assert html.count("Conforme") == 1 and html.count("No coincide") == 1
|
||||
assert "Rechazado · 2 observaciones" in html
|
||||
|
||||
|
||||
def test_enlaces_de_contacto_llevan_el_codigo_y_el_comprobante():
|
||||
contexto = _ejemplos()["rechazado_dos_observaciones"]["contexto"]
|
||||
html = plantillas.renderizar_plantilla("rechazado", contexto)
|
||||
|
||||
assert f"mailto:{contexto['contacto_correo']}?subject=Consulta%20F002-486%20ABR-261019-00045" in html
|
||||
assert f'href="mailto:{contexto["envio_correo"]}"' in html
|
||||
|
||||
|
||||
def test_rechazo_sin_comprobante_oculta_lo_que_no_existe():
|
||||
ejemplo = _ejemplos()["rechazado_sin_comprobante"]
|
||||
html = plantillas.renderizar_plantilla("rechazado", ejemplo["contexto"])
|
||||
|
||||
assert "Guías revisadas" not in html # sin tabla de guías vacía
|
||||
assert "Comprobante</td>" not in html # sin la fila "Comprobante —"
|
||||
assert "RUC —" not in html and html.count("RUC") == 1 # solo el RUC del pie (empresa)
|
||||
assert "No pudimos registrar su comprobante" in html
|
||||
|
||||
|
||||
def test_rechazo_con_comprobante_si_muestra_guias_ruc_y_fila_comprobante():
|
||||
html = plantillas.renderizar_plantilla("rechazado", _ejemplos()["rechazado_dos_observaciones"]["contexto"])
|
||||
|
||||
assert "Guías revisadas" in html and "RUC 20537929520" in html and "Comprobante</td>" in html
|
||||
|
||||
Reference in new issue
Block a user