mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
fix(foresttrip-vacancy): exclude 예비 rooms, gate useDt strictly, dedup duplicate rooms
월별예약조회 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>
This commit is contained in:
parent
abe26e411d
commit
00a6d9feae
8 changed files with 476 additions and 2 deletions
|
|
@ -80,6 +80,7 @@ python3 foresttrip-vacancy/scripts/run_foresttrip_vacancy.py --forest-name 유
|
|||
3. helper로 read-only 월별예약조회 endpoint를 실행한다.
|
||||
4. helper가 로그인 세션, CSRF, 공식 휴양림 ID 목록을 확보한다.
|
||||
5. 날짜, 휴양림명, 객실/시설명, 숙박/야영 구분, 정원 중심으로 요약한다.
|
||||
6. 응답 정제: API가 `srchDate` 기준 최대 5일 윈도우를 반환할 수 있어 helper가 요청 범위 밖 `useDt`, 운영자 보유분("예비" 포함 객실), 같은 객실 중복 행을 자동 제거한다.
|
||||
|
||||
2026-04-29 확인 기준, 로그인 없이 월별예약조회 화면에 접근하면 `401 Unauthorized`가 반환되고, 조회 endpoint는 JSON 대신 안내 HTML을 반환한다. 따라서 현재 구현은 로그인 세션/CSRF 확보를 필수 전제로 둔다.
|
||||
|
||||
|
|
@ -136,6 +137,8 @@ python3 foresttrip-vacancy/scripts/run_foresttrip_vacancy.py --all --text --date
|
|||
- aggressive polling은 피한다.
|
||||
- 조회 결과는 시점 차이로 숲나들e 화면과 달라질 수 있다.
|
||||
- 로그인 실패 시 계정 정보 또는 숲나들e 정책 변경을 먼저 확인한다.
|
||||
- API가 요청 날짜보다 넓은 5일 윈도우를 반환해도 출력에는 요청 범위(`today`–`last_day`) 안의 행만 포함된다.
|
||||
- "예비" 표기가 있는 객실은 사용자 예약 화면에 노출되지 않는 운영자 보유분이라 결과에서 자동 제외된다.
|
||||
|
||||
## 흔한 문제 해결
|
||||
|
||||
|
|
|
|||
|
|
@ -100,6 +100,8 @@ python3 -m playwright install chromium
|
|||
|
||||
2026-04-29 확인 기준, 로그인 없이 월별예약조회 화면에 접근하면 `401 Unauthorized`가 반환되고, 조회 endpoint는 JSON 대신 안내 HTML을 반환한다. 따라서 현재 helper는 로그인 세션/CSRF 확보를 필수 전제로 둔다.
|
||||
|
||||
API는 `srchDate` 단일 일자만 요청해도 응답에 5일 윈도우를 포함할 수 있다. helper는 요청 범위(`today`–`last_day`) 밖 `useDt` 행을 자동 제거하므로 사용자에게는 요청한 날짜의 빈자리만 노출된다.
|
||||
|
||||
전체 자연휴양림에서 특정 날짜 조회:
|
||||
|
||||
```bash
|
||||
|
|
@ -138,6 +140,8 @@ python3 scripts/run_foresttrip_vacancy.py --forest-name 유명산 --text --dates
|
|||
|
||||
결과가 없으면 "조회 시점 기준 예약 가능 객실 없음"이라고 말한다. 실제 예약 가능 여부는 숲나들e 화면에서 재확인될 수 있음을 덧붙인다.
|
||||
|
||||
`goodsNm`에 "예비"가 포함된 객실은 운영자가 보유하는 내부용 자리로, 사용자 예약 화면에는 노출되지 않는다. helper는 이 객실들을 결과에서 자동 제외한다. 같은 `(휴양림, 날짜, 객실명)` 조합의 중복 행도 dedup된다.
|
||||
|
||||
## Done when
|
||||
|
||||
- 요청 날짜와 조회 범위가 명확하다.
|
||||
|
|
@ -152,6 +156,8 @@ python3 scripts/run_foresttrip_vacancy.py --forest-name 유명산 --text --dates
|
|||
- Playwright browser 미설치: `python3 -m playwright install chromium`
|
||||
- fetch failure 일부 발생: 결과와 실패 개수를 함께 보고하고, 필요하면 `--refresh-session` 으로 1회 재조회
|
||||
- 숲나들e 표면 변경: helper의 login/session bootstrap 또는 parser 점검 필요
|
||||
- "(예비)" 객실이 결과에 안 나옴: 정상 동작이다. 사용자 예약 화면에 노출되지 않는 운영자 보유분이라 의도적으로 제외된다.
|
||||
- 사용자 화면 객실 수와 helper 결과가 다름: 같은 객실의 중복 행이 dedup되었거나, 요청 범위 밖 `useDt`가 제거됐을 가능성이 높다. raw API 응답을 확인하려면 helper 로직을 우회해서 직접 호출 필요.
|
||||
|
||||
## Maintainer review notes
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ DEFAULT_CONCURRENCY = 4
|
|||
MAX_CONCURRENCY = 5
|
||||
DEFAULT_WEEK_RANGE = 1
|
||||
CATEGORY_CODES = {"01", "02"}
|
||||
RESERVE_ROOM_MARKER = "예비"
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -382,6 +383,10 @@ def is_available(row: dict[str, Any]) -> bool:
|
|||
return row.get("rsrvtAvail") == "Y" and row.get("rsrvtCnt") == 0
|
||||
|
||||
|
||||
def is_reserve_room(row: dict[str, Any]) -> bool:
|
||||
return RESERVE_ROOM_MARKER in (row.get("goodsNm") or "")
|
||||
|
||||
|
||||
def normalize_row(row: dict[str, Any], forests: dict[str, str]) -> dict[str, Any]:
|
||||
instt_id = str(row.get("insttId") or "")
|
||||
return {
|
||||
|
|
@ -443,11 +448,26 @@ def collect_results(
|
|||
for row in data:
|
||||
if not is_available(row):
|
||||
continue
|
||||
if is_reserve_room(row):
|
||||
continue
|
||||
use_dt = row.get("useDt") or ""
|
||||
if use_dt < today or use_dt > last_day:
|
||||
continue
|
||||
normalized = normalize_row(row, session.forests)
|
||||
if date_filter is not None and normalized["use_dt"] not in date_filter:
|
||||
continue
|
||||
rows.append(normalized)
|
||||
|
||||
seen: set[tuple[str, str, str]] = set()
|
||||
deduped: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
key = (row["forest_id"], row["use_dt"], row["name"])
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
deduped.append(row)
|
||||
rows = deduped
|
||||
|
||||
grouped: dict[str, dict[str, list[dict[str, Any]]]] = {}
|
||||
for row in sorted(rows, key=lambda item: (item["forest"], item["use_dt"], item["name"])):
|
||||
grouped.setdefault(row["forest"], {}).setdefault(row["use_dt"], []).append(row)
|
||||
|
|
|
|||
0
foresttrip-vacancy/tests/__init__.py
Normal file
0
foresttrip-vacancy/tests/__init__.py
Normal file
167
foresttrip-vacancy/tests/fixtures/geoje_window.json
vendored
Normal file
167
foresttrip-vacancy/tests/fixtures/geoje_window.json
vendored
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
[
|
||||
{
|
||||
"insttId": "ID02030059",
|
||||
"insttNm": "거제자연휴양림",
|
||||
"useDt": "20260513",
|
||||
"dywkDtTpcd": "수",
|
||||
"goodsNm": "동백1",
|
||||
"goodsId": "GID-A1",
|
||||
"insttArea": "50㎡",
|
||||
"mxmmAccptCnt": "8",
|
||||
"goodsClsscNm": "숲속의집",
|
||||
"insttAreaNm": null,
|
||||
"wtngPssblYn": "Y",
|
||||
"rsrvtAvail": "Y",
|
||||
"rsrvtCnt": 0
|
||||
},
|
||||
{
|
||||
"insttId": "ID02030059",
|
||||
"insttNm": "거제자연휴양림",
|
||||
"useDt": "20260513",
|
||||
"dywkDtTpcd": "수",
|
||||
"goodsNm": "동백1",
|
||||
"goodsId": "GID-A2",
|
||||
"insttArea": "50㎡",
|
||||
"mxmmAccptCnt": "8",
|
||||
"goodsClsscNm": "숲속의집",
|
||||
"insttAreaNm": null,
|
||||
"wtngPssblYn": "Y",
|
||||
"rsrvtAvail": "Y",
|
||||
"rsrvtCnt": 0
|
||||
},
|
||||
{
|
||||
"insttId": "ID02030059",
|
||||
"insttNm": "거제자연휴양림",
|
||||
"useDt": "20260513",
|
||||
"dywkDtTpcd": "수",
|
||||
"goodsNm": "해송2",
|
||||
"goodsId": "GID-B",
|
||||
"insttArea": "50㎡",
|
||||
"mxmmAccptCnt": "8",
|
||||
"goodsClsscNm": "숲속의집",
|
||||
"insttAreaNm": null,
|
||||
"wtngPssblYn": "Y",
|
||||
"rsrvtAvail": "Y",
|
||||
"rsrvtCnt": 0
|
||||
},
|
||||
{
|
||||
"insttId": "ID02030059",
|
||||
"insttNm": "거제자연휴양림",
|
||||
"useDt": "20260513",
|
||||
"dywkDtTpcd": "수",
|
||||
"goodsNm": "고로쇠1",
|
||||
"goodsId": "GID-C",
|
||||
"insttArea": "60㎡",
|
||||
"mxmmAccptCnt": "10",
|
||||
"goodsClsscNm": "숲속의집",
|
||||
"insttAreaNm": null,
|
||||
"wtngPssblYn": "Y",
|
||||
"rsrvtAvail": "Y",
|
||||
"rsrvtCnt": 0
|
||||
},
|
||||
{
|
||||
"insttId": "ID02030059",
|
||||
"insttNm": "거제자연휴양림",
|
||||
"useDt": "20260513",
|
||||
"dywkDtTpcd": "수",
|
||||
"goodsNm": "(예비) 201호",
|
||||
"goodsId": "GID-RES1",
|
||||
"insttArea": "32㎡",
|
||||
"mxmmAccptCnt": "4",
|
||||
"goodsClsscNm": "휴양관",
|
||||
"insttAreaNm": null,
|
||||
"wtngPssblYn": "Y",
|
||||
"rsrvtAvail": "Y",
|
||||
"rsrvtCnt": 0
|
||||
},
|
||||
{
|
||||
"insttId": "ID02030059",
|
||||
"insttNm": "거제자연휴양림",
|
||||
"useDt": "20260514",
|
||||
"dywkDtTpcd": "목",
|
||||
"goodsNm": "동백3",
|
||||
"goodsId": "GID-D",
|
||||
"insttArea": "50㎡",
|
||||
"mxmmAccptCnt": "8",
|
||||
"goodsClsscNm": "숲속의집",
|
||||
"insttAreaNm": null,
|
||||
"wtngPssblYn": "Y",
|
||||
"rsrvtAvail": "Y",
|
||||
"rsrvtCnt": 0
|
||||
},
|
||||
{
|
||||
"insttId": "ID02030059",
|
||||
"insttNm": "거제자연휴양림",
|
||||
"useDt": "20260516",
|
||||
"dywkDtTpcd": "토",
|
||||
"goodsNm": "동백1",
|
||||
"goodsId": "GID-A1",
|
||||
"insttArea": "50㎡",
|
||||
"mxmmAccptCnt": "8",
|
||||
"goodsClsscNm": "숲속의집",
|
||||
"insttAreaNm": null,
|
||||
"wtngPssblYn": "Y",
|
||||
"rsrvtAvail": "Y",
|
||||
"rsrvtCnt": 1
|
||||
},
|
||||
{
|
||||
"insttId": "ID02030059",
|
||||
"insttNm": "거제자연휴양림",
|
||||
"useDt": "20260516",
|
||||
"dywkDtTpcd": "토",
|
||||
"goodsNm": "해송2",
|
||||
"goodsId": "GID-B",
|
||||
"insttArea": "50㎡",
|
||||
"mxmmAccptCnt": "8",
|
||||
"goodsClsscNm": "숲속의집",
|
||||
"insttAreaNm": null,
|
||||
"wtngPssblYn": "Y",
|
||||
"rsrvtAvail": "Y",
|
||||
"rsrvtCnt": 1
|
||||
},
|
||||
{
|
||||
"insttId": "ID02030059",
|
||||
"insttNm": "거제자연휴양림",
|
||||
"useDt": "20260516",
|
||||
"dywkDtTpcd": "토",
|
||||
"goodsNm": "(예비) 201호",
|
||||
"goodsId": "GID-RES1",
|
||||
"insttArea": "32㎡",
|
||||
"mxmmAccptCnt": "4",
|
||||
"goodsClsscNm": "휴양관",
|
||||
"insttAreaNm": null,
|
||||
"wtngPssblYn": "Y",
|
||||
"rsrvtAvail": "Y",
|
||||
"rsrvtCnt": 0
|
||||
},
|
||||
{
|
||||
"insttId": "ID02030059",
|
||||
"insttNm": "거제자연휴양림",
|
||||
"useDt": "20260517",
|
||||
"dywkDtTpcd": "일",
|
||||
"goodsNm": "중산막2",
|
||||
"goodsId": "GID-E",
|
||||
"insttArea": "32㎡",
|
||||
"mxmmAccptCnt": "4",
|
||||
"goodsClsscNm": "숲속의집",
|
||||
"insttAreaNm": null,
|
||||
"wtngPssblYn": "Y",
|
||||
"rsrvtAvail": "Y",
|
||||
"rsrvtCnt": 0
|
||||
},
|
||||
{
|
||||
"insttId": "ID02030059",
|
||||
"insttNm": "거제자연휴양림",
|
||||
"useDt": "20260517",
|
||||
"dywkDtTpcd": "일",
|
||||
"goodsNm": "동백3",
|
||||
"goodsId": "GID-F",
|
||||
"insttArea": "50㎡",
|
||||
"mxmmAccptCnt": "8",
|
||||
"goodsClsscNm": "숲속의집",
|
||||
"insttAreaNm": null,
|
||||
"wtngPssblYn": "Y",
|
||||
"rsrvtAvail": "Y",
|
||||
"rsrvtCnt": 0
|
||||
}
|
||||
]
|
||||
62
foresttrip-vacancy/tests/fixtures/gujaebong_window.json
vendored
Normal file
62
foresttrip-vacancy/tests/fixtures/gujaebong_window.json
vendored
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
[
|
||||
{
|
||||
"insttId": "ID02030072",
|
||||
"insttNm": "구재봉자연휴양림",
|
||||
"useDt": "20260516",
|
||||
"dywkDtTpcd": "토",
|
||||
"goodsNm": "206호 쑥부쟁이방",
|
||||
"goodsId": "GID-G1",
|
||||
"insttArea": "37㎡",
|
||||
"mxmmAccptCnt": "8",
|
||||
"goodsClsscNm": "숲속휴양관",
|
||||
"insttAreaNm": null,
|
||||
"wtngPssblYn": "Y",
|
||||
"rsrvtAvail": "Y",
|
||||
"rsrvtCnt": 0
|
||||
},
|
||||
{
|
||||
"insttId": "ID02030072",
|
||||
"insttNm": "구재봉자연휴양림",
|
||||
"useDt": "20260516",
|
||||
"dywkDtTpcd": "토",
|
||||
"goodsNm": "201호 배꽃방(예비용)",
|
||||
"goodsId": "GID-G2",
|
||||
"insttArea": "30㎡",
|
||||
"mxmmAccptCnt": "6",
|
||||
"goodsClsscNm": "숲속휴양관",
|
||||
"insttAreaNm": null,
|
||||
"wtngPssblYn": "Y",
|
||||
"rsrvtAvail": "Y",
|
||||
"rsrvtCnt": 0
|
||||
},
|
||||
{
|
||||
"insttId": "ID02030072",
|
||||
"insttNm": "구재봉자연휴양림",
|
||||
"useDt": "20260516",
|
||||
"dywkDtTpcd": "토",
|
||||
"goodsNm": "편백나무2호(예비용)",
|
||||
"goodsId": "GID-G3",
|
||||
"insttArea": "28㎡",
|
||||
"mxmmAccptCnt": "6",
|
||||
"goodsClsscNm": "숲속의집",
|
||||
"insttAreaNm": null,
|
||||
"wtngPssblYn": "Y",
|
||||
"rsrvtAvail": "Y",
|
||||
"rsrvtCnt": 0
|
||||
},
|
||||
{
|
||||
"insttId": "ID02030072",
|
||||
"insttNm": "구재봉자연휴양림",
|
||||
"useDt": "20260516",
|
||||
"dywkDtTpcd": "토",
|
||||
"goodsNm": "은행나무방(예비용)",
|
||||
"goodsId": "GID-G4",
|
||||
"insttArea": "22㎡",
|
||||
"mxmmAccptCnt": "4",
|
||||
"goodsClsscNm": "트리하우스",
|
||||
"insttAreaNm": null,
|
||||
"wtngPssblYn": "Y",
|
||||
"rsrvtAvail": "Y",
|
||||
"rsrvtCnt": 0
|
||||
}
|
||||
]
|
||||
216
foresttrip-vacancy/tests/test_run_foresttrip_vacancy.py
Normal file
216
foresttrip-vacancy/tests/test_run_foresttrip_vacancy.py
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
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()
|
||||
|
|
@ -10,9 +10,9 @@
|
|||
"scripts": {
|
||||
"build": "npm run build --workspaces --if-present",
|
||||
"build:manus-bundle": "node scripts/build-manus-bundle.js",
|
||||
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js scripts/korean_middle_korean.js scripts/test_korean_middle_korean.js scripts/build-manus-bundle.js scripts/test_build_manus_bundle.js scripts/workflow-actions.test.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/nts_business_registration.py scripts/test_nts_business_registration.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py nts-business-registration/scripts/nts_business_registration.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py scripts/test_ohou_today_deal.py scripts/ticket_availability.py scripts/test_ticket_availability.py scripts/test_danawa_price_search.py ticket-availability/scripts/ticket_availability.py coupang-product-search/scripts/coupang_partners_mcp.py ohou-today-deal/scripts/ohou_today_deal.py kakaotalk-mac/scripts/kakaotalk_mac.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py seoul-bike/scripts/seoul_bike.py scripts/test_seoul_bike.py danawa-price-search/scripts/danawa_search.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py kstartup-search/scripts/run_kstartup.py kstartup-search/tests/test_run_kstartup.py intercity-bus-booking/scripts/intercity_bus_search.py daangn-used-goods-search/scripts/daangn_used_goods.py daangn-realty-search/scripts/daangn_realty.py daangn-jobs-search/scripts/daangn_jobs.py daangn-cars-search/scripts/daangn_cars.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
|
||||
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js scripts/korean_middle_korean.js scripts/test_korean_middle_korean.js scripts/build-manus-bundle.js scripts/test_build_manus_bundle.js scripts/workflow-actions.test.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/nts_business_registration.py scripts/test_nts_business_registration.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py nts-business-registration/scripts/nts_business_registration.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py scripts/test_ohou_today_deal.py scripts/ticket_availability.py scripts/test_ticket_availability.py scripts/test_danawa_price_search.py ticket-availability/scripts/ticket_availability.py coupang-product-search/scripts/coupang_partners_mcp.py ohou-today-deal/scripts/ohou_today_deal.py kakaotalk-mac/scripts/kakaotalk_mac.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py seoul-bike/scripts/seoul_bike.py scripts/test_seoul_bike.py danawa-price-search/scripts/danawa_search.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py kstartup-search/scripts/run_kstartup.py kstartup-search/tests/test_run_kstartup.py intercity-bus-booking/scripts/intercity_bus_search.py daangn-used-goods-search/scripts/daangn_used_goods.py daangn-realty-search/scripts/daangn_realty.py daangn-jobs-search/scripts/daangn_jobs.py daangn-cars-search/scripts/daangn_cars.py foresttrip-vacancy/scripts/run_foresttrip_vacancy.py foresttrip-vacancy/tests/test_run_foresttrip_vacancy.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "python3 -m pip install --user --quiet beautifulsoup4 && node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js scripts/test_korean_middle_korean.js scripts/test_build_manus_bundle.js scripts/workflow-actions.test.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_k_skill_cleaner scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_nts_business_registration scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_korean_slang_writing scripts.test_kakaotalk_mac scripts.test_coupang_partners_mcp_wrapper scripts.test_ohou_today_deal scripts.test_ticket_availability scripts.test_seoul_bike scripts.test_danawa_price_search && PYTHONPATH=.:scripts:korean-scholarship-search/scripts python3 -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && PYTHONPATH=.:scripts:kosis-stats/scripts python3 -m unittest discover -s kosis-stats/tests -p 'test_run_kosis_stats.py' && PYTHONPATH=.:scripts:kstartup-search/scripts python3 -m unittest discover -s kstartup-search/tests -p 'test_run_kstartup.py' && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
|
||||
"test": "python3 -m pip install --user --quiet beautifulsoup4 && node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js scripts/test_korean_middle_korean.js scripts/test_build_manus_bundle.js scripts/workflow-actions.test.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_k_skill_cleaner scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_nts_business_registration scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_korean_slang_writing scripts.test_kakaotalk_mac scripts.test_coupang_partners_mcp_wrapper scripts.test_ohou_today_deal scripts.test_ticket_availability scripts.test_seoul_bike scripts.test_danawa_price_search && PYTHONPATH=.:scripts:korean-scholarship-search/scripts python3 -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && PYTHONPATH=.:scripts:kosis-stats/scripts python3 -m unittest discover -s kosis-stats/tests -p 'test_run_kosis_stats.py' && PYTHONPATH=.:scripts:kstartup-search/scripts python3 -m unittest discover -s kstartup-search/tests -p 'test_run_kstartup.py' && PYTHONPATH=.:foresttrip-vacancy/scripts python3 -m unittest discover -s foresttrip-vacancy/tests -p 'test_run_foresttrip_vacancy.py' && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
|
||||
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace market-kurly-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace public-restroom-nearby --dry-run && npm pack --workspace parking-lot-search --dry-run && npm pack --workspace court-auction-notice-search --dry-run && npm pack --workspace donation-place-search --dry-run && npm pack --workspace gongsijiga-search --dry-run && npm pack --workspace kbl-results --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run && npm pack --workspace k-skill-rhwp --dry-run && npm pack --workspace korean-marathon-schedule --dry-run && npm pack --workspace gangnamunni-clinic-search --dry-run && npm pack --workspace daishin-report-search --dry-run && npm pack --workspace sh-notice-search --dry-run && npm pack --workspace emergency-room-beds --dry-run && npm pack --workspace local-election-candidate-search --dry-run",
|
||||
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
|
||||
"version-packages": "changeset version",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue