Spaces:
Sleeping
Sleeping
fix(chatbot): asyncio.run() replaces deprecated get_event_loop in tests; update appendix Chapter C figures
5fabc4a | """ | |
| Unit tests for pure helper functions extracted into routes.py. | |
| Covers: | |
| - Module-level constants: _TRAVEL_INTENTS, _TRAVEL_KEYWORDS_RE, _TERRAIN_KEYWORDS | |
| - _build_destination_info_card() | |
| - _build_nearby_attractions_card() | |
| - _build_travel_tips_card() | |
| - _classify_intent_safe() — exception path returns safe fallback | |
| - _extract_entities_safe() — exception path returns empty entities dict | |
| """ | |
| import re | |
| import pytest | |
| import asyncio | |
| from app.api.routes import ( | |
| _TRAVEL_INTENTS, | |
| _TRAVEL_KEYWORDS_RE, | |
| _TERRAIN_KEYWORDS, | |
| _build_destination_info_card, | |
| _build_nearby_attractions_card, | |
| _build_travel_tips_card, | |
| _classify_intent_safe, | |
| _extract_entities_safe, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Module-level constants | |
| # --------------------------------------------------------------------------- | |
| class TestModuleConstants: | |
| def test_travel_intents_is_frozenset(self): | |
| assert isinstance(_TRAVEL_INTENTS, frozenset) | |
| def test_travel_intents_contains_core_intents(self): | |
| for intent in ("plan_trip", "find_hotel", "find_flight", "budget_advice"): | |
| assert intent in _TRAVEL_INTENTS, f"Expected '{intent}' in _TRAVEL_INTENTS" | |
| def test_travel_keywords_re_is_compiled_pattern(self): | |
| assert isinstance(_TRAVEL_KEYWORDS_RE, type(re.compile(""))) | |
| def test_travel_keywords_re_matches_english_keywords(self): | |
| assert _TRAVEL_KEYWORDS_RE.search("I want to book a hotel") is not None | |
| assert _TRAVEL_KEYWORDS_RE.search("looking for flight tickets") is not None | |
| def test_travel_keywords_re_matches_vietnamese_keywords(self): | |
| assert _TRAVEL_KEYWORDS_RE.search("đặt phòng khách sạn") is not None | |
| def test_travel_keywords_re_no_match_for_unrelated(self): | |
| # Should not match random non-travel text | |
| assert _TRAVEL_KEYWORDS_RE.search("hello world foo bar") is None | |
| def test_terrain_keywords_has_vi_and_en(self): | |
| assert "vi" in _TERRAIN_KEYWORDS | |
| assert "en" in _TERRAIN_KEYWORDS | |
| def test_terrain_keywords_vi_has_terrain_types(self): | |
| vi = _TERRAIN_KEYWORDS["vi"] | |
| for key in ("beach", "mountain", "city"): | |
| assert key in vi, f"Expected terrain type '{key}' in vi terrain keywords" | |
| # --------------------------------------------------------------------------- | |
| # _build_destination_info_card() | |
| # --------------------------------------------------------------------------- | |
| class TestBuildDestinationInfoCard: | |
| def _sample_dest(self, **overrides): | |
| base = { | |
| "id": "hanoi-001", | |
| "name": "Hà Nội", | |
| "name_en": "Hanoi", | |
| "description": "Capital of Vietnam", | |
| "description_en": "Capital of Vietnam", | |
| "best_months": [10, 11, 12], | |
| "tags": ["city", "heritage", "food"], | |
| "highlights": ["Hoan Kiem Lake", "Old Quarter"], | |
| "avg_budget_per_day": 500000, | |
| "lat": 21.028511, | |
| "lon": 105.804817, | |
| "image_url": "https://example.com/hanoi.jpg", | |
| "region": "North Vietnam", | |
| } | |
| base.update(overrides) | |
| return base | |
| def test_returns_correct_type(self): | |
| card = _build_destination_info_card(self._sample_dest()) | |
| assert card["type"] == "destination_info" | |
| def test_data_contains_required_keys(self): | |
| card = _build_destination_info_card(self._sample_dest()) | |
| data = card["data"] | |
| for key in ("id", "name", "name_en", "description", "best_months", "tags", | |
| "highlights", "avg_budget_per_day", "lat", "lon", "image_url", "region"): | |
| assert key in data, f"Missing key '{key}' in destination_info card data" | |
| def test_tags_limited_to_8(self): | |
| dest = self._sample_dest(tags=["t1", "t2", "t3", "t4", "t5", "t6", "t7", "t8", "t9"]) | |
| card = _build_destination_info_card(dest) | |
| assert len(card["data"]["tags"]) <= 8 | |
| def test_highlights_limited_to_5(self): | |
| dest = self._sample_dest(highlights=[f"h{i}" for i in range(10)]) | |
| card = _build_destination_info_card(dest) | |
| assert len(card["data"]["highlights"]) <= 5 | |
| def test_empty_destination_uses_defaults(self): | |
| """Empty dict should not raise — all fields fall back to defaults.""" | |
| card = _build_destination_info_card({}) | |
| assert card["type"] == "destination_info" | |
| assert card["data"]["id"] == "" | |
| assert card["data"]["best_months"] == [] | |
| def test_fallback_description_uses_description_vi(self): | |
| dest = {"description_vi": "Mô tả tiếng Việt"} | |
| card = _build_destination_info_card(dest) | |
| assert card["data"]["description"] == "Mô tả tiếng Việt" | |
| def test_budget_per_day_fallback(self): | |
| dest = {"budget_per_day": 300000} | |
| card = _build_destination_info_card(dest) | |
| assert card["data"]["avg_budget_per_day"] == 300000 | |
| # --------------------------------------------------------------------------- | |
| # _build_nearby_attractions_card() | |
| # --------------------------------------------------------------------------- | |
| class TestBuildNearbyAttractionsCard: | |
| def test_returns_correct_type(self): | |
| card = _build_nearby_attractions_card({"id": "x", "name": "Test"}) | |
| assert card["type"] == "nearby_attractions" | |
| def test_data_contains_required_keys(self): | |
| card = _build_nearby_attractions_card({"id": "x", "name": "Test"}) | |
| for key in ("destination_id", "destination_name", "attractions", "tags"): | |
| assert key in card["data"], f"Missing key '{key}'" | |
| def test_dict_activities_normalised_correctly(self): | |
| dest = { | |
| "id": "d1", | |
| "name": "Da Nang", | |
| "popular_activities": [ | |
| {"name": "Beach walk", "type": "outdoor", "cost_vnd": 0, "duration": "2h", | |
| "description": "Walk along the beach"}, | |
| ], | |
| } | |
| card = _build_nearby_attractions_card(dest) | |
| attractions = card["data"]["attractions"] | |
| assert len(attractions) == 1 | |
| assert attractions[0]["name"] == "Beach walk" | |
| assert attractions[0]["type"] == "outdoor" | |
| def test_string_activities_normalised_to_dict(self): | |
| dest = { | |
| "id": "d2", | |
| "name": "Hoi An", | |
| "popular_activities": ["Lantern festival", "Boat ride"], | |
| } | |
| card = _build_nearby_attractions_card(dest) | |
| attractions = card["data"]["attractions"] | |
| assert all(isinstance(a, dict) for a in attractions) | |
| assert attractions[0]["name"] == "Lantern festival" | |
| def test_attractions_limited_to_8(self): | |
| dest = { | |
| "popular_activities": [{"name": f"act{i}", "type": "tour"} for i in range(12)], | |
| } | |
| card = _build_nearby_attractions_card(dest) | |
| assert len(card["data"]["attractions"]) <= 8 | |
| def test_empty_destination_no_crash(self): | |
| card = _build_nearby_attractions_card({}) | |
| assert card["type"] == "nearby_attractions" | |
| assert card["data"]["attractions"] == [] | |
| # --------------------------------------------------------------------------- | |
| # _build_travel_tips_card() | |
| # --------------------------------------------------------------------------- | |
| class TestBuildTravelTipsCard: | |
| def test_returns_correct_type(self): | |
| card = _build_travel_tips_card({"id": "x", "name": "Test"}) | |
| assert card["type"] == "travel_tips" | |
| def test_data_contains_required_keys(self): | |
| card = _build_travel_tips_card({"id": "x", "name": "Test"}) | |
| for key in ("destination_id", "destination_name", "best_months", "transport", "visa", | |
| "safety_tips", "currency"): | |
| assert key in card["data"] | |
| def test_transport_as_list_coerced_to_empty_dict(self): | |
| """If transport is a list (invalid shape), coerce to empty dict.""" | |
| dest = {"transport": ["bus", "taxi"]} | |
| card = _build_travel_tips_card(dest) | |
| assert isinstance(card["data"]["transport"], dict) | |
| assert card["data"]["transport"] == {} | |
| def test_transport_as_dict_preserved(self): | |
| dest = {"transport": {"bus": "available", "taxi": "cheap"}} | |
| card = _build_travel_tips_card(dest) | |
| assert card["data"]["transport"] == {"bus": "available", "taxi": "cheap"} | |
| def test_visa_info_string_coerced_to_dict(self): | |
| dest = {"visa_info": "Visa-free for 30 days"} | |
| card = _build_travel_tips_card(dest) | |
| assert isinstance(card["data"]["visa"], dict) | |
| assert card["data"]["visa"] == {"note": "Visa-free for 30 days"} | |
| def test_visa_fallback_to_visa_key(self): | |
| dest = {"visa": {"requirement": "e-visa"}} | |
| card = _build_travel_tips_card(dest) | |
| assert card["data"]["visa"] == {"requirement": "e-visa"} | |
| def test_empty_destination_no_crash(self): | |
| card = _build_travel_tips_card({}) | |
| assert card["type"] == "travel_tips" | |
| # --------------------------------------------------------------------------- | |
| # _classify_intent_safe() — async helper | |
| # --------------------------------------------------------------------------- | |
| class TestClassifyIntentSafe: | |
| def _run(self, coro): | |
| return asyncio.run(coro) | |
| def test_normal_classifier_returns_result(self): | |
| class FakeClf: | |
| def predict(self, text, lang): | |
| return {"intent": "find_hotel", "confidence": 0.9, "method": "ml_model"} | |
| result = self._run(_classify_intent_safe("book a hotel", "en", FakeClf())) | |
| assert result["intent"] == "find_hotel" | |
| def test_classifier_raises_returns_fallback(self): | |
| class BrokenClf: | |
| def predict(self, text, lang): | |
| raise RuntimeError("model crashed") | |
| result = self._run(_classify_intent_safe("anything", "en", BrokenClf())) | |
| assert result["intent"] == "fallback" | |
| assert result["confidence"] == 0.0 | |
| assert result["method"] == "error_fallback" | |
| def test_fallback_dict_has_required_keys(self): | |
| class BrokenClf: | |
| def predict(self, text, lang): | |
| raise ValueError("bad input") | |
| result = self._run(_classify_intent_safe("text", "vi", BrokenClf())) | |
| assert {"intent", "confidence", "method"} <= set(result.keys()) | |
| # --------------------------------------------------------------------------- | |
| # _extract_entities_safe() — async helper | |
| # --------------------------------------------------------------------------- | |
| class TestExtractEntitiesSafe: | |
| def _run(self, coro): | |
| return asyncio.run(coro) | |
| def test_normal_ner_returns_result(self): | |
| class FakeNER: | |
| def extract(self, text, lang): | |
| return {"locations": ["Hanoi"], "destination_locations": []} | |
| result = self._run(_extract_entities_safe("trip to Hanoi", "en", FakeNER())) | |
| assert result["locations"] == ["Hanoi"] | |
| def test_ner_raises_returns_empty_entities(self): | |
| class BrokenNER: | |
| def extract(self, text, lang): | |
| raise RuntimeError("NER model crashed") | |
| result = self._run(_extract_entities_safe("text", "en", BrokenNER())) | |
| # Should return empty entities dict without raising | |
| assert isinstance(result, dict) | |
| assert "locations" in result | |
| assert result["locations"] == [] | |
| def test_empty_entities_has_all_expected_keys(self): | |
| class BrokenNER: | |
| def extract(self, text, lang): | |
| raise Exception("any error") | |
| result = self._run(_extract_entities_safe("text", "vi", BrokenNER())) | |
| expected_keys = { | |
| "locations", "origin", "destination_locations", | |
| "duration", "budget", "num_people", | |
| "hotel_type", "dietary_restrictions", "cuisine_preferences", | |
| "travel_style", "budget_priorities", "transport_preference", | |
| "time_preference", "activity_preferences", "is_multi_destination", | |
| } | |
| assert expected_keys <= set(result.keys()) | |