"""일정 응답 해석 — 모델이 준 JSON 에서 화면에 설 수 있는 코스만 남긴다. DB 도 네트워크도 쓰지 않는 순수 단위 테스트다. ★ 스키마 전체를 검증하지 않는다. 항목 모양의 단일 출처는 `shared/lib/section-data.ts` 이고, 그 모양을 파이썬에 한 벌 더 적으면 프론트가 필드를 늘린 날 서버가 조용히 떨어뜨린다 (`grounding/story.py` 머리주석과 같은 판단). 여기서는 "화면에 설 수 있는가" 만 본다. """ import json from services.grounding import itinerary as grounding # 이 파일의 모든 parse_courses 호출이 쓰는 업소명 — 실제 값은 안 중요하다(DB 값을 그대로 # 통과시키는지만 본다). 테스트 실제 좌표가 필요한 곳은 개별 테스트가 지정한다. _PLACE = "테스트업소" def _payload(items: list, *, fence: bool = False, search: list | None = None) -> dict: envelope = {"kind": "itinerary", "version": 1, "title": "추천 일정", "items": items} text = json.dumps(envelope, ensure_ascii=False) if fence: text = f"```json\n{text}\n```" return { "choices": [{"message": {"content": text}}], "search_results": search if search is not None else [ {"title": "군산문화관광", "url": "https://www.gunsan.go.kr/tour/"} ], } def _course(name: str, stops: list[str], *, days: int = 2) -> dict: per_day = max(1, len(stops) // days) chunks = [stops[i:i + per_day] for i in range(0, len(stops), per_day)][:days] return { "name": name, "duration": "1박 2일", "audience": "누구에게나", "why": "이유 한 문장.", "days": [ { "label": f"{i + 1}일차", "startTime": "14:00", "stops": [ {"name": s, "minutes": 60, "moveMinutes": 10, "searchQuery": s, "latitude": 35.99, "longitude": 126.71} for s in chunk ], } for i, chunk in enumerate(chunks) ], } def test_accepts_five_distinct_courses(): """검증: 정상 응답 5개. 기대결과: 전부 채택되고 버린 것이 없다.""" items = [_course(f"코스{i}", [f"장소{i}-{j}" for j in range(4)]) for i in range(5)] courses, dropped = grounding.parse_courses(_payload(items), "1박 2일", _PLACE) assert len(courses) == 5 assert dropped == [] assert [c["name"] for c in courses] == [f"코스{i}" for i in range(5)] def test_strips_code_fence(): """검증: 코드펜스를 두르고 온 응답. 기대결과: 규칙 1 로 금지했지만 모델은 종종 어긴다 — 벗겨서 읽는다.""" items = [_course("코스", ["가", "나"])] courses, dropped = grounding.parse_courses(_payload(items, fence=True), "1박 2일", _PLACE) assert len(courses) == 1 def test_forces_duration_even_if_model_wrote_something_else(): """검증: 모델이 duration 을 다르게 적은 코스. 기대결과: 우리가 요청한 기간으로 덮어쓴다 — 이 값이 화면 탭을 가르므로 틀리면 탭이 갈린다.""" item = _course("코스", ["가", "나"]) item["duration"] = "반나절" courses, _ = grounding.parse_courses(_payload([item]), "1박 2일", _PLACE) assert courses[0]["duration"] == "1박 2일" def test_drops_course_without_name(): """검증: 이름 없는 코스. 기대결과: 버린다 — 제목 없는 카드가 된다.""" item = _course("", ["가", "나"]) courses, dropped = grounding.parse_courses(_payload([item]), "1박 2일", _PLACE) assert courses == [] assert dropped and "name" in dropped[0] def test_drops_course_without_stops(): """검증: days 가 있어도 정거장이 하나도 없는 코스. 기대결과: 버린다 — 발행본 ItinerarySection 이 어차피 걸러 빈 탭을 만든다.""" item = {"name": "빈 코스", "duration": "1박 2일", "days": [{"label": "1일차", "stops": []}]} courses, dropped = grounding.parse_courses(_payload([item]), "1박 2일", _PLACE) assert courses == [] assert dropped def test_drops_a_course_whose_stop_set_duplicates_an_earlier_one(): """검증: 앞 코스와 정거장 집합이 완전히 같은 코스(순서만 다름). 기대결과: 뒤에 온 것을 버린다 — 손님 눈에는 같은 코스 둘이다. ★ 정거장 '겹침' 자체는 허용이다(2026-09-11 결정). 집합이 **같을 때**만 버린다.""" first = _course("원도심 코스", ["가", "나", "다", "라"]) same = _course("미식 코스", ["라", "다", "나", "가"]) # 순서만 다르다 overlap = _course("자연 코스", ["가", "나", "마", "바"]) # 절반 겹치지만 다른 코스다 courses, dropped = grounding.parse_courses(_payload([first, same, overlap]), "1박 2일", _PLACE) assert [c["name"] for c in courses] == ["원도심 코스", "자연 코스"] assert dropped and "미식 코스" in dropped[0] def test_attaches_search_result_as_source_when_model_gave_none(): """검증: 코스에 source 가 없을 때. 기대결과: Perplexity 가 실제로 읽은 첫 출처를 붙인다 — 모델 답변보다 이게 실재한다. ★ source 가 아예 없으면 발행본 카드 하단에 빈 점선 띠가 남는다(ItinerarySection 껍데기).""" courses, _ = grounding.parse_courses(_payload([_course("코스", ["가", "나"])]), "1박 2일", _PLACE) assert courses[0]["source"]["url"] == "https://www.gunsan.go.kr/tour/" def test_keeps_model_source_when_it_is_a_real_url(): """검증: 모델이 준 source 가 http 로 시작할 때. 기대결과: 그걸 쓴다.""" item = _course("코스", ["가", "나"]) item["source"] = {"name": "군산시", "url": "https://www.gunsan.go.kr/index.gunsan"} courses, _ = grounding.parse_courses(_payload([item]), "1박 2일", _PLACE) assert courses[0]["source"]["url"] == "https://www.gunsan.go.kr/index.gunsan" def test_course_survives_without_any_source(): """검증: 모델 source 도 없고 search_results 도 빈 응답. 기대결과: 코스는 살린다 — 출처가 없다고 일정을 버리면 화면이 통째로 빈다. (지역 이야기와 다른 판단이다. 저쪽은 '사실' 이고 이쪽은 '제안' 이다.)""" items = [_course("코스", ["가", "나"])] courses, _ = grounding.parse_courses(_payload(items, search=[]), "1박 2일", _PLACE) assert len(courses) == 1 assert "source" not in courses[0] def test_returns_empty_on_non_json(): """검증: JSON 이 아닌 응답. 기대결과: 빈 목록 + 이유. 예외를 던지지 않는다 — 잡이 죽으면 다른 기간도 못 받는다.""" payload = {"choices": [{"message": {"content": "죄송합니다, 일정을 만들 수 없습니다."}}]} courses, dropped = grounding.parse_courses(payload, "1박 2일", _PLACE) assert courses == [] assert dropped def test_returns_empty_when_items_is_not_a_list(): """검증: items 가 배열이 아닌 응답.""" payload = {"choices": [{"message": {"content": json.dumps({"items": "없음"})}}]} courses, dropped = grounding.parse_courses(payload, "1박 2일", _PLACE) assert courses == [] assert dropped # ── 하루 시각표 강제 (2026-09-11, 사장님 지시) ──────────────────────────── def _stop(name: str, minutes: int = 60, move: int = 10) -> dict: return {"name": name, "minutes": minutes, "moveMinutes": move, "searchQuery": name, "latitude": 35.99, "longitude": 126.71} def test_overrides_start_time_and_label_regardless_of_model_values(): """검증: 모델이 startTime·label 을 다르게 적어도. 기대결과: DAY_SCHEDULE 값(15:00/첫째 날, 09:00/둘째 날)으로 덮어쓴다 — duration 과 같은 판단.""" item = { "name": "코스", "duration": "1박 2일", "days": [ {"label": "Day A", "startTime": "10:00", "stops": [_stop("가")]}, {"label": "Day B", "startTime": "08:00", "stops": [_stop("나")]}, ], } courses, dropped = grounding.parse_courses(_payload([item]), "1박 2일", _PLACE) assert dropped == [] days = courses[0]["days"] assert (days[0]["label"], days[0]["startTime"]) == ("첫째 날", "15:00") assert (days[1]["label"], days[1]["startTime"]) == ("둘째 날", "09:00") def test_trims_stops_that_would_run_past_the_days_end_time(): """검증: 첫째 날(15:00~19:00)에 정거장을 오래 머무는 것부터 채운다. 기대결과: 예산을 넘기는 뒤쪽 정거장만 잘린다 — 순서는 보존된다. ★ 업소가 각 날 맨 앞(출발)에, 복귀하는 날(첫째 날)은 맨 뒤에도 선다 — 이건 시간 예산을 먹기 전에(트리밍 이후) 얹으므로 트리밍 자체에는 영향이 없다.""" item = { "name": "코스", "duration": "1박 2일", "days": [ # 15:10 도착·18:10 종료(180분) → 다음 정거장은 18:20 도착에 18:20+180=21:20 으로 19:00 초과. {"stops": [_stop("가", minutes=180), _stop("나", minutes=180)]}, {"stops": [_stop("다")]}, ], } courses, dropped = grounding.parse_courses(_payload([item]), "1박 2일", _PLACE) assert dropped == [] # 첫째 날은 복귀가 있다 — 업소(출발) → 가 → 업소(복귀). assert [s["name"] for s in courses[0]["days"][0]["stops"]] == [_PLACE, "가", _PLACE] # 둘째 날(마지막 날)은 체크아웃이라 복귀가 없다 — 업소(출발) → 다. assert [s["name"] for s in courses[0]["days"][1]["stops"]] == [_PLACE, "다"] def test_drops_course_when_first_stop_of_a_day_already_overruns(): """검증: 첫째 날 첫 정거장부터 종료 시각(19:00)을 넘긴다. 기대결과: 그 날이 통째로 비어 코스 자체를 버린다 — 빈 날을 카드로 보여주지 않는다.""" item = { "name": "코스", "duration": "1박 2일", "days": [ {"stops": [_stop("가", minutes=300)]}, # 15:10 도착, 300분 → 20:10 종료(19:00 초과) {"stops": [_stop("나")]}, ], } courses, dropped = grounding.parse_courses(_payload([item]), "1박 2일", _PLACE) assert courses == [] assert dropped and "시각표" in dropped[0] def test_drops_course_whose_day_count_is_short_of_the_duration(): """검증: '1박 2일'인데 하루치 밖에 없다. 기대결과: 버린다 — 둘째 날이 아예 없는 일정을 내보내지 않는다.""" item = {"name": "코스", "duration": "1박 2일", "days": [{"stops": [_stop("가")]}]} courses, dropped = grounding.parse_courses(_payload([item]), "1박 2일", _PLACE) assert courses == [] assert dropped and "시각표" in dropped[0] def test_lodging_is_the_departure_and_return_point(): """검증: 업소는 실제 정거장(stops[])이다(2026-09-11 결정: "업소는 출발지에 포함"). 기대결과: 복귀하는 날은 맨 앞·맨 뒤 둘 다, 체크아웃 날(마지막 날)은 맨 앞만 — LLM 이 고른 정거장 수("하루 3~5곳")와는 별개로 얹는다.""" item = { "name": "코스", "duration": "2박 3일", "days": [ {"stops": [_stop("가"), _stop("나")]}, {"stops": [_stop("다"), _stop("라")]}, {"stops": [_stop("마")]}, ], } courses, dropped = grounding.parse_courses(_payload([item]), "2박 3일", "군산모텔", 35.98642, 126.70612) assert dropped == [] days = courses[0]["days"] assert [s["name"] for s in days[0]["stops"]] == ["군산모텔", "가", "나", "군산모텔"] assert [s["name"] for s in days[1]["stops"]] == ["군산모텔", "다", "라", "군산모텔"] assert [s["name"] for s in days[2]["stops"]] == ["군산모텔", "마"] # 마지막 날 — 복귀 없음 def test_lodging_stop_carries_coordinates_only_when_known(): """검증: 업소 좌표가 있을 때/없을 때. 기대결과: 있으면 싣고, 없으면 이름만 — 틀린 핀을 찍느니 안 찍는다(PlannerStop 과 같은 규칙).""" item = {"name": "코스", "duration": "1박 2일", "days": [{"stops": [_stop("가")]}, {"stops": [_stop("나")]}]} with_coords, _ = grounding.parse_courses( _payload([item]), "1박 2일", "군산모텔", 35.98642, 126.70612) lodging = with_coords[0]["days"][0]["stops"][0] assert lodging["name"] == "군산모텔" assert (lodging["latitude"], lodging["longitude"]) == (35.98642, 126.70612) without_coords, _ = grounding.parse_courses(_payload([item]), "1박 2일", "군산모텔") lodging2 = without_coords[0]["days"][0]["stops"][0] assert "latitude" not in lodging2 and "longitude" not in lodging2