项目文件夹

文件
wehub-resource-sync 2114b14ee0
Sync main into demo / sync (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:26 +08:00

803 行
30 KiB
Python

"""
Calendar app state accessor.
"""
from __future__ import annotations
import datetime
import re
from collections import Counter
from typing import Any
from bench_env.task.base import BaseApp
from bench_env.task.utils import date_match_labels, day_bounds_ms, extract_numbers, sim_datetime, sim_today
CALENDAR_WEEK_START_VALUES = {"周一": "monday", "周日": "sunday"}
CALENDAR_DEFAULT_REMINDER_VALUES = {
"开始时": "0_minutes_before",
"5分钟前": "5_minutes_before",
"15分钟前": "15_minutes_before",
"30分钟前": "30_minutes_before",
"1小时前": "60_minutes_before",
"1天前": "1_day_before",
}
CALENDAR_ALLDAY_REMINDER_VALUES = {
"当天零点": "start_of_day",
"当天早上九点": "9_am_on_day",
"前一天早上九点": "9_am_day_before",
}
CALENDAR_LATER_REMINDER_VALUES = {
"5分钟后": "5_minutes",
"10分钟后": "10_minutes",
"30分钟后": "30_minutes",
"1小时后": "60_minutes",
}
CALENDAR_EVENT_REMINDER_VALUES = {
"开始时": 0,
"5分钟前": 5,
"15分钟前": 15,
"30分钟前": 30,
"1小时前": 60,
"1天前": 24 * 60,
}
CALENDAR_EVENT_TYPE_VALUES = {
"日程": "event",
"生日": "birthday",
"纪念日": "anniversary",
"倒数日": "countdown",
}
SEED_DAY_OFFSETS = [3, 4, 7, 8]
SEED_BIRTHDAY_OFFSET = 14
CALENDAR_SEARCH_KEYWORDS = ["团队", "项目"]
CALENDAR_DELETE_TITLES = ["团队周会", "客户拜访", "项目复盘"]
CALENDAR_EDIT_TITLES = ["团队周会", "客户拜访", "项目汇报"]
HOLIDAY_REST_DAYS = {
"元旦": 3,
"春节": 9,
"清明": 3,
"五一": 5,
"端午": 3,
"中秋": 3,
"国庆": 7,
}
HOLIDAY_FIRST_REST = {
"元旦": "2026-01-01",
"春节": "2026-02-15",
"清明": "2026-04-04",
"五一": "2026-05-01",
"端午": "2026-06-19",
"中秋": "2026-09-25",
"国庆": "2026-10-01",
}
# 记录每个假期结束后(最后一天休假之后)第一个补班日;对应模拟器日历中 WORK_REST_2026。
# 假期前的补班不计。清明、端午、中秋 2026 均不涉及假期后补班(假期与周末相邻,不需调休),故未列入。
# QueryMakeupWorkday 用 regex search 判定,Agent 回答包含该日期即通过。
HOLIDAY_MAKEUP_DAYS = {
"元旦": "2026-01-04",
"春节": "2026-02-28",
"五一": "2026-05-09",
"国庆": "2026-10-10",
}
CALENDAR_HOLIDAY_VALUES = list(HOLIDAY_REST_DAYS.keys())
CALENDAR_HOLIDAY_WITH_MAKEUP_VALUES = list(HOLIDAY_MAKEUP_DAYS.keys())
def _ts(date_text: str, time_text: str = "00:00") -> int:
date_value = datetime.date.fromisoformat(date_text)
hour, minute = [int(part) for part in time_text.split(":")]
dt = datetime.datetime(date_value.year, date_value.month, date_value.day, hour, minute)
return int(dt.timestamp() * 1000)
def build_seed_events(today: datetime.date) -> list[dict[str, Any]]:
"""Build seed events relative to today, always in the future."""
day_a = (today + datetime.timedelta(days=SEED_DAY_OFFSETS[0])).isoformat()
day_b = (today + datetime.timedelta(days=SEED_DAY_OFFSETS[1])).isoformat()
day_c = (today + datetime.timedelta(days=SEED_DAY_OFFSETS[2])).isoformat()
day_d = (today + datetime.timedelta(days=SEED_DAY_OFFSETS[3])).isoformat()
day_bday = (today + datetime.timedelta(days=SEED_BIRTHDAY_OFFSET)).isoformat()
day_bday_end = (today + datetime.timedelta(days=SEED_BIRTHDAY_OFFSET + 1)).isoformat()
return [
{
"id": "seed_team_weekly",
"type": "event",
"title": "团队周会",
"description": "每周同步项目进度",
"allDay": False,
"startTs": _ts(day_a, "09:00"),
"endTs": _ts(day_a, "10:00"),
"reminderMinutesBefore": 15,
"alarmEnabled": True,
"calendarAccount": "小米日历",
},
{
"id": "seed_product_review",
"type": "event",
"title": "产品评审",
"description": "确认版本交付范围",
"allDay": False,
"startTs": _ts(day_a, "14:00"),
"endTs": _ts(day_a, "16:00"),
"reminderMinutesBefore": 30,
"alarmEnabled": False,
"calendarAccount": "小米日历",
},
{
"id": "seed_client_visit",
"type": "event",
"title": "客户拜访",
"description": "去浦东见客户",
"allDay": False,
"startTs": _ts(day_b, "10:00"),
"endTs": _ts(day_b, "11:30"),
"reminderMinutesBefore": 15,
"alarmEnabled": False,
"calendarAccount": "小米日历",
},
{
"id": "seed_team_dinner",
"type": "event",
"title": "团队聚餐",
"description": "项目阶段庆功",
"allDay": False,
"startTs": _ts(day_b, "18:00"),
"endTs": _ts(day_b, "20:00"),
"reminderMinutesBefore": 30,
"alarmEnabled": False,
"calendarAccount": "小米日历",
},
{
"id": "seed_project_report",
"type": "event",
"title": "项目汇报",
"description": "向管理层汇报进展",
"allDay": False,
"startTs": _ts(day_c, "09:30"),
"endTs": _ts(day_c, "11:00"),
"reminderMinutesBefore": 15,
"alarmEnabled": True,
"calendarAccount": "小米日历",
},
{
"id": "seed_project_retro",
"type": "event",
"title": "项目复盘",
"description": "整理项目风险与总结",
"allDay": False,
"startTs": _ts(day_c, "14:00"),
"endTs": _ts(day_c, "15:30"),
"reminderMinutesBefore": 30,
"alarmEnabled": False,
"calendarAccount": "小米日历",
},
{
"id": "seed_project_kickoff",
"type": "event",
"title": "项目启动会",
"description": "项目正式启动",
"allDay": False,
"startTs": _ts(day_d, "10:00"),
"endTs": _ts(day_d, "12:00"),
"reminderMinutesBefore": 15,
"alarmEnabled": False,
"calendarAccount": "小米日历",
},
{
"id": "seed_project_summary",
"type": "event",
"title": "部门项目总结",
"description": "季度项目盘点",
"allDay": False,
"startTs": _ts(day_d, "15:00"),
"endTs": _ts(day_d, "16:30"),
"reminderMinutesBefore": 30,
"alarmEnabled": False,
"calendarAccount": "小米日历",
},
{
"id": "seed_mom_birthday",
"type": "birthday",
"title": "妈妈生日",
"description": "记得准备蛋糕",
"allDay": True,
"startTs": _ts(day_bday, "00:00"),
"endTs": _ts(day_bday_end, "00:00"),
"reminderMinutesBefore": 24 * 60,
"alarmEnabled": False,
"calendarAccount": "小米日历",
},
]
CALENDAR_EVENT_CHANGES = ["calendar.events", "calendar.selectedDateTs"]
class Calendar(BaseApp):
"""
Calendar state accessor.
Seed event setup:
state = await env.get_state()
today = sim_today(state["os"])
patch = Calendar.prepare_state_with_seed_events(today)
await env.set_state({"apps": {"calendar": patch}}, deep=True, reload=False)
"""
@staticmethod
def prepare_state_with_seed_events(today: datetime.date) -> dict[str, Any]:
"""Return a calendar state patch with dynamic seed events."""
return {"events": build_seed_events(today)}
@staticmethod
def parse_ymd(date_text: str) -> datetime.date:
return datetime.date.fromisoformat(date_text)
@staticmethod
def timestamp(date_text: str, time_text: str = "00:00") -> int:
return _ts(date_text, time_text)
@staticmethod
def start_of_day_ts(date_text: str) -> int:
return _ts(date_text, "00:00")
@staticmethod
def prepare_event(
*,
event_id: str,
title: str,
date_text: str,
start_time: str,
end_time: str,
created_at: int,
description: str = "",
reminder_minutes_before: int = 15,
alarm_enabled: bool = False,
event_type: str = "event",
all_day: bool = False,
calendar_account: str = "小米日历",
) -> dict[str, Any]:
return {
"id": event_id,
"type": event_type,
"title": title,
"description": description,
"startTs": _ts(date_text, start_time),
"endTs": _ts(date_text, end_time),
"allDay": all_day,
"alarmEnabled": alarm_enabled,
"reminderMinutesBefore": reminder_minutes_before,
"calendarAccount": calendar_account,
"createdAt": int(created_at),
"updatedAt": int(created_at),
}
def prepare_state_with_event(
self,
*,
event_id: str,
title: str,
date_text: str,
start_time: str,
end_time: str,
created_at: int,
description: str = "",
reminder_minutes_before: int = 15,
alarm_enabled: bool = False,
event_type: str = "event",
all_day: bool = False,
calendar_account: str = "小米日历",
) -> dict[str, Any]:
next_state = dict(self.raw)
next_state["events"] = list(self.get_list("events"))
next_state["events"].append(
Calendar.prepare_event(
event_id=event_id,
title=title,
date_text=date_text,
start_time=start_time,
end_time=end_time,
created_at=created_at,
description=description,
reminder_minutes_before=reminder_minutes_before,
alarm_enabled=alarm_enabled,
event_type=event_type,
all_day=all_day,
calendar_account=calendar_account,
)
)
return next_state
@staticmethod
def date_answer_pattern(date_value: str, os_state: dict[str, Any] | None = None) -> re.Pattern[str]:
labels = date_match_labels(date_value, os_state)
return re.compile("|".join(re.escape(label) for label in labels))
@staticmethod
def hhmm(timestamp_ms: int) -> str:
dt = datetime.datetime.fromtimestamp(timestamp_ms / 1000.0)
return f"{dt.hour:02d}:{dt.minute:02d}"
@staticmethod
def sample_future_date(env_state: dict[str, Any], rng: Any) -> str:
"""Sample a future date 3-14 days from sim_today."""
today = sim_today(env_state.get("os", {}))
offset = rng.choice(range(3, 15))
return (today + datetime.timedelta(days=offset)).isoformat()
@staticmethod
def sample_seed_date(env_state: dict[str, Any], rng: Any) -> str:
"""Sample a date with seed events (non-birthday)."""
today = sim_today(env_state.get("os", {}))
offset = rng.choice(SEED_DAY_OFFSETS)
return (today + datetime.timedelta(days=offset)).isoformat()
@staticmethod
def sample_seed_date_with_birthday(env_state: dict[str, Any], rng: Any) -> str:
"""Sample a date with seed events (including birthday)."""
today = sim_today(env_state.get("os", {}))
offset = rng.choice(SEED_DAY_OFFSETS + [SEED_BIRTHDAY_OFFSET])
return (today + datetime.timedelta(days=offset)).isoformat()
@staticmethod
def sample_seed_date_pair(env_state: dict[str, Any], rng: Any) -> dict[str, str]:
"""Sample two dates with guaranteed different event counts.
All SEED_DAY_OFFSETS dates have 2 events each, so picking two of
those always yields equal density. Instead, pair one dense seed date
(2 events) with a sparser date: the birthday date (1 event) or a
non-seed future date (0 events).
"""
today = sim_today(env_state.get("os", {}))
dense_offset = rng.choice(SEED_DAY_OFFSETS)
# birthday = 1 event, 30-day-out = 0 events
sparse_offset = rng.choice([SEED_BIRTHDAY_OFFSET, 30])
dates = [
(today + datetime.timedelta(days=dense_offset)).isoformat(),
(today + datetime.timedelta(days=sparse_offset)).isoformat(),
]
rng.shuffle(dates)
return {"date1": dates[0], "date2": dates[1]}
@staticmethod
def sample_interval_pair(env_state: dict[str, Any], rng: Any) -> dict[str, str]:
"""Sample two future dates for interval calculation.
覆盖 date1 和 date2 两个参数。gap 保证 ≥30 天。
"""
today = sim_today(env_state.get("os", {}))
start_offset = rng.randint(1, 30)
gap = rng.randint(30, 100)
d1 = (today + datetime.timedelta(days=start_offset)).isoformat()
d2 = (today + datetime.timedelta(days=start_offset + gap)).isoformat()
return {"date1": d1, "date2": d2}
@staticmethod
def sample_calc_forward(env_state: dict[str, Any], rng: Any) -> dict[str, Any]:
"""Sample a future date and day count for forward calculation.
覆盖 date 和 days 两个参数;参数 schema 上的 min/max 不生效。
"""
today = sim_today(env_state.get("os", {}))
offset = rng.randint(1, 30)
days = rng.randint(30, 100)
return {"date": (today + datetime.timedelta(days=offset)).isoformat(), "days": days}
@staticmethod
def sample_future_holiday(env_state: dict[str, Any], rng: Any) -> str:
"""Sample a holiday whose first rest day is still in the future."""
today = sim_today(env_state.get("os", {}))
future = [h for h, d in HOLIDAY_FIRST_REST.items()
if datetime.date.fromisoformat(d) > today]
if not future:
return rng.choice(list(HOLIDAY_FIRST_REST.keys()))
return rng.choice(future)
@staticmethod
def sample_future_holiday_with_makeup(env_state: dict[str, Any], rng: Any) -> str:
"""Sample a holiday with makeup workday still in the future."""
today = sim_today(env_state.get("os", {}))
future = [h for h, d in HOLIDAY_MAKEUP_DAYS.items()
if datetime.date.fromisoformat(d) > today]
if not future:
return rng.choice(list(HOLIDAY_MAKEUP_DAYS.keys()))
return rng.choice(future)
@staticmethod
def sample_time_range(env_state: dict[str, Any], rng: Any) -> dict[str, str]:
return rng.choice(
[
{"start": "09:00", "end": "10:30"},
{"start": "14:00", "end": "15:00"},
{"start": "18:30", "end": "20:00"},
]
)
@property
def events(self) -> list[dict[str, Any]]:
events = self.get("events")
if not isinstance(events, list):
raise ValueError("calendar.events is not a list")
return events
@staticmethod
def _match_title(actual: str, expected: str, fuzzy: bool = False) -> bool:
"""Compare event title. When fuzzy=True, check if expected is contained in actual."""
a = str(actual).strip()
e = expected.strip()
if not fuzzy:
return a == e
# Fuzzy match: check if expected keywords are contained in actual
return e in a
def count_events_with_title(self, title: str, *, fuzzy: bool = False) -> int:
return sum(1 for event in self.events if self._match_title(event["title"], title, fuzzy))
@staticmethod
def coerce_ts(value: Any, day: datetime.date | None = None) -> int | None:
if isinstance(value, (int, float)) and float(value) > 0:
return int(value)
text = str(value).strip()
if not text:
return None
if re.fullmatch(r"\d{12,}", text):
return int(text)
matched = re.fullmatch(r"(\d{1,2}):(\d{2})", text)
if matched and day:
hour = int(matched.group(1))
minute = int(matched.group(2))
dt = datetime.datetime(day.year, day.month, day.day, hour, minute, 0)
return int(dt.timestamp() * 1000)
dt = datetime.datetime.fromisoformat(text.replace("Z", "+00:00"))
return int(dt.timestamp() * 1000)
def find_events_on_date(self, day: datetime.date) -> list[dict[str, Any]]:
"""返回指定日期的日历事件。"""
start_ms, end_ms = day_bounds_ms(day)
events: list[dict[str, Any]] = []
for event in self.events:
start_ts = self.coerce_ts(event["startTs"], day)
end_ts = self.coerce_ts(event["endTs"], day)
if start_ts is None:
raise ValueError(f"calendar event {event['id']} missing startTs")
if end_ts is None:
if start_ms <= start_ts < end_ms:
events.append(event)
continue
if start_ts < end_ms and end_ts > start_ms:
events.append(event)
events.sort(key=lambda event: self.coerce_ts(event["startTs"], day) or 0)
return events
def count_events_on_date(self, day: datetime.date) -> int:
return len(self.find_events_on_date(day))
def first_event_on_date(self, day: datetime.date) -> dict[str, Any]:
events = self.find_events_on_date(day)
if not events:
raise ValueError(f"no events found on {day.isoformat()}")
return events[0]
def find_event_by_title(self, title: str, *, fuzzy: bool = False) -> dict[str, Any] | None:
for event in self.events:
if self._match_title(event["title"], title, fuzzy):
return event
return None
def find_events_by_keyword(self, keyword: str) -> list[dict[str, Any]]:
lowered = keyword.strip().lower()
matches = [
event
for event in self.events
if lowered in str(event["title"]).lower()
or lowered in str(event.get("description", "")).lower()
]
matches.sort(key=lambda event: self.coerce_ts(event["startTs"]) or 0, reverse=True)
return matches
def check_event_created(self, title: str, *, field: str = "event_created", fuzzy: bool = False) -> dict[str, Any]:
init_count = self.init.count_events_with_title(title, fuzzy=fuzzy)
curr_count = self.count_events_with_title(title, fuzzy=fuzzy)
return {
"field": field,
"expected": f"新增标题为 {title} 的日程",
"actual": {"initCount": init_count, "currCount": curr_count},
"passed": curr_count > init_count,
}
def check_no_event_created(
self, title: str, *, field: str = "event_not_created", fuzzy: bool = False
) -> dict[str, Any]:
init_count = self.init.count_events_with_title(title, fuzzy=fuzzy)
curr_count = self.count_events_with_title(title, fuzzy=fuzzy)
return {
"field": field,
"expected": f"不新增标题为 {title} 的日程",
"actual": {"initCount": init_count, "currCount": curr_count},
"passed": curr_count == init_count,
}
@staticmethod
def _event_identity(event: dict[str, Any]) -> str:
return str(event["id"])
def check_no_new_events(self, *, field: str = "no_new_events") -> dict[str, Any]:
"""检查是否没有新增任何日程事件(基于实体身份,非纯数量)。"""
init_counter = Counter(self._event_identity(e) for e in self.init.events)
curr_counter = Counter(self._event_identity(e) for e in self.events)
added = list((curr_counter - init_counter).elements())
return {
"field": field,
"expected": "不新增任何日程",
"actual": {"addedCount": len(added), "addedPreview": added[:5]},
"passed": len(added) == 0,
}
def check_event_deleted(self, title: str, *, field: str = "event_deleted") -> dict[str, Any]:
init_count = self.init.count_events_with_title(title)
curr_count = self.count_events_with_title(title)
return {
"field": field,
"expected": f"删除标题为 {title} 的日程",
"actual": {"initCount": init_count, "currCount": curr_count},
"passed": curr_count < init_count,
}
def check_event_on_date(self, title: str, date_value: str, *, field: str = "event_date", fuzzy: bool = False) -> dict[str, Any]:
event = self.find_event_by_title(title, fuzzy=fuzzy)
expected_start = self.start_of_day_ts(date_value)
actual_start = None if event is None else self.coerce_ts(event["startTs"])
passed = event is not None and actual_start is not None and expected_start <= actual_start < expected_start + 86400000
actual_date = None
if actual_start is not None:
actual_date = datetime.datetime.fromtimestamp(actual_start / 1000.0).strftime("%Y-%m-%d")
return {
"field": field,
"expected": date_value,
"actual": actual_date,
"passed": passed,
}
def check_event_time(
self,
title: str,
start_ts: int,
end_ts: int,
*,
field: str = "event_time",
) -> dict[str, Any]:
event = self.find_event_by_title(title)
actual = None if event is None else {
"startTs": self.coerce_ts(event["startTs"]),
"endTs": self.coerce_ts(event["endTs"]),
}
passed = event is not None and actual == {"startTs": start_ts, "endTs": end_ts}
return {
"field": field,
"expected": {"startTs": start_ts, "endTs": end_ts},
"actual": actual,
"passed": passed,
}
def check_event_type(self, title: str, event_type: str, *, field: str = "event_type", fuzzy: bool = False) -> dict[str, Any]:
event = self.find_event_by_title(title, fuzzy=fuzzy)
actual = None if event is None else event["type"]
return {
"field": field,
"expected": event_type,
"actual": actual,
"passed": event is not None and actual == event_type,
}
def check_event_reminder(self, title: str, reminder_minutes: int | None, *, field: str = "event_reminder") -> dict[str, Any]:
event = self.find_event_by_title(title)
actual = None if event is None else event.get("reminderMinutesBefore")
return {
"field": field,
"expected": reminder_minutes,
"actual": actual,
"passed": event is not None and actual == reminder_minutes,
}
def check_event_all_day(self, title: str, *, field: str = "event_all_day") -> dict[str, Any]:
event = self.find_event_by_title(title)
actual = None if event is None else event["allDay"]
return {
"field": field,
"expected": True,
"actual": actual,
"passed": event is not None and actual is True,
}
def check_event_alarm(self, title: str, enabled: bool, *, field: str = "event_alarm") -> dict[str, Any]:
event = self.find_event_by_title(title)
actual = None if event is None else event.get("alarmEnabled")
return {
"field": field,
"expected": enabled,
"actual": actual,
"passed": event is not None and actual == enabled,
}
def check_event_description_contains(
self, title: str, *keywords: str,
field: str = "event_description", fuzzy: bool = False,
) -> dict[str, Any]:
"""检查事件备注/描述是否包含所有关键词。"""
event = self.find_event_by_title(title, fuzzy=fuzzy)
desc = str(event.get("description", "") or "") if event is not None else ""
missing = [kw for kw in keywords if kw not in desc]
return {
"field": field,
"expected": f"备注包含 {list(keywords)}",
"actual": desc[:200] or "(none)",
"passed": event is not None and not missing,
}
def check_event_description_contains_number(
self, title: str, expected: float, *,
tolerance: float = 1.0, field: str = "event_description_number",
fuzzy: bool = False,
) -> dict[str, Any]:
"""检查事件备注/描述是否包含指定数值(±tolerance)。"""
event = self.find_event_by_title(title, fuzzy=fuzzy)
desc = str(event.get("description", "") or "") if event is not None else ""
numbers = extract_numbers(desc)
passed = event is not None and any(
abs(num - float(expected)) <= tolerance for num in numbers
)
return {
"field": field,
"expected": {"number": float(expected), "tolerance": tolerance},
"actual": {"desc": desc[:200] or "(none)", "numbers": numbers[:10]},
"passed": passed,
}
def check_event_start_aligns_ms(
self,
title: str,
target_start_ms: int,
*,
tolerance_ms: int = 3 * 60 * 1000,
field: str | None = None,
) -> dict[str, Any]:
"""日程标题存在且 startTs 与目标毫秒时间差在容差内(跨 App 对齐会议开始)。"""
if field is None:
field = "calendar_event_matches_meeting"
ev = self.find_event_by_title(title)
if ev is None:
return {
"field": field,
"expected": f"日历新增「{title}」,startTs ≈ 会议开始 ({target_start_ms})",
"actual": "event=无, startTs=None",
"passed": False,
}
actual_ms = self.coerce_ts(ev["startTs"])
time_ok = isinstance(actual_ms, int) and abs(actual_ms - int(target_start_ms)) <= int(tolerance_ms)
return {
"field": field,
"expected": f"日历新增「{title}」,startTs ≈ 会议开始 ({target_start_ms})",
"actual": f"event=存在, startTs={actual_ms}",
"passed": time_ok,
}
def check_event_start_reminder_alarm(
self,
title: str,
target_start_ms: int,
*,
reminder_minutes_before: int = 15,
alarm_enabled: bool = True,
tolerance_ms: int = 5 * 60 * 1000,
field: str | None = None,
) -> dict[str, Any]:
"""日程开始时间对齐,且提醒分钟数与闹钟开关满足期望。"""
if field is None:
field = f"flow_calendar_{title}"
ev = self.find_event_by_title(title)
if not ev:
return {
"field": field,
"expected": f"日程提前{reminder_minutes_before}分钟提醒且闹钟={alarm_enabled}",
"actual": None,
"passed": False,
}
est = self.coerce_ts(ev["startTs"])
cal_ok = (
isinstance(est, int)
and abs(est - int(target_start_ms)) <= int(tolerance_ms)
and ev["alarmEnabled"] is alarm_enabled
and int(ev["reminderMinutesBefore"]) == int(reminder_minutes_before)
)
cal_actual = {
"alarmEnabled": ev["alarmEnabled"],
"reminder": ev["reminderMinutesBefore"],
"startTs": ev["startTs"],
}
return {
"field": field,
"expected": f"日程提前{reminder_minutes_before}分钟提醒且闹钟={alarm_enabled}",
"actual": cal_actual,
"passed": cal_ok,
}
def check_event_title_updated(self, old_title: str, new_title: str, *, field: str = "event_title") -> dict[str, Any]:
old_curr = self.count_events_with_title(old_title)
old_init = self.init.count_events_with_title(old_title)
new_curr = self.count_events_with_title(new_title)
new_init = self.init.count_events_with_title(new_title)
return {
"field": field,
"expected": {"old": old_title, "new": new_title},
"actual": {
"oldInit": old_init,
"oldCurr": old_curr,
"newInit": new_init,
"newCurr": new_curr,
},
"passed": old_curr < old_init and new_curr > new_init,
}
def event_blocks_slot_on_date(
self,
day: datetime.date,
hour: int,
minute: int,
*,
slot_duration_minutes: int = 60,
) -> bool:
"""某日是否存在与 [day HH:MM, +slot_duration) 重叠的日程。"""
slot_ms = self.timestamp(day.isoformat(), f"{hour:02d}:{minute:02d}")
slot_end = slot_ms + int(slot_duration_minutes) * 60 * 1000
for ev in self.find_events_on_date(day):
st = self.coerce_ts(ev["startTs"], day)
if st is None:
continue
et = self.coerce_ts(ev["endTs"], day)
if et is None:
et = st + 3600000
if st < slot_end and et > slot_ms:
return True
return False
def tomorrow_blocks_slot(
self,
os_state: dict[str, Any],
hour: int,
minute: int,
*,
slot_duration_minutes: int = 60,
) -> bool:
day = sim_today(os_state) + datetime.timedelta(days=1)
return self.event_blocks_slot_on_date(day, hour, minute, slot_duration_minutes=slot_duration_minutes)
@staticmethod
def parse_hh_mm(time_text: str) -> tuple[int, int]:
matched = re.fullmatch(r"(\d{1,2}):(\d{2})", str(time_text).strip())
if not matched:
raise ValueError(f"时间格式应为 HH:MM,收到 {time_text!r}")
return int(matched.group(1)), int(matched.group(2))
@staticmethod
def tomorrow_datetime_at_hh_mm(os_state: dict, time_hhmm: str) -> datetime.datetime:
"""模拟「明天」在 HH:MM 的本地时间(用于跨 App 会议预约判题)。"""
hour, minute = Calendar.parse_hh_mm(time_hhmm)
base = sim_datetime(os_state) + datetime.timedelta(days=1)
return base.replace(hour=hour, minute=minute, second=0, microsecond=0)
@staticmethod
def tomorrow_timestamp_ms_at_hh_mm(os_state: dict, time_hhmm: str) -> int:
return int(Calendar.tomorrow_datetime_at_hh_mm(os_state, time_hhmm).timestamp() * 1000)