purewhiter--mobilegym
753 行
28 KiB
Python
753 行
28 KiB
Python
"""
|
|
Map task correctness tests.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import inspect
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from bench_env.task.base import BaseTask
|
|
from bench_env.task.common_tasks import AnswerTask, CriteriaTask
|
|
from bench_env.task.judge import JudgeInput
|
|
from bench_env.task.map import tasks as _tasks_module
|
|
from bench_env.task.map.app import Map
|
|
from bench_env.tests.conftest import make_judge_input
|
|
|
|
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]
|
|
ANSWER_TASK_CLASSES = [cls for cls in ALL_TASK_CLASSES if issubclass(cls, AnswerTask)]
|
|
|
|
TEST_OS_STATE = {"time": {"timestamp": 1742025600000}}
|
|
DEFAULT_ROUTE = {"app": "map", "path": "/"}
|
|
|
|
|
|
def _load_defaults() -> dict[str, Any]:
|
|
path = Path(__file__).resolve().parents[3] / "apps" / "Map" / "data" / "defaults.json"
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def _base_state() -> dict[str, Any]:
|
|
state = copy.deepcopy(_load_defaults())
|
|
state["currentLocation"] = {"latitude": 39.9042, "longitude": 116.4074}
|
|
state["currentView"] = {
|
|
"searchResults": [],
|
|
"poi": None,
|
|
"route": None,
|
|
"routeModes": {},
|
|
"autocomplete": None,
|
|
}
|
|
return state
|
|
|
|
|
|
DEFAULTS = _load_defaults()
|
|
BASE_STATE = _base_state()
|
|
|
|
|
|
def _make_task_input(
|
|
init_state: dict[str, Any],
|
|
curr_state: dict[str, Any],
|
|
*,
|
|
route: dict[str, Any] | None = None,
|
|
init_route: dict[str, Any] | None = None,
|
|
answer: str | None = None,
|
|
) -> JudgeInput:
|
|
return make_judge_input(
|
|
{"apps": {"map": init_state}, "os": TEST_OS_STATE},
|
|
{"apps": {"map": curr_state}, "os": TEST_OS_STATE},
|
|
route=route or DEFAULT_ROUTE,
|
|
init_route=init_route,
|
|
answer=answer,
|
|
)
|
|
|
|
|
|
def _deep_update(target: dict[str, Any], patch: dict[str, Any]) -> None:
|
|
for key, value in patch.items():
|
|
if isinstance(value, dict) and isinstance(target.get(key), dict):
|
|
_deep_update(target[key], value)
|
|
else:
|
|
target[key] = value
|
|
|
|
|
|
def _state(
|
|
*,
|
|
search_results: list[dict[str, Any]] | None = None,
|
|
active_poi: dict[str, Any] | None = None,
|
|
route: dict[str, Any] | None = None,
|
|
route_modes: dict[str, Any] | None = None,
|
|
autocomplete: dict[str, Any] | None = None,
|
|
settings_patch: dict[str, Any] | None = None,
|
|
user_patch: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
state = copy.deepcopy(BASE_STATE)
|
|
if search_results is not None:
|
|
state["currentView"]["searchResults"] = copy.deepcopy(search_results)
|
|
if active_poi is not None:
|
|
state["currentView"]["poi"] = copy.deepcopy(active_poi)
|
|
if route is not None:
|
|
state["currentView"]["route"] = copy.deepcopy(route)
|
|
if route_modes is not None:
|
|
state["currentView"]["routeModes"] = copy.deepcopy(route_modes)
|
|
if autocomplete is not None:
|
|
state["currentView"]["autocomplete"] = copy.deepcopy(autocomplete)
|
|
if settings_patch is not None:
|
|
_deep_update(state["settings"], copy.deepcopy(settings_patch))
|
|
if user_patch is not None:
|
|
_deep_update(state["user"], copy.deepcopy(user_patch))
|
|
return state
|
|
|
|
|
|
def _place(
|
|
name: str,
|
|
*,
|
|
address: str,
|
|
rating: float | None = None,
|
|
review_count: int | None = None,
|
|
phone: str | None = None,
|
|
distance: str | None = None,
|
|
distance_meters: int | None = None,
|
|
) -> dict[str, Any]:
|
|
result: dict[str, Any] = {
|
|
"name": name,
|
|
"address": address,
|
|
"formatted_address": address,
|
|
}
|
|
if rating is not None:
|
|
result["rating"] = rating
|
|
if review_count is not None:
|
|
result["user_ratings_total"] = review_count
|
|
if phone is not None:
|
|
result["formatted_phone_number"] = phone
|
|
if distance is not None:
|
|
result["distance"] = distance
|
|
if distance_meters is not None:
|
|
result["distance_meters"] = distance_meters
|
|
return result
|
|
|
|
|
|
def _route(
|
|
*,
|
|
mode: str,
|
|
destination: str,
|
|
origin: str = "当前位置",
|
|
distance: str = "2.4公里",
|
|
steps: list[dict[str, Any]] | None = None,
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"mode": mode,
|
|
"origin": {"name": origin},
|
|
"destination": {"name": destination},
|
|
"distance": distance,
|
|
"steps": copy.deepcopy(steps or []),
|
|
}
|
|
|
|
|
|
def _route_modes() -> dict[str, Any]:
|
|
return {
|
|
"modes": {
|
|
"WALKING": {
|
|
"distance": "3.2公里",
|
|
"distance_meters": 3200,
|
|
"duration": "45分钟",
|
|
"duration_seconds": 2700,
|
|
},
|
|
"DRIVING": {
|
|
"distance": "4.8公里",
|
|
"distance_meters": 4800,
|
|
"duration": "18分钟",
|
|
"duration_seconds": 1080,
|
|
},
|
|
}
|
|
}
|
|
|
|
|
|
NATIONAL_MUSEUM = _place(
|
|
"中国国家博物馆",
|
|
address="北京市东城区东长安街16号",
|
|
rating=4.8,
|
|
review_count=1280,
|
|
phone="010-65116400",
|
|
distance="1.2公里",
|
|
distance_meters=1200,
|
|
)
|
|
FORBIDDEN_CITY = _place(
|
|
"故宫",
|
|
address="北京市东城区景山前街4号",
|
|
rating=4.8,
|
|
review_count=5621,
|
|
phone="400-950-1925",
|
|
distance="2.6公里",
|
|
distance_meters=2600,
|
|
)
|
|
PIZZA_HUT = _place(
|
|
"必胜客(西单店)",
|
|
address="北京市西城区西单北大街131号",
|
|
rating=4.5,
|
|
review_count=256,
|
|
phone="010-66012345",
|
|
distance="500米",
|
|
distance_meters=500,
|
|
)
|
|
HAIDILAO = _place(
|
|
"海底捞(西单店)",
|
|
address="北京市西城区西单北大街133号",
|
|
rating=4.9,
|
|
review_count=1600,
|
|
phone="010-66056789",
|
|
distance="900米",
|
|
distance_meters=900,
|
|
)
|
|
BURGER_KING = _place(
|
|
"汉堡王(西单店)",
|
|
address="北京市西城区西单北大街129号",
|
|
rating=4.2,
|
|
review_count=380,
|
|
phone="010-66011111",
|
|
distance="300米",
|
|
distance_meters=300,
|
|
)
|
|
MANNER = _place(
|
|
"Manner Coffee(王府井店)",
|
|
address="北京市东城区王府井大街138号",
|
|
rating=4.9,
|
|
review_count=420,
|
|
phone="010-67010001",
|
|
distance="260米",
|
|
distance_meters=260,
|
|
)
|
|
LUCKIN = _place(
|
|
"瑞幸咖啡(东华门店)",
|
|
address="北京市东城区东华门大街20号",
|
|
rating=4.7,
|
|
review_count=320,
|
|
phone="010-67010002",
|
|
distance="180米",
|
|
distance_meters=180,
|
|
)
|
|
STARBUCKS = _place(
|
|
"星巴克(王府井店)",
|
|
address="北京市东城区王府井大街88号",
|
|
rating=4.6,
|
|
review_count=510,
|
|
phone="010-67010003",
|
|
distance="320米",
|
|
distance_meters=320,
|
|
)
|
|
APM = _place(
|
|
"apm购物中心",
|
|
address="北京市东城区王府井大街138号",
|
|
rating=4.6,
|
|
review_count=980,
|
|
phone="010-85186688",
|
|
distance="300米",
|
|
distance_meters=300,
|
|
)
|
|
JOY_CITY = _place(
|
|
"大悦城",
|
|
address="北京市西城区西单北大街131号",
|
|
rating=4.7,
|
|
review_count=2100,
|
|
phone="010-66018888",
|
|
distance="500米",
|
|
distance_meters=500,
|
|
)
|
|
SKP = _place(
|
|
"SKP",
|
|
address="北京市朝阳区建国路87号",
|
|
rating=4.8,
|
|
review_count=3200,
|
|
phone="010-65307788",
|
|
distance="1.8公里",
|
|
distance_meters=1800,
|
|
)
|
|
|
|
def _with_new_search(state: dict[str, Any], keyword: str = "测试搜索") -> dict[str, Any]:
|
|
"""Add a new searchHistory entry so check_searched passes."""
|
|
state = copy.deepcopy(state)
|
|
history = state.setdefault("searchHistory", [])
|
|
history.append({"id": "test_new_search", "kind": "query", "text": keyword})
|
|
return state
|
|
|
|
|
|
RESTAURANT_RESULTS = [BURGER_KING, PIZZA_HUT, HAIDILAO]
|
|
CAFE_RESULTS = [LUCKIN, MANNER, STARBUCKS]
|
|
SHOPPING_RESULTS = [APM, JOY_CITY, SKP]
|
|
MUSEUM_RESULTS = [NATIONAL_MUSEUM]
|
|
FORBIDDEN_CITY_RESULTS = [FORBIDDEN_CITY]
|
|
AUTOCOMPLETE_RESULTS = {
|
|
"query": "银行",
|
|
"suggestions": [
|
|
{"main_text": "中国银行(王府井支行)", "description": "中国银行(王府井支行)"},
|
|
{"main_text": "工商银行(东长安街支行)", "description": "工商银行(东长安街支行)"},
|
|
{"main_text": "建设银行(天安门支行)", "description": "建设银行(天安门支行)"},
|
|
],
|
|
}
|
|
ROUTE_STEPS = [
|
|
{"instruction": "向东出发", "distance": "120米"},
|
|
{"instruction": "右转进入东华门大街", "distance": "300米"},
|
|
{"instruction": "继续直行到达目的地", "distance": "500米"},
|
|
]
|
|
|
|
|
|
def _parse_path(path: str) -> list[str]:
|
|
return [part for part in path.split(".") if part]
|
|
|
|
|
|
def _set_by_path(state: dict[str, Any], path: str, value: Any) -> None:
|
|
current: Any = state
|
|
parts = _parse_path(path)
|
|
for part in parts[:-1]:
|
|
current = current[part]
|
|
current[parts[-1]] = value
|
|
|
|
|
|
def _resolve_criteria_value(value: Any, params: dict[str, Any]) -> Any:
|
|
if isinstance(value, str):
|
|
match = __import__("re").fullmatch(r"\{(\w+)\}", value)
|
|
if match:
|
|
return params[match.group(1)]
|
|
return value
|
|
|
|
|
|
SLOT_LABELS = {
|
|
"name": "名称",
|
|
"rating": "评分",
|
|
"address": "地址",
|
|
"phone": "电话",
|
|
}
|
|
|
|
|
|
def _natural_answer(expected: Any) -> str:
|
|
if isinstance(expected, dict):
|
|
parts = []
|
|
for key, value in expected.items():
|
|
label = SLOT_LABELS.get(key, key)
|
|
parts.append(f"{label}是{value}")
|
|
return ",".join(parts)
|
|
if isinstance(expected, float) and expected.is_integer():
|
|
expected = int(expected)
|
|
return f"我看到的是{expected}"
|
|
|
|
|
|
def _wrong_answer(expected: Any) -> str:
|
|
if isinstance(expected, dict):
|
|
return "名称是别的地方,评分是1,地址是错误地址,电话是0000"
|
|
if isinstance(expected, int):
|
|
return f"我看到的是{expected + 1}"
|
|
if isinstance(expected, float):
|
|
return f"我看到的是{expected + 0.5:g}"
|
|
return "我看到的是另一个结果"
|
|
|
|
|
|
def _search_place_address_expected(place: str) -> str:
|
|
return Map.extract_address(Map.resolve_places(place)[0])
|
|
|
|
|
|
def _place_detail_full_expected(place: str) -> dict[str, Any]:
|
|
resolved = Map.resolve_places(place)[0]
|
|
return {
|
|
"rating": Map.extract_rating(resolved),
|
|
"address": Map.extract_address(resolved),
|
|
"phone": Map.extract_phone(resolved),
|
|
}
|
|
|
|
|
|
def _positive_answer_case(
|
|
task: BaseTask,
|
|
curr_state: dict[str, Any],
|
|
*,
|
|
route: dict[str, Any] | None = None,
|
|
) -> tuple[BaseTask, JudgeInput]:
|
|
curr = copy.deepcopy(curr_state)
|
|
inp = _make_task_input(copy.deepcopy(BASE_STATE), curr, route=route)
|
|
expected = task.get_answer(inp) # type: ignore[attr-defined]
|
|
return task, _make_task_input(copy.deepcopy(BASE_STATE), curr, route=route, answer=_natural_answer(expected))
|
|
|
|
|
|
def _negative_answer_case(
|
|
task: BaseTask,
|
|
curr_state: dict[str, Any],
|
|
*,
|
|
route: dict[str, Any] | None = None,
|
|
) -> tuple[BaseTask, JudgeInput]:
|
|
curr = copy.deepcopy(curr_state)
|
|
inp = _make_task_input(copy.deepcopy(BASE_STATE), curr, route=route)
|
|
expected = task.get_answer(inp) # type: ignore[attr-defined]
|
|
return task, _make_task_input(copy.deepcopy(BASE_STATE), curr, route=route, answer=_wrong_answer(expected))
|
|
|
|
|
|
def _positive_criteria_case(task: CriteriaTask) -> tuple[BaseTask, JudgeInput]:
|
|
curr = copy.deepcopy(BASE_STATE)
|
|
route = DEFAULT_ROUTE
|
|
for path, raw_value in task.criteria.items():
|
|
value = _resolve_criteria_value(raw_value, task.params)
|
|
if path == "route":
|
|
route = {"app": "map", "path": str(value)}
|
|
continue
|
|
_set_by_path(curr, path, value)
|
|
return task, _make_task_input(copy.deepcopy(BASE_STATE), curr, route=route)
|
|
|
|
|
|
def _negative_criteria_case(task: CriteriaTask) -> tuple[BaseTask, JudgeInput]:
|
|
return task, _make_task_input(copy.deepcopy(BASE_STATE), copy.deepcopy(BASE_STATE))
|
|
|
|
|
|
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 "map" in task.apps
|
|
|
|
@pytest.mark.parametrize("cls", ALL_TASK_CLASSES, ids=ALL_TASK_IDS)
|
|
def test_description_renders(self, cls):
|
|
task = cls()
|
|
task._env_state = {"os": TEST_OS_STATE}
|
|
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")
|
|
|
|
@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", ANSWER_TASK_CLASSES, ids=[c.__name__ for c in ANSWER_TASK_CLASSES])
|
|
def test_answer_task_has_answer_or_get_answer(self, cls):
|
|
has_answer_attr = cls.answer is not None
|
|
has_get_answer_override = cls.get_answer is not AnswerTask.get_answer
|
|
has_check_goals_override = cls.check_goals is not AnswerTask.check_goals
|
|
assert has_answer_attr or has_get_answer_override or has_check_goals_override
|
|
|
|
|
|
class TestMapAccessor:
|
|
@pytest.fixture
|
|
def map_app(self) -> Map:
|
|
return Map(
|
|
_state(
|
|
search_results=RESTAURANT_RESULTS,
|
|
active_poi=NATIONAL_MUSEUM,
|
|
route=_route(mode="DRIVING", origin="故宫", destination="中国国家博物馆", distance="1.2公里", steps=ROUTE_STEPS),
|
|
route_modes=_route_modes(),
|
|
autocomplete=AUTOCOMPLETE_RESULTS,
|
|
)
|
|
)
|
|
|
|
def test_user_properties(self, map_app: Map):
|
|
assert map_app.user_name == "pure"
|
|
assert map_app.favorite_place_count == 0
|
|
|
|
def test_search_and_settings_access(self, map_app: Map):
|
|
assert len(map_app.search_history) == 2
|
|
assert map_app.get_setting("navigation.keepMapNorthUp") is False
|
|
|
|
def test_runtime_view_access(self, map_app: Map):
|
|
assert map_app.active_poi["name"] == "中国国家博物馆"
|
|
assert map_app.active_route["mode"] == "DRIVING"
|
|
assert len(map_app.search_results) == 3
|
|
assert "WALKING" in map_app.route_modes["modes"]
|
|
|
|
def test_place_queries(self, map_app: Map):
|
|
poi = map_app.active_poi
|
|
assert isinstance(poi, dict)
|
|
assert Map.extract_address(poi) == "北京市东城区东长安街16号"
|
|
assert Map.extract_phone(poi) == "010-65116400"
|
|
assert Map.extract_rating(poi) == 4.8
|
|
|
|
def test_normalize_place_prefers_details_national_phone(self):
|
|
place = Map._normalize_place(
|
|
{
|
|
"placeId": "test-place",
|
|
"name": "测试地点",
|
|
"formattedAddress": "北京市海淀区测试路1号",
|
|
"nationalPhoneNumber": "010-11112222",
|
|
"internationalPhoneNumber": "+86 10 1111 2222",
|
|
"details": {
|
|
"displayName": "测试地点",
|
|
"formattedAddress": "北京市海淀区测试路1号",
|
|
"nationalPhoneNumber": "010-33334444",
|
|
"internationalPhoneNumber": "+86 10 3333 4444",
|
|
},
|
|
}
|
|
)
|
|
assert Map.extract_phone(place) == "010-33334444"
|
|
|
|
def test_search_result_helpers(self, map_app: Map):
|
|
pizza_candidates = [
|
|
r for r in map_app.search_results
|
|
if isinstance(r, dict) and "必胜客" in str(r.get("name") or "")
|
|
]
|
|
assert pizza_candidates and pizza_candidates[0]["name"] == "必胜客(西单店)"
|
|
assert Map.best_rated_from_results(RESTAURANT_RESULTS)["name"] == "海底捞(西单店)"
|
|
assert Map.nearest_from_results(RESTAURANT_RESULTS)["name"] == "汉堡王(西单店)"
|
|
assert Map.rating_rank_from_results(RESTAURANT_RESULTS, "必胜客") == 2
|
|
|
|
def test_radius_filter_uses_frontend_display_distance(self):
|
|
results = [
|
|
{"name": "1995m", "distance_meters": 1995},
|
|
{"name": "2010m", "distance_meters": 2010},
|
|
{"name": "2048m", "distance_meters": 2048},
|
|
{"name": "2059m", "distance_meters": 2059},
|
|
]
|
|
|
|
filtered = Map.filter_results(results, max_distance_meters=2000)
|
|
|
|
assert [item["name"] for item in filtered] == ["1995m", "2010m", "2048m"]
|
|
|
|
def test_alias_resolution_helpers(self):
|
|
forbidden_city_names = [place["name"] for place in Map.resolve_places("故宫")]
|
|
assert forbidden_city_names[:2] == ["故宫博物院", "故宫"]
|
|
assert "故宫角楼" not in forbidden_city_names
|
|
|
|
library_names = [place["name"] for place in Map.resolve_places("国家图书馆")]
|
|
assert "中国国家图书馆" in library_names
|
|
assert "国家图书馆" in library_names
|
|
assert library_names.count("国家图书馆") >= 2
|
|
assert "国家图书馆古籍馆" not in library_names
|
|
|
|
assert Map.geo_resolve("圆明园")[3] == "圆明园遗址公园"
|
|
|
|
def test_route_helpers(self, map_app: Map):
|
|
assert map_app.route_distance_meters("中国国家博物馆") == 1200
|
|
|
|
def test_check_helpers(self, map_app: Map):
|
|
assert map_app.check_route(
|
|
mode="DRIVING",
|
|
origin_hint="故宫",
|
|
destination_hint="中国国家博物馆",
|
|
)["passed"]
|
|
assert Map.check_place_rating_answer(
|
|
"评分是4.8分。",
|
|
4.8,
|
|
)["passed"]
|
|
assert Map.check_place_address_answer(
|
|
"地址是北京市东城区东长安街16号。",
|
|
"北京市东城区东长安街16号",
|
|
)["passed"]
|
|
|
|
def test_accessor_raise_on_missing_data(self, map_app: Map):
|
|
with pytest.raises(ValueError):
|
|
Map.extract_address({})
|
|
with pytest.raises(ValueError):
|
|
Map.extract_rating({})
|
|
assert Map.extract_phone({}) is None
|
|
|
|
def test_geo_search_filters_registered_subbrand_pois_by_default(self):
|
|
expected_filtered_keywords = {
|
|
"肯德基": ("甜品站", "宅急送", "Select"),
|
|
"麦当劳": ("甜品站",),
|
|
"必胜客": ("宅急送",),
|
|
}
|
|
for query, keywords in expected_filtered_keywords.items():
|
|
raw_names = [str(place["name"]) for place in Map.geo_search(query, limit=0, excludes=None)]
|
|
filtered_names = [str(place["name"]) for place in Map.geo_search(query, limit=0)]
|
|
assert any(any(keyword in name for keyword in keywords) for name in raw_names), query
|
|
assert all(not any(keyword in name for keyword in keywords) for name in filtered_names), query
|
|
assert len(filtered_names) < len(raw_names), query
|
|
|
|
|
|
def _check_drive_route_positive_case():
|
|
task = _tasks_module.CheckDriveRoute()
|
|
curr = _state(route=_route(mode="DRIVING", destination="故宫", distance="2.6公里"))
|
|
return task, _make_task_input(copy.deepcopy(BASE_STATE), curr)
|
|
|
|
|
|
def _check_drive_route_negative_case():
|
|
task = _tasks_module.CheckDriveRoute()
|
|
curr = _state(route=_route(mode="WALKING", destination="故宫", distance="2.6公里"))
|
|
return task, _make_task_input(copy.deepcopy(BASE_STATE), curr)
|
|
|
|
|
|
def _query_driving_distance_alias_positive_case():
|
|
task = _tasks_module.QueryDrivingDistance(place="国家图书馆")
|
|
place = next(
|
|
place for place in Map.resolve_places("国家图书馆")
|
|
if place["name"] == "国家图书馆"
|
|
)
|
|
route = Map.geo_route_from_current(str(place["place_id"]), "DRIVING")
|
|
return task, _make_task_input(
|
|
copy.deepcopy(BASE_STATE),
|
|
copy.deepcopy(BASE_STATE),
|
|
answer=f"我看到的是{route['distance']}",
|
|
)
|
|
|
|
|
|
def _query_driving_distance_same_name_duplicate_positive_case():
|
|
task = _tasks_module.QueryDrivingDistance(place="国家图书馆")
|
|
places = [
|
|
place for place in Map.resolve_places("国家图书馆")
|
|
if place["name"] == "国家图书馆"
|
|
]
|
|
place = places[-1]
|
|
route = Map.geo_route_from_current(str(place["place_id"]), "DRIVING")
|
|
return task, _make_task_input(
|
|
copy.deepcopy(BASE_STATE),
|
|
copy.deepcopy(BASE_STATE),
|
|
answer=f"我看到的是{route['distance']}",
|
|
)
|
|
def _best_rated_route_case_state(
|
|
task: BaseTask,
|
|
*,
|
|
mode: str,
|
|
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
|
|
results = Map.geo_search(task.p.category, limit=0)
|
|
best = Map.best_rated_from_results(results, max_distance_meters=float(task.p.radius))
|
|
route = Map.geo_route_from_current(str(best["place_id"]), mode)
|
|
curr_state = _with_new_search(
|
|
_state(
|
|
route=_route(
|
|
mode=mode,
|
|
destination=str(best["name"]),
|
|
distance=str(route["distance"]),
|
|
)
|
|
),
|
|
str(task.p.category),
|
|
)
|
|
return best, route, curr_state
|
|
|
|
|
|
def _filtered_subbrand_with_route(
|
|
query: str,
|
|
*,
|
|
mode: str,
|
|
max_distance_meters: float | None = None,
|
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
filtered_ids = {
|
|
str(place.get("place_id"))
|
|
for place in Map.geo_search(query, limit=0)
|
|
if place.get("place_id") is not None
|
|
}
|
|
for place in Map.geo_search(query, limit=0, excludes=None):
|
|
place_id = place.get("place_id")
|
|
if place_id is None or str(place_id) in filtered_ids:
|
|
continue
|
|
if max_distance_meters is not None and float(place.get("distance_meters") or 0) > max_distance_meters:
|
|
continue
|
|
try:
|
|
route = Map.geo_route_from_current(str(place_id), mode)
|
|
except ValueError:
|
|
continue
|
|
return place, route
|
|
raise AssertionError(f"no filtered sub-brand result with {mode} route for {query!r}")
|
|
|
|
|
|
def _find_best_rated_and_route_mcdonalds_positive_case():
|
|
task = _tasks_module.FindBestRatedAndRoute(category="麦当劳", radius=3000)
|
|
best, route, curr_state = _best_rated_route_case_state(task, mode="DRIVING")
|
|
answer = f"我看评分最高且最近的是{best['name']},开车大概{route['distance']}"
|
|
return task, _make_task_input(copy.deepcopy(BASE_STATE), curr_state, answer=answer)
|
|
|
|
|
|
def _find_best_rated_and_route_filtered_subbrand_negative_case():
|
|
task = _tasks_module.FindBestRatedAndRoute(category="麦当劳", radius=3000)
|
|
_, _, curr_state = _best_rated_route_case_state(task, mode="DRIVING")
|
|
removed_place, removed_route = _filtered_subbrand_with_route(
|
|
task.p.category,
|
|
mode="DRIVING",
|
|
max_distance_meters=float(task.p.radius),
|
|
)
|
|
answer = f"我看评分最高且最近的是{removed_place['name']},开车大概{removed_route['distance']}"
|
|
return task, _make_task_input(copy.deepcopy(BASE_STATE), curr_state, answer=answer)
|
|
|
|
|
|
def _best_rated_with_walk_route_kfc_positive_case():
|
|
task = _tasks_module.BestRatedWithWalkRoute(category="肯德基", radius=3000)
|
|
best, route, curr_state = _best_rated_route_case_state(task, mode="WALKING")
|
|
answer = f"我看到的是{best['name']},走过去大概{route['distance']}"
|
|
return task, _make_task_input(copy.deepcopy(BASE_STATE), curr_state, answer=answer)
|
|
|
|
|
|
def _best_rated_with_walk_route_filtered_subbrand_negative_case():
|
|
task = _tasks_module.BestRatedWithWalkRoute(category="肯德基", radius=3000)
|
|
_, _, curr_state = _best_rated_route_case_state(task, mode="WALKING")
|
|
removed_place, removed_route = _filtered_subbrand_with_route(
|
|
task.p.category,
|
|
mode="WALKING",
|
|
max_distance_meters=float(task.p.radius),
|
|
)
|
|
answer = f"我看到的是{removed_place['name']},走过去大概{removed_route['distance']}"
|
|
return task, _make_task_input(copy.deepcopy(BASE_STATE), curr_state, answer=answer)
|
|
|
|
|
|
PRIMARY_POSITIVE_CASES = [
|
|
("CheckDriveRoute", _check_drive_route_positive_case),
|
|
("CheckHighestRatedPlace", lambda: _positive_answer_case(_tasks_module.CheckHighestRatedPlace(category="咖啡馆"), _with_new_search(_state(search_results=CAFE_RESULTS), "咖啡馆"))),
|
|
("CheckNearestPlaceAddress", lambda: _positive_answer_case(_tasks_module.CheckNearestPlaceAddress(category="咖啡馆"), _with_new_search(_state(search_results=CAFE_RESULTS), "咖啡馆"))),
|
|
("SetMapNorthUp", lambda: _positive_criteria_case(_tasks_module.SetMapNorthUp())),
|
|
("ModifyMultiSettings", lambda: _positive_criteria_case(_tasks_module.ModifyMultiSettings())),
|
|
("DarkModeSettings", lambda: _positive_criteria_case(_tasks_module.DarkModeSettings())),
|
|
("FindNearestWithRating", lambda: _positive_answer_case(_tasks_module.FindNearestWithRating(category="咖啡馆"), _with_new_search(_state(search_results=CAFE_RESULTS), "咖啡馆"))),
|
|
]
|
|
|
|
PRIMARY_NEGATIVE_CASES = [
|
|
("CheckDriveRoute", _check_drive_route_negative_case),
|
|
("CheckHighestRatedPlace", lambda: _negative_answer_case(_tasks_module.CheckHighestRatedPlace(category="咖啡馆"), _with_new_search(_state(search_results=CAFE_RESULTS), "咖啡馆"))),
|
|
("CheckNearestPlaceAddress", lambda: _negative_answer_case(_tasks_module.CheckNearestPlaceAddress(category="咖啡馆"), _with_new_search(_state(search_results=CAFE_RESULTS), "咖啡馆"))),
|
|
("SetMapNorthUp", lambda: _negative_criteria_case(_tasks_module.SetMapNorthUp())),
|
|
("ModifyMultiSettings", lambda: _negative_criteria_case(_tasks_module.ModifyMultiSettings())),
|
|
("DarkModeSettings", lambda: _negative_criteria_case(_tasks_module.DarkModeSettings())),
|
|
("FindNearestWithRating", lambda: _negative_answer_case(_tasks_module.FindNearestWithRating(category="咖啡馆"), _with_new_search(_state(search_results=CAFE_RESULTS), "咖啡馆"))),
|
|
]
|
|
|
|
EXTRA_POSITIVE_CASES: list[tuple[str, Any]] = [
|
|
("QueryDrivingDistance_alias_name", _query_driving_distance_alias_positive_case),
|
|
("QueryDrivingDistance_same_name_duplicate", _query_driving_distance_same_name_duplicate_positive_case),
|
|
("FindBestRatedAndRoute_mcdonalds_filtered_brand", _find_best_rated_and_route_mcdonalds_positive_case),
|
|
("BestRatedWithWalkRoute_kfc_filtered_brand", _best_rated_with_walk_route_kfc_positive_case),
|
|
]
|
|
|
|
EXTRA_NEGATIVE_CASES: list[tuple[str, Any]] = [
|
|
("FindBestRatedAndRoute_filtered_subbrand_answer", _find_best_rated_and_route_filtered_subbrand_negative_case),
|
|
("BestRatedWithWalkRoute_filtered_subbrand_answer", _best_rated_with_walk_route_filtered_subbrand_negative_case),
|
|
]
|
|
|
|
PRIMARY_TASK_NAMES = {name for name, _ in PRIMARY_POSITIVE_CASES}
|
|
|
|
|
|
class TestTaskJudgeMatrixOffline:
|
|
def test_offline_judge_matrix_complete(self):
|
|
positive = {name for name, _ in PRIMARY_POSITIVE_CASES}
|
|
negative = {name for name, _ in PRIMARY_NEGATIVE_CASES}
|
|
assert positive == negative, "positive/negative case sets must match"
|
|
all_names = {cls.__name__ for cls in ALL_TASK_CLASSES}
|
|
assert positive <= all_names, f"test references unknown tasks: {positive - all_names}"
|
|
|
|
@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"
|