from datetime import datetime, timedelta
import pytz
import changes
TZ = pytz.timezone("Europe/Moscow")
NOW = TZ.localize(datetime(2026, 10, 7, 12, 0)) # среда
def ics(*events):
body = "\r\n".join(events)
return ("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n" + body + "\r\nEND:VCALENDAR\r\n").encode()
def vevent(uid, start, minutes=60, summary="Встреча", extra=""):
end = start + timedelta(minutes=minutes)
return (f"BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20261007T090000Z\r\n"
f"DTSTART;TZID=Europe/Moscow:{start:%Y%m%dT%H%M%S}\r\nDTEND;TZID=Europe/Moscow:{end:%Y%m%dT%H%M%S}\r\n"
f"SUMMARY:{summary}\r\n{extra}END:VEVENT")
def at(day, hour, minute=0):
return datetime(2026, 10, day, hour, minute)
def report(old, new):
return changes.report(ics(*old), ics(*new), TZ, now=NOW)
def test_no_changes_and_dtstamp_ignored():
event = vevent("a", at(8, 10))
assert report([event], [event.replace("DTSTAMP:20261007T090000Z", "DTSTAMP:20261007T091300Z")]) is None
def test_renamed_event_is_not_reported():
assert report([vevent("a", at(8, 10), summary="Старое")], [vevent("a", at(8, 10), summary="Новое")]) is None
def test_added_event():
text = report([], [vevent("a", at(8, 10), summary="Созвон <важный> & срочный")])
assert text.startswith("Изменения в календаре")
assert "Новое событие: Созвон <важный> & срочный" in text
assert "чт 08.10 10:00 — 11:00" in text
def test_past_events_are_ignored():
# Яндекс выгружает всю историю: правка старого события не должна приходить уведомлением
assert report([], [vevent("old", at(1, 10))]) is None
assert report([vevent("old", at(1, 10))], []) is None
assert report([vevent("old", at(1, 10))], [vevent("old", at(2, 10))]) is None
def test_event_in_progress_counts_as_upcoming():
assert "Удалено" in report([vevent("now", at(7, 11, 30))], [])
def test_deleted_event():
text = report([vevent("a", at(9, 15), summary="Планёрка")], [])
assert "Удалено: Планёрка\nпт 09.10 15:00 — 16:00" in text
def test_moved_event():
text = report([vevent("a", at(8, 10), summary="Планёрка")], [vevent("a", at(9, 14, 30), summary="Планёрка")])
assert "Перенесено: Планёрка\nбыло: чт 08.10 10:00 — 11:00\nстало: пт 09.10 14:30 — 15:30" in text
def test_moved_from_past_to_future():
assert "Перенесено" in report([vevent("a", at(6, 10))], [vevent("a", at(12, 10))])
def test_all_day_event():
event = ("BEGIN:VEVENT\r\nUID:day\r\nDTSTART;VALUE=DATE:20261010\r\nDTEND;VALUE=DATE:20261011\r\n"
"SUMMARY:Выходной\r\nEND:VEVENT")
assert "сб 10.10, весь день" in report([], [event])
today = event.replace("20261010", "20261007").replace("20261011", "20261008")
assert "ср 07.10, весь день" in report([today], []) # день ещё не закончился
def test_recurring_occurrence_deleted():
weekly = vevent("w", at(1, 9), summary="Стендап", extra="RRULE:FREQ=WEEKLY\r\n")
without = weekly.replace("RRULE:FREQ=WEEKLY\r\n", "RRULE:FREQ=WEEKLY\r\nEXDATE;TZID=Europe/Moscow:20261015T090000\r\n")
text = report([weekly], [without])
assert "Удалено: Стендап\nчт 15.10 09:00 — 10:00" in text
assert "повторяется" not in text
def test_recurring_occurrence_moved_and_moved_back():
weekly = vevent("w", at(1, 9), summary="Стендап", extra="RRULE:FREQ=WEEKLY\r\n")
override = vevent("w", at(15, 11), summary="Стендап", extra="RECURRENCE-ID;TZID=Europe/Moscow:20261015T090000\r\n")
text = report([weekly], [weekly, override])
assert "было: чт 15.10 09:00 — 10:00\nстало: чт 15.10 11:00 — 12:00" in text
text = report([weekly, override], [weekly])
assert "было: чт 15.10 11:00 — 12:00\nстало: чт 15.10 09:00 — 10:00" in text
def test_recurring_override_deleted():
override = vevent("w", at(15, 11), extra="RECURRENCE-ID;TZID=Europe/Moscow:20261015T090000\r\n")
weekly = vevent("w", at(1, 9), extra="RRULE:FREQ=WEEKLY\r\n")
without = weekly.replace("RRULE:FREQ=WEEKLY\r\n", "RRULE:FREQ=WEEKLY\r\nEXDATE;TZID=Europe/Moscow:20261015T090000\r\n")
text = report([weekly, override], [without])
assert text.count("Удалено") == 1 and "чт 15.10 11:00 — 12:00" in text
def test_recurring_series():
weekly = vevent("w", at(1, 9), summary="Стендап", extra="RRULE:FREQ=WEEKLY\r\n")
assert "чт 01.10 09:00 — 10:00, повторяется" in report([], [weekly])
assert "Удалено" in report([weekly], [])
ended = vevent("e", at(1, 9), extra="RRULE:FREQ=WEEKLY;UNTIL=20261005T060000Z\r\n")
assert report([ended], []) is None
def test_many_changes_are_capped():
text = report([], [vevent(f"e{i}", at(10, 8) + timedelta(hours=i)) for i in range(13)])
assert text.count("Новое событие") == changes.MAX_ITEMS
assert text.endswith("…и ещё 3")
assert text.index("08:00") < text.index("09:00") # по времени события
def test_floating_time_uses_calendar_zone():
event = "BEGIN:VEVENT\r\nUID:f\r\nDTSTART:20261008T100000\r\nDTEND:20261008T110000\r\nSUMMARY:X\r\nEND:VEVENT"
assert "чт 08.10 10:00 — 11:00" in report([], [event])