k-skill/scripts/test_srt_seats.py
iamiks 52dbfee064
feat(srt-booking): SRT 좌석 확인과 탐색 우선순위 개선 (#305)
* feat(srt): 좌석 조회와 탐색 우선순위 추가

SRT search 결과의 stable train_id로 객차별 좌석을 조회하고, 특정 호차/좌석 확인과 탐색 우선순위 옵션을 제공한다.

Constraint: SRT와 KTX는 별도 upstream 표면이므로 SRT HTML 파서와 테스트를 분리함
Rejected: KTX 좌석 helper 공유 | Korail API와 SRT 웹 좌석선택 HTML 계약이 달라 혼용하면 파서 안정성이 낮아짐
Confidence: medium
Scope-risk: moderate
Directive: SRT 좌석선택 HTML에서 노출되지 않는 속성은 추정하지 말고 명시적으로 처리할 것
Tested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_srt_booking scripts.test_ktx_booking; python3 -m py_compile scripts/srt_booking.py scripts/srt_seats.py scripts/test_srt_booking.py
Not-tested: 실제 예약 API에 우선순위 좌석 선택을 연결하는 흐름

* fix(srt): 좌석 조회 JSON 출력 안정화

SRT 대기열 메시지가 stdout에 섞여 seats JSON을 깨는 실제 표면 문제를 막고, 누락된 좌석 방향/위치 속성을 unknown으로 정규화한다.

Constraint: issue #303 범위는 예약 부작용이 없는 좌석 조회 보조 흐름으로 제한됨
Rejected: 실제 예약 subcommand 추가 | 좌석 선점/예약은 외부 부작용이라 이번 acceptance criteria에 포함되지 않음
Confidence: high
Scope-risk: narrow
Directive: SRTrain upstream 출력이 추가되더라도 helper stdout은 JSON 전용으로 유지할 것
Tested: RED→GREEN in .omo/ulw-loop/evidence/srt-c002-red-green-tests.txt; live SRT tmux QA in .omo/ulw-loop/evidence/srt-c001-live-search-seats.txt; npm run ci in .omo/ulw-loop/evidence/srt-c003-regression-ci.txt
Not-tested: 실제 예약/결제/취소 부작용 흐름

* test(srt): split seat helper regression coverage

---------

Co-authored-by: Jeffrey (Dongkyu) Kim <vkehfdl1@gmail.com>
2026-06-09 10:57:54 +09:00

82 lines
4.4 KiB
Python

from __future__ import annotations
import unittest
import srt_seats
SEAT_HTML = "\n".join([
'<li class="scar-01 off"><strong>일반실<br />1호차</strong></li>',
'<li class="scar-04 on"><a href="#none" onclick="selectScarInfo(\'0004\'); return false;"><strong>일반실<br />4호차</strong></a></li>',
'<li class="scar-05"><a href="#none" onclick="selectScarInfo(\'0005\'); return false;"><strong>일반실<br />5호차</strong></a></li>',
'<li class="scar-03 off"><strong>특실<br />3호차</strong></li>',
'<a href="#none" onclick="selectSeatInfo(this, \'23\', \'6C\'); return false;">6C<strong><em>(정방향, 내측)</em></strong></a>',
'<a href="#none" onclick="selectSeatInfo(this, \'11\', \'3A\'); return false;">3A<strong><em>(역방향, 창측)</em></strong></a>',
"<span>5C<strong><em>(정방향, 내측, 선택불가)</em></strong></span>",
])
MISSING_DETAIL_HTML = "\n".join([
'<a href="#none" onclick="selectSeatInfo(this, \'41\', \'9A\'); return false;">9A<strong><em>()</em></strong></a>',
])
class SrtSeatParserTests(unittest.TestCase):
def test_normalize_car_and_seat_maps_srt_html(self) -> None:
cars = srt_seats.parse_cars(SEAT_HTML)
seats = srt_seats.parse_seats(SEAT_HTML)
self.assertEqual([car["car_no"] for car in cars if car["available"]], [4, 5])
self.assertEqual(cars[1]["room_class"], "일반실")
self.assertTrue(cars[1]["current"])
self.assertEqual([seat["seat"] for seat in seats if seat["available"]], ["6C", "3A"])
self.assertEqual([seat["seat"] for seat in seats if not seat["available"]], ["5C"])
self.assertEqual(seats[0]["direction"], "정방향")
self.assertEqual(seats[0]["position"], "내측")
self.assertEqual(seats[2]["notes"], ["선택불가"])
def test_booking_priority_sorts_middle_cars_before_end_cars(self) -> None:
cars: list[srt_seats.SrtCar] = [
{"car_no": 1, "car_no_raw": "0001", "room_class": "일반실", "available": True, "current": False},
{"car_no": 8, "car_no_raw": "0008", "room_class": "일반실", "available": True, "current": False},
{"car_no": 2, "car_no_raw": "0002", "room_class": "일반실", "available": True, "current": False},
{"car_no": 7, "car_no_raw": "0007", "room_class": "일반실", "available": True, "current": False},
{"car_no": 3, "car_no_raw": "0003", "room_class": "일반실", "available": True, "current": False},
{"car_no": 6, "car_no_raw": "0006", "room_class": "일반실", "available": True, "current": False},
{"car_no": 4, "car_no_raw": "0004", "room_class": "일반실", "available": True, "current": False},
{"car_no": 5, "car_no_raw": "0005", "room_class": "일반실", "available": True, "current": False},
]
sorted_cars = srt_seats.sort_cars_for_booking(cars)
self.assertEqual([car["car_no"] for car in sorted_cars], [4, 5, 3, 6, 2, 7, 1, 8])
def test_booking_priority_sorts_forward_window_before_other_seats(self) -> None:
seats: list[srt_seats.SrtSeat] = [
{"seat": "3A", "seat_no": "11", "available": True, "direction": "역방향", "position": "창측", "notes": []},
{"seat": "6C", "seat_no": "23", "available": True, "direction": "정방향", "position": "내측", "notes": []},
{"seat": "2A", "seat_no": "7", "available": True, "direction": "정방향", "position": "창측", "notes": []},
]
sorted_seats = srt_seats.sort_seats_for_booking(seats, "forward-window")
self.assertEqual([seat["seat"] for seat in sorted_seats], ["2A", "6C", "3A"])
def test_booking_priority_treats_single_seat_as_window_preference(self) -> None:
seats: list[srt_seats.SrtSeat] = [
{"seat": "1C", "seat_no": "3", "available": True, "direction": "정방향", "position": "내측", "notes": []},
{"seat": "2A", "seat_no": "5", "available": True, "direction": "정방향", "position": "1인석", "notes": []},
]
sorted_seats = srt_seats.sort_seats_for_booking(seats, "window-forward")
self.assertEqual([seat["seat"] for seat in sorted_seats], ["2A", "1C"])
def test_parse_seat_page_marks_missing_detail_attributes_unknown(self) -> None:
seats = srt_seats.parse_seats(MISSING_DETAIL_HTML)
self.assertEqual(seats[0]["direction"], "unknown")
self.assertEqual(seats[0]["position"], "unknown")
if __name__ == "__main__":
unittest.main()