883 lines
34 KiB
Python
883 lines
34 KiB
Python
"""
|
|
crossapp_work task correctness tests.
|
|
|
|
This suite has no dedicated app accessor (`bench_env/task/crossapp_work/app.py`),
|
|
so we cover:
|
|
1. task definition validation
|
|
2. cross-app metadata sanity checks from bench_env/docs/task/TASK_CODE_SPEC.md
|
|
3. offline judge positive/negative matrix
|
|
4. extra branch cases for conditional tasks
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import datetime
|
|
import importlib
|
|
import inspect
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from bench_env.task.base import BaseTask
|
|
from bench_env.task.calendar.app import Calendar
|
|
from bench_env.task.clock.app import Clock
|
|
from bench_env.task.crossapp_work import tasks as _tasks_module
|
|
from bench_env.task.judge import JudgeInput
|
|
from bench_env.task.map.app import Map
|
|
from bench_env.task.sms.app import Sms
|
|
from bench_env.task.tencent_meeting.app import TencentMeeting
|
|
from bench_env.task.utils import parse_duration_to_minutes, sim_today
|
|
from bench_env.task.weather.app import Weather
|
|
from bench_env.task.wechat.app import Wechat
|
|
from bench_env.tests.conftest import make_judge_input
|
|
from bench_env.tests.calendar.test_tasks import BASE_STATE as CALENDAR_BASE_STATE, _add_event
|
|
from bench_env.tests.clock.test_tasks import DEFAULTS as CLOCK_BASE_STATE, _with_new_alarm
|
|
from bench_env.tests.map.test_tasks import BASE_STATE as MAP_BASE_STATE, _with_new_search, _state as _map_state
|
|
from bench_env.tests.notes.test_tasks import BASE_STATE as NOTES_BASE_STATE, _add_note
|
|
from bench_env.tests.sms.test_tasks import (
|
|
BASE_APP_STATE as SMS_APP_STATE,
|
|
BASE_STATE as SMS_PROVIDER_STATE,
|
|
_append_outgoing_message as _append_sms_outgoing,
|
|
)
|
|
from bench_env.tests.tencent_meeting.test_tasks import (
|
|
BASE_STATE as TM_BASE_STATE,
|
|
TEST_OS_STATE,
|
|
_make_active_meeting,
|
|
_make_new_scheduled_meeting,
|
|
)
|
|
from bench_env.tests.weather.test_tasks import BASE_STATE as WEATHER_BASE_STATE
|
|
|
|
ROOT = Path(__file__).resolve().parents[3]
|
|
DEFAULT_ROUTE = {"app": "launcher", "path": "/"}
|
|
|
|
|
|
def _load_json(*parts: str) -> dict[str, Any]:
|
|
path = ROOT.joinpath(*parts)
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
WECHAT_BASE_STATE = _load_json("apps", "Wechat", "data", "defaults.json")
|
|
CONTACTS_PROVIDER_STATE = _load_json("os", "providers", "defaults", "contacts.json")
|
|
|
|
ALL_TASK_CLASSES: list[type[BaseTask]] = [
|
|
obj
|
|
for _, obj in inspect.getmembers(_tasks_module, inspect.isclass)
|
|
if issubclass(obj, BaseTask) and obj is not BaseTask and obj.__module__ == _tasks_module.__name__
|
|
]
|
|
ALL_TASK_IDS = [cls.__name__ for cls in ALL_TASK_CLASSES]
|
|
|
|
|
|
def _base_apps() -> dict[str, Any]:
|
|
return {
|
|
"calendar": copy.deepcopy(CALENDAR_BASE_STATE),
|
|
"clock": copy.deepcopy(CLOCK_BASE_STATE),
|
|
"map": copy.deepcopy(MAP_BASE_STATE),
|
|
"notes": copy.deepcopy(NOTES_BASE_STATE),
|
|
"sms": copy.deepcopy(SMS_APP_STATE),
|
|
"tencent_meeting": copy.deepcopy(TM_BASE_STATE),
|
|
"weather": copy.deepcopy(WEATHER_BASE_STATE),
|
|
"wechat": copy.deepcopy(WECHAT_BASE_STATE),
|
|
}
|
|
|
|
|
|
def _apps_state(**patches: dict[str, Any]) -> dict[str, Any]:
|
|
apps = _base_apps()
|
|
for key, value in patches.items():
|
|
apps[key] = copy.deepcopy(value)
|
|
return apps
|
|
|
|
|
|
def _base_os() -> dict[str, Any]:
|
|
return {
|
|
"time": copy.deepcopy(TEST_OS_STATE["time"]),
|
|
"providers": {
|
|
"contacts": copy.deepcopy(CONTACTS_PROVIDER_STATE),
|
|
"sms": copy.deepcopy(SMS_PROVIDER_STATE),
|
|
},
|
|
}
|
|
|
|
|
|
def _make_input(
|
|
init_apps: dict[str, Any],
|
|
curr_apps: dict[str, Any],
|
|
*,
|
|
init_os: dict[str, Any] | None = None,
|
|
curr_os: dict[str, Any] | None = None,
|
|
route: dict[str, Any] | None = None,
|
|
answer: str | None = None,
|
|
) -> JudgeInput:
|
|
return make_judge_input(
|
|
{"apps": init_apps, "os": init_os or _base_os()},
|
|
{"apps": curr_apps, "os": curr_os or _base_os()},
|
|
route=route or DEFAULT_ROUTE,
|
|
answer=answer,
|
|
)
|
|
|
|
|
|
def _ensure_wechat_chat(state: dict[str, Any], contact_name: str) -> dict[str, Any]:
|
|
wechat = Wechat(state)
|
|
wxid = wechat.require_contact_wxid(contact_name)
|
|
chat = next((item for item in state["chats"] if str(item["id"]) == wxid), None)
|
|
if chat is not None:
|
|
return chat
|
|
contact = wechat.contact_by_name(contact_name)
|
|
chat = {
|
|
"id": wxid,
|
|
"user": {
|
|
"wxid": wxid,
|
|
"name": contact["name"],
|
|
"avatar": contact.get("avatar", ""),
|
|
},
|
|
"isMuted": False,
|
|
"isSticky": False,
|
|
"isAlert": False,
|
|
"messages": [],
|
|
}
|
|
state["chats"].insert(0, chat)
|
|
return chat
|
|
|
|
|
|
def _append_wechat_outgoing(state: dict[str, Any], contact_name: str, content: str) -> None:
|
|
chat = _ensure_wechat_chat(state, contact_name)
|
|
chat["messages"].append(
|
|
{
|
|
"id": f"test_out_{len(chat['messages']) + 1}",
|
|
"type": "text",
|
|
"content": content,
|
|
"senderId": state["user"]["wxid"],
|
|
"timestamp": TEST_OS_STATE["time"]["timestamp"],
|
|
}
|
|
)
|
|
|
|
|
|
def _append_wechat_incoming(state: dict[str, Any], contact_name: str, content: str) -> None:
|
|
chat = _ensure_wechat_chat(state, contact_name)
|
|
chat["messages"].append(
|
|
{
|
|
"id": f"test_in_{len(chat['messages']) + 1}",
|
|
"type": "text",
|
|
"content": content,
|
|
"senderId": chat["user"]["wxid"],
|
|
"timestamp": TEST_OS_STATE["time"]["timestamp"],
|
|
}
|
|
)
|
|
|
|
|
|
def _map_search_state(query: str) -> dict[str, Any]:
|
|
results = Map.geo_search(query, limit=0)
|
|
if not results:
|
|
raise ValueError(f"No map search results for {query!r}")
|
|
return _with_new_search(_map_state(search_results=results, active_poi=results[0]), query)
|
|
|
|
|
|
def _tm_new_meeting(
|
|
*,
|
|
topic: str,
|
|
start_ms: int,
|
|
duration: int = 60,
|
|
meeting_id: str = "888 666 1234",
|
|
password: str | None = None,
|
|
invitees: list[dict[str, Any]] | None = None,
|
|
) -> dict[str, Any]:
|
|
meeting = _make_new_scheduled_meeting(
|
|
topic=topic,
|
|
duration=duration,
|
|
password=password,
|
|
invitees=invitees,
|
|
)
|
|
meeting["startTime"] = start_ms
|
|
meeting["createdAt"] = TEST_OS_STATE["time"]["timestamp"] + 1000
|
|
meeting["meetingId"] = meeting_id
|
|
return meeting
|
|
|
|
|
|
def _tm_with_added_meeting(state: dict[str, Any], meeting: dict[str, Any]) -> dict[str, Any]:
|
|
next_state = copy.deepcopy(state)
|
|
next_state["scheduledMeetings"] = [*next_state["scheduledMeetings"], copy.deepcopy(meeting)]
|
|
return next_state
|
|
|
|
|
|
def _tm_without_topic(state: dict[str, Any], topic: str) -> dict[str, Any]:
|
|
next_state = copy.deepcopy(state)
|
|
next_state["scheduledMeetings"] = [
|
|
item for item in next_state["scheduledMeetings"] if str(item["title"]) != topic
|
|
]
|
|
return next_state
|
|
|
|
|
|
def _tm_with_active_meeting(
|
|
state: dict[str, Any],
|
|
*,
|
|
host_name: str,
|
|
topic: str,
|
|
self_name: str,
|
|
) -> dict[str, Any]:
|
|
next_state = copy.deepcopy(state)
|
|
ongoing = TencentMeeting(next_state).find_ongoing_entry(host_name, topic)
|
|
next_state["activeMeeting"] = _make_active_meeting(
|
|
title=topic,
|
|
host_id=str(ongoing["hostId"]),
|
|
host_name=host_name,
|
|
meeting_id=str(ongoing["meetingId"]),
|
|
user_name=self_name,
|
|
)
|
|
return next_state
|
|
|
|
|
|
def _inject_sms_incoming(
|
|
provider_state: dict[str, Any],
|
|
sender: str,
|
|
*,
|
|
content: str,
|
|
is_unread: bool = True,
|
|
) -> dict[str, Any]:
|
|
return Sms(copy.deepcopy(provider_state)).prepare_state_with_incoming_message(
|
|
sender,
|
|
content,
|
|
message_id=f"test_in_{sender.encode('utf-8').hex()[:16]}",
|
|
timestamp="18:00",
|
|
is_unread=is_unread,
|
|
)
|
|
|
|
|
|
def _meeting_date_and_time(start_ms: int) -> tuple[str, str]:
|
|
dt = datetime.datetime.fromtimestamp(start_ms / 1000.0)
|
|
return dt.date().isoformat(), dt.strftime("%H:%M")
|
|
|
|
|
|
class TestTaskDefinitions:
|
|
@pytest.mark.parametrize("cls", ALL_TASK_CLASSES, ids=ALL_TASK_IDS)
|
|
def test_instantiation(self, cls):
|
|
task = cls()
|
|
assert task.name == cls.__name__
|
|
assert task.templates
|
|
assert len(task.apps) >= 2
|
|
|
|
@pytest.mark.parametrize("cls", ALL_TASK_CLASSES, ids=ALL_TASK_IDS)
|
|
def test_description_renders(self, cls):
|
|
task = cls()
|
|
task._env_state = {"os": _base_os()}
|
|
desc = task.description
|
|
assert desc
|
|
assert "{" not in desc
|
|
|
|
@pytest.mark.parametrize("cls", ALL_TASK_CLASSES, ids=ALL_TASK_IDS)
|
|
def test_required_class_attrs(self, cls):
|
|
assert cls.scope in ("S1", "S2", "S3")
|
|
assert cls.objective in ("operate", "query", "hybrid")
|
|
assert cls.composition in ("atomic", "sequential", "transfer", "deep_dive")
|
|
assert cls.difficulty in ("L1", "L2", "L3", "L4")
|
|
assert isinstance(cls.capabilities, list) and cls.capabilities
|
|
|
|
@pytest.mark.parametrize("cls", ALL_TASK_CLASSES, ids=ALL_TASK_IDS)
|
|
def test_parameter_defaults_present(self, cls):
|
|
for key, schema in cls.parameters.items():
|
|
if key.startswith("_"):
|
|
continue
|
|
assert "default" in schema
|
|
|
|
@pytest.mark.parametrize("cls", ALL_TASK_CLASSES, ids=ALL_TASK_IDS)
|
|
def test_expected_changes_use_scoped_paths(self, cls):
|
|
for path in getattr(cls, "expected_changes", []):
|
|
if path.startswith(("apps.", "os.")):
|
|
continue
|
|
assert "." in path, f"{cls.__name__}: cross-app expected_changes 必须使用 app.path 形式,收到 {path!r}"
|
|
|
|
|
|
class TestWechatInjectionHelpers:
|
|
def test_prepare_state_with_incoming_text_creates_standard_message(self):
|
|
state = copy.deepcopy(WECHAT_BASE_STATE)
|
|
wechat = Wechat(state)
|
|
updated = wechat.prepare_state_with_incoming_text(
|
|
"陈静",
|
|
"周末去中国国家博物馆怎么样?",
|
|
message_id="crossapp_life_place_中国国家博物馆",
|
|
timestamp=TEST_OS_STATE["time"]["timestamp"],
|
|
)
|
|
updated_wechat = Wechat(updated)
|
|
assert updated_wechat.last_received_text_from("陈静") == "周末去中国国家博物馆怎么样?"
|
|
chat = updated_wechat.get_chat_with("陈静")
|
|
assert chat is not None
|
|
assert chat["messages"][-1]["id"] == "crossapp_life_place_中国国家博物馆"
|
|
|
|
|
|
class TestSmsInjectionHelpers:
|
|
def test_prepare_state_with_incoming_message_creates_contact_conversation(self):
|
|
state = copy.deepcopy(SMS_PROVIDER_STATE)
|
|
updated = Sms(state).prepare_state_with_incoming_message(
|
|
"张三",
|
|
"我是张三,明早九点前回我。",
|
|
message_id="crossapp_work_sms_contact_zhangsan",
|
|
timestamp="18:00",
|
|
is_unread=True,
|
|
)
|
|
|
|
sms = Sms(updated)
|
|
conv = sms.conversation_by_sender("张三")
|
|
assert conv["isUnread"] is True
|
|
assert sms.latest_incoming_content_from("张三") == "我是张三,明早九点前回我。"
|
|
|
|
|
|
class TestCrossAppAccessorHelpers:
|
|
def test_tencent_meeting_any_new_scheduled_meeting(self):
|
|
start_ms = Calendar.tomorrow_timestamp_ms_at_hh_mm(_base_os(), "15:00")
|
|
curr_tm = _tm_with_added_meeting(
|
|
TM_BASE_STATE,
|
|
_tm_new_meeting(topic="临时同步会", start_ms=start_ms, meeting_id="777 888 999"),
|
|
)
|
|
tm = TencentMeeting(curr_tm, init=TM_BASE_STATE)
|
|
|
|
meeting = tm.any_new_scheduled_meeting()
|
|
|
|
assert meeting is not None
|
|
assert meeting["title"] == "临时同步会"
|
|
|
|
def test_tencent_meeting_check_no_new_scheduled_meeting(self):
|
|
tm = TencentMeeting(copy.deepcopy(TM_BASE_STATE), init=copy.deepcopy(TM_BASE_STATE))
|
|
|
|
result = tm.check_no_new_scheduled_meeting("临时协调会", field="busy_no_meeting")
|
|
|
|
assert result["passed"] is True
|
|
|
|
def test_calendar_check_no_event_created(self):
|
|
calendar = Calendar(copy.deepcopy(CALENDAR_BASE_STATE), init=copy.deepcopy(CALENDAR_BASE_STATE))
|
|
|
|
result = calendar.check_no_event_created("临时协调会", field="busy_no_calendar_event")
|
|
|
|
assert result["passed"] is True
|
|
|
|
def test_clock_check_no_new_alarm_at(self):
|
|
clock = Clock(copy.deepcopy(CLOCK_BASE_STATE), init=copy.deepcopy(CLOCK_BASE_STATE))
|
|
|
|
result = clock.check_no_new_alarm_at(3, 15, field="busy_no_alarm")
|
|
|
|
assert result["passed"] is True
|
|
|
|
def test_sms_check_no_new_sent_to(self):
|
|
sms = Sms(copy.deepcopy(SMS_PROVIDER_STATE), init=copy.deepcopy(SMS_PROVIDER_STATE))
|
|
|
|
result = sms.check_no_new_sent_to("张三", field="busy_no_sms")
|
|
|
|
assert result["passed"] is True
|
|
|
|
|
|
class _FakeEnv:
|
|
def __init__(self, state: dict[str, Any]):
|
|
self.state = copy.deepcopy(state)
|
|
|
|
async def get_state(self) -> dict[str, Any]:
|
|
return copy.deepcopy(self.state)
|
|
|
|
async def set_state(self, patch: dict[str, Any], *, deep: bool, reload: bool) -> None:
|
|
assert deep is True
|
|
assert reload is False
|
|
self.state["os"]["providers"]["sms"] = copy.deepcopy(patch["os"]["providers"]["sms"])
|
|
def _existing_meeting_to_calendar_positive():
|
|
task = _tasks_module.ExistingMeetingToCalendar(topic="项目例会")
|
|
meeting = TencentMeeting(copy.deepcopy(TM_BASE_STATE)).find_scheduled_meeting(task.p.topic)
|
|
date_value, time_value = _meeting_date_and_time(TencentMeeting.meeting_start_timestamp_ms(meeting))
|
|
curr_calendar = _add_event(
|
|
CALENDAR_BASE_STATE,
|
|
title=task.p.topic,
|
|
date_value=date_value,
|
|
start=time_value,
|
|
end="12:00",
|
|
)
|
|
return task, _make_input(_apps_state(), _apps_state(calendar=curr_calendar))
|
|
|
|
|
|
def _existing_meeting_to_calendar_negative():
|
|
task = _tasks_module.ExistingMeetingToCalendar(topic="项目例会")
|
|
curr_calendar = _add_event(
|
|
CALENDAR_BASE_STATE,
|
|
title=task.p.topic,
|
|
date_value="2026-03-16",
|
|
start="11:30",
|
|
end="12:30",
|
|
)
|
|
return task, _make_input(_apps_state(), _apps_state(calendar=curr_calendar))
|
|
def _calendar_earliest_to_alarm_positive():
|
|
task = _tasks_module.CalendarEarliestToAlarm()
|
|
tomorrow = (sim_today(_base_os()) + datetime.timedelta(days=1)).isoformat()
|
|
calendar_state = _add_event(CALENDAR_BASE_STATE, title="临时早会A", date_value=tomorrow, start="09:30", end="10:30")
|
|
calendar_state = _add_event(calendar_state, title="临时早会B", date_value=tomorrow, start="11:00", end="12:00")
|
|
clock_state = _with_new_alarm(CLOCK_BASE_STATE, alarm_id="test_earliest", hour=9, minute=0)
|
|
return task, _make_input(
|
|
_apps_state(calendar=calendar_state),
|
|
_apps_state(calendar=calendar_state, clock=clock_state),
|
|
)
|
|
|
|
|
|
def _calendar_earliest_to_alarm_negative():
|
|
task = _tasks_module.CalendarEarliestToAlarm()
|
|
tomorrow = (sim_today(_base_os()) + datetime.timedelta(days=1)).isoformat()
|
|
calendar_state = _add_event(CALENDAR_BASE_STATE, title="临时早会A", date_value=tomorrow, start="09:30", end="10:30")
|
|
calendar_state = _add_event(calendar_state, title="临时早会B", date_value=tomorrow, start="11:00", end="12:00")
|
|
clock_state = _with_new_alarm(CLOCK_BASE_STATE, alarm_id="test_earliest", hour=8, minute=45)
|
|
return task, _make_input(
|
|
_apps_state(calendar=calendar_state),
|
|
_apps_state(calendar=calendar_state, clock=clock_state),
|
|
)
|
|
|
|
|
|
def _meeting_longest_info_to_wechat_positive():
|
|
task = _tasks_module.MeetingLongestInfoToWechat(date="2026-02-03", contact="陈静")
|
|
longest = TencentMeeting(copy.deepcopy(TM_BASE_STATE)).longest_history_meeting_on_date(task.p.date)
|
|
mid = re.sub(r"\s+", "", str(longest["meetingId"]))
|
|
curr_wechat = copy.deepcopy(WECHAT_BASE_STATE)
|
|
_append_wechat_outgoing(curr_wechat, task.p.contact, f"{task.p.date} 开得最久的是{longest['title']},会议号 {mid}")
|
|
return task, _make_input(_apps_state(), _apps_state(wechat=curr_wechat))
|
|
|
|
|
|
def _meeting_longest_info_to_wechat_negative():
|
|
task = _tasks_module.MeetingLongestInfoToWechat(date="2026-02-03", contact="陈静")
|
|
curr_wechat = copy.deepcopy(WECHAT_BASE_STATE)
|
|
_append_wechat_outgoing(curr_wechat, task.p.contact, "2026-02-03 开得最久的是长时间研讨会,会议号 111222333")
|
|
return task, _make_input(_apps_state(), _apps_state(wechat=curr_wechat))
|
|
|
|
|
|
def _meeting_duration_to_wechat_positive():
|
|
task = _tasks_module.MeetingDurationToWechat(date="2026-02-03", contact="陈静")
|
|
total = TencentMeeting(copy.deepcopy(TM_BASE_STATE)).total_participation_minutes_on_date(task.p.date)
|
|
expected = TencentMeeting.format_duration_minutes_zh(total)
|
|
curr_wechat = copy.deepcopy(WECHAT_BASE_STATE)
|
|
_append_wechat_outgoing(curr_wechat, task.p.contact, f"{task.p.date} 这天我一共开了 {expected}")
|
|
return task, _make_input(_apps_state(), _apps_state(wechat=curr_wechat))
|
|
|
|
|
|
def _meeting_duration_to_wechat_negative():
|
|
task = _tasks_module.MeetingDurationToWechat(date="2026-02-03", contact="陈静")
|
|
curr_wechat = copy.deepcopy(WECHAT_BASE_STATE)
|
|
_append_wechat_outgoing(curr_wechat, task.p.contact, f"{task.p.date} 这天我一共开了 4小时8分")
|
|
return task, _make_input(_apps_state(), _apps_state(wechat=curr_wechat))
|
|
def _weather_conditional_cancel_meeting_positive():
|
|
task = _tasks_module.WeatherConditionalCancelMeeting(city="北京", topic="项目例会")
|
|
meeting = TencentMeeting(copy.deepcopy(TM_BASE_STATE)).find_scheduled_meeting(task.p.topic)
|
|
start_ms = TencentMeeting.meeting_start_timestamp_ms(meeting)
|
|
alarm_dt = datetime.datetime.fromtimestamp(start_ms / 1000.0) - datetime.timedelta(minutes=30)
|
|
curr_clock = _with_new_alarm(
|
|
CLOCK_BASE_STATE,
|
|
alarm_id="weather_keep_alarm",
|
|
hour=alarm_dt.hour,
|
|
minute=alarm_dt.minute,
|
|
)
|
|
return task, _make_input(_apps_state(), _apps_state(clock=curr_clock))
|
|
|
|
|
|
def _weather_conditional_cancel_meeting_negative():
|
|
task = _tasks_module.WeatherConditionalCancelMeeting(city="北京", topic="项目例会")
|
|
curr_tm = _tm_without_topic(TM_BASE_STATE, task.p.topic)
|
|
return task, _make_input(_apps_state(), _apps_state(tencent_meeting=curr_tm))
|
|
def _meeting_join_and_notify_sms_positive():
|
|
task = _tasks_module.MeetingJoinAndNotifySms(
|
|
host_name="老王",
|
|
topic="老王的快速会议",
|
|
name="访客小王",
|
|
contact="张三",
|
|
)
|
|
curr_tm = _tm_with_active_meeting(TM_BASE_STATE, host_name=task.p.host_name, topic=task.p.topic, self_name=task.p.name)
|
|
curr_os = _base_os()
|
|
_append_sms_outgoing(curr_os["providers"]["sms"], task.p.contact, "我已经入会了")
|
|
return task, _make_input(_apps_state(), _apps_state(tencent_meeting=curr_tm), curr_os=curr_os)
|
|
|
|
|
|
def _meeting_join_and_notify_sms_negative():
|
|
task = _tasks_module.MeetingJoinAndNotifySms(
|
|
host_name="老王",
|
|
topic="老王的快速会议",
|
|
name="访客小王",
|
|
contact="张三",
|
|
)
|
|
curr_tm = _tm_with_active_meeting(TM_BASE_STATE, host_name=task.p.host_name, topic=task.p.topic, self_name="错误昵称")
|
|
curr_os = _base_os()
|
|
_append_sms_outgoing(curr_os["providers"]["sms"], task.p.contact, "我已经入会了")
|
|
return task, _make_input(_apps_state(), _apps_state(tencent_meeting=curr_tm), curr_os=curr_os)
|
|
|
|
|
|
def _meeting_multi_channel_notify_positive():
|
|
task = _tasks_module.MeetingMultiChannelNotify(contact1="陈静", contact2="张三")
|
|
meeting = _tm_new_meeting(
|
|
topic="临时同步会",
|
|
start_ms=Calendar.tomorrow_timestamp_ms_at_hh_mm(_base_os(), "15:00"),
|
|
meeting_id="777 888 999",
|
|
)
|
|
curr_tm = _tm_with_added_meeting(TM_BASE_STATE, meeting)
|
|
curr_wechat = copy.deepcopy(WECHAT_BASE_STATE)
|
|
_append_wechat_outgoing(curr_wechat, task.p.contact1, f"会议号 {meeting['meetingId']}")
|
|
curr_os = _base_os()
|
|
_append_sms_outgoing(curr_os["providers"]["sms"], task.p.contact2, f"会议号 {meeting['meetingId']}")
|
|
return task, _make_input(
|
|
_apps_state(),
|
|
_apps_state(tencent_meeting=curr_tm, wechat=curr_wechat),
|
|
curr_os=curr_os,
|
|
)
|
|
|
|
|
|
def _meeting_multi_channel_notify_negative():
|
|
task = _tasks_module.MeetingMultiChannelNotify(contact1="陈静", contact2="张三")
|
|
meeting = _tm_new_meeting(
|
|
topic="临时同步会",
|
|
start_ms=Calendar.tomorrow_timestamp_ms_at_hh_mm(_base_os(), "15:00"),
|
|
meeting_id="777 888 999",
|
|
)
|
|
curr_tm = _tm_with_added_meeting(TM_BASE_STATE, meeting)
|
|
curr_wechat = copy.deepcopy(WECHAT_BASE_STATE)
|
|
_append_wechat_outgoing(curr_wechat, task.p.contact1, f"会议号 {meeting['meetingId']}")
|
|
curr_os = _base_os()
|
|
_append_sms_outgoing(curr_os["providers"]["sms"], task.p.contact2, "会议号 111 222 333")
|
|
return task, _make_input(
|
|
_apps_state(),
|
|
_apps_state(tencent_meeting=curr_tm, wechat=curr_wechat),
|
|
curr_os=curr_os,
|
|
)
|
|
|
|
|
|
def _meeting_route_eta_to_wechat_positive():
|
|
task = _tasks_module.MeetingRouteEtaToWechat(place="中国国家博物馆", contact="陈静")
|
|
route = Map.geo_route_to(task.p.place, "WALKING")
|
|
duration = str(route["duration"])
|
|
time_str = TencentMeeting(copy.deepcopy(TM_BASE_STATE)).meeting_start_hh_mm(
|
|
TencentMeeting(copy.deepcopy(TM_BASE_STATE)).first_upcoming_scheduled(TEST_OS_STATE["time"]["timestamp"])
|
|
)
|
|
curr_wechat = copy.deepcopy(WECHAT_BASE_STATE)
|
|
_append_wechat_outgoing(curr_wechat, task.p.contact, f"走到{task.p.place}大概{duration},会议 {time_str} 开始")
|
|
curr_map = _map_search_state(task.p.place)
|
|
return task, _make_input(_apps_state(), _apps_state(map=curr_map, wechat=curr_wechat))
|
|
|
|
|
|
def _meeting_route_eta_to_wechat_negative():
|
|
task = _tasks_module.MeetingRouteEtaToWechat(place="中国国家博物馆", contact="陈静")
|
|
route = Map.geo_route_to(task.p.place, "WALKING")
|
|
duration = str(route["duration"])
|
|
curr_wechat = copy.deepcopy(WECHAT_BASE_STATE)
|
|
_append_wechat_outgoing(curr_wechat, task.p.contact, f"走到{task.p.place}大概{duration}")
|
|
curr_map = _map_search_state(task.p.place)
|
|
return task, _make_input(_apps_state(), _apps_state(map=curr_map, wechat=curr_wechat))
|
|
def _meeting_full_flow_to_wechat_positive():
|
|
task = _tasks_module.MeetingFullFlowToWechat(time="10:00", contact="陈静")
|
|
start_ms = Calendar.tomorrow_timestamp_ms_at_hh_mm(_base_os(), task.p.time)
|
|
date_value, time_value = _meeting_date_and_time(start_ms)
|
|
meeting = _tm_new_meeting(topic=task.title_meeting, start_ms=start_ms, meeting_id="321 654 987")
|
|
curr_tm = _tm_with_added_meeting(TM_BASE_STATE, meeting)
|
|
curr_calendar = _add_event(
|
|
CALENDAR_BASE_STATE,
|
|
title=task.title_meeting,
|
|
date_value=date_value,
|
|
start=time_value,
|
|
end="11:00",
|
|
reminder=15,
|
|
alarm=True,
|
|
)
|
|
curr_wechat = copy.deepcopy(WECHAT_BASE_STATE)
|
|
_append_wechat_outgoing(curr_wechat, task.p.contact, f"项目周会会议号 {meeting['meetingId']}")
|
|
return task, _make_input(
|
|
_apps_state(),
|
|
_apps_state(tencent_meeting=curr_tm, calendar=curr_calendar, wechat=curr_wechat),
|
|
)
|
|
|
|
|
|
def _meeting_full_flow_to_wechat_negative():
|
|
task = _tasks_module.MeetingFullFlowToWechat(time="10:00", contact="陈静")
|
|
start_ms = Calendar.tomorrow_timestamp_ms_at_hh_mm(_base_os(), task.p.time)
|
|
date_value, time_value = _meeting_date_and_time(start_ms)
|
|
meeting = _tm_new_meeting(topic=task.title_meeting, start_ms=start_ms, meeting_id="321 654 987")
|
|
curr_tm = _tm_with_added_meeting(TM_BASE_STATE, meeting)
|
|
curr_calendar = _add_event(
|
|
CALENDAR_BASE_STATE,
|
|
title=task.title_meeting,
|
|
date_value=date_value,
|
|
start=time_value,
|
|
end="11:00",
|
|
reminder=5,
|
|
alarm=True,
|
|
)
|
|
curr_wechat = copy.deepcopy(WECHAT_BASE_STATE)
|
|
_append_wechat_outgoing(curr_wechat, task.p.contact, f"项目周会会议号 {meeting['meetingId']}")
|
|
return task, _make_input(
|
|
_apps_state(),
|
|
_apps_state(tencent_meeting=curr_tm, calendar=curr_calendar, wechat=curr_wechat),
|
|
)
|
|
|
|
|
|
def _full_meeting_conflict_check_broadcast_positive():
|
|
task = _tasks_module.FullMeetingConflictCheckBroadcast(
|
|
time="03:30",
|
|
contact="陈静",
|
|
contact2="张三",
|
|
flow_topic="临时协调会",
|
|
)
|
|
start_ms = Calendar.tomorrow_timestamp_ms_at_hh_mm(_base_os(), task.p.time)
|
|
date_value, time_value = _meeting_date_and_time(start_ms)
|
|
meeting = _tm_new_meeting(topic=task.p.flow_topic, start_ms=start_ms, meeting_id="555 444 333")
|
|
curr_tm = _tm_with_added_meeting(TM_BASE_STATE, meeting)
|
|
curr_calendar = _add_event(
|
|
CALENDAR_BASE_STATE,
|
|
title=task.p.flow_topic,
|
|
date_value=date_value,
|
|
start=time_value,
|
|
end="04:30",
|
|
reminder=15,
|
|
alarm=True,
|
|
)
|
|
curr_clock = _with_new_alarm(CLOCK_BASE_STATE, alarm_id="prep", hour=3, minute=15)
|
|
curr_wechat = copy.deepcopy(WECHAT_BASE_STATE)
|
|
_append_wechat_outgoing(curr_wechat, task.p.contact, f"会议号 {meeting['meetingId']}")
|
|
curr_os = _base_os()
|
|
_append_sms_outgoing(curr_os["providers"]["sms"], task.p.contact2, f"会议号 {meeting['meetingId']}")
|
|
return task, _make_input(
|
|
_apps_state(),
|
|
_apps_state(
|
|
calendar=curr_calendar,
|
|
clock=curr_clock,
|
|
tencent_meeting=curr_tm,
|
|
wechat=curr_wechat,
|
|
),
|
|
curr_os=curr_os,
|
|
)
|
|
|
|
|
|
def _full_meeting_conflict_check_broadcast_negative():
|
|
task = _tasks_module.FullMeetingConflictCheckBroadcast(
|
|
time="03:30",
|
|
contact="陈静",
|
|
contact2="张三",
|
|
flow_topic="临时协调会",
|
|
)
|
|
start_ms = Calendar.tomorrow_timestamp_ms_at_hh_mm(_base_os(), task.p.time)
|
|
date_value, time_value = _meeting_date_and_time(start_ms)
|
|
meeting = _tm_new_meeting(topic=task.p.flow_topic, start_ms=start_ms, meeting_id="555 444 333")
|
|
curr_tm = _tm_with_added_meeting(TM_BASE_STATE, meeting)
|
|
curr_calendar = _add_event(
|
|
CALENDAR_BASE_STATE,
|
|
title=task.p.flow_topic,
|
|
date_value=date_value,
|
|
start=time_value,
|
|
end="04:30",
|
|
reminder=15,
|
|
alarm=True,
|
|
)
|
|
curr_clock = _with_new_alarm(CLOCK_BASE_STATE, alarm_id="prep", hour=3, minute=15)
|
|
curr_wechat = copy.deepcopy(WECHAT_BASE_STATE)
|
|
_append_wechat_outgoing(curr_wechat, task.p.contact, f"会议号 {meeting['meetingId']}")
|
|
return task, _make_input(
|
|
_apps_state(),
|
|
_apps_state(
|
|
calendar=curr_calendar,
|
|
clock=curr_clock,
|
|
tencent_meeting=curr_tm,
|
|
wechat=curr_wechat,
|
|
),
|
|
)
|
|
def _meeting_reminder_to_notes_positive():
|
|
task = _tasks_module.MeetingReminderToNotes()
|
|
tm = TencentMeeting(copy.deepcopy(TM_BASE_STATE))
|
|
target_meetings, _ = tm.upcoming_or_ongoing()
|
|
curr_notes = copy.deepcopy(NOTES_BASE_STATE)
|
|
content = "\n".join(
|
|
f"{meeting['title']} {tm.parse_meeting_time(meeting['startTime']).strftime('%H:%M')}"
|
|
for meeting in target_meetings
|
|
)
|
|
_add_note(curr_notes, "会议提醒", content=content)
|
|
return task, _make_input(
|
|
_apps_state(),
|
|
_apps_state(notes=curr_notes),
|
|
)
|
|
|
|
|
|
def _meeting_reminder_to_notes_negative():
|
|
task = _tasks_module.MeetingReminderToNotes()
|
|
tm = TencentMeeting(copy.deepcopy(TM_BASE_STATE))
|
|
target_meetings, _ = tm.upcoming_or_ongoing()
|
|
curr_notes = copy.deepcopy(NOTES_BASE_STATE)
|
|
content = "\n".join(f"{meeting['title']} 00:00" for meeting in target_meetings)
|
|
_add_note(curr_notes, "会议提醒", content=content)
|
|
return task, _make_input(
|
|
_apps_state(),
|
|
_apps_state(notes=curr_notes),
|
|
)
|
|
|
|
|
|
def _sms_and_calendar_on_date_positive():
|
|
task = _tasks_module.SmsAndCalendarOnDate(
|
|
contact="张三",
|
|
message="明天见",
|
|
event_title="约会",
|
|
)
|
|
# tomorrow is derived from OS time: 2026-03-16 + 1 day = 2026-03-17
|
|
tomorrow = "2026-03-17"
|
|
curr_os = _base_os()
|
|
_append_sms_outgoing(curr_os["providers"]["sms"], task.p.contact, task.p.message)
|
|
curr_calendar = _add_event(
|
|
CALENDAR_BASE_STATE,
|
|
title=task.p.event_title,
|
|
date_value=tomorrow,
|
|
start="10:00",
|
|
end="11:00",
|
|
)
|
|
return task, _make_input(
|
|
_apps_state(),
|
|
_apps_state(calendar=curr_calendar),
|
|
curr_os=curr_os,
|
|
)
|
|
|
|
|
|
def _sms_and_calendar_on_date_negative():
|
|
task = _tasks_module.SmsAndCalendarOnDate(
|
|
contact="张三",
|
|
message="明天见",
|
|
event_title="约会",
|
|
)
|
|
# wrong date: not tomorrow (2026-03-18 instead of 2026-03-17)
|
|
curr_os = _base_os()
|
|
_append_sms_outgoing(curr_os["providers"]["sms"], task.p.contact, task.p.message)
|
|
curr_calendar = _add_event(
|
|
CALENDAR_BASE_STATE,
|
|
title=task.p.event_title,
|
|
date_value="2026-03-18",
|
|
start="10:00",
|
|
end="11:00",
|
|
)
|
|
return task, _make_input(
|
|
_apps_state(),
|
|
_apps_state(calendar=curr_calendar),
|
|
curr_os=curr_os,
|
|
)
|
|
|
|
|
|
PRIMARY_POSITIVE_CASES = [
|
|
("ExistingMeetingToCalendar", _existing_meeting_to_calendar_positive),
|
|
("CalendarEarliestToAlarm", _calendar_earliest_to_alarm_positive),
|
|
("MeetingLongestInfoToWechat", _meeting_longest_info_to_wechat_positive),
|
|
("MeetingDurationToWechat", _meeting_duration_to_wechat_positive),
|
|
("WeatherConditionalCancelMeeting", _weather_conditional_cancel_meeting_positive),
|
|
("MeetingJoinAndNotifySms", _meeting_join_and_notify_sms_positive),
|
|
("MeetingMultiChannelNotify", _meeting_multi_channel_notify_positive),
|
|
("MeetingRouteEtaToWechat", _meeting_route_eta_to_wechat_positive),
|
|
("MeetingFullFlowToWechat", _meeting_full_flow_to_wechat_positive),
|
|
("FullMeetingConflictCheckBroadcast", _full_meeting_conflict_check_broadcast_positive),
|
|
("MeetingReminderToNotes", _meeting_reminder_to_notes_positive),
|
|
("SmsAndCalendarOnDate", _sms_and_calendar_on_date_positive),
|
|
]
|
|
|
|
PRIMARY_NEGATIVE_CASES = [
|
|
("ExistingMeetingToCalendar", _existing_meeting_to_calendar_negative),
|
|
("CalendarEarliestToAlarm", _calendar_earliest_to_alarm_negative),
|
|
("MeetingLongestInfoToWechat", _meeting_longest_info_to_wechat_negative),
|
|
("MeetingDurationToWechat", _meeting_duration_to_wechat_negative),
|
|
("WeatherConditionalCancelMeeting", _weather_conditional_cancel_meeting_negative),
|
|
("MeetingJoinAndNotifySms", _meeting_join_and_notify_sms_negative),
|
|
("MeetingMultiChannelNotify", _meeting_multi_channel_notify_negative),
|
|
("MeetingRouteEtaToWechat", _meeting_route_eta_to_wechat_negative),
|
|
("MeetingFullFlowToWechat", _meeting_full_flow_to_wechat_negative),
|
|
("FullMeetingConflictCheckBroadcast", _full_meeting_conflict_check_broadcast_negative),
|
|
("MeetingReminderToNotes", _meeting_reminder_to_notes_negative),
|
|
("SmsAndCalendarOnDate", _sms_and_calendar_on_date_negative),
|
|
]
|
|
|
|
|
|
EXTRA_POSITIVE_CASES = [
|
|
(
|
|
"WeatherConditionalCancelMeeting__rainy_branch",
|
|
lambda: (
|
|
task := _tasks_module.WeatherConditionalCancelMeeting(city="广州", topic="项目例会"),
|
|
_make_input(
|
|
_apps_state(),
|
|
_apps_state(tencent_meeting=_tm_without_topic(TM_BASE_STATE, task.p.topic)),
|
|
),
|
|
),
|
|
),
|
|
(
|
|
"FullMeetingConflictCheckBroadcast__busy_branch",
|
|
lambda: (
|
|
task := _tasks_module.FullMeetingConflictCheckBroadcast(
|
|
time="03:30",
|
|
contact="陈静",
|
|
contact2="张三",
|
|
flow_topic="临时协调会",
|
|
),
|
|
(
|
|
lambda init_calendar: _make_input(
|
|
_apps_state(calendar=init_calendar),
|
|
_apps_state(
|
|
calendar=init_calendar,
|
|
wechat=(
|
|
lambda state: (
|
|
_append_wechat_outgoing(state, task.p.contact, "那个时间不行,日程有冲突") or state
|
|
)
|
|
)(copy.deepcopy(WECHAT_BASE_STATE)),
|
|
),
|
|
)
|
|
)(
|
|
_add_event(
|
|
CALENDAR_BASE_STATE,
|
|
title="占用的固定安排",
|
|
date_value=(sim_today(_base_os()) + datetime.timedelta(days=1)).isoformat(),
|
|
start="02:30",
|
|
end="05:30",
|
|
)
|
|
),
|
|
),
|
|
),
|
|
]
|
|
|
|
|
|
EXTRA_NEGATIVE_CASES = [
|
|
(
|
|
"FullMeetingConflictCheckBroadcast__busy_branch_missing_reject",
|
|
lambda: (
|
|
task := _tasks_module.FullMeetingConflictCheckBroadcast(
|
|
time="03:30",
|
|
contact="陈静",
|
|
contact2="张三",
|
|
flow_topic="临时协调会",
|
|
),
|
|
_make_input(
|
|
_apps_state(
|
|
calendar=_add_event(
|
|
CALENDAR_BASE_STATE,
|
|
title="占用的固定安排",
|
|
date_value=(sim_today(_base_os()) + datetime.timedelta(days=1)).isoformat(),
|
|
start="02:30",
|
|
end="05:30",
|
|
)
|
|
),
|
|
_apps_state(
|
|
calendar=_add_event(
|
|
CALENDAR_BASE_STATE,
|
|
title="占用的固定安排",
|
|
date_value=(sim_today(_base_os()) + datetime.timedelta(days=1)).isoformat(),
|
|
start="02:30",
|
|
end="05:30",
|
|
)
|
|
),
|
|
),
|
|
),
|
|
),
|
|
]
|
|
|
|
|
|
class TestTaskJudgeMatrixOffline:
|
|
@pytest.mark.parametrize(
|
|
"task_name,builder",
|
|
PRIMARY_POSITIVE_CASES + EXTRA_POSITIVE_CASES,
|
|
ids=[name for name, _ in PRIMARY_POSITIVE_CASES + EXTRA_POSITIVE_CASES],
|
|
)
|
|
def test_positive_case(self, task_name, builder):
|
|
task, inp = builder()
|
|
result = task.evaluate(inp)
|
|
assert result.success, f"{task_name} positive failed: issues={result.issues}, warnings={result.warnings}"
|
|
|
|
@pytest.mark.parametrize(
|
|
"task_name,builder",
|
|
PRIMARY_NEGATIVE_CASES + EXTRA_NEGATIVE_CASES,
|
|
ids=[name for name, _ in PRIMARY_NEGATIVE_CASES + EXTRA_NEGATIVE_CASES],
|
|
)
|
|
def test_negative_case(self, task_name, builder):
|
|
task, inp = builder()
|
|
result = task.evaluate(inp)
|
|
assert not result.success, f"{task_name} negative unexpectedly passed"
|