k-skill/scripts/test_srt_booking.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

209 lines
6.7 KiB
Python

from __future__ import annotations
import argparse
import io
import json
import unittest
from contextlib import redirect_stdout
from unittest.mock import patch
import srt_booking
from srt_booking_test_support import EmptyClient, FakeClient, FakeTrain, NoisyClient, SpecialClient
class SrtSeatTests(unittest.TestCase):
def test_command_seats_outputs_available_seats_by_booking_preference(self) -> None:
train = FakeTrain()
train_id = srt_booking.build_train_id(train)
client = FakeClient(train)
args = argparse.Namespace(
dep="수서",
arr="부산",
date="20260610",
time="080000",
time_limit=None,
train_id=train_id,
room="general",
car_no=4,
seat="6C",
available_only=False,
car_priority="center",
seat_priority="forward-window",
limit=10,
)
output = io.StringIO()
with patch.object(srt_booking, "build_client", return_value=client):
with redirect_stdout(output):
srt_booking.command_seats(args)
result = json.loads(output.getvalue())
car = result["cars"][0]
self.assertEqual(car["car_no"], 4)
self.assertTrue(car["requested_seat_available"])
self.assertEqual(car["available_seats"], ["6C"])
self.assertEqual(client._session.calls[-1]["scarNo1"], "0004")
def test_command_seats_filters_unavailable_when_available_only(self) -> None:
train = FakeTrain()
train_id = srt_booking.build_train_id(train)
client = FakeClient(train)
args = argparse.Namespace(
dep="수서",
arr="부산",
date="20260610",
time="080000",
time_limit=None,
train_id=train_id,
room="general",
car_no=4,
seat=None,
available_only=True,
car_priority="center",
seat_priority="forward-window",
limit=10,
)
output = io.StringIO()
with patch.object(srt_booking, "build_client", return_value=client):
with redirect_stdout(output):
srt_booking.command_seats(args)
result = json.loads(output.getvalue())
shown_seats = result["cars"][0]["seats"]
self.assertEqual([seat["seat"] for seat in shown_seats], ["6C", "3A"])
self.assertTrue(all(seat["available"] for seat in shown_seats))
def test_command_seats_returns_special_room_cars(self) -> None:
train = FakeTrain()
train_id = srt_booking.build_train_id(train)
client = SpecialClient(train)
args = argparse.Namespace(
dep="수서",
arr="부산",
date="20260610",
time="080000",
time_limit=None,
train_id=train_id,
room="special",
car_no=3,
seat=None,
available_only=True,
car_priority="center",
seat_priority="window-forward",
limit=10,
)
output = io.StringIO()
with patch.object(srt_booking, "build_client", return_value=client):
with redirect_stdout(output):
srt_booking.command_seats(args)
result = json.loads(output.getvalue())
self.assertEqual(result["room"], "special")
self.assertEqual(result["cars"][0]["room_class"], "특실")
self.assertEqual(result["cars"][0]["available_seats"], ["1A"])
def test_command_seats_fails_when_train_id_is_stale(self) -> None:
train = FakeTrain()
args = argparse.Namespace(
dep="수서",
arr="부산",
date="20260610",
time="080000",
time_limit=None,
train_id=srt_booking.build_train_id(train),
room="general",
car_no=4,
seat=None,
available_only=False,
car_priority="center",
seat_priority="forward-window",
limit=10,
)
with patch.object(srt_booking, "build_client", return_value=EmptyClient(train)):
with self.assertRaises(SystemExit) as exc:
with redirect_stdout(io.StringIO()):
srt_booking.command_seats(args)
self.assertIn("train_id", str(exc.exception))
def test_command_seats_keeps_json_stdout_when_upstream_prints_queue_messages(self) -> None:
train = FakeTrain()
train_id = srt_booking.build_train_id(train)
client = NoisyClient(train)
args = argparse.Namespace(
dep="수서",
arr="부산",
date="20260610",
time="080000",
time_limit=None,
train_id=train_id,
room="general",
car_no=4,
seat=None,
available_only=True,
car_priority="center",
seat_priority="forward-window",
limit=10,
)
output = io.StringIO()
with patch.object(srt_booking, "build_client", return_value=client):
with redirect_stdout(output):
srt_booking.command_seats(args)
result = json.loads(output.getvalue())
self.assertEqual(result["cars"][0]["available_seats"], ["6C", "3A"])
def test_command_seats_explores_middle_cars_first(self) -> None:
train = FakeTrain()
train_id = srt_booking.build_train_id(train)
client = FakeClient(train)
args = argparse.Namespace(
dep="수서",
arr="부산",
date="20260610",
time="080000",
time_limit=None,
train_id=train_id,
room="general",
car_no=None,
seat=None,
available_only=True,
car_priority="center",
seat_priority="forward-window",
limit=10,
)
with patch.object(srt_booking, "build_client", return_value=client):
with redirect_stdout(io.StringIO()):
srt_booking.command_seats(args)
self.assertEqual([call["scarNo1"] for call in client._session.calls], ["", "0004", "0005"])
def test_build_parser_accepts_seats_filters(self) -> None:
args = srt_booking.build_parser().parse_args([
"seats",
"수서",
"부산",
"20260610",
"080000",
"--train-id",
"srt:v1:test",
"--car-no",
"5",
"--seat",
"11A",
"--seat-priority",
"window-forward",
])
self.assertEqual(args.car_no, 5)
self.assertEqual(args.seat, "11A")
self.assertEqual(args.seat_priority, "window-forward")
if __name__ == "__main__":
unittest.main()