mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
Expose KTX malformed seat detail evidence
Keep seat-priority output from reporting upstream seat-detail schema failures as authoritative zero-seat availability. Preserve car-level remaining_seats and surface seat_lookup_error when detail payloads are malformed while seats remain. Constraint: PR #295 review follow-up keeps advisory priority/filter behavior while avoiding false zero-seat results. Rejected: Treat malformed or empty detail payloads as [] | masks Korail endpoint/schema failures as no availability. Confidence: high Scope-risk: narrow Directive: Keep detailed seat lookup advisory unless reserve gains explicit car/seat selection support. Tested: 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; npm run typecheck Not-tested: live authenticated Korail seat-detail lookup
This commit is contained in:
parent
70b92d6b03
commit
1d8fd333d8
4 changed files with 105 additions and 5 deletions
|
|
@ -108,7 +108,7 @@ python3 scripts/ktx_booking.py seats 남춘천 용산 20260503 150000 \
|
|||
--available-only
|
||||
```
|
||||
|
||||
`seats` 응답은 호차별 `remaining_seats`, `available_seats`, 좌석별 순방향/역방향, 창측/내측, 좌석 종류, 문 근처 여부, 콘센트 힌트를 JSON 으로 반환한다. 이 단계는 좌석을 선택하거나 선점하지 않고, 예약 전 확인만 한다.
|
||||
`seats` 응답은 호차별 `remaining_seats`, `available_seats`, 좌석별 순방향/역방향, 창측/내측, 좌석 종류, 문 근처 여부, 콘센트 힌트를 JSON 으로 반환한다. 호차 요약에는 잔여석이 있는데 좌석 상세 응답 형식이 깨졌다면 해당 호차에 `seat_lookup_error` 를 포함하고 `remaining_seats` 를 보존한다. 이 단계는 좌석을 선택하거나 선점하지 않고, 예약 전 확인만 한다.
|
||||
|
||||
예약:
|
||||
|
||||
|
|
|
|||
|
|
@ -165,6 +165,7 @@ python3 scripts/ktx_booking.py seats 남춘천 용산 20260503 150000 \
|
|||
- 좌석별 `direction`, `position`, `seat_type`
|
||||
- 콘센트 힌트 (`power_outlet`)
|
||||
- 문 근처 여부 (`near_door`)
|
||||
- 호차 요약에는 잔여석이 있는데 상세 좌석 응답 형식이 깨졌다면 `seat_lookup_error` 와 보존된 `remaining_seats`
|
||||
|
||||
이 기능은 좌석을 선택/선점하지 않는다. 실제 예약은 다음 단계의 `reserve` 로만 진행한다.
|
||||
|
||||
|
|
|
|||
|
|
@ -799,6 +799,36 @@ def normalize_car(raw_car: dict[str, object]) -> dict[str, object]:
|
|||
}
|
||||
|
||||
|
||||
def extract_car_seat_rows(raw: object, remaining_seats: int) -> tuple[list[dict[str, object]], str | None]:
|
||||
def malformed_error(reason: str) -> tuple[list[dict[str, object]], str | None]:
|
||||
if remaining_seats > 0:
|
||||
return [], f"malformed seat detail response: {reason}"
|
||||
return [], None
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
return malformed_error("payload is not an object")
|
||||
|
||||
seat_infos = raw.get("seat_infos")
|
||||
if not isinstance(seat_infos, dict):
|
||||
return malformed_error("seat_infos is not an object")
|
||||
if "seat_info" not in seat_infos:
|
||||
return malformed_error("seat_info is missing")
|
||||
|
||||
raw_seats = seat_infos["seat_info"]
|
||||
if raw_seats is None:
|
||||
raw_seats = []
|
||||
if isinstance(raw_seats, dict):
|
||||
raw_seats = [raw_seats]
|
||||
if not isinstance(raw_seats, list):
|
||||
return malformed_error("seat_info is not a list or object")
|
||||
if any(not isinstance(seat, dict) for seat in raw_seats):
|
||||
return malformed_error("seat_info contains a non-object entry")
|
||||
if not raw_seats and remaining_seats > 0:
|
||||
return malformed_error("seat_info is empty while car summary has remaining seats")
|
||||
|
||||
return raw_seats, None
|
||||
|
||||
|
||||
def car_center_priority(car: dict[str, object], car_numbers: list[int]) -> tuple[float, int]:
|
||||
car_no = int(car["car_no"])
|
||||
if not car_numbers:
|
||||
|
|
@ -927,9 +957,7 @@ 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", [])
|
||||
if isinstance(raw_seats, dict):
|
||||
raw_seats = [raw_seats]
|
||||
raw_seats, seat_lookup_error = extract_car_seat_rows(raw, int(car["remaining_seats"]))
|
||||
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:
|
||||
|
|
@ -939,7 +967,9 @@ def command_seats(args: argparse.Namespace) -> None:
|
|||
available_seats = [seat for seat in seats if seat["available"]]
|
||||
seats = seats[: args.limit]
|
||||
car_payload = dict(car)
|
||||
car_payload["available_seat_count"] = len(available_seats)
|
||||
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["shown_seat_count"] = len(seats)
|
||||
car_payload["seats"] = seats
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ class FakeClient:
|
|||
train_details=None,
|
||||
cars=None,
|
||||
seats_by_car=None,
|
||||
seat_payloads_by_car=None,
|
||||
):
|
||||
self._trains = trains
|
||||
self._search_handler = search_handler
|
||||
|
|
@ -117,6 +118,7 @@ class FakeClient:
|
|||
self._train_details = train_details
|
||||
self._cars = cars or []
|
||||
self._seats_by_car = seats_by_car or {}
|
||||
self._seat_payloads_by_car = seat_payloads_by_car or {}
|
||||
self.search_calls = []
|
||||
self.search_detail_calls = []
|
||||
self.train_car_calls = []
|
||||
|
|
@ -151,6 +153,8 @@ class FakeClient:
|
|||
"passenger_count": passenger_count,
|
||||
"room_class": room_class,
|
||||
})
|
||||
if car_no in self._seat_payloads_by_car:
|
||||
return self._seat_payloads_by_car[car_no]
|
||||
return {"seat_infos": {"seat_info": list(self._seats_by_car.get(car_no, []))}}
|
||||
|
||||
def reserve(self, train, **kwargs):
|
||||
|
|
@ -830,6 +834,71 @@ class KtxBookingTests(unittest.TestCase):
|
|||
|
||||
self.assertIn("seat car data is unavailable", str(exc.exception))
|
||||
|
||||
def run_seats_for_car_payload(self, seat_payload, remaining_seats="9"):
|
||||
selected = FakeTrain(train_no="009", dep_time="090000", arr_time="113000", label="selected")
|
||||
raw_train = {"h_trn_no": "009", "h_dpt_dt": "20260328"}
|
||||
train_id = ktx_booking.normalize_train(selected, index=1)["train_id"]
|
||||
client = FakeClient(
|
||||
[],
|
||||
train_details=[(selected, raw_train)],
|
||||
cars=[{"h_srcar_no": "05", "h_psrm_cl_cd": "1", "h_seat_cnt": "48", "h_rest_seat_cnt": remaining_seats}],
|
||||
seat_payloads_by_car={"05": seat_payload},
|
||||
)
|
||||
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,
|
||||
)
|
||||
output = io.StringIO()
|
||||
|
||||
with patch.object(ktx_booking, "build_client", return_value=client):
|
||||
with redirect_stdout(output):
|
||||
ktx_booking.command_seats(args)
|
||||
|
||||
return json.loads(output.getvalue())["cars"][0]
|
||||
|
||||
def test_command_seats_reports_malformed_seat_detail_when_car_has_remaining_seats(self):
|
||||
malformed_payloads = [
|
||||
{},
|
||||
{"seat_infos": []},
|
||||
{"seat_infos": {}},
|
||||
{"seat_infos": {"seat_info": None}},
|
||||
{"seat_infos": {"seat_info": []}},
|
||||
]
|
||||
|
||||
for seat_payload in malformed_payloads:
|
||||
with self.subTest(seat_payload=seat_payload):
|
||||
car = self.run_seats_for_car_payload(seat_payload)
|
||||
self.assertEqual(car["remaining_seats"], 9)
|
||||
self.assertIn("seat_lookup_error", car)
|
||||
self.assertIn("malformed seat detail", car["seat_lookup_error"])
|
||||
self.assertEqual(car["available_seat_count"], None)
|
||||
self.assertEqual(car["available_seats"], [])
|
||||
self.assertEqual(car["shown_seat_count"], 0)
|
||||
self.assertEqual(car["seats"], [])
|
||||
|
||||
def test_command_seats_allows_empty_seat_detail_when_car_has_no_remaining_seats(self):
|
||||
car = self.run_seats_for_car_payload({"seat_infos": {"seat_info": []}}, remaining_seats="0")
|
||||
|
||||
self.assertEqual(car["remaining_seats"], 0)
|
||||
self.assertNotIn("seat_lookup_error", car)
|
||||
self.assertEqual(car["available_seat_count"], 0)
|
||||
self.assertEqual(car["available_seats"], [])
|
||||
self.assertEqual(car["shown_seat_count"], 0)
|
||||
self.assertEqual(car["seats"], [])
|
||||
|
||||
def test_build_parser_has_ncard_commands(self):
|
||||
parser = ktx_booking.build_parser()
|
||||
help_text = parser.format_help()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue