feat(browser): auto-commit con 178 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
---
|
||||
name: build_vevent
|
||||
kind: function
|
||||
lang: py
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def build_vevent(event: dict) -> str"
|
||||
description: "Serializa un evento (dict) a un texto VCALENDAR (RFC 5545) con un VEVENT dentro. Analoga de build_vcard pero para calendarios. Pura, solo compone texto (sin red, sin disco, sin reloj: nunca usa datetime.now). Acepta claves en espanol e ingles, normaliza fechas de varios formatos humanos a iCal compacto, sintetiza UID determinista si falta, soporta all_day, RRULE y VALARM. Salida CRLF terminando en END:VCALENDAR."
|
||||
tags: [dav, caldav, ical, vevent, calendar, serialize]
|
||||
params:
|
||||
- name: event
|
||||
desc: "dict del evento. Claves opcionales salvo lo indicado (acepta nombre ES o EN): uid (identificador; si falta se sintetiza determinista 'evt-'+md5(summary+start)[:16]), summary/titulo/resumen (-> SUMMARY, OBLIGATORIO), start/inicio (fecha/hora inicio -> DTSTART, OBLIGATORIO), end/fin (-> DTEND; si falta deriva +1h o dia siguiente si all_day), all_day/todo_el_dia (bool -> DTSTART;VALUE=DATE), location/ubicacion/lugar (-> LOCATION), description/descripcion/notas (-> DESCRIPTION), rrule/recurrencia (string RRULE -> linea RRULE), dtstamp (iCal opcional; fallback determinista a DTSTART), alarm_minutes/recordatorio_min (int -> bloque VALARM con TRIGGER:-PTnM). Fechas aceptadas: 'YYYY-MM-DDTHH:MM[:SS]', con sufijo 'Z' para UTC, 'YYYY-MM-DD', o iCal compacto ya formado."
|
||||
output: "Texto VCALENDAR (RFC 5545) con lineas separadas por CRLF: BEGIN:VCALENDAR / VERSION:2.0 / PRODID / CALSCALE:GREGORIAN, un VEVENT con UID, DTSTAMP, DTSTART, DTEND, SUMMARY y campos opcionales, terminando en END:VCALENDAR\\r\\n. Valores de texto escapados segun RFC 5545; RRULE no se escapa (sus ';'/',' son separadores propios)."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [hashlib, datetime]
|
||||
tested: true
|
||||
tests: ["test_golden_evento_con_hora", "test_all_day", "test_rrule", "test_uid_sintetico_determinista", "test_end_derivado_mas_una_hora", "test_utc_con_z", "test_escape_caracteres_especiales", "test_alarm", "test_claves_espanol_equivalentes", "test_falta_summary_lanza_valueerror", "test_falta_start_lanza_valueerror"]
|
||||
test_file_path: "python/functions/core/build_vevent_test.py"
|
||||
file_path: "python/functions/core/build_vevent.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from core.build_vevent import build_vevent
|
||||
|
||||
vcal = build_vevent({
|
||||
"summary": "Cita dentista",
|
||||
"start": "2026-06-20T17:00",
|
||||
"end": "2026-06-20T18:00",
|
||||
"location": "Clinica",
|
||||
"description": "Revision anual",
|
||||
"alarm_minutes": 30,
|
||||
})
|
||||
print(vcal)
|
||||
# BEGIN:VCALENDAR
|
||||
# VERSION:2.0
|
||||
# PRODID:-//fn_registry//build_vevent//ES
|
||||
# CALSCALE:GREGORIAN
|
||||
# BEGIN:VEVENT
|
||||
# UID:evt-<md5(summary+start)>
|
||||
# DTSTAMP:20260620T170000
|
||||
# DTSTART:20260620T170000
|
||||
# DTEND:20260620T180000
|
||||
# SUMMARY:Cita dentista
|
||||
# LOCATION:Clinica
|
||||
# DESCRIPTION:Revision anual
|
||||
# BEGIN:VALARM
|
||||
# ACTION:DISPLAY
|
||||
# DESCRIPTION:Cita dentista
|
||||
# TRIGGER:-PT30M
|
||||
# END:VALARM
|
||||
# END:VEVENT
|
||||
# END:VCALENDAR
|
||||
|
||||
# Evento de todo el dia recurrente:
|
||||
build_vevent({"titulo": "Cumpleanos", "inicio": "2026-06-20", "all_day": True,
|
||||
"recurrencia": "FREQ=YEARLY"})
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando hay que materializar un evento a texto iCalendar para subirlo a CalDAV.
|
||||
Es el paso "componer el VCALENDAR" previo a `caldav_put_event_py_infra`: le pasas
|
||||
el texto que devuelve y el UID. La usa el pipeline `add_event_dav_py_pipelines`
|
||||
para anadir un evento de un tiro. Si no das `uid`, el UID sintetico determinista
|
||||
hace que re-construir el mismo evento produzca el mismo recurso `<uid>.ics`
|
||||
(idempotente al subir). Reserva `build_vcard_py_core` para contactos (vCard) y
|
||||
esta para eventos (VEVENT).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Pura salvo `ValueError`**: determinista, sin efectos (no red, no disco, no
|
||||
reloj). NUNCA llama `datetime.now()` — `datetime.strptime`/`timedelta` solo se
|
||||
usan para parsear y derivar fechas a partir de los inputs. La unica excepcion
|
||||
posible es `ValueError` cuando falta `summary` o `start` (sin ellos no hay
|
||||
evento) — validacion de entrada aceptable en una pura, en paridad con
|
||||
`build_vcard`.
|
||||
- **DTSTAMP siempre presente**: RFC 5545 lo exige. Si no se pasa `dtstamp`, se
|
||||
usa el valor de `DTSTART` como fallback determinista (no la hora actual), para
|
||||
que la salida sea reproducible y la funcion siga siendo pura.
|
||||
- **RRULE no se escapa**: el valor de `rrule` es un recurrence rule estructurado
|
||||
(`FREQ=...;BYDAY=...`) cuyos `;` y `,` son separadores propios. Se emite tal
|
||||
cual (stripeado). El resto de campos de texto (SUMMARY/LOCATION/DESCRIPTION) si
|
||||
se escapan (RFC 5545: `\`, `\n`, `,`, `;`; el `\r` crudo se elimina).
|
||||
- **all_day usa VALUE=DATE**: con `all_day=True` el DTSTART/DTEND salen como
|
||||
`;VALUE=DATE:YYYYMMDD` y el DTEND por defecto es el dia siguiente (convencion
|
||||
iCal: fin exclusivo). Sin `all_day`, son datetime y el DTEND por defecto es
|
||||
start+1h.
|
||||
- **Formatos de fecha**: acepta varios formatos humanos y los normaliza, pero un
|
||||
string mal formado (que `strptime` no entienda) lanza `ValueError` del propio
|
||||
`strptime` — valida tus inputs si vienen de fuera.
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Serializa un evento (dict) a un VCALENDAR completo con un VEVENT dentro.
|
||||
|
||||
Analoga de ``build_vcard`` pero para calendarios: compone un texto iCalendar
|
||||
(RFC 5545) con un envoltorio VCALENDAR y un unico VEVENT. Es una funcion pura —
|
||||
solo compone texto, sin red, sin disco y sin reloj (nunca usa ``datetime.now``).
|
||||
La unica excepcion posible es ``ValueError`` por validacion de entrada (falta de
|
||||
``summary`` o ``start``), lo cual es aceptable para una funcion pura, en paridad
|
||||
con ``build_vcard``.
|
||||
|
||||
Acepta claves en espanol e ingles. Las fechas se aceptan en varios formatos
|
||||
humanos y se normalizan al formato iCal compacto. Si falta ``uid``, se sintetiza
|
||||
un UID determinista (md5 de summary+start) para que el mismo evento produzca
|
||||
siempre el mismo recurso (idempotencia al subir a CalDAV).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
_PRODID = "-//fn_registry//build_vevent//ES"
|
||||
|
||||
|
||||
def _ical_escape(value: str) -> str:
|
||||
"""Escapa un valor de texto para una linea iCal (RFC 5545).
|
||||
|
||||
Reglas: ``\\`` -> ``\\\\``, salto de linea -> ``\\n``, ``,`` -> ``\\,``,
|
||||
``;`` -> ``\\;``. El retorno de carro ``\\r`` crudo se ELIMINA (no se escapa),
|
||||
mismo criterio que el escape vCard de ``build_vcard``: un ``\\r`` solo sin
|
||||
``\\n`` que lo siga sobreviviria al escape de ``\\n`` y quedaria como caracter
|
||||
de control capaz de inyectar propiedades nuevas. Eliminarlo cierra ese vector.
|
||||
"""
|
||||
return (
|
||||
value.replace("\\", "\\\\")
|
||||
.replace("\r", "")
|
||||
.replace("\n", "\\n")
|
||||
.replace(",", "\\,")
|
||||
.replace(";", "\\;")
|
||||
)
|
||||
|
||||
|
||||
def _pick(event: dict, *keys):
|
||||
"""Devuelve el primer valor no vacio entre ``keys`` (acepta ES/EN)."""
|
||||
for key in keys:
|
||||
val = event.get(key)
|
||||
if val:
|
||||
return val
|
||||
return None
|
||||
|
||||
|
||||
def _is_compact_datetime(s: str) -> bool:
|
||||
"""True si ``s`` ya viene en formato iCal compacto (YYYYMMDDTHHMMSS[Z])."""
|
||||
body = s[:-1] if s.endswith("Z") else s
|
||||
if "T" not in body:
|
||||
# Posible fecha compacta YYYYMMDD.
|
||||
return len(body) == 8 and body.isdigit()
|
||||
date_part, _, time_part = body.partition("T")
|
||||
return (
|
||||
len(date_part) == 8
|
||||
and date_part.isdigit()
|
||||
and len(time_part) == 6
|
||||
and time_part.isdigit()
|
||||
)
|
||||
|
||||
|
||||
def _is_compact_date(s: str) -> bool:
|
||||
"""True si ``s`` es una fecha iCal compacta YYYYMMDD (sin hora)."""
|
||||
return len(s) == 8 and s.isdigit()
|
||||
|
||||
|
||||
def _parse_date(value: str) -> str:
|
||||
"""Normaliza una fecha (sin hora) a iCal compacto YYYYMMDD.
|
||||
|
||||
Acepta 'YYYY-MM-DD', 'YYYYMMDD', o un datetime humano del que toma la fecha.
|
||||
"""
|
||||
s = str(value).strip()
|
||||
if _is_compact_date(s):
|
||||
return s
|
||||
if _is_compact_datetime(s):
|
||||
# Tiene hora pero se pidio all_day: quedarse con la parte de fecha.
|
||||
return s[:8]
|
||||
# Formato con guiones, posiblemente con hora.
|
||||
if "T" in s:
|
||||
s = s.split("T", 1)[0]
|
||||
dt = datetime.strptime(s, "%Y-%m-%d")
|
||||
return dt.strftime("%Y%m%d")
|
||||
|
||||
|
||||
def _parse_datetime(value: str) -> str:
|
||||
"""Normaliza una fecha/hora a iCal compacto YYYYMMDDTHHMMSS[Z].
|
||||
|
||||
Acepta:
|
||||
- '2026-06-20T17:00' o '2026-06-20T17:00:00' (naive local)
|
||||
- '2026-06-20T17:00:00Z' o '2026-06-20T17:00Z' (UTC, sufijo Z)
|
||||
- '2026-06-20' (solo fecha -> medianoche local)
|
||||
- '20260620T170000' / '20260620T170000Z' (ya compacto, se respeta)
|
||||
Nunca usa el reloj del sistema: la conversion es determinista.
|
||||
"""
|
||||
s = str(value).strip()
|
||||
if _is_compact_datetime(s):
|
||||
return s
|
||||
if _is_compact_date(s):
|
||||
return s + "T000000"
|
||||
|
||||
utc = s.endswith("Z")
|
||||
if utc:
|
||||
s = s[:-1]
|
||||
|
||||
if "T" in s:
|
||||
try:
|
||||
dt = datetime.strptime(s, "%Y-%m-%dT%H:%M:%S")
|
||||
except ValueError:
|
||||
dt = datetime.strptime(s, "%Y-%m-%dT%H:%M")
|
||||
else:
|
||||
# Solo fecha -> medianoche.
|
||||
dt = datetime.strptime(s, "%Y-%m-%d")
|
||||
|
||||
compact = dt.strftime("%Y%m%dT%H%M%S")
|
||||
return compact + "Z" if utc else compact
|
||||
|
||||
|
||||
def _next_day_compact(date_compact: str) -> str:
|
||||
"""Dada una fecha iCal compacta YYYYMMDD devuelve la del dia siguiente."""
|
||||
dt = datetime.strptime(date_compact, "%Y%m%d") + timedelta(days=1)
|
||||
return dt.strftime("%Y%m%d")
|
||||
|
||||
|
||||
def _plus_one_hour(dt_compact: str) -> str:
|
||||
"""Dada una datetime iCal compacta devuelve la misma +1h preservando Z."""
|
||||
utc = dt_compact.endswith("Z")
|
||||
body = dt_compact[:-1] if utc else dt_compact
|
||||
dt = datetime.strptime(body, "%Y%m%dT%H%M%S") + timedelta(hours=1)
|
||||
out = dt.strftime("%Y%m%dT%H%M%S")
|
||||
return out + "Z" if utc else out
|
||||
|
||||
|
||||
def build_vevent(event: dict) -> str:
|
||||
"""Serializa un evento (dict) a un VCALENDAR completo con un VEVENT.
|
||||
|
||||
Args:
|
||||
event: dict del evento. Claves opcionales salvo lo indicado (acepta
|
||||
nombre ES o EN):
|
||||
- ``uid``: identificador del evento. Si falta, se sintetiza
|
||||
determinista a partir de summary+start: '<evt->md5(...)[:16]>'.
|
||||
- ``summary`` / ``titulo`` / ``resumen``: -> SUMMARY (OBLIGATORIO).
|
||||
- ``start`` / ``inicio``: fecha/hora de inicio -> DTSTART (OBLIGATORIO).
|
||||
- ``end`` / ``fin``: fecha/hora de fin -> DTEND. Si falta y no es
|
||||
all_day, se deriva +1h del start; si es all_day, el dia siguiente.
|
||||
- ``all_day`` / ``todo_el_dia`` (bool): si True emite
|
||||
DTSTART;VALUE=DATE:YYYYMMDD (y DTEND como fecha siguiente).
|
||||
- ``location`` / ``ubicacion`` / ``lugar``: -> LOCATION.
|
||||
- ``description`` / ``descripcion`` / ``notas``: -> DESCRIPTION.
|
||||
- ``rrule`` / ``recurrencia``: string RRULE -> linea RRULE:...
|
||||
- ``dtstamp``: timestamp iCal opcional. Si falta, se usa el valor de
|
||||
DTSTART como fallback DETERMINISTA (nunca datetime.now). DTSTAMP es
|
||||
obligatorio en RFC 5545, por eso siempre se emite.
|
||||
- ``alarm_minutes`` / ``recordatorio_min`` (int): si presente, anade
|
||||
un bloque VALARM (display) con TRIGGER:-PT<N>M (N minutos antes).
|
||||
|
||||
Returns:
|
||||
Texto VCALENDAR (RFC 5545) con lineas separadas por CRLF, empezando en
|
||||
BEGIN:VCALENDAR / VERSION:2.0 / PRODID / CALSCALE:GREGORIAN, conteniendo
|
||||
un VEVENT, y terminando en ``END:VCALENDAR\\r\\n``. Valores de texto
|
||||
escapados segun RFC 5545.
|
||||
|
||||
Raises:
|
||||
ValueError: si falta ``summary`` o ``start`` (sin estos no hay evento).
|
||||
"""
|
||||
summary = _pick(event, "summary", "titulo", "resumen")
|
||||
if not summary:
|
||||
raise ValueError("build_vevent: falta summary (titulo/resumen)")
|
||||
summary = str(summary).strip()
|
||||
|
||||
start_raw = _pick(event, "start", "inicio")
|
||||
if not start_raw:
|
||||
raise ValueError("build_vevent: falta start (inicio)")
|
||||
|
||||
all_day = bool(event.get("all_day") or event.get("todo_el_dia"))
|
||||
|
||||
if all_day:
|
||||
dtstart = _parse_date(start_raw)
|
||||
end_raw = _pick(event, "end", "fin")
|
||||
dtend = _parse_date(end_raw) if end_raw else _next_day_compact(dtstart)
|
||||
else:
|
||||
dtstart = _parse_datetime(start_raw)
|
||||
end_raw = _pick(event, "end", "fin")
|
||||
dtend = _parse_datetime(end_raw) if end_raw else _plus_one_hour(dtstart)
|
||||
|
||||
# UID: explicito o sintetico determinista (md5 de summary+start crudo).
|
||||
uid = event.get("uid")
|
||||
if uid:
|
||||
uid = str(uid).strip()
|
||||
else:
|
||||
digest = hashlib.md5(
|
||||
("%s%s" % (summary, dtstart)).encode("utf-8")
|
||||
).hexdigest()[:16]
|
||||
uid = "evt-%s" % digest
|
||||
|
||||
# DTSTAMP: explicito o fallback determinista al DTSTART.
|
||||
dtstamp = event.get("dtstamp")
|
||||
dtstamp = str(dtstamp).strip() if dtstamp else dtstart
|
||||
|
||||
lines = [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:%s" % _PRODID,
|
||||
"CALSCALE:GREGORIAN",
|
||||
"BEGIN:VEVENT",
|
||||
"UID:%s" % _ical_escape(uid),
|
||||
"DTSTAMP:%s" % dtstamp,
|
||||
]
|
||||
|
||||
if all_day:
|
||||
lines.append("DTSTART;VALUE=DATE:%s" % dtstart)
|
||||
lines.append("DTEND;VALUE=DATE:%s" % dtend)
|
||||
else:
|
||||
lines.append("DTSTART:%s" % dtstart)
|
||||
lines.append("DTEND:%s" % dtend)
|
||||
|
||||
lines.append("SUMMARY:%s" % _ical_escape(summary))
|
||||
|
||||
location = _pick(event, "location", "ubicacion", "lugar")
|
||||
if location:
|
||||
lines.append("LOCATION:%s" % _ical_escape(str(location)))
|
||||
|
||||
description = _pick(event, "description", "descripcion", "notas")
|
||||
if description:
|
||||
lines.append("DESCRIPTION:%s" % _ical_escape(str(description)))
|
||||
|
||||
rrule = _pick(event, "rrule", "recurrencia")
|
||||
if rrule:
|
||||
# RRULE es un valor estructurado (FREQ=...;BYDAY=...): NO se escapa el
|
||||
# contenido, sus ';' y ',' son separadores propios del recurrence rule.
|
||||
lines.append("RRULE:%s" % str(rrule).strip())
|
||||
|
||||
alarm_minutes = _pick(event, "alarm_minutes", "recordatorio_min")
|
||||
if alarm_minutes:
|
||||
minutes = int(alarm_minutes)
|
||||
lines.append("BEGIN:VALARM")
|
||||
lines.append("ACTION:DISPLAY")
|
||||
lines.append("DESCRIPTION:%s" % _ical_escape(summary))
|
||||
lines.append("TRIGGER:-PT%dM" % minutes)
|
||||
lines.append("END:VALARM")
|
||||
|
||||
lines.append("END:VEVENT")
|
||||
lines.append("END:VCALENDAR")
|
||||
return "\r\n".join(lines) + "\r\n"
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Tests para build_vevent."""
|
||||
|
||||
from core.build_vevent import build_vevent
|
||||
|
||||
|
||||
def _lines(text: str) -> list:
|
||||
"""Parte la salida CRLF en lineas para asserts puntuales."""
|
||||
return text.split("\r\n")
|
||||
|
||||
|
||||
def test_golden_evento_con_hora():
|
||||
out = build_vevent({
|
||||
"uid": "evt-demo",
|
||||
"summary": "Cita dentista",
|
||||
"start": "2026-06-20T17:00",
|
||||
"end": "2026-06-20T18:00",
|
||||
"location": "Clinica",
|
||||
})
|
||||
lines = _lines(out)
|
||||
assert lines[0] == "BEGIN:VCALENDAR"
|
||||
assert "VERSION:2.0" in lines
|
||||
assert "CALSCALE:GREGORIAN" in lines
|
||||
assert "BEGIN:VEVENT" in lines
|
||||
assert "UID:evt-demo" in lines
|
||||
assert "DTSTART:20260620T170000" in lines
|
||||
assert "DTEND:20260620T180000" in lines
|
||||
assert "SUMMARY:Cita dentista" in lines
|
||||
assert "LOCATION:Clinica" in lines
|
||||
assert "DTSTAMP:20260620T170000" in lines # fallback determinista a DTSTART
|
||||
assert out.endswith("END:VCALENDAR\r\n")
|
||||
|
||||
|
||||
def test_all_day():
|
||||
out = build_vevent({
|
||||
"summary": "Cumpleanos",
|
||||
"start": "2026-06-20",
|
||||
"all_day": True,
|
||||
})
|
||||
lines = _lines(out)
|
||||
assert "DTSTART;VALUE=DATE:20260620" in lines
|
||||
assert "DTEND;VALUE=DATE:20260621" in lines # dia siguiente derivado
|
||||
assert "DTSTAMP:20260620" in lines
|
||||
|
||||
|
||||
def test_rrule():
|
||||
out = build_vevent({
|
||||
"summary": "Standup",
|
||||
"start": "2026-06-22T09:00",
|
||||
"rrule": "FREQ=WEEKLY;BYDAY=MO",
|
||||
})
|
||||
lines = _lines(out)
|
||||
# El contenido del RRULE NO se escapa (sus ';' son separadores propios).
|
||||
assert "RRULE:FREQ=WEEKLY;BYDAY=MO" in lines
|
||||
|
||||
|
||||
def test_uid_sintetico_determinista():
|
||||
e = {"summary": "Reunion", "start": "2026-06-20T17:00"}
|
||||
a = build_vevent(e)
|
||||
b = build_vevent(e)
|
||||
assert a == b # mismo input -> misma salida
|
||||
uid_lines = [l for l in _lines(a) if l.startswith("UID:")]
|
||||
assert len(uid_lines) == 1
|
||||
assert uid_lines[0].startswith("UID:evt-")
|
||||
# Cambiar el summary cambia el UID sintetico.
|
||||
c = build_vevent({"summary": "Otra", "start": "2026-06-20T17:00"})
|
||||
uid_c = [l for l in _lines(c) if l.startswith("UID:")][0]
|
||||
assert uid_c != uid_lines[0]
|
||||
|
||||
|
||||
def test_end_derivado_mas_una_hora():
|
||||
out = build_vevent({"summary": "X", "start": "2026-06-20T23:30"})
|
||||
lines = _lines(out)
|
||||
assert "DTSTART:20260620T233000" in lines
|
||||
assert "DTEND:20260621T003000" in lines # cruza medianoche +1h
|
||||
|
||||
|
||||
def test_utc_con_z():
|
||||
out = build_vevent({
|
||||
"summary": "Llamada",
|
||||
"start": "2026-06-20T17:00:00Z",
|
||||
"end": "2026-06-20T18:00:00Z",
|
||||
})
|
||||
lines = _lines(out)
|
||||
assert "DTSTART:20260620T170000Z" in lines
|
||||
assert "DTEND:20260620T180000Z" in lines
|
||||
|
||||
|
||||
def test_escape_caracteres_especiales():
|
||||
out = build_vevent({
|
||||
"summary": "Reunion, urgente; con notas\nlinea2",
|
||||
"start": "2026-06-20T10:00",
|
||||
"location": "Sala A, planta 2",
|
||||
"description": "punto 1; punto 2",
|
||||
})
|
||||
lines = _lines(out)
|
||||
assert "SUMMARY:Reunion\\, urgente\\; con notas\\nlinea2" in lines
|
||||
assert "LOCATION:Sala A\\, planta 2" in lines
|
||||
assert "DESCRIPTION:punto 1\\; punto 2" in lines
|
||||
|
||||
|
||||
def test_alarm():
|
||||
out = build_vevent({
|
||||
"summary": "Cita",
|
||||
"start": "2026-06-20T17:00",
|
||||
"alarm_minutes": 30,
|
||||
})
|
||||
lines = _lines(out)
|
||||
assert "BEGIN:VALARM" in lines
|
||||
assert "ACTION:DISPLAY" in lines
|
||||
assert "TRIGGER:-PT30M" in lines
|
||||
assert "END:VALARM" in lines
|
||||
# El VALARM va dentro del VEVENT (antes de END:VEVENT).
|
||||
assert lines.index("END:VALARM") < lines.index("END:VEVENT")
|
||||
|
||||
|
||||
def test_claves_espanol_equivalentes():
|
||||
out = build_vevent({
|
||||
"titulo": "Evento ES",
|
||||
"inicio": "2026-06-20T12:00",
|
||||
"fin": "2026-06-20T13:00",
|
||||
"ubicacion": "Madrid",
|
||||
"descripcion": "desc",
|
||||
"recurrencia": "FREQ=DAILY",
|
||||
"recordatorio_min": 15,
|
||||
})
|
||||
lines = _lines(out)
|
||||
assert "SUMMARY:Evento ES" in lines
|
||||
assert "DTSTART:20260620T120000" in lines
|
||||
assert "DTEND:20260620T130000" in lines
|
||||
assert "LOCATION:Madrid" in lines
|
||||
assert "DESCRIPTION:desc" in lines
|
||||
assert "RRULE:FREQ=DAILY" in lines
|
||||
assert "TRIGGER:-PT15M" in lines
|
||||
|
||||
|
||||
def test_falta_summary_lanza_valueerror():
|
||||
try:
|
||||
build_vevent({"start": "2026-06-20T10:00"})
|
||||
assert False, "deberia haber lanzado ValueError"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def test_falta_start_lanza_valueerror():
|
||||
try:
|
||||
build_vevent({"summary": "X"})
|
||||
assert False, "deberia haber lanzado ValueError"
|
||||
except ValueError:
|
||||
pass
|
||||
Reference in New Issue
Block a user