Initial commit: gmTouringMiniApp project

This commit is contained in:
周瑞哲
2026-07-30 16:04:34 +08:00
commit ebcae02d35
201 changed files with 49545 additions and 0 deletions
+160
View File
@@ -0,0 +1,160 @@
import os
import pytest
from fastapi.testclient import TestClient
os.environ["LLM_MODE"] = "mock"
from app.main import app # noqa: E402
client = TestClient(app)
def preferences():
return {
"destination": "深圳市光明区",
"themes": ["亲子"],
"duration": "half_day",
"pace": "moderate",
"interests": ["自然风光"],
"adults": 2,
"children": 1,
"childAges": [6],
"transport": "public_transport",
"budgetLevel": "standard",
"extraRequirements": "",
}
def candidates(count: int = 9):
return [
{
"id": f"poi_candidate_{index}",
"name": f"候选点位 {index}",
"categoryCode": "urban_park" if index % 2 else "cultural_venue",
"tagCodes": ["family_friendly", "nature_view"],
"summary": "仅用于接口契约测试。",
"recommendationIndex": 1 + index % 5,
}
for index in range(1, count + 1)
]
def request_payload(**overrides):
payload = {
"mode": "quick",
"preferences": preferences(),
"durationMinutes": 240,
"selectedPoiIds": [],
"candidates": candidates(),
}
payload.update(overrides)
return payload
def test_health_distinguishes_mock_and_configuration():
response = client.get("/api/v1/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert data["mode"] == "mock"
assert data["ready"] is True
assert isinstance(data["configured"], bool)
def test_quick_recommendations_only_use_candidate_whitelist():
payload = request_payload(durationMinutes=480)
response = client.post("/api/v1/plans", json=payload)
assert response.status_code == 200
data = response.json()
allowed_ids = {candidate["id"] for candidate in payload["candidates"]}
ids = [item["poiId"] for item in data["recommendations"]]
assert 3 <= len(ids) <= 8
assert len(ids) == len(set(ids))
assert set(ids) <= allowed_ids
assert all("longitude" not in item for item in data["recommendations"])
def test_custom_keeps_every_selected_poi_even_when_duration_is_short():
selected = [f"poi_candidate_{index}" for index in range(1, 7)]
response = client.post(
"/api/v1/plans",
json=request_payload(
mode="custom",
durationMinutes=120,
selectedPoiIds=selected,
),
)
assert response.status_code == 200
data = response.json()
ids = [item["poiId"] for item in data["recommendations"]]
assert ids == selected
assert data["mode"] == "custom"
@pytest.mark.parametrize(
"selected",
[
["poi_candidate_1"],
[f"poi_candidate_{index}" for index in range(1, 10)],
],
)
def test_custom_requires_two_to_eight_pois(selected):
response = client.post(
"/api/v1/plans",
json=request_payload(mode="custom", selectedPoiIds=selected),
)
assert response.status_code == 422
def test_unknown_selected_id_is_rejected():
response = client.post(
"/api/v1/plans",
json=request_payload(
mode="custom",
selectedPoiIds=["poi_candidate_1", "poi_not_in_whitelist"],
),
)
assert response.status_code == 422
@pytest.mark.parametrize("duration", [90, 125, 630])
def test_duration_must_use_supported_range_and_step(duration):
response = client.post(
"/api/v1/plans",
json=request_payload(durationMinutes=duration),
)
assert response.status_code == 422
def test_quick_rejects_preselected_pois():
response = client.post(
"/api/v1/plans",
json=request_payload(selectedPoiIds=["poi_candidate_1"]),
)
assert response.status_code == 422
def test_unknown_fields_are_rejected_in_preferences_and_candidates():
payload = request_payload()
payload["preferences"]["unexpected"] = "not allowed"
response = client.post("/api/v1/plans", json=payload)
assert response.status_code == 422
payload = request_payload()
payload["candidates"][0]["address"] = "not sent to model"
response = client.post("/api/v1/plans", json=payload)
assert response.status_code == 422
def test_origin_and_coordinates_are_rejected_at_the_contract_boundary():
payload = request_payload(origin={"longitude": 113.9, "latitude": 22.7})
response = client.post("/api/v1/plans", json=payload)
assert response.status_code == 422
payload = request_payload()
payload["candidates"][0]["longitude"] = 113.9
response = client.post("/api/v1/plans", json=payload)
assert response.status_code == 422
+105
View File
@@ -0,0 +1,105 @@
import asyncio
import pytest
from app.llm import LLMError, LLMService
from app.planner import RecommendationService
from app.schemas import ModelRecommendationSet, PlanRecommendationRequest
def custom_request() -> PlanRecommendationRequest:
return PlanRecommendationRequest.model_validate(
{
"mode": "custom",
"preferences": {
"destination": "深圳市光明区",
"themes": ["朋友"],
"duration": "half_day",
"pace": "moderate",
"interests": ["自然风光"],
"adults": 2,
"children": 0,
"childAges": [],
"transport": "public_transport",
"budgetLevel": "standard",
"extraRequirements": "",
},
"durationMinutes": 120,
"selectedPoiIds": ["poi_a", "poi_b", "poi_c"],
"candidates": [
{
"id": poi_id,
"name": poi_id,
"categoryCode": "urban_park",
"tagCodes": ["walking"],
"summary": "test",
"recommendationIndex": 4,
}
for poi_id in ["poi_a", "poi_b", "poi_c", "poi_d"]
],
}
)
class PartialCustomLLM:
mode = "mock"
async def recommend(self, _request):
return ModelRecommendationSet.model_validate(
{
"recommendations": [
{"poiId": "poi_c", "reason": "先参观 C", "order": 1},
{"poiId": "poi_d", "reason": "模型多选了 D", "order": 2},
]
}
)
class UnknownPoiLLM:
mode = "mock"
async def recommend(self, _request):
return ModelRecommendationSet.model_validate(
{
"recommendations": [
{"poiId": "poi_unknown", "reason": "unknown", "order": 1}
]
}
)
def test_custom_selection_is_owned_by_user_not_model():
result = asyncio.run(RecommendationService(PartialCustomLLM()).create(custom_request()))
ids = [item.poi_id for item in result.recommendations]
assert ids == ["poi_c", "poi_a", "poi_b"]
assert set(ids) == {"poi_a", "poi_b", "poi_c"}
def test_model_output_with_unknown_id_is_rejected():
with pytest.raises(LLMError, match="白名单外"):
asyncio.run(RecommendationService(UnknownPoiLLM()).create(custom_request()))
def test_model_health_states_distinguish_mode_configuration_and_readiness(monkeypatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_MODEL", raising=False)
monkeypatch.setenv("LLM_MODE", "mock")
mock = LLMService()
assert (mock.mode, mock.configured, mock.ready) == ("mock", False, True)
monkeypatch.setenv("LLM_MODE", "real")
unconfigured = LLMService()
assert (unconfigured.mode, unconfigured.configured, unconfigured.ready) == (
"real",
False,
False,
)
monkeypatch.setenv("OPENAI_API_KEY", "test-only-placeholder")
monkeypatch.setenv("OPENAI_MODEL", "test-only-model")
configured = LLMService()
assert (configured.mode, configured.configured, configured.ready) == (
"real",
True,
True,
)