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: 실제 예약/결제/취소 부작용 흐름
This commit is contained in:
iamiks 2026-06-03 11:16:52 +09:00
commit d1d2eee71f
3 changed files with 199 additions and 5 deletions

View file

@ -3,6 +3,8 @@ from __future__ import annotations
import argparse
import base64
import contextlib
import io
import importlib
import json
import os
@ -164,19 +166,22 @@ def seat_page_params(train: SrtTrainLike, room: str, car_no: int | None) -> dict
def fetch_seat_page(client: SrtClientLike, train: SrtTrainLike, room: str, car_no: int | None) -> str:
response = client._session.get(SRT_SEAT_ENDPOINT, params=seat_page_params(train, room, car_no))
with contextlib.redirect_stdout(io.StringIO()):
response = client._session.get(SRT_SEAT_ENDPOINT, params=seat_page_params(train, room, car_no))
return response.text
def command_search(args: argparse.Namespace) -> None:
client = build_client(auto_login=False)
trains = client.search_train(args.dep, args.arr, args.date, args.time, args.time_limit, args.available_only)
with contextlib.redirect_stdout(io.StringIO()):
trains = client.search_train(args.dep, args.arr, args.date, args.time, args.time_limit, args.available_only)
print_json({"count": len(trains[: args.limit]), "trains": [normalize_train(train, index) for index, train in enumerate(trains[: args.limit], 1)]})
def command_seats(args: argparse.Namespace) -> None:
client = build_client(auto_login=False)
trains = client.search_train(args.dep, args.arr, args.date, args.time, args.time_limit, available_only=False)
with contextlib.redirect_stdout(io.StringIO()):
trains = client.search_train(args.dep, args.arr, args.date, args.time, args.time_limit, available_only=False)
train = find_train_by_id(trains, args.train_id)
if train is None:
raise SystemExit("train_id no longer matches any current search result; rerun search and choose a fresh train_id")

View file

@ -44,8 +44,8 @@ def strip_tags(value: str) -> str:
def parse_detail(detail: str) -> tuple[str, str, list[str]]:
parts = [part.strip() for part in detail.split(",")]
direction = next((part for part in parts if part in {"정방향", "역방향"}), "")
position = next((part for part in parts if part in {"창측", "내측", "1인석"}), "")
direction = next((part for part in parts if part in {"정방향", "역방향"}), "unknown")
position = next((part for part in parts if part in {"창측", "내측", "1인석"}), "unknown")
notes = [part for part in parts if part not in {direction, position} and part]
return direction, position, notes

View file

@ -21,6 +21,17 @@ SEAT_HTML = "\n".join([
"<span>5C<strong><em>(정방향, 내측, 선택불가)</em></strong></span>",
])
SPECIAL_SEAT_HTML = "\n".join([
'<li class="scar-03 on"><a href="#none" onclick="selectScarInfo(\'0003\'); return false;"><strong>특실<br />3호차</strong></a></li>',
'<li class="scar-05 off"><strong>일반실<br />5호차</strong></li>',
'<a href="#none" onclick="selectSeatInfo(this, \'31\', \'1A\'); return false;">1A<strong><em>(정방향, 1인석)</em></strong></a>',
"<span>2C<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 FakeTrain:
train_number = "313"
@ -82,6 +93,55 @@ class FakeClient:
return [self.train]
class NoisySession(FakeSession):
def get(self, _url: str, params: dict[str, str]) -> FakeResponse:
print("접속자가 많아 대기열에 들어갑니다.")
return super().get(_url, params)
class NoisyClient(FakeClient):
def __init__(self, train: FakeTrain) -> None:
self.train = train
self._session = NoisySession()
def search_train(
self,
_dep: str,
_arr: str,
_date: str,
_time: str,
_time_limit: str | None = None,
available_only: bool = True,
) -> list[FakeTrain]:
print("대기인원: 6명")
return [self.train]
class EmptyClient(FakeClient):
def search_train(
self,
_dep: str,
_arr: str,
_date: str,
_time: str,
_time_limit: str | None = None,
available_only: bool = True,
) -> list[FakeTrain]:
return []
class SpecialSession(FakeSession):
def get(self, _url: str, params: dict[str, str]) -> FakeResponse:
self.calls.append(params)
return FakeResponse(SPECIAL_SEAT_HTML)
class SpecialClient(FakeClient):
def __init__(self, train: FakeTrain) -> None:
self.train = train
self._session = SpecialSession()
class SrtSeatTests(unittest.TestCase):
def test_normalize_car_and_seat_maps_srt_html(self) -> None:
cars = srt_seats.parse_cars(SEAT_HTML)
@ -123,6 +183,22 @@ class SrtSeatTests(unittest.TestCase):
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")
def test_command_seats_outputs_available_seats_by_booking_preference(self) -> None:
train = FakeTrain()
train_id = srt_booking.build_train_id(train)
@ -155,6 +231,119 @@ class SrtSeatTests(unittest.TestCase):
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)