diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f43bf05 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +# 백엔드 단위 테스트 + 프론트 빌드를 CI에서 검증한다. +name: ci + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + backend-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install dependencies + working-directory: backend + run: pip install -r requirements.txt + - name: Run unit tests + working-directory: backend + run: python -m unittest discover -s tests -v + + frontend-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Install and build + working-directory: frontend + run: | + npm ci + npm run build diff --git a/backend/main.py b/backend/main.py index a67ab20..8089cb3 100644 --- a/backend/main.py +++ b/backend/main.py @@ -699,6 +699,12 @@ async def get_leg_duration(origin: LocationItem, dest: LocationItem, mode: str, _FINAL_LEG_TTL_SEC: Dict[str, int] = {"car": 600, "transit": 600} # walk 미지정 = 무기한 +def _now() -> float: + """캐시 TTL 판정용 단조 시계. 테스트에서 이 함수만 교체해 시간 경과를 흉내 낸다 + (time.monotonic을 직접 패치하면 asyncio 이벤트 루프 시계까지 바뀌므로 분리한다).""" + return time.monotonic() + + def _final_leg_key(origin: LocationItem, dest: LocationItem, mode: str) -> Tuple: return (mode, round(origin.lat, 6), round(origin.lng, 6), round(dest.lat, 6), round(dest.lng, 6)) @@ -714,7 +720,7 @@ async def get_final_leg(origin: LocationItem, dest: LocationItem, mode: str, cli entry = _FINAL_LEG_CACHE.get(key) if entry is not None: expires_at, value = entry - if expires_at is None or expires_at > time.monotonic(): + if expires_at is None or expires_at > _now(): _FINAL_LEG_CACHE.move_to_end(key) # LRU: 최근 사용으로 갱신 return value del _FINAL_LEG_CACHE[key] # 만료 → 제거 후 재조회 @@ -722,7 +728,7 @@ async def get_final_leg(origin: LocationItem, dest: LocationItem, mode: str, cli duration, distance, path = await get_leg_duration(origin, dest, mode, client, include_path=True) if path: ttl = _FINAL_LEG_TTL_SEC.get(mode) - expires_at = (time.monotonic() + ttl) if ttl else None + expires_at = (_now() + ttl) if ttl else None _FINAL_LEG_CACHE[key] = (expires_at, (duration, distance, path)) _FINAL_LEG_CACHE.move_to_end(key) while len(_FINAL_LEG_CACHE) > _FINAL_LEG_CACHE_MAX: diff --git a/backend/tests/test_cache.py b/backend/tests/test_cache.py new file mode 100644 index 0000000..49f543b --- /dev/null +++ b/backend/tests/test_cache.py @@ -0,0 +1,95 @@ +"""최종 경로 leg 캐시(get_final_leg)의 동작 검증. + +실 API를 흉내 내는 가짜 함수를 주입해, 캐시 히트/미스·LRU 상한·TTL 만료· +폴백(path 없음) 미캐시를 외부 호출 없이 검증한다. +""" +import asyncio +import os +import sys +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import main +from main import LocationItem + + +def loc(i): + return LocationItem(name=f"p{i}", task="t", lat=37.5 + i * 0.001, lng=127.0 + i * 0.001) + + +class DummyClient: + async def get(self, *a, **k): + raise AssertionError("get_final_leg 경로에서 실제 client.get이 호출되면 안 된다") + + +class GetFinalLegCacheTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + main._FINAL_LEG_CACHE.clear() + self._orig = main.get_leg_duration + self._orig_max = main._FINAL_LEG_CACHE_MAX + self._orig_now = main._now + self.calls = 0 + + def tearDown(self): + main.get_leg_duration = self._orig + main._FINAL_LEG_CACHE_MAX = self._orig_max + main._now = self._orig_now + main._FINAL_LEG_CACHE.clear() + + def _patch_real(self, path): + async def fake(origin, dest, mode, client, include_path=False): + self.calls += 1 + return 600.0, 800.0, list(path) + main.get_leg_duration = fake + + async def test_second_call_hits_cache(self): + self._patch_real([[37.5, 127.0], [37.6, 127.1]]) + c = DummyClient() + a = await main.get_final_leg(loc(0), loc(1), "walk", c) + b = await main.get_final_leg(loc(0), loc(1), "walk", c) + self.assertEqual(self.calls, 1) # 두 번째는 캐시 히트 + self.assertEqual(a, b) + + async def test_fallback_without_path_is_not_cached(self): + self._patch_real([]) # 폴백 흉내: path 비어 있음 + c = DummyClient() + await main.get_final_leg(loc(0), loc(1), "transit", c) + await main.get_final_leg(loc(0), loc(1), "transit", c) + self.assertEqual(self.calls, 2) # 캐시 안 됨 → 매번 호출 + + async def test_walk_cache_does_not_expire(self): + self._patch_real([[37.5, 127.0], [37.6, 127.1]]) + base = [1000.0] + main._now = lambda: base[0] + c = DummyClient() + await main.get_final_leg(loc(0), loc(1), "walk", c) + base[0] = 1000.0 + 10 ** 6 # 아주 오래 지나도 + await main.get_final_leg(loc(0), loc(1), "walk", c) + self.assertEqual(self.calls, 1) # 도보는 TTL 없음(무기한) + + async def test_car_cache_expires_after_ttl(self): + self._patch_real([[37.5, 127.0], [37.6, 127.1]]) + base = [1000.0] + main._now = lambda: base[0] + c = DummyClient() + await main.get_final_leg(loc(0), loc(1), "car", c) # 저장(만료 1000+600) + base[0] = 1000.0 + 300 # 만료 전 + await main.get_final_leg(loc(0), loc(1), "car", c) + self.assertEqual(self.calls, 1) + base[0] = 1000.0 + 700 # 만료 후 + await main.get_final_leg(loc(0), loc(1), "car", c) + self.assertEqual(self.calls, 2) + + async def test_lru_evicts_oldest_over_capacity(self): + self._patch_real([[37.5, 127.0], [37.6, 127.1]]) + main._FINAL_LEG_CACHE_MAX = 3 + main._now = lambda: 0.0 + c = DummyClient() + for i in range(5): + await main.get_final_leg(loc(i), loc(i + 100), "walk", c) + self.assertLessEqual(len(main._FINAL_LEG_CACHE), 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_optimization.py b/backend/tests/test_optimization.py new file mode 100644 index 0000000..cf9fef1 --- /dev/null +++ b/backend/tests/test_optimization.py @@ -0,0 +1,155 @@ +"""경로 최적화 solver(solve_shortest / solve_priority / solve_ai)와 스케줄 계산 +(build_schedule / recommended_departure)에 대한 단위 테스트. + +독립적인 완전탐색(브루트포스) 결과와 대조해 solver가 실제로 최적해를 내는지 검증한다. +외부 API 호출 없이 순수 계산만 다루므로 키 없이 실행 가능하다. + +실행: + cd backend + python -m unittest discover -s tests -v +""" +import itertools +import os +import sys +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import main +from main import ( + LocationItem, + path_travel_time, + importance_score, + solve_shortest, + solve_priority, + solve_ai, + build_schedule, + recommended_departure, + AI_PRIORITY_WEIGHT_SEC, +) + + +def loc(name, priority=3, appt=None, dwell=0): + # 좌표는 solver 계산에 쓰이지 않으므로(행렬을 직접 주입) 더미값. + return LocationItem(name=name, task="t", priority=priority, lat=37.5, lng=127.0, + appointment_time=appt, duration_min=dwell) + + +# 0=출발지 포함 4개 지점의 비대칭 이동시간 행렬(초). 대칭이 아니어서 순서가 결과에 영향을 준다. +TIME_MATRIX = [ + [0, 600, 900, 300], + [600, 0, 400, 800], + [900, 400, 0, 500], + [300, 800, 500, 0], +] + + +class SolveShortestTests(unittest.TestCase): + def test_returns_true_minimum_order(self): + stops = [1, 2, 3] + order = solve_shortest(stops, TIME_MATRIX, None, None) + best = min(path_travel_time(list(p), TIME_MATRIX) for p in itertools.permutations(stops)) + self.assertEqual(path_travel_time(order, TIME_MATRIX), best) + + def test_visits_all_stops_once(self): + stops = [1, 2, 3] + order = solve_shortest(stops, TIME_MATRIX, None, None) + self.assertEqual(sorted(order), stops) + + +class SolvePriorityTests(unittest.TestCase): + def test_priority_is_non_decreasing(self): + # priority 1(중요) 그룹이 5(여유)보다 반드시 먼저 방문돼야 한다. + locs = [loc("start", 0), loc("A", 5), loc("B", 1), loc("C", 1), loc("D", 3)] + order = solve_priority([1, 2, 3, 4], TIME_MATRIX_5, locs) + prios = [locs[i].priority for i in order] + self.assertEqual(prios, sorted(prios)) + + def test_optimal_within_group_ordering(self): + # 같은 우선순위끼리는 이동시간이 최소인 순서여야 한다(그룹 제약을 지키는 순열 중 최소). + locs = [loc("start", 0), loc("A", 1), loc("B", 1), loc("C", 2)] + order = solve_priority([1, 2, 3], TIME_MATRIX, locs) + cost = path_travel_time(order, TIME_MATRIX) + # 우선순위 비감소를 만족하는 순열들 중 최소 비용과 같아야 한다. + feasible = [ + p for p in itertools.permutations([1, 2, 3]) + if all(locs[p[i]].priority <= locs[p[i + 1]].priority for i in range(len(p) - 1)) + ] + best = min(path_travel_time(list(p), TIME_MATRIX) for p in feasible) + self.assertEqual(cost, best) + + +class SolveAiTests(unittest.TestCase): + def _ai_score(self, order, locs): + travel = path_travel_time(order, TIME_MATRIX) + penalty = sum(importance_score(locs[n].priority) * pos * AI_PRIORITY_WEIGHT_SEC + for pos, n in enumerate(order)) + return travel + penalty + + def test_minimizes_travel_plus_priority_penalty(self): + locs = [loc("start", 0), loc("A", 1), loc("B", 5), loc("C", 3)] + order = solve_ai([1, 2, 3], TIME_MATRIX, locs, None) + best = min(self._ai_score(list(p), locs) for p in itertools.permutations([1, 2, 3])) + self.assertEqual(self._ai_score(order, locs), best) + + def test_important_place_goes_first_when_travel_is_neutral(self): + # 이동시간이 모든 쌍에서 동일하면, ai는 우선순위가 높은(숫자가 작은) 곳을 먼저 방문한다. + locs = [loc("start", 0), loc("important", 1), loc("trivial", 5)] + m = [[0, 300, 300], [300, 0, 300], [300, 300, 0]] # 모든 이동 300초 동일 + ai = solve_ai([1, 2], m, locs, None) + self.assertEqual(ai[0], 1) # 이동시간이 같으니 중요한 곳을 먼저 + + +# solve_priority 테스트용 5지점 행렬. +TIME_MATRIX_5 = [ + [0, 600, 900, 300, 700], + [600, 0, 400, 800, 200], + [900, 400, 0, 500, 600], + [300, 800, 500, 0, 450], + [700, 200, 600, 450, 0], +] + + +class BuildScheduleTests(unittest.TestCase): + def test_arrival_accumulates_travel_time(self): + locs = [loc("start", 0), loc("A", 3)] + # start(0)->A(1) 600초 = 10분. 출발 09:00(540분) → 도착 550분. + m = [[0, 600], [600, 0]] + schedule, viol, _ = build_schedule([0, 1], locs, m, 540) + self.assertEqual(schedule[1]["arrival_min"], 550) + self.assertEqual(viol, []) + + def test_detects_appointment_violation(self): + # A 약속 09:05인데 09:10 도착 → 위반. + locs = [loc("start", 0), loc("A", 3, appt="09:05")] + m = [[0, 600], [600, 0]] # 10분 + _, viol, _ = build_schedule([0, 1], locs, m, 540) # 09:00 출발 + self.assertIn(1, viol) + + def test_waits_until_appointment_when_early(self): + # 약속 10:00인데 09:10 도착 → 10:00까지 대기 후 체류. + locs = [loc("start", 0), loc("A", 3, appt="10:00", dwell=30)] + m = [[0, 600], [600, 0]] + schedule, viol, _ = build_schedule([0, 1], locs, m, 540) + self.assertEqual(viol, []) + self.assertEqual(schedule[1]["depart_min"], 600 + 30) # 10:00 + 체류30분 + + +class RecommendedDepartureTests(unittest.TestCase): + def test_none_when_no_appointment(self): + locs = [loc("start", 0), loc("A", 3)] + m = [[0, 600], [600, 0]] + dep, feasible = recommended_departure([0, 1], locs, m) + self.assertIsNone(dep) + + def test_latest_departure_still_makes_appointment(self): + # A 약속 10:00, 이동 10분 → 가장 늦어도 09:50엔 나가야 한다. + locs = [loc("start", 0), loc("A", 3, appt="10:00")] + m = [[0, 600], [600, 0]] + dep, feasible = recommended_departure([0, 1], locs, m) + self.assertEqual(dep, 590) # 09:50 + self.assertTrue(feasible) + + +if __name__ == "__main__": + unittest.main()