240 lines
8.5 KiB
Python
240 lines
8.5 KiB
Python
"""시간 기반 회기 종료(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()
|