mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
월별예약조회 API가 srchDate 단일 일자 요청에도 5일 윈도우를 반환하고, "예비"로 표기된 운영자 보유분이 raw 응답에 포함되며, 같은 객실이 다른 goodsId로 중복 표시되는 세 가지 문제를 한꺼번에 fix한다. 수정: - collect_results 안에 strict useDt gate 추가 (today~last_day 범위 밖 행 차단) - is_reserve_room() helper로 goodsNm에 "예비" 포함 객실 제외 - (forest_id, use_dt, name) 단위 dedup으로 중복 행 제거 - is_available()는 시그니처/로직 변경 없이 booking-state predicate 유지 추가: - foresttrip-vacancy/tests/ 18개 단위 테스트 (mock + fixture 기반) - IsReserveRoomTest, IsAvailableTest, CollectResultsFilterTest, StrictUseDtGateTest, GroundTruthTest 다섯 클래스 - 거제·구재봉 fixture로 사용자 라이브 검증 결과 회귀 보호 - package.json lint·test 스크립트에 등록 문서: - SKILL.md: API 5일 윈도우/예비 객실/중복 dedup 자동 처리 명시 + 회복 시나리오 보강 - docs/features/foresttrip-vacancy.md: 기본 흐름 6단계와 주의할 점 보강 사용자 라이브 검증 ground truth (2026-05-12 기준): - 거제자연휴양림 5/13 ~9개, 5/16 0개, 5/17 19개, 5/23 0개, 5/24 0개 - 구재봉자연휴양림 5/16 1개 (206호 쑥부쟁이방) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
216 lines
7.5 KiB
Python
216 lines
7.5 KiB
Python
import importlib.util
|
|
import json
|
|
import sys
|
|
import unittest
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
|
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
HELPER_PATH = SCRIPT_DIR.parent / "scripts" / "run_foresttrip_vacancy.py"
|
|
FIXTURES_DIR = SCRIPT_DIR / "fixtures"
|
|
|
|
|
|
def load_helper():
|
|
spec = importlib.util.spec_from_file_location("run_foresttrip_vacancy", HELPER_PATH)
|
|
if spec is None or spec.loader is None:
|
|
raise RuntimeError(f"cannot load helper from {HELPER_PATH}")
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules["run_foresttrip_vacancy"] = module
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
helper = load_helper()
|
|
|
|
|
|
def load_fixture(name):
|
|
return json.loads((FIXTURES_DIR / name).read_text(encoding="utf-8"))
|
|
|
|
|
|
GEOJE_ROWS = load_fixture("geoje_window.json")
|
|
GUJAEBONG_ROWS = load_fixture("gujaebong_window.json")
|
|
|
|
GEOJE_FOREST_ID = "ID02030059"
|
|
GEOJE_FOREST_NAME = "[공립](거제시)거제자연휴양림"
|
|
GUJAEBONG_FOREST_ID = "ID02030072"
|
|
GUJAEBONG_FOREST_NAME = "[공립](하동군)구재봉자연휴양림"
|
|
|
|
FIXED_NOW = datetime(2026, 5, 12, 0, 0, 0)
|
|
|
|
|
|
def make_session(forests):
|
|
return helper.Session(
|
|
cookies={},
|
|
csrf="dummy-csrf",
|
|
user_agent="test-ua",
|
|
forests=forests,
|
|
expires_at=FIXED_NOW.timestamp() + 3600,
|
|
)
|
|
|
|
|
|
def stub_fetch(rows):
|
|
def _stub(*, session, forest_id, category, today, last_day):
|
|
matched = [r for r in rows if r.get("insttId") == forest_id]
|
|
return forest_id, category, matched, None
|
|
return _stub
|
|
|
|
|
|
def run_collect(session, targets, rows, *, dates=None, week_range=None, categories=("01",)):
|
|
with mock.patch.object(helper, "fetch_one", side_effect=stub_fetch(rows)):
|
|
with mock.patch.object(helper, "datetime", wraps=datetime) as mock_dt:
|
|
mock_dt.now.return_value = FIXED_NOW
|
|
return helper.collect_results(
|
|
session=session,
|
|
targets=targets,
|
|
categories=categories,
|
|
dates=tuple(dates) if dates else None,
|
|
week_range=week_range,
|
|
concurrency=1,
|
|
)
|
|
|
|
|
|
class IsReserveRoomTest(unittest.TestCase):
|
|
def test_parens_with_suffix(self):
|
|
self.assertTrue(helper.is_reserve_room({"goodsNm": "201호 배꽃방(예비용)"}))
|
|
|
|
def test_parens_prefix(self):
|
|
self.assertTrue(helper.is_reserve_room({"goodsNm": "(예비) 201호"}))
|
|
|
|
def test_predicate_with_simple_suffix(self):
|
|
self.assertTrue(helper.is_reserve_room({"goodsNm": "편백나무2호(예비용)"}))
|
|
|
|
def test_normal_room_passes(self):
|
|
self.assertFalse(helper.is_reserve_room({"goodsNm": "동백1"}))
|
|
|
|
def test_empty_name(self):
|
|
self.assertFalse(helper.is_reserve_room({"goodsNm": ""}))
|
|
|
|
def test_missing_name_key(self):
|
|
self.assertFalse(helper.is_reserve_room({}))
|
|
|
|
|
|
class IsAvailableTest(unittest.TestCase):
|
|
def test_y_and_zero_count(self):
|
|
self.assertTrue(helper.is_available({"rsrvtAvail": "Y", "rsrvtCnt": 0}))
|
|
|
|
def test_y_but_already_booked(self):
|
|
self.assertFalse(helper.is_available({"rsrvtAvail": "Y", "rsrvtCnt": 1}))
|
|
|
|
def test_not_available(self):
|
|
self.assertFalse(helper.is_available({"rsrvtAvail": "N", "rsrvtCnt": 0}))
|
|
|
|
|
|
class CollectResultsFilterTest(unittest.TestCase):
|
|
def setUp(self):
|
|
self.session = make_session({GEOJE_FOREST_ID: GEOJE_FOREST_NAME})
|
|
self.targets = {GEOJE_FOREST_ID: GEOJE_FOREST_NAME}
|
|
|
|
def test_geoje_5_13_three_unique_rooms_after_dedup_and_reserve_filter(self):
|
|
payload = run_collect(self.session, self.targets, GEOJE_ROWS, dates=["20260513"])
|
|
self.assertEqual(payload["filter_hits"], 3)
|
|
names = {
|
|
room["name"]
|
|
for forest in payload["results"]
|
|
for date in forest["dates"]
|
|
for room in date["rooms"]
|
|
}
|
|
self.assertEqual(names, {"동백1", "해송2", "고로쇠1"})
|
|
|
|
def test_geoje_5_16_returns_zero_when_only_reserved_or_booked(self):
|
|
payload = run_collect(self.session, self.targets, GEOJE_ROWS, dates=["20260516"])
|
|
self.assertEqual(payload["filter_hits"], 0)
|
|
self.assertEqual(payload["results"], [])
|
|
|
|
def test_geoje_5_17_two_rooms(self):
|
|
payload = run_collect(self.session, self.targets, GEOJE_ROWS, dates=["20260517"])
|
|
self.assertEqual(payload["filter_hits"], 2)
|
|
names = {
|
|
room["name"]
|
|
for forest in payload["results"]
|
|
for date in forest["dates"]
|
|
for room in date["rooms"]
|
|
}
|
|
self.assertEqual(names, {"중산막2", "동백3"})
|
|
|
|
def test_dates_outside_request_filtered_out(self):
|
|
payload = run_collect(self.session, self.targets, GEOJE_ROWS, dates=["20260513"])
|
|
observed_dates = {
|
|
room["use_dt"]
|
|
for forest in payload["results"]
|
|
for date in forest["dates"]
|
|
for room in date["rooms"]
|
|
}
|
|
self.assertEqual(observed_dates, {"20260513"})
|
|
|
|
def test_reserve_rooms_excluded_across_all_dates(self):
|
|
payload = run_collect(
|
|
self.session, self.targets, GEOJE_ROWS,
|
|
dates=["20260513", "20260516", "20260517"],
|
|
)
|
|
for forest in payload["results"]:
|
|
for date in forest["dates"]:
|
|
for room in date["rooms"]:
|
|
self.assertNotIn("예비", room["name"])
|
|
|
|
def test_dedup_collapses_duplicate_room_with_different_goods_id(self):
|
|
payload = run_collect(self.session, self.targets, GEOJE_ROWS, dates=["20260513"])
|
|
donbaek_count = sum(
|
|
1
|
|
for forest in payload["results"]
|
|
for date in forest["dates"]
|
|
for room in date["rooms"]
|
|
if room["name"] == "동백1"
|
|
)
|
|
self.assertEqual(donbaek_count, 1)
|
|
|
|
|
|
class StrictUseDtGateTest(unittest.TestCase):
|
|
"""Bug 1 regression: API returns 5-day window even when single-day requested."""
|
|
|
|
def test_useDt_before_today_blocked_even_if_available(self):
|
|
past_row = dict(GEOJE_ROWS[0])
|
|
past_row["useDt"] = "20260101"
|
|
rows = [past_row]
|
|
session = make_session({GEOJE_FOREST_ID: GEOJE_FOREST_NAME})
|
|
payload = run_collect(
|
|
session, {GEOJE_FOREST_ID: GEOJE_FOREST_NAME}, rows,
|
|
week_range=1,
|
|
)
|
|
self.assertEqual(payload["filter_hits"], 0)
|
|
|
|
def test_useDt_after_last_day_blocked(self):
|
|
far_future = dict(GEOJE_ROWS[0])
|
|
far_future["useDt"] = "20300101"
|
|
rows = [far_future]
|
|
session = make_session({GEOJE_FOREST_ID: GEOJE_FOREST_NAME})
|
|
payload = run_collect(
|
|
session, {GEOJE_FOREST_ID: GEOJE_FOREST_NAME}, rows,
|
|
week_range=1,
|
|
)
|
|
self.assertEqual(payload["filter_hits"], 0)
|
|
|
|
|
|
class GroundTruthTest(unittest.TestCase):
|
|
"""Anchored to user-verified counts from foresttrip.go.kr on 2026-05-12.
|
|
Fixtures are simplified; tests assert the per-(forest, date) shape matches."""
|
|
|
|
def test_gujaebong_5_16_one_room_named_쑥부쟁이방(self):
|
|
session = make_session({GUJAEBONG_FOREST_ID: GUJAEBONG_FOREST_NAME})
|
|
payload = run_collect(
|
|
session, {GUJAEBONG_FOREST_ID: GUJAEBONG_FOREST_NAME}, GUJAEBONG_ROWS,
|
|
dates=["20260516"],
|
|
)
|
|
self.assertEqual(payload["filter_hits"], 1)
|
|
names = [
|
|
room["name"]
|
|
for forest in payload["results"]
|
|
for date in forest["dates"]
|
|
for room in date["rooms"]
|
|
]
|
|
self.assertEqual(names, ["206호 쑥부쟁이방"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|