Make KTX seat summaries match active filters

Constraint: PR #296 follow-up must address reproducible review findings without live Korail credentials.
Rejected: Keeping available_seats unfiltered | It conflicted with --power-only user intent and review feedback.
Confidence: high
Scope-risk: narrow
Directive: Keep filtered availability and all_available_* semantics documented together when changing seats output.
Tested: PYTHONPATH=.:scripts PYTHONNOUSERSITE=1 python3 -m unittest discover -s scripts -p 'test_ktx_booking.py'; PYTHONPATH=.:scripts python3 -m unittest scripts.test_ktx_booking; python3 -m py_compile scripts/ktx_booking.py scripts/test_ktx_booking.py; node --check scripts/skill-docs.test.js; node --test scripts/skill-docs.test.js; git diff --check origin/dev...HEAD; ruff check scripts/ktx_booking.py scripts/test_ktx_booking.py; PYTHONPATH=.:scripts python3 scripts/ktx_booking.py seats --help
Not-tested: live authenticated Korail seat lookup
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-05-29 01:00:27 +09:00
commit 002c6c13bc
4 changed files with 55 additions and 17 deletions

View file

@ -106,7 +106,7 @@ python3 scripts/ktx_booking.py seats 남춘천 용산 20260503 150000 \
--available-only
```
`seats` 응답은 호차별 `remaining_seats`, `available_seats`, 좌석별 순방향/역방향, 창측/내측, 좌석 종류, 문 근처 여부, 콘센트 힌트를 JSON 으로 반환한다. 호차 요약에는 잔여석이 있는데 좌석 상세 응답 형식이 깨졌다면 해당 호차에 `seat_lookup_error` 를 포함하고 `remaining_seats` 를 보존한다. 이 단계는 좌석을 선택하거나 선점하지 않고, 예약 전 확인만 한다.
`seats` 응답은 호차별 `remaining_seats`, 활성 필터에 맞는 `available_seats`/`available_seat_count`, 필터 전 전체 잔여 좌석 요약인 `all_available_seats`/`all_available_seat_count`, 좌석별 순방향/역방향, 창측/내측, 좌석 종류, 문 근처 여부, 콘센트 힌트를 JSON 으로 반환한다. 예를 들어 `--power-only` 를 쓰면 `available_seats` 는 콘센트 힌트가 있는 좌석만 요약하고, `all_available_seats` 는 필터 전 잔여 좌석을 보존한다. 호차 요약에는 잔여석이 있는데 좌석 상세 응답 형식이 깨졌다면 해당 호차에 `seat_lookup_error` 를 포함하고 `remaining_seats` 를 보존한다. 이 단계는 좌석을 선택하거나 선점하지 않고, 예약 전 확인만 한다.
예약:

View file

@ -159,7 +159,7 @@ python3 scripts/ktx_booking.py seats 남춘천 용산 20260503 150000 \
상세 좌석 응답을 보여줄 때는 사용자 의도에 맞춰 아래를 우선 요약한다.
- 호차별 `remaining_seats`, `available_seat_count`
- 남은 좌석 번호 (`available_seats`)
- 활성 필터에 맞는 남은 좌석 번호 (`available_seats`)와 필터 전 전체 잔여 좌석 번호 (`all_available_seats`)
- 좌석별 `direction`, `position`, `seat_type`
- 콘센트 힌트 (`power_outlet`)
- 문 근처 여부 (`near_door`)

View file

@ -558,7 +558,19 @@ class PatchedKorail(Korail):
return {}
def _seat_lookup_payload(self, raw_train: dict[str, object], passenger_count: int, room_class: str) -> dict[str, object]:
missing_fields = [field for field in SEAT_LOOKUP_FIELD_MAP if not str(raw_train.get(field, ""))]
seat_context: dict[str, str] = {}
missing_fields: list[str] = []
for field, payload_name in SEAT_LOOKUP_FIELD_MAP.items():
value = raw_train.get(field)
if value is None:
missing_fields.append(field)
continue
normalized_value = str(value).strip()
if not normalized_value:
missing_fields.append(field)
continue
seat_context[payload_name] = normalized_value
if missing_fields:
raise make_korail_error(
"seat lookup context missing "
@ -567,10 +579,7 @@ class PatchedKorail(Korail):
"KSKILL_SEAT_CONTEXT",
)
payload = {
payload_name: str(raw_train[field])
for field, payload_name in SEAT_LOOKUP_FIELD_MAP.items()
}
payload: dict[str, object] = dict(seat_context)
payload.update({
"Device": self._device,
"Version": self._version,
@ -956,24 +965,28 @@ 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, seat_lookup_error = extract_car_seat_rows(raw, int(car["remaining_seats"]))
remaining_seats = int(str(car["remaining_seats"]))
raw_seats, seat_lookup_error = extract_car_seat_rows(raw, remaining_seats)
all_seats = [
normalize_seat(seat)
for seat in raw_seats
if seat.get("h_con_seat_no") != "0A"
]
available_seats = [seat for seat in all_seats if seat["available"]]
seats = all_seats
all_available_seats = [seat for seat in all_seats if seat["available"]]
matching_seats = all_seats
if args.available_only:
seats = available_seats
matching_seats = all_available_seats
if args.power_only:
seats = [seat for seat in seats if seat["power_outlet"] != "none"]
seats = seats[: args.limit]
matching_seats = [seat for seat in matching_seats if seat["power_outlet"] != "none"]
matching_available_seats = [seat for seat in matching_seats if seat["available"]]
seats = matching_seats[: args.limit]
car_payload = dict(car)
if seat_lookup_error:
car_payload["seat_lookup_error"] = seat_lookup_error
car_payload["available_seat_count"] = None if seat_lookup_error else len(available_seats)
car_payload["available_seats"] = [seat["seat"] for seat in available_seats]
car_payload["all_available_seat_count"] = None if seat_lookup_error else len(all_available_seats)
car_payload["all_available_seats"] = [seat["seat"] for seat in all_available_seats]
car_payload["available_seat_count"] = None if seat_lookup_error else len(matching_available_seats)
car_payload["available_seats"] = [seat["seat"] for seat in matching_available_seats]
car_payload["shown_seat_count"] = len(seats)
car_payload["seats"] = seats
car_payloads.append(car_payload)

View file

@ -349,6 +349,29 @@ class KtxBookingTests(unittest.TestCase):
self.assertIn("h_arv_rs_stn_cd", message)
self.assertIn("h_trn_gp_cd", message)
def test_seat_lookup_payload_rejects_none_and_blank_context_fields(self):
client = object.__new__(ktx_booking.PatchedKorail)
client._device = "AD"
client._version = "240906001"
client._key = "session-key"
raw_train: dict[str, object] = {
field: "context-value"
for field in ktx_booking.SEAT_LOOKUP_FIELD_MAP
}
bad_values: tuple[object, ...] = (None, "", " ")
for bad_value in bad_values:
with self.subTest(bad_value=bad_value):
raw_train["h_trn_no"] = bad_value
with self.assertRaises(ktx_booking.KorailError) as exc:
client._seat_lookup_payload(raw_train, 1, "1")
message = str(exc.exception)
self.assertIn("seat lookup context missing", message)
self.assertIn("h_trn_no", message)
self.assertNotIn("txtTrnNo", message)
raw_train["h_trn_no"] = "009"
def test_command_search_replays_selected_train_type(self):
selected = FakeTrain(
train_no="2080",
@ -541,8 +564,10 @@ class KtxBookingTests(unittest.TestCase):
self.assertEqual(len(result["cars"]), 1)
self.assertEqual(result["cars"][0]["car_no"], 5)
self.assertEqual(result["cars"][0]["remaining_seats"], 3)
self.assertEqual(result["cars"][0]["available_seat_count"], 2)
self.assertEqual(result["cars"][0]["available_seats"], ["1A", "2A"])
self.assertEqual(result["cars"][0]["all_available_seat_count"], 2)
self.assertEqual(result["cars"][0]["all_available_seats"], ["1A", "2A"])
self.assertEqual(result["cars"][0]["available_seat_count"], 1)
self.assertEqual(result["cars"][0]["available_seats"], ["1A"])
self.assertEqual(result["cars"][0]["shown_seat_count"], 1)
self.assertEqual(result["cars"][0]["seats"][0]["seat"], "1A")
self.assertEqual(result["cars"][0]["seats"][0]["power_outlet"], "direct")