#!/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())