Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
10 changes: 8 additions & 2 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand All @@ -714,15 +720,15 @@ 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] # 만료 → 제거 후 재조회

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:
Expand Down
95 changes: 95 additions & 0 deletions backend/tests/test_cache.py
Original file line number Diff line number Diff line change
@@ -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()
155 changes: 155 additions & 0 deletions backend/tests/test_optimization.py
Original file line number Diff line number Diff line change
@@ -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()
Loading