Files
feadulta/tests/test_liturgical_calendar_coverage.py
T
rafa 14b94be5ea fix: reescribir generador del calendario litúrgico 2026-2040 (issue #20)
Retira el commit 2713e13, que tenía 3 causas raíz confirmadas por Opix y
Claudix (ver issue #20, comment-676 y siguientes): domingos de TO/Pascua
desplazados -1 semana, Cuaresma contada mal en bloques de 7 desde Ceniza, y
Adviento evaluado antes que Navidad (fuga el 26-31 de diciembre, rompe la
Sagrada Familia). Además special() daba precedencia a fiestas fijas sobre
domingos y hasta sobre la Pascua misma (2035: la Anunciación se comía el
Domingo de Resurrección).

Reescritura completa:
- Numeración de Tiempo Ordinario con omisión de semanas según la norma real
  (Normas Universales sobre el Año Litúrgico, 1969): se cuenta hacia atrás
  desde Cristo Rey = semana 34.
- Las 3 semanas tapadas por Pentecostés/Trinidad/Corpus llevan ferias de TO
  en sus días laborables (el TO arranca el lunes después de Pentecostés).
- Ascensión y Corpus trasladados al domingo (práctica CEE); Bautismo del
  Señor, Sagrada Familia, Cristo Rey y Ascensión tienen índice propio (antes
  se disolvían en la etiqueta genérica del domingo vecino).
- 26 dic-Bautismo indexado por fecha fija (como el libro), no por día de la
  semana.
- Precedencia real para las 11 solemnidades de fecha fija: ceden ante
  domingos de Adviento/Cuaresma/Pascua, Ramos, lunes-jueves de Semana Santa,
  Triduo, Ascensión y Pentecostés; entre solemnidades "del Señor" > "de un
  Santo" (Corpus gana a San Juan Bautista en 2038, no al revés). Anunciación
  con la regla de traslado específica documentada.

Cambio de diseño: cada fecha referencia directamente `book_index` (1-500)
sobre entradas-libro.json en vez de una clave de texto libre — evita que el
vocabulario del calendario diverja del contenido real del libro.

Test de aceptación (tests/test_liturgical_calendar_coverage.py): cobertura
bidireccional contra las 500 entradas + fechas móviles verificadas con un
cómputo de Pascua independiente. 497/500 entradas alcanzadas en 2026-2040;
las 3 restantes (34, 62, 134) están documentadas en el propio JSON como
aritmética real del rango, no fallo del generador.

Metodología de validación con fuentes no-CEE: horariosdemisa.com y
archisevilla.org (traslado de la Ascensión), remitly.com/aciprensa/nacion.com
(traslado de Corpus), catholicvirginian.org/diocesetucson (regla de traslado
de la Anunciación). Detalle completo en issue #20.

Co-authored-by: rafa <rcalvotorrejon@gmail.com>
2026-08-18 11:03:47 -04:00

156 lines
5.7 KiB
Python

#!/usr/bin/env python3
"""Test de aceptación del calendario litúrgico 2026-2040 (issue #20).
Criterio acordado entre Codix/Opix/Claudix: cobertura bidireccional contra
las 500 entradas del libro, más un puñado de fechas móviles verificadas de
forma independiente (cómputo de Pascua propio, no la fuente del generador).
Uso: python3 tests/test_liturgical_calendar_coverage.py
Sale con exit code != 0 si algo falla.
"""
from __future__ import annotations
import json
import sys
from datetime import date, timedelta
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CAL_PATH = ROOT / "data/evangelio-diario/calendario-liturgico-2026-2040.json"
BOOK_PATH = ROOT / "data/evangelio-diario/entradas-libro.json"
def easter_sunday(year: int) -> date:
a = year % 19
b = year // 100
c = year % 100
d_ = b // 4
e = b % 4
f = (b + 8) // 25
g = (b - f + 1) // 3
h = (19 * a + b - d_ - g + 15) % 30
i = c // 4
k = c % 4
l = (32 + 2 * e + 2 * i - h - k) % 7
m = (a + 11 * h + 22 * l) // 451
month = (h + l - 7 * m + 114) // 31
day = ((h + l - 7 * m + 114) % 31) + 1
return date(year, month, day)
def fail(msg: str, errors: list[str]) -> None:
errors.append(msg)
def main() -> int:
errors: list[str] = []
cal = json.loads(CAL_PATH.read_text(encoding="utf-8"))
book = json.loads(BOOK_PATH.read_text(encoding="utf-8"))
dates = cal["dates"]
n_book = len(book)
# 1) toda fecha del rango existe y tiene book_index valido
d = date.fromisoformat(cal["range"]["from"])
end = date.fromisoformat(cal["range"]["to"])
n_dates = 0
while d <= end:
iso = d.isoformat()
if iso not in dates:
fail(f"fecha ausente: {iso}", errors)
else:
idx = dates[iso]["book_index"]
if not (1 <= idx <= n_book):
fail(f"{iso}: book_index fuera de rango: {idx}", errors)
n_dates += 1
d += timedelta(days=1)
# 2) toda entrada del libro es alcanzable en algun año del rango
used = {v["book_index"] for v in dates.values()}
never_used = sorted(set(range(1, n_book + 1)) - used)
# las 3 excepciones conocidas y documentadas en el propio JSON (ver "notes")
known_gaps = {34, 62, 134}
unexpected_gaps = [i for i in never_used if i not in known_gaps]
if unexpected_gaps:
fail(f"entradas del libro nunca alcanzadas (no documentadas): {unexpected_gaps}", errors)
# 3) fechas moviles clave, computadas de forma independiente (no depende
# del propio generador ni de su fuente CEE)
checks_ok = 0
for year in range(2026, 2041):
easter = easter_sunday(year)
expects = [
(easter, 199, "Pascua"),
(easter - timedelta(days=46), 141, "Ceniza"),
(easter + timedelta(days=49), 258, "Pentecostes"),
]
for dt, expected_idx, label in expects:
v = dates.get(dt.isoformat())
got = v["book_index"] if v else None
if got != expected_idx:
fail(f"{year} {label} {dt}: esperado idx={expected_idx}, obtenido={got}", errors)
else:
checks_ok += 1
palm = easter - timedelta(days=7)
v = dates.get(palm.isoformat())
if not (v and 190 <= v["book_index"] <= 192):
fail(f"{year} Ramos {palm}: obtenido={v}", errors)
else:
checks_ok += 1
trinity = easter + timedelta(days=56)
v = dates.get(trinity.isoformat())
if not (v and 259 <= v["book_index"] <= 261):
fail(f"{year} Trinidad {trinity}: obtenido={v}", errors)
else:
checks_ok += 1
# 4) solemnidades con clave propia, todos los años (no diluidas en un
# domingo/feria generico) -- el bug central del commit retirado 2713e13
for year in range(2026, 2041):
ep = date(year, 1, 6)
baptism = (ep + timedelta(days=1)) if ep.weekday() == 6 else (
ep + timedelta(days=(6 - ep.weekday()) % 7)
)
v = dates.get(baptism.isoformat())
if not (v and 54 <= v["book_index"] <= 56):
fail(f"{year} Bautismo {baptism}: sin clave propia ({v})", errors)
easter = easter_sunday(year)
ascension = easter + timedelta(days=42)
v = dates.get(ascension.isoformat())
if not (v and 249 <= v["book_index"] <= 251):
fail(f"{year} Ascension {ascension}: sin clave propia ({v})", errors)
corpus = easter + timedelta(days=63)
v = dates.get(corpus.isoformat())
if not (v and 262 <= v["book_index"] <= 264):
fail(f"{year} Corpus {corpus}: sin clave propia o no en domingo ({v})", errors)
years_with_ck = {k[:4] for k, v in dates.items() if 481 <= v["book_index"] <= 483}
if len(years_with_ck) != 15:
fail(f"Cristo Rey ausente en algun año: {sorted(years_with_ck)}", errors)
years_with_family = {k[:4] for k, v in dates.items() if v["book_index"] == 40}
if len(years_with_family) != 15:
fail(f"Sagrada Familia ausente en algun año: {sorted(years_with_family)}", errors)
# 5) Adviento no debe seguir contando tras Navidad (fuga del commit retirado)
leak = [k for k, v in dates.items()
if k[5:7] == "12" and int(k[8:10]) >= 26 and v["book_index"] <= 37]
if leak:
fail(f"fuga de Adviento tras Navidad: {leak[:10]}", errors)
print(f"fechas verificadas: {n_dates}")
print(f"book_index distintos usados: {len(used)}/{n_book}")
print(f"chequeos de fechas moviles ok: {checks_ok}")
if errors:
print(f"\nFALLOS ({len(errors)}):")
for e in errors:
print(" -", e)
return 1
print("\nTodo OK.")
return 0
if __name__ == "__main__":
sys.exit(main())