mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
feat(ktx-booking): 좌석 탐색 우선순위 개선
KTX 상세 좌석 조회에서 가운데 호차를 먼저 탐색하고, 같은 호차 안에서는 콘센트 좌석과 순방향 좌석을 우선 노출합니다. #294 요구사항을 우선순위 함수와 command_seats 회귀 테스트로 고정합니다. Constraint: #294는 기존 seats 공개 인터페이스 유지를 요구함 Rejected: 예약 API에 객차/좌석 번호를 직접 주입 | 현재 helper 예약 경로가 해당 선택 인자를 노출하지 않음 Confidence: high Scope-risk: narrow Directive: 좌석 우선순위 변경 시 command_seats 출력 순서 테스트를 함께 갱신할 것 Tested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_ktx_booking Tested: node --test scripts/skill-docs.test.js Tested: npm run typecheck Tested: npm run ci
This commit is contained in:
parent
8420792a82
commit
24feb3edca
4 changed files with 176 additions and 4 deletions
|
|
@ -85,6 +85,8 @@ python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <t
|
|||
python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <train_id> --available-only
|
||||
```
|
||||
|
||||
특정 호차를 지정하지 않으면 `seats` 는 승강장 이동 거리가 짧은 가운데 호차부터 탐색한다. 각 호차 안에서는 콘센트 힌트가 있는 좌석을 먼저, 같은 조건에서는 순방향 좌석을 먼저 반환한다.
|
||||
|
||||
특정 호차의 남은 좌석만 확인:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -135,6 +135,8 @@ python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <t
|
|||
python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <train_id> --available-only
|
||||
```
|
||||
|
||||
특정 호차를 지정하지 않으면 `seats` 는 승강장 이동 거리가 짧은 가운데 호차부터 탐색한다. 각 호차 안의 좌석은 콘센트 힌트가 있는 좌석(`direct`, `adjacent`)을 먼저, 같은 조건에서는 순방향 좌석을 먼저 보여준다.
|
||||
|
||||
특정 호차만 확인하려면 `--car-no` 를 쓴다.
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ except ModuleNotFoundError as exc:
|
|||
|
||||
class _FallbackKorailModule:
|
||||
EMAIL_REGEX = re.compile(r".+@.+")
|
||||
PHONE_NUMBER_REGEX = re.compile(r"^\d+$")
|
||||
PHONE_NUMBER_REGEX = re.compile(r"(\d{3})-(\d{3,4})-(\d{4})")
|
||||
|
||||
korail_mod = _FallbackKorailModule()
|
||||
else:
|
||||
|
|
@ -200,7 +200,7 @@ POWER_OUTLET_ADJACENT_COLUMNS = {"B", "C"}
|
|||
|
||||
|
||||
def is_phone_login_id(korail_id: str) -> bool:
|
||||
return bool(korail_mod.PHONE_NUMBER_REGEX.match(korail_id) or PHONE_NUMBER_DIGITS_REGEX.match(korail_id))
|
||||
return bool(korail_mod.PHONE_NUMBER_REGEX.fullmatch(korail_id) or PHONE_NUMBER_DIGITS_REGEX.fullmatch(korail_id))
|
||||
|
||||
|
||||
def ensure_runtime_dependencies() -> None:
|
||||
|
|
@ -799,6 +799,30 @@ def normalize_car(raw_car: dict[str, object]) -> dict[str, object]:
|
|||
}
|
||||
|
||||
|
||||
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:
|
||||
return (0.0, car_no)
|
||||
center = (min(car_numbers) + max(car_numbers)) / 2
|
||||
return (abs(car_no - center), car_no)
|
||||
|
||||
|
||||
def sort_cars_for_booking(cars: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||
car_numbers = [int(car["car_no"]) for car in cars]
|
||||
return sorted(cars, key=lambda car: car_center_priority(car, car_numbers))
|
||||
|
||||
|
||||
def seat_preference_key(seat: dict[str, object]) -> tuple[int, int, int, str]:
|
||||
power_rank = {"direct": 0, "adjacent": 1, "none": 2}.get(str(seat.get("power_outlet")), 2)
|
||||
direction_rank = 0 if seat.get("direction") == "순방향" else 1
|
||||
row, column = parse_seat_label(str(seat.get("seat", "")))
|
||||
return (power_rank, direction_rank, row if row is not None else 999, column)
|
||||
|
||||
|
||||
def sort_seats_for_booking(seats: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||
return sorted(seats, key=seat_preference_key)
|
||||
|
||||
|
||||
def mask_identifier(value: object, visible: int = 4) -> str:
|
||||
text = str(value or "")
|
||||
if not text:
|
||||
|
|
@ -895,6 +919,8 @@ def command_seats(args: argparse.Namespace) -> None:
|
|||
cars = [car for car in cars if car["car_no"] == args.car_no]
|
||||
if not cars:
|
||||
raise SystemExit(f"car_no {args.car_no} is not available for {args.room}")
|
||||
else:
|
||||
cars = sort_cars_for_booking(cars)
|
||||
|
||||
car_payloads: list[dict[str, object]] = []
|
||||
for car in cars:
|
||||
|
|
@ -903,8 +929,8 @@ def command_seats(args: argparse.Namespace) -> None:
|
|||
if isinstance(raw_seats, dict):
|
||||
raw_seats = [raw_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
|
||||
available_seats = sort_seats_for_booking([seat for seat in all_seats if seat["available"]])
|
||||
seats = sort_seats_for_booking(all_seats)
|
||||
if args.available_only:
|
||||
seats = available_seats
|
||||
if args.power_only:
|
||||
|
|
|
|||
|
|
@ -321,6 +321,34 @@ class KtxBookingTests(unittest.TestCase):
|
|||
self.assertEqual(ktx_booking.power_outlet_match("2A"), "none")
|
||||
self.assertEqual(ktx_booking.power_outlet_match("bad"), "none")
|
||||
|
||||
def test_booking_priority_sorts_middle_cars_before_end_cars(self):
|
||||
cars = [
|
||||
{"car_no": 1},
|
||||
{"car_no": 8},
|
||||
{"car_no": 2},
|
||||
{"car_no": 7},
|
||||
{"car_no": 3},
|
||||
{"car_no": 6},
|
||||
{"car_no": 4},
|
||||
{"car_no": 5},
|
||||
]
|
||||
|
||||
sorted_cars = ktx_booking.sort_cars_for_booking(cars)
|
||||
|
||||
self.assertEqual([car["car_no"] for car in sorted_cars], [4, 5, 3, 6, 2, 7, 1, 8])
|
||||
|
||||
def test_booking_priority_sorts_power_outlet_before_forward_direction(self):
|
||||
seats = [
|
||||
{"seat": "2A", "power_outlet": "none", "direction": "순방향"},
|
||||
{"seat": "1C", "power_outlet": "adjacent", "direction": "역방향"},
|
||||
{"seat": "1A", "power_outlet": "direct", "direction": "역방향"},
|
||||
{"seat": "3A", "power_outlet": "direct", "direction": "순방향"},
|
||||
]
|
||||
|
||||
sorted_seats = ktx_booking.sort_seats_for_booking(seats)
|
||||
|
||||
self.assertEqual([seat["seat"] for seat in sorted_seats], ["3A", "1A", "1C", "2A"])
|
||||
|
||||
def test_is_phone_login_id_accepts_digits_only_mobile_numbers(self):
|
||||
self.assertTrue(ktx_booking.is_phone_login_id("01012345678"))
|
||||
self.assertTrue(ktx_booking.is_phone_login_id("0101234567"))
|
||||
|
|
@ -531,6 +559,120 @@ class KtxBookingTests(unittest.TestCase):
|
|||
self.assertEqual(client.train_car_calls[-1]["room_class"], "1")
|
||||
self.assertEqual(client.car_seat_calls[-1]["car_no"], "05")
|
||||
|
||||
def test_command_seats_explores_middle_cars_first(self):
|
||||
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": "01", "h_psrm_cl_cd": "1", "h_seat_cnt": "48", "h_rest_seat_cnt": "1"},
|
||||
{"h_srcar_no": "08", "h_psrm_cl_cd": "1", "h_seat_cnt": "48", "h_rest_seat_cnt": "1"},
|
||||
{"h_srcar_no": "04", "h_psrm_cl_cd": "1", "h_seat_cnt": "48", "h_rest_seat_cnt": "1"},
|
||||
{"h_srcar_no": "05", "h_psrm_cl_cd": "1", "h_seat_cnt": "48", "h_rest_seat_cnt": "1"},
|
||||
],
|
||||
)
|
||||
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)
|
||||
|
||||
result = json.loads(output.getvalue())
|
||||
self.assertEqual([car["car_no"] for car in result["cars"]], [4, 5, 1, 8])
|
||||
self.assertEqual([call["car_no"] for call in client.car_seat_calls], ["04", "05", "01", "08"])
|
||||
|
||||
def test_command_seats_outputs_available_seats_by_booking_preference(self):
|
||||
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": "4"}],
|
||||
seats_by_car={
|
||||
"05": [
|
||||
{
|
||||
"h_con_seat_no": "2A",
|
||||
"h_seat_no": "002001",
|
||||
"h_sale_psb_flg": "Y",
|
||||
"h_for_rev_dir_dv": "009",
|
||||
"h_sigl_win_in_dv": "012",
|
||||
"h_dmd_seat_att": "015",
|
||||
},
|
||||
{
|
||||
"h_con_seat_no": "1C",
|
||||
"h_seat_no": "001003",
|
||||
"h_sale_psb_flg": "Y",
|
||||
"h_for_rev_dir_dv": "010",
|
||||
"h_sigl_win_in_dv": "013",
|
||||
"h_dmd_seat_att": "015",
|
||||
},
|
||||
{
|
||||
"h_con_seat_no": "1A",
|
||||
"h_seat_no": "001001",
|
||||
"h_sale_psb_flg": "Y",
|
||||
"h_for_rev_dir_dv": "010",
|
||||
"h_sigl_win_in_dv": "012",
|
||||
"h_dmd_seat_att": "015",
|
||||
},
|
||||
{
|
||||
"h_con_seat_no": "3A",
|
||||
"h_seat_no": "003001",
|
||||
"h_sale_psb_flg": "Y",
|
||||
"h_for_rev_dir_dv": "009",
|
||||
"h_sigl_win_in_dv": "012",
|
||||
"h_dmd_seat_att": "015",
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
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=True,
|
||||
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)
|
||||
|
||||
result = json.loads(output.getvalue())
|
||||
car = result["cars"][0]
|
||||
self.assertEqual(car["available_seats"], ["3A", "1A", "1C", "2A"])
|
||||
self.assertEqual([seat["seat"] for seat in car["seats"]], ["3A", "1A", "1C", "2A"])
|
||||
|
||||
def test_command_seats_supports_special_room_and_stale_train_error(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"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue