vignette/apps/api/app/test_session_time_and_goals.py
2026-07-15 21:31:30 +09:00

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()