전 저장소 리팩터링과 SSOT 정비
This commit is contained in:
parent
14ecbd4e7d
commit
3dfddcac6f
173 changed files with 19679 additions and 6952 deletions
240
apps/api/app/test_session_time_and_goals.py
Normal file
240
apps/api/app/test_session_time_and_goals.py
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
"""시간 기반 회기 종료(P1) + 회기 목표 선택 계약 테스트.
|
||||
|
||||
2026-07-13 한신대 회의: 세션 종료를 4단계 완수가 아니라 시간(기본 60분)이 결정하고,
|
||||
회기 시작 전 학습자가 이번 회기 목표 단계를 2개 수준으로 선택한다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from .config import settings
|
||||
from .deps import Principal, Role
|
||||
from .routes import sessions
|
||||
from .services import memory, persona as persona_service, state_machine
|
||||
from .store import InProcSession, store
|
||||
|
||||
|
||||
def _principal() -> Principal:
|
||||
return Principal(
|
||||
user_id="00000000-0000-0000-0000-000000000102",
|
||||
role=Role.LEARNER,
|
||||
cohort_ids=[],
|
||||
email="time-goal-test@hs.ac.kr",
|
||||
display_name="Time Goal Test",
|
||||
consent_at=1.0,
|
||||
profile_completed_at=1.0,
|
||||
)
|
||||
|
||||
|
||||
def _session(principal: Principal, *, created_at: float | None = None) -> InProcSession:
|
||||
card = persona_service.P1
|
||||
sess = InProcSession(
|
||||
session_id="time-goal-session",
|
||||
case_id="time-goal-case",
|
||||
learner_id=principal.user_id,
|
||||
persona_code=card.code,
|
||||
theory_mode="humanistic",
|
||||
persona=card,
|
||||
state=state_machine.SessionState(
|
||||
resistance=card.base_resistance(),
|
||||
ideation_stage=card.ideation_baseline(),
|
||||
),
|
||||
)
|
||||
if created_at is not None:
|
||||
sess.created_at = created_at
|
||||
store.put(sess)
|
||||
return sess
|
||||
|
||||
|
||||
class SessionGoalContractTest(unittest.TestCase):
|
||||
def test_goal_stages_are_deduped(self) -> None:
|
||||
req = sessions.SessionStartRequest(
|
||||
persona_code="P1",
|
||||
goal_stages=["라포", "라포"],
|
||||
)
|
||||
self.assertEqual(req.goal_stages, ["라포"])
|
||||
|
||||
def test_goal_stages_allow_up_to_four(self) -> None:
|
||||
req = sessions.SessionStartRequest(
|
||||
persona_code="P1",
|
||||
goal_stages=["라포", "탐색", "개입", "정리"],
|
||||
)
|
||||
self.assertEqual(req.goal_stages, ["라포", "탐색", "개입", "정리"])
|
||||
|
||||
def test_goal_stages_reject_more_than_four_items(self) -> None:
|
||||
with self.assertRaises(Exception):
|
||||
sessions.SessionStartRequest(
|
||||
persona_code="P1",
|
||||
goal_stages=["라포", "탐색", "개입", "정리", "라포"],
|
||||
)
|
||||
|
||||
def test_goal_stages_reject_unknown_stage_label(self) -> None:
|
||||
with self.assertRaises(Exception):
|
||||
sessions.SessionStartRequest(
|
||||
persona_code="P1",
|
||||
goal_stages=["존재하지않는단계"],
|
||||
)
|
||||
|
||||
def test_goal_stages_default_empty_for_legacy_clients(self) -> None:
|
||||
req = sessions.SessionStartRequest(persona_code="P1")
|
||||
self.assertEqual(req.goal_stages, [])
|
||||
|
||||
|
||||
class SessionTimeLimitTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self) -> None:
|
||||
store._sessions.clear()
|
||||
sessions._RECALL_CACHE.clear()
|
||||
sessions._KB_CUES_CACHE.clear()
|
||||
|
||||
async def asyncTearDown(self) -> None:
|
||||
store._sessions.clear()
|
||||
sessions._RECALL_CACHE.clear()
|
||||
sessions._KB_CUES_CACHE.clear()
|
||||
|
||||
def test_session_context_cache_has_one_owner_and_defensive_reads(self) -> None:
|
||||
session_id = "cache-owner-session"
|
||||
sessions._RECALL_CACHE[session_id] = memory.RecallContext(recall_summary="기억")
|
||||
sessions._KB_CUES_CACHE[session_id] = ["단서"]
|
||||
|
||||
cues = sessions.cached_kb_cues(session_id)
|
||||
cues.append("외부 변경")
|
||||
self.assertEqual(sessions.cached_kb_cues(session_id), ["단서"])
|
||||
|
||||
sessions.invalidate_session_context_cache(session_id)
|
||||
self.assertNotIn(session_id, sessions._RECALL_CACHE)
|
||||
self.assertEqual(sessions.cached_kb_cues(session_id), [])
|
||||
|
||||
def test_session_time_over_false_within_limit(self) -> None:
|
||||
principal = _principal()
|
||||
sess = _session(principal, created_at=time.time() - 30 * 60)
|
||||
self.assertFalse(sessions.session_time_over(sess))
|
||||
|
||||
def test_session_time_over_false_within_overtime_grace(self) -> None:
|
||||
"""제한(60분) 초과 직후에는 마무리 유예 안이라 턴이 계속 허용된다."""
|
||||
principal = _principal()
|
||||
limit_minutes = settings.session_duration_minutes
|
||||
sess = _session(principal, created_at=time.time() - (limit_minutes + 2) * 60)
|
||||
self.assertFalse(sessions.session_time_over(sess))
|
||||
|
||||
def test_session_time_over_true_after_grace(self) -> None:
|
||||
principal = _principal()
|
||||
over_minutes = (
|
||||
settings.session_duration_minutes
|
||||
+ settings.session_overtime_grace_minutes
|
||||
+ 1
|
||||
)
|
||||
sess = _session(principal, created_at=time.time() - over_minutes * 60)
|
||||
self.assertTrue(sessions.session_time_over(sess))
|
||||
|
||||
async def test_prepare_turn_context_rejects_after_grace(self) -> None:
|
||||
principal = _principal()
|
||||
over_minutes = (
|
||||
settings.session_duration_minutes
|
||||
+ settings.session_overtime_grace_minutes
|
||||
+ 1
|
||||
)
|
||||
sess = _session(principal, created_at=time.time() - over_minutes * 60)
|
||||
with patch.object(
|
||||
sessions,
|
||||
"ensure_recall_context",
|
||||
AsyncMock(return_value=memory.RecallContext()),
|
||||
):
|
||||
with self.assertRaises(HTTPException) as caught:
|
||||
await sessions._prepare_turn_context(
|
||||
session_id=sess.session_id,
|
||||
sess=sess,
|
||||
learner_text="오늘 이야기 나눠주셔서 감사해요.",
|
||||
)
|
||||
self.assertEqual(caught.exception.status_code, 409)
|
||||
self.assertEqual(caught.exception.detail, "session_time_over")
|
||||
|
||||
|
||||
class SessionStartGoalPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self) -> None:
|
||||
store._sessions.clear()
|
||||
sessions._RECALL_CACHE.clear()
|
||||
|
||||
async def asyncTearDown(self) -> None:
|
||||
store._sessions.clear()
|
||||
sessions._RECALL_CACHE.clear()
|
||||
|
||||
async def test_start_session_passes_goals_and_returns_time_contract(self) -> None:
|
||||
principal = _principal()
|
||||
card = persona_service.P1
|
||||
catalog_persona = SimpleNamespace(
|
||||
card=card,
|
||||
persona_id="00000000-0000-0000-0000-0000000000a1",
|
||||
version=1,
|
||||
degraded=False,
|
||||
)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def fake_create_session(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return InProcSession(
|
||||
session_id="goal-session",
|
||||
case_id="goal-case",
|
||||
learner_id=principal.user_id,
|
||||
persona_code=card.code,
|
||||
theory_mode=kwargs["theory_mode"],
|
||||
persona=card,
|
||||
state=kwargs["state"],
|
||||
session_no=kwargs["session_no"],
|
||||
prev_rapport_credit=kwargs["carry_rapport"],
|
||||
goal_stages=list(kwargs["goal_stages"]),
|
||||
)
|
||||
|
||||
def close_background(coro):
|
||||
coro.close()
|
||||
return None
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
sessions, "get_catalog_persona", AsyncMock(return_value=catalog_persona)
|
||||
),
|
||||
patch.object(
|
||||
sessions.session_persistence,
|
||||
"get_case_context",
|
||||
AsyncMock(return_value=None),
|
||||
),
|
||||
patch.object(
|
||||
sessions,
|
||||
"_build_seed_recall",
|
||||
AsyncMock(return_value=memory.RecallContext()),
|
||||
),
|
||||
patch.object(
|
||||
sessions.session_persistence,
|
||||
"create_session",
|
||||
fake_create_session,
|
||||
),
|
||||
patch.object(sessions.asyncio, "create_task", close_background),
|
||||
):
|
||||
response = await sessions.start_session(
|
||||
sessions.SessionStartRequest(
|
||||
persona_code=card.code,
|
||||
goal_stages=["라포", "탐색"],
|
||||
),
|
||||
principal,
|
||||
)
|
||||
|
||||
self.assertEqual(captured["goal_stages"], ["라포", "탐색"])
|
||||
self.assertEqual(response.goal_stages, ["라포", "탐색"])
|
||||
self.assertEqual(
|
||||
response.duration_limit_seconds,
|
||||
settings.session_duration_minutes * 60,
|
||||
)
|
||||
self.assertEqual(
|
||||
response.warning_before_end_seconds,
|
||||
settings.session_warning_minutes * 60,
|
||||
)
|
||||
self.assertTrue(response.started_at)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue