feat(hands,memory): the recall carries the next week of both calendars daily

This commit is contained in:
hh
2026-09-09 08:21:03 +00:00
parent f3a625087f
commit 8d0f80440e
7 changed files with 775 additions and 4 deletions
+140
View File
@@ -0,0 +1,140 @@
import urllib.error
from email.message import Message
from datetime import UTC, datetime
from zoneinfo import ZoneInfo
from beaver_agent.hands.calendars import (
Calendars,
Feed,
calendars,
events_in,
feeds,
short,
unfold,
)
ZONE = ZoneInfo("Europe/Warsaw")
NOW = datetime(2026, 9, 1, 12, 0, tzinfo=UTC) # 14:00 в Варшаве
MCPS = (
"personal=https://calendar-mcp.com/api/mcp?email=b%40b.com"
"&icsUrl=https%3A%2F%2Fcalendar.google.com%2Fical%2Fprivate-abc%2Fbasic.ics,"
"student=https://calendar-mcp.com/api/mcp?icsUrl=https%3A%2F%2Fusos.pl%2Fics%3Fkey%3Dz,"
"bare=https://calendar-mcp.com/mcp/AAAA"
)
def ics(*events: str) -> str:
body = "".join(f"BEGIN:VEVENT\n{e.strip()}\nEND:VEVENT\n" for e in events)
return f"BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//test//EN\n{body}END:VCALENDAR\n"
def starts(text: str, *, days: int = 7) -> list[str]:
found = events_in(text, zone=ZONE, since=NOW.astimezone(ZONE), days=days)
return [
f"{e.start.astimezone(ZONE):%m-%d %H:%M} {e.title}"
for e in sorted(found, key=lambda e: e.start)
]
def test_feeds_take_the_ical_url_out_of_the_same_mcp_urls():
assert [f.name for f in feeds(MCPS)] == ["personal", "student"]
assert feeds(MCPS)[0].url == (
"https://calendar.google.com/ical/private-abc/basic.ics"
)
assert feeds(MCPS)[1].url == "https://usos.pl/ics?key=z"
assert feeds("") == ()
assert feeds("plain=https://example.com/plan.ics")[0].url.endswith("plan.ics")
# MCP-рука остаётся у модели, даже если ical-фида в записи нет
assert [m.name for m in calendars(MCPS)] == [
"calendar-personal",
"calendar-student",
"calendar-bare",
]
def test_folded_lines_are_glued_back():
assert unfold("SUMMARY:длин\r\n ное\r\nUID:x") == ["SUMMARY:длинное", "UID:x"]
def test_window_sorts_by_start_and_keeps_only_the_next_days():
text = ics(
"SUMMARY:позже\nDTSTART:20260904T080000Z",
"SUMMARY:раньше\nDTSTART:20260902T080000Z",
"SUMMARY:за окном\nDTSTART:20260920T080000Z",
"SUMMARY:уже прошло\nDTSTART:20260801T080000Z",
)
assert starts(text) == ["09-02 10:00 раньше", "09-04 10:00 позже"]
def test_all_day_event_of_today_still_counts_as_upcoming():
text = ics("SUMMARY:отпуск\nDTSTART;VALUE=DATE:20260901\nDTEND;VALUE=DATE:20260902")
found = events_in(text, zone=ZONE, since=NOW.astimezone(ZONE), days=7)
assert len(found) == 1
assert found[0].all_day
assert f"{found[0].start:%H:%M}" == "00:00"
def test_place_takes_the_room_from_the_description_and_the_address_from_location():
text = ics(
"SUMMARY:Analiza matematyczna II - Egzamin\n"
"DTSTART;VALUE=DATE-TIME:20260903T110000\n"
"DESCRIPTION:Sala: 3\\nA23 - CW - Centrum Wykładowe\\n\n"
"LOCATION:ul. Piotrowo 2\\, 61-138 Poznań\\, Polska"
)
found = events_in(text, zone=ZONE, since=NOW.astimezone(ZONE), days=7)
assert found[0].place == "Sala: 3, ul. Piotrowo 2, 61-138 Poznań, Polska"
# плавающее время читается в зоне агента, а не в UTC
assert f"{found[0].start:%H:%M}" == "11:00"
def test_cancelled_events_are_skipped():
text = ics("SUMMARY:отменено\nDTSTART:20260902T080000Z\nSTATUS:CANCELLED")
assert starts(text) == []
def test_weekly_repeat_expands_and_exdate_with_moved_instance_are_subtracted():
series = (
"UID:w1\nSUMMARY:польский\nDTSTART;TZID=Europe/Warsaw:20260824T100000\n"
"RRULE:FREQ=WEEKLY;BYDAY=MO,WE"
)
assert starts(ics(series)) == ["09-02 10:00 польский", "09-07 10:00 польский"]
moved = ics(
series + "\nEXDATE;TZID=Europe/Warsaw:20260902T100000",
"UID:w1\nSUMMARY:польский (перенос)\n"
"RECURRENCE-ID;TZID=Europe/Warsaw:20260907T100000\n"
"DTSTART;TZID=Europe/Warsaw:20260907T173000",
)
assert starts(moved) == ["09-07 17:30 польский (перенос)"]
def test_daily_repeat_stops_on_until_and_count():
until = ics(
"SUMMARY:завтрак\nDTSTART:20260801T060000Z\n"
"RRULE:FREQ=DAILY;UNTIL=20260903T060000Z"
)
assert starts(until) == ["09-02 08:00 завтрак", "09-03 08:00 завтрак"]
counted = ics("SUMMARY:курс\nDTSTART:20260902T060000Z\nRRULE:FREQ=DAILY;COUNT=3")
assert len(starts(counted)) == 3
yearly = ics(
"SUMMARY:часы\nDTSTART:20200906T060000Z\nRRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=1SU"
)
assert starts(yearly) == ["09-06 08:00 часы"]
def test_a_feed_that_fails_becomes_a_line_not_an_exception():
class Broken(Calendars):
def fetch(self, url: str) -> str:
raise urllib.error.HTTPError(url, 403, "Forbidden", Message(), None)
broken = Broken((Feed("student", "https://usos.pl/ics"),), tz="Europe/Warsaw")
found = broken.upcoming(NOW)
assert found.read == 0
assert found.events == ()
assert found.errors == ("student: HTTP 403",)
def test_short_reason_is_one_line_and_never_carries_the_url():
assert short(TimeoutError()) == "таймаут"
assert short(urllib.error.URLError("no route")).startswith("сеть:")
assert short(ValueError("а\nб")) == "ValueError: а б"