2026-10-22 08:55:54 -05:00
|
|
|
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>")
|
|
|
|
|
|
|
|
|
|
|
2026-10-22 12:08:38 -05:00
|
|
|
@pytest.mark.parametrize("nombre", ["aceptado", "rechazado", "fuera-de-linea"])
|
2026-10-22 08:55:54 -05:00
|
|
|
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
|
|
|
|
|
|
2026-10-22 12:08:38 -05:00
|
|
|
|
|
|
|
|
def test_fuera_de_linea_dice_que_quedo_en_cola_y_no_pide_reenviar():
|
|
|
|
|
html = plantillas.renderizar_plantilla("fuera-de-linea", _ejemplos()["fuera_de_linea"]["contexto"])
|
|
|
|
|
|
|
|
|
|
assert "EN COLA" in html and "Recibimos su factura" in html
|
|
|
|
|
assert "temporalmente fuera de línea" in html and "No necesita reenviarla" in html
|
|
|
|
|
assert "Guías revisadas" not in html and "Pago estimado" not in html and "ACEPTADO" not in html
|
|
|
|
|
assert "ABR-261019-00048" in html and "{{" not in html
|
|
|
|
|
|