purewhiter--mobilegym
803 行
30 KiB
Python
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)
|