test_plan_validation_service.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. from __future__ import annotations
  2. from datetime import date, time
  3. from app.schemas.itinerary import (
  4. DailyItinerary,
  5. FlightSelection,
  6. HotelSelection,
  7. ItineraryActivity,
  8. ItineraryPlan,
  9. )
  10. from app.schemas.place import (
  11. MapResearchResult,
  12. PlaceCandidate,
  13. )
  14. from app.schemas.route import (
  15. HotelRouteEvaluation,
  16. RouteEvaluationResult,
  17. )
  18. from app.schemas.selection import (
  19. CandidateSelectionResult,
  20. RankedHotelCandidate,
  21. RoundTripOption,
  22. )
  23. from app.schemas.travel_request import (
  24. TravelRequest,
  25. )
  26. from app.services.plan_validation_service import (
  27. PlanValidationService,
  28. )
  29. def build_context(
  30. *,
  31. total_budget: float = 8000,
  32. ):
  33. request = TravelRequest(
  34. origin_city="上海",
  35. destination_city="成都",
  36. departure_date=date(2026, 8, 10),
  37. return_date=date(2026, 8, 13),
  38. trip_days=4,
  39. nights=3,
  40. adults=2,
  41. total_budget=total_budget,
  42. pace="relaxed",
  43. )
  44. outbound = {
  45. "option_id": "out-001",
  46. "flight_numbers": ["MU001"],
  47. "departure_time": (
  48. "2026-08-10 08:00"
  49. ),
  50. "arrival_time": (
  51. "2026-08-10 11:00"
  52. ),
  53. "price": 2000,
  54. "currency": "CNY",
  55. }
  56. return_flight = {
  57. "option_id": "ret-001",
  58. "flight_numbers": ["MU002"],
  59. "departure_time": (
  60. "2026-08-13 12:00"
  61. ),
  62. "arrival_time": (
  63. "2026-08-13 15:00"
  64. ),
  65. "price": 2000,
  66. "currency": "CNY",
  67. }
  68. hotel = {
  69. "hotel_id": "hotel-001",
  70. "name": "成都测试酒店",
  71. "price_per_night": 500,
  72. "currency": "CNY",
  73. }
  74. selection = CandidateSelectionResult(
  75. flight_type="round_trip",
  76. round_trip_options=[
  77. RoundTripOption(
  78. combination_id="round-001",
  79. score=90,
  80. quoted_price=2000,
  81. currency="CNY",
  82. outbound=outbound,
  83. return_flight=return_flight,
  84. reasons=[],
  85. warnings=[],
  86. )
  87. ],
  88. hotels=[
  89. RankedHotelCandidate(
  90. hotel_id="hotel-001",
  91. score=90,
  92. reasons=[],
  93. warnings=[],
  94. hotel=hotel,
  95. )
  96. ],
  97. )
  98. map_result = MapResearchResult(
  99. city="成都",
  100. attractions=[
  101. PlaceCandidate(
  102. poi_id="poi-001",
  103. name="测试景点",
  104. location="104.01,30.01",
  105. )
  106. ],
  107. restaurants=[
  108. PlaceCandidate(
  109. poi_id="food-001",
  110. name="测试餐厅",
  111. location="104.02,30.02",
  112. )
  113. ],
  114. )
  115. route_result = RouteEvaluationResult(
  116. evaluated_hotels=[
  117. HotelRouteEvaluation(
  118. hotel_id="hotel-001",
  119. hotel_name="成都测试酒店",
  120. final_score=90,
  121. base_hotel_score=85,
  122. centrality_score=95,
  123. subway_score=90,
  124. hotel=hotel,
  125. )
  126. ],
  127. selected_hotel_id="hotel-001",
  128. )
  129. plan = ItineraryPlan(
  130. title="成都四日旅行",
  131. overview="轻松游览成都。",
  132. selected_flight=FlightSelection(
  133. flight_type="round_trip",
  134. combination_id="round-001",
  135. outbound_option_id="out-001",
  136. return_option_id="ret-001",
  137. quoted_price=2000,
  138. currency="CNY",
  139. selection_reason="综合价格和时间。",
  140. ),
  141. selected_hotel=HotelSelection(
  142. hotel_id="hotel-001",
  143. name="成都测试酒店",
  144. check_in_date=date(2026, 8, 10),
  145. check_out_date=date(2026, 8, 13),
  146. nights=3,
  147. price_per_night=500,
  148. estimated_total_price=1500,
  149. currency="CNY",
  150. selection_reason="位置较方便。",
  151. ),
  152. days=[
  153. DailyItinerary(
  154. day_index=1,
  155. date=date(2026, 8, 10),
  156. theme="抵达成都",
  157. activities=[
  158. ItineraryActivity(
  159. sequence=1,
  160. activity_type="flight",
  161. name="上海飞往成都",
  162. start_time=time(8, 0),
  163. end_time=time(11, 0),
  164. ),
  165. ItineraryActivity(
  166. sequence=2,
  167. activity_type="transport",
  168. name="机场前往酒店",
  169. start_time=time(11, 30),
  170. end_time=time(13, 0),
  171. ),
  172. ItineraryActivity(
  173. sequence=3,
  174. activity_type=(
  175. "hotel_check_in"
  176. ),
  177. name="办理入住",
  178. start_time=time(14, 0),
  179. end_time=time(14, 30),
  180. ),
  181. ],
  182. ),
  183. DailyItinerary(
  184. day_index=2,
  185. date=date(2026, 8, 11),
  186. theme="景点游览",
  187. activities=[
  188. ItineraryActivity(
  189. sequence=1,
  190. activity_type="attraction",
  191. reference_id="poi-001",
  192. name="测试景点",
  193. start_time=time(9, 0),
  194. end_time=time(12, 0),
  195. ),
  196. ItineraryActivity(
  197. sequence=2,
  198. activity_type="restaurant",
  199. reference_id="food-001",
  200. name="测试餐厅",
  201. start_time=time(12, 30),
  202. end_time=time(14, 0),
  203. ),
  204. ],
  205. ),
  206. DailyItinerary(
  207. day_index=3,
  208. date=date(2026, 8, 12),
  209. theme="自由活动",
  210. activities=[
  211. ItineraryActivity(
  212. sequence=1,
  213. activity_type="free_time",
  214. name="自由活动",
  215. start_time=time(10, 0),
  216. end_time=time(16, 0),
  217. )
  218. ],
  219. ),
  220. DailyItinerary(
  221. day_index=4,
  222. date=date(2026, 8, 13),
  223. theme="返程",
  224. activities=[
  225. ItineraryActivity(
  226. sequence=1,
  227. activity_type=(
  228. "hotel_check_out"
  229. ),
  230. name="办理退房",
  231. start_time=time(8, 0),
  232. end_time=time(9, 0),
  233. ),
  234. ItineraryActivity(
  235. sequence=2,
  236. activity_type="transport",
  237. name="前往机场",
  238. start_time=time(9, 0),
  239. end_time=time(10, 0),
  240. ),
  241. ItineraryActivity(
  242. sequence=3,
  243. activity_type="flight",
  244. name="成都飞往上海",
  245. start_time=time(12, 0),
  246. end_time=time(15, 0),
  247. ),
  248. ],
  249. ),
  250. ],
  251. )
  252. return (
  253. request,
  254. plan,
  255. selection,
  256. map_result,
  257. route_result,
  258. )
  259. def test_valid_plan_passes() -> None:
  260. service = PlanValidationService()
  261. (
  262. request,
  263. plan,
  264. selection,
  265. map_result,
  266. route_result,
  267. ) = build_context()
  268. result = service.validate(
  269. request=request,
  270. plan=plan,
  271. selection=selection,
  272. map_result=map_result,
  273. route_result=route_result,
  274. )
  275. assert result.is_valid is True
  276. assert result.error_count == 0
  277. assert result.budget.known_total == 3500
  278. assert (
  279. result.budget.status
  280. == "within_known_budget"
  281. )
  282. def test_unknown_flight_combination_fails() -> None:
  283. service = PlanValidationService()
  284. (
  285. request,
  286. plan,
  287. selection,
  288. map_result,
  289. route_result,
  290. ) = build_context()
  291. plan = plan.model_copy(
  292. update={
  293. "selected_flight": (
  294. plan.selected_flight.model_copy(
  295. update={
  296. "combination_id": (
  297. "round-not-found"
  298. )
  299. }
  300. )
  301. )
  302. }
  303. )
  304. result = service.validate(
  305. request=request,
  306. plan=plan,
  307. selection=selection,
  308. map_result=map_result,
  309. route_result=route_result,
  310. )
  311. assert result.is_valid is False
  312. codes = {
  313. issue.code
  314. for issue in result.issues
  315. }
  316. assert (
  317. "FLIGHT_COMBINATION_NOT_FOUND"
  318. in codes
  319. )
  320. def test_unknown_attraction_fails() -> None:
  321. service = PlanValidationService()
  322. (
  323. request,
  324. plan,
  325. selection,
  326. map_result,
  327. route_result,
  328. ) = build_context()
  329. invalid_activity = (
  330. plan.days[1]
  331. .activities[0]
  332. .model_copy(
  333. update={
  334. "reference_id": "unknown-poi",
  335. "name": "不存在的景点",
  336. }
  337. )
  338. )
  339. invalid_day = plan.days[1].model_copy(
  340. update={
  341. "activities": [
  342. invalid_activity,
  343. plan.days[1].activities[1],
  344. ]
  345. }
  346. )
  347. invalid_plan = plan.model_copy(
  348. update={
  349. "days": [
  350. plan.days[0],
  351. invalid_day,
  352. plan.days[2],
  353. plan.days[3],
  354. ]
  355. }
  356. )
  357. result = service.validate(
  358. request=request,
  359. plan=invalid_plan,
  360. selection=selection,
  361. map_result=map_result,
  362. route_result=route_result,
  363. )
  364. assert result.is_valid is False
  365. assert any(
  366. issue.code.startswith("UNKNOWN_")
  367. for issue in result.issues
  368. )
  369. def test_known_cost_over_budget_fails() -> None:
  370. service = PlanValidationService()
  371. (
  372. request,
  373. plan,
  374. selection,
  375. map_result,
  376. route_result,
  377. ) = build_context(
  378. total_budget=3000
  379. )
  380. result = service.validate(
  381. request=request,
  382. plan=plan,
  383. selection=selection,
  384. map_result=map_result,
  385. route_result=route_result,
  386. )
  387. assert result.is_valid is False
  388. assert result.budget.status == "over_budget"
  389. assert any(
  390. issue.code
  391. == "KNOWN_BUDGET_EXCEEDED"
  392. for issue in result.issues
  393. )