Keep malformed KTX seat payloads actionable

Convert malformed Korail seat-detail payloads into the existing CLI failure path so advisory lookup callers get a clear retryable error instead of an AttributeError.

Constraint: PR #295 review watch item identified successful-but-malformed seat_infos payloads as an unhelpful crash surface.

Rejected: Letting raw adapter fallbacks leak AttributeError | CLI users need actionable SystemExit diagnostics at the command boundary.

Confidence: high

Scope-risk: narrow

Directive: Keep detailed seat lookup advisory; validate raw Korail shapes before exposing fields to JSON callers.

Tested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_ktx_booking; node --test scripts/skill-docs.test.js; npm run typecheck; python3 -m compileall -q scripts/ktx_booking.py scripts/test_ktx_booking.py; ruff check scripts/ktx_booking.py scripts/test_ktx_booking.py; shellcheck scripts/validate-skills.sh; bash scripts/validate-skills.sh; PATH=<pyenv 3.11.9 shim> npm run ci

Not-tested: Plain npm run ci with /opt/homebrew Python 3.14 due local pyexpat/libexpat linkage error reproduced before project tests.
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-05-29 00:11:29 +09:00
commit eb08ef6134
2 changed files with 46 additions and 1 deletions

View file

@ -927,9 +927,20 @@ def command_seats(args: argparse.Namespace) -> None:
car_payloads: list[dict[str, object]] = []
for car in cars:
raw = client.car_seats(raw_train, str(car["car_no_raw"]), passenger_count, room_class)
raw_seats = raw.get("seat_infos", {}).get("seat_info", [])
seat_infos = raw.get("seat_infos") if isinstance(raw, dict) else None
if not isinstance(seat_infos, dict):
raise SystemExit(
f"seat detail data is unavailable for car_no {car['car_no']}; "
"retry search or choose another train"
)
raw_seats = seat_infos.get("seat_info", [])
if isinstance(raw_seats, dict):
raw_seats = [raw_seats]
if not isinstance(raw_seats, list):
raise SystemExit(
f"seat detail data is unavailable for car_no {car['car_no']}; "
"retry search or choose another train"
)
all_seats = [normalize_seat(seat) for seat in raw_seats if seat.get("h_con_seat_no") != "0A"]
seats = sort_seats_for_booking(all_seats)
if args.available_only:

View file

@ -797,6 +797,40 @@ class KtxBookingTests(unittest.TestCase):
self.assertIn("car_no 5", str(exc.exception))
def test_command_seats_fails_when_seat_payload_is_malformed(self):
selected = FakeTrain(train_no="009", dep_time="090000", arr_time="113000", label="selected")
train_id = ktx_booking.normalize_train(selected, index=1)["train_id"]
client = FakeClient(
[],
train_details=[(selected, {"h_trn_no": "009"})],
cars=[{"h_srcar_no": "05", "h_psrm_cl_cd": "1", "h_seat_cnt": "48", "h_rest_seat_cnt": "4"}],
)
client.car_seats = lambda *args, **kwargs: {"seat_infos": None}
args = argparse.Namespace(
dep="서울",
arr="부산",
date="20260328",
time="090000",
adults=1,
children=0,
toddlers=0,
seniors=0,
train_id=train_id,
room="general",
train_type="ktx",
car_no=None,
available_only=False,
power_only=False,
limit=10,
)
with patch.object(ktx_booking, "build_client", return_value=client):
with self.assertRaises(SystemExit) as exc:
with redirect_stdout(io.StringIO()):
ktx_booking.command_seats(args)
self.assertIn("seat detail data is unavailable", str(exc.exception))
def test_command_seats_fails_when_car_data_is_unavailable(self):
selected = FakeTrain(train_no="009", dep_time="090000", arr_time="113000", label="selected")
train_id = ktx_booking.normalize_train(selected, index=1)["train_id"]