"""교수자 대시보드 집계 테스트.""" from __future__ import annotations import unittest from unittest.mock import AsyncMock, patch from fastapi import HTTPException from .config import settings from .deps import Principal, Role from .routes import teacher from .services import state_machine from .services.persona import P1 from .store import InProcSession, TurnRecord class _ListSessionsConn: def __init__(self) -> None: self.query = "" self.args: tuple[object, ...] = () async def fetch(self, query: str, *args: object) -> list[object]: self.query = query self.args = args return [] async def execute(self, _query: str, *args: object) -> None: return None class _AcquireCtx: def __init__(self, conn: _ListSessionsConn) -> None: self.conn = conn async def __aenter__(self) -> _ListSessionsConn: return self.conn async def __aexit__(self, exc_type, exc, tb) -> bool: return False def _principal() -> Principal: return Principal( user_id="00000000-0000-0000-0000-000000000901", role=Role.TEACHER, ) def _session( *, session_id: str, session_no: int, learner_id: str, score: str, rapport: float, technique: str, created_at: float, learner_label: str | None = None, with_turns: bool = True, ) -> InProcSession: state = state_machine.init_state(params=P1.openness_params()) state.stage = state_machine.Stage.EXPLORE return InProcSession( session_id=session_id, case_id=session_id, learner_id=learner_id, persona_code=P1.code, theory_mode="humanistic", persona=P1, state=state, session_no=session_no, created_at=created_at, ended_at=created_at + 600, ended=True, learner_label=learner_label, turns=[ TurnRecord( turn_seq=1, speaker="counselor", stage=state.stage.value, text="상담자 발화", text_masked="상담자 발화", evaluation={ "appropriateness": score, "rapport_signal": rapport, "techniques": [{"label": technique}], }, ), TurnRecord( turn_seq=2, speaker="client", stage=state.stage.value, text="내담자 응답", text_masked="내담자 응답", ), ] if with_turns else [], ) class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase): async def test_list_all_sessions_has_no_recent_limit_for_teacher_summaries(self) -> None: principal = _principal() conn = _ListSessionsConn() with ( patch.object(teacher.session_persistence, "get_pool"), patch.object( teacher.session_persistence, "acquire", return_value=_AcquireCtx(conn), ), ): sessions, durable = await teacher.session_persistence.list_all_sessions(principal) self.assertEqual(sessions, []) self.assertTrue(durable) self.assertNotIn("LIMIT", conn.query) self.assertEqual(conn.args, ()) async def test_list_recent_sessions_keeps_recent_limit_for_general_lists(self) -> None: principal = _principal() conn = _ListSessionsConn() with ( patch.object(teacher.session_persistence, "get_pool"), patch.object( teacher.session_persistence, "acquire", return_value=_AcquireCtx(conn), ), ): sessions, durable = await teacher.session_persistence.list_recent_sessions(principal) self.assertEqual(sessions, []) self.assertTrue(durable) self.assertIn("LIMIT $1", conn.query) self.assertEqual(conn.args, (100,)) async def test_dashboard_returns_learner_growth_from_turn_evaluations(self) -> None: learner_id = "00000000-0000-0000-0000-000000000111" sessions = [ _session( session_id="00000000-0000-0000-0000-00000000a111", session_no=1, learner_id=learner_id, learner_label="김민수", score="neutral", rapport=0.1, technique="reflection", created_at=1_000.0, ), _session( session_id="00000000-0000-0000-0000-00000000a112", session_no=2, learner_id=learner_id, learner_label="김민수", score="pos", rapport=0.5, technique="reflection", created_at=2_000.0, ), ] principal = _principal() with ( patch.object( teacher.session_persistence, "list_all_sessions", AsyncMock(return_value=(sessions, True)), ) as list_all_sessions, patch.object( teacher.session_persistence, "list_safety_alerts", AsyncMock(return_value=([], True)), ), patch.object( teacher.session_persistence, "list_session_review_statuses", AsyncMock(return_value=({}, True)), ), patch.object( teacher.session_persistence, "list_session_evaluations", AsyncMock(return_value=({}, True)), ), ): response = await teacher.teacher_dashboard(principal) list_all_sessions.assert_awaited_once_with(principal, include_turn_evaluation=True) self.assertEqual(response.total_learners, 1) self.assertEqual(len(response.learner_growth), 1) growth = response.learner_growth[0] self.assertEqual(growth.learner_label, "김민수") self.assertEqual(growth.sessions, 2) self.assertEqual(growth.ended_sessions, 2) self.assertEqual(growth.first_score, 0.5) self.assertEqual(growth.latest_score, 1.0) self.assertEqual(growth.score_delta, 0.5) self.assertEqual(growth.trend, "up") self.assertEqual(growth.top_techniques, ["reflection"]) self.assertEqual([point.session_no for point in growth.points], [1, 2]) self.assertEqual(response.recent_sessions[0].learner_label, "김민수") self.assertEqual(len(response.pending_reviews), 2) async def test_dashboard_does_not_truncate_student_analysis_list(self) -> None: sessions = [ _session( session_id=f"many-learners-session-{idx:02d}", session_no=1, learner_id=f"many-learner-{idx:02d}", learner_label=f"학생 {idx:02d}", score="pos", rapport=0.2, technique="reflection", created_at=1_000.0 + idx, ) for idx in range(13) ] principal = _principal() with ( patch.object( teacher.session_persistence, "list_all_sessions", AsyncMock(return_value=(sessions, True)), ), patch.object( teacher.session_persistence, "list_safety_alerts", AsyncMock(return_value=([], True)), ), patch.object( teacher.session_persistence, "list_session_review_statuses", AsyncMock(return_value=({}, True)), ), patch.object( teacher.session_persistence, "list_session_evaluations", AsyncMock(return_value=({}, True)), ), ): response = await teacher.teacher_dashboard(principal) self.assertEqual(response.total_learners, 13) self.assertEqual(len(response.learner_growth), 13) self.assertEqual( {item.learner_id for item in response.learner_growth}, {f"many-learner-{idx:02d}" for idx in range(13)}, ) async def test_dashboard_excludes_closed_session_reviews_from_pending_queue(self) -> None: learner_id = "00000000-0000-0000-0000-000000000111" open_session = _session( session_id="00000000-0000-0000-0000-00000000b111", session_no=1, learner_id=learner_id, score="neutral", rapport=0.1, technique="reflection", created_at=1_000.0, ) closed_session = _session( session_id="00000000-0000-0000-0000-00000000b112", session_no=2, learner_id=learner_id, score="pos", rapport=0.5, technique="reflection", created_at=2_000.0, ) principal = _principal() with ( patch.object( teacher.session_persistence, "list_all_sessions", AsyncMock(return_value=([open_session, closed_session], True)), ), patch.object( teacher.session_persistence, "list_safety_alerts", AsyncMock(return_value=([], True)), ), patch.object( teacher.session_persistence, "list_session_review_statuses", AsyncMock( return_value=( { closed_session.session_id: { "session_id": closed_session.session_id, "status": "closed", "note": "확인 완료", "reviewed_at": "2026-06-27T10:00:00Z", } }, True, ) ), ), patch.object( teacher.session_persistence, "list_session_evaluations", AsyncMock(return_value=({}, True)), ), ): response = await teacher.teacher_dashboard(principal) self.assertEqual([item.session_id for item in response.pending_reviews], [open_session.session_id]) closed_summary = next( item for item in response.recent_sessions if item.session_id == closed_session.session_id ) self.assertEqual(closed_summary.review_status, "closed") async def test_dashboard_excludes_empty_ended_sessions_from_pending_queue(self) -> None: principal = _principal() empty_session = _session( session_id="00000000-0000-0000-0000-00000000b411", session_no=1, learner_id="00000000-0000-0000-0000-000000000333", learner_label="하린", score="neutral", rapport=0.1, technique="reflection", created_at=1_000.0, with_turns=False, ) reviewable_session = _session( session_id="00000000-0000-0000-0000-00000000b412", session_no=2, learner_id="00000000-0000-0000-0000-000000000333", learner_label="하린", score="pos", rapport=0.5, technique="reflection", created_at=2_000.0, ) with ( patch.object( teacher.session_persistence, "list_all_sessions", AsyncMock(return_value=([empty_session, reviewable_session], True)), ), patch.object( teacher.session_persistence, "list_safety_alerts", AsyncMock(return_value=([], True)), ), patch.object( teacher.session_persistence, "list_session_review_statuses", AsyncMock(return_value=({}, True)), ), patch.object( teacher.session_persistence, "list_session_evaluations", AsyncMock(return_value=({}, True)), ), ): response = await teacher.teacher_dashboard(principal) self.assertEqual([item.session_id for item in response.pending_reviews], [reviewable_session.session_id]) empty_summary = next( item for item in response.recent_sessions if item.session_id == empty_session.session_id ) self.assertEqual(empty_summary.turn_count, 0) self.assertEqual(empty_summary.supervisor_state, "기록 대기") self.assertFalse(empty_summary.review_ready) async def test_dashboard_surfaces_session_evaluation_failure(self) -> None: principal = _principal() sess = _session( session_id="00000000-0000-0000-0000-00000000b311", session_no=6, learner_id="00000000-0000-0000-0000-000000000333", learner_label="하린", score="neutral", rapport=0.1, technique="reflection", created_at=1_000.0, ) evaluation_record = { "status": "error", "source": "engine", "scope": "session_end", "stage": "정리", "payload": {}, "error": "session evaluation timeout after 45s", "updated_at": "2026-07-01T00:00:00Z", } with ( patch.object( teacher.session_persistence, "list_all_sessions", AsyncMock(return_value=([sess], True)), ), patch.object( teacher.session_persistence, "list_safety_alerts", AsyncMock(return_value=([], True)), ), patch.object( teacher.session_persistence, "list_session_review_statuses", AsyncMock(return_value=({}, True)), ), patch.object( teacher.session_persistence, "list_session_evaluations", AsyncMock(return_value=({sess.session_id: evaluation_record}, True)), ), ): response = await teacher.teacher_dashboard(principal) failed = response.pending_reviews[0] self.assertEqual(failed.learner_label, "하린") self.assertEqual(failed.evaluation_status, "error") self.assertFalse(failed.review_ready) self.assertEqual(failed.supervisor_state, "평가 실패") self.assertEqual( failed.evaluation_error, "AI 평가가 제한 시간 안에 끝나지 않았습니다. 다시 시도할 수 있습니다.", ) self.assertNotIn("session evaluation timeout after 45s", response.model_dump_json()) async def test_dashboard_marks_stale_missing_session_evaluation_as_failed(self) -> None: principal = _principal() sess = _session( session_id="00000000-0000-0000-0000-00000000b411", session_no=7, learner_id="00000000-0000-0000-0000-000000000333", learner_label="하린", score="neutral", rapport=0.1, technique="reflection", created_at=1_000.0, ) with ( patch.object( teacher.session_persistence, "list_all_sessions", AsyncMock(return_value=([sess], True)), ), patch.object( teacher.session_persistence, "list_safety_alerts", AsyncMock(return_value=([], True)), ), patch.object( teacher.session_persistence, "list_session_review_statuses", AsyncMock(return_value=({}, True)), ), patch.object( teacher.session_persistence, "list_session_evaluations", AsyncMock(return_value=({}, True)), ), ): response = await teacher.teacher_dashboard(principal) failed = response.pending_reviews[0] self.assertEqual(failed.learner_label, "하린") self.assertEqual(failed.evaluation_status, "error") self.assertFalse(failed.review_ready) self.assertEqual(failed.supervisor_state, "평가 실패") self.assertEqual( failed.evaluation_error, "회기말 평가 기록이 아직 저장되지 않았습니다. 다시 시도할 수 있습니다.", ) async def test_dashboard_fails_closed_when_safety_alerts_are_not_durable_outside_dev(self) -> None: principal = _principal() sess = _session( session_id="00000000-0000-0000-0000-00000000b211", session_no=1, learner_id="00000000-0000-0000-0000-000000000333", score="neutral", rapport=0.1, technique="reflection", created_at=1_000.0, ) previous_environment = settings.environment settings.environment = "staging" try: with ( patch.object( teacher.session_persistence, "list_all_sessions", AsyncMock(return_value=([sess], True)), ), patch.object( teacher.session_persistence, "list_safety_alerts", AsyncMock(return_value=([], False)), ), patch.object( teacher.session_persistence, "list_session_review_statuses", AsyncMock(return_value=({}, True)), ), patch.object( teacher.session_persistence, "list_session_evaluations", AsyncMock(return_value=({}, True)), ), ): with self.assertRaises(HTTPException) as raised: await teacher.teacher_dashboard(principal) finally: settings.environment = previous_environment self.assertEqual(raised.exception.status_code, 503) self.assertIn("safety alert list persistence unavailable", raised.exception.detail) async def test_teacher_can_mark_session_review_closed_with_note(self) -> None: principal = _principal() sess = _session( session_id="00000000-0000-0000-0000-00000000c111", session_no=1, learner_id="00000000-0000-0000-0000-000000000222", score="pos", rapport=0.5, technique="reflection", created_at=3_000.0, ) with ( patch.object( teacher.session_persistence, "load_session", AsyncMock(return_value=sess), ), patch.object( teacher.session_persistence, "load_session_evaluation", AsyncMock(return_value=({"status": "ready"}, True)), ), patch.object( teacher.session_persistence, "save_session_review_status", AsyncMock( return_value=( { "session_id": sess.session_id, "reviewer_id": principal.user_id, "status": "closed", "note": "다음 회기에서 반영 질문을 늘리도록 지도", "worksheet_status": "changes_requested", "worksheet_note": "보호요인 근거를 한 줄 더 보강", "worksheet_reviewed_at": "2026-06-27T10:05:00Z", "reviewed_at": "2026-06-27T10:00:00Z", "updated_at": "2026-06-27T10:00:00Z", }, True, ) ), ) as save_status, ): response = await teacher.update_session_review_status( sess.session_id, teacher.TeacherSessionReviewStatusRequest( status="closed", note="다음 회기에서 반영 질문을 늘리도록 지도", worksheet_status="changes_requested", worksheet_note="보호요인 근거를 한 줄 더 보강", ), principal, ) save_status.assert_awaited_once() save_kwargs = save_status.await_args.kwargs self.assertEqual(save_kwargs["worksheet_status"], "changes_requested") self.assertIn("보호요인", save_kwargs["worksheet_note"]) self.assertEqual(response.status, "closed") self.assertIn("반영 질문", response.note) self.assertEqual(response.worksheet_status, "changes_requested") self.assertIn("보호요인", response.worksheet_note) self.assertEqual(response.worksheet_reviewed_at, "2026-06-27T10:05:00Z") async def test_teacher_cannot_close_review_before_ai_session_evaluation_ready(self) -> None: principal = _principal() sess = _session( session_id="00000000-0000-0000-0000-00000000c211", session_no=2, learner_id="00000000-0000-0000-0000-000000000222", score="pos", rapport=0.5, technique="reflection", created_at=3_000.0, ) with ( patch.object( teacher.session_persistence, "load_session", AsyncMock(return_value=sess), ), patch.object( teacher.session_persistence, "load_session_evaluation", AsyncMock(return_value=({"status": "error", "error": "timeout"}, True)), ), patch.object( teacher.session_persistence, "save_session_review_status", AsyncMock(), ) as save_status, ): with self.assertRaises(HTTPException) as raised: await teacher.update_session_review_status( sess.session_id, teacher.TeacherSessionReviewStatusRequest(status="closed", note="완료"), principal, ) self.assertEqual(raised.exception.status_code, 409) self.assertIn("session evaluation must be ready", raised.exception.detail) save_status.assert_not_awaited() async def test_learner_analysis_returns_full_ordered_history(self) -> None: learner_id = "00000000-0000-0000-0000-000000000111" other_learner_id = "00000000-0000-0000-0000-000000000222" learner_sessions = [ _session( session_id=f"analysis-session-{idx}", session_no=idx, learner_id=learner_id, learner_label="이서연", score="pos" if idx >= 5 else "neutral", rapport=0.1 * idx, technique="reflection" if idx % 2 else "open question", created_at=1_000.0 + idx, ) for idx in range(1, 8) ] sessions = [ learner_sessions[6], _session( session_id="analysis-other-session", session_no=1, learner_id=other_learner_id, score="warn", rapport=-0.2, technique="advice", created_at=1_500.0, ), *learner_sessions[:6], ] principal = _principal() with ( patch.object( teacher.session_persistence, "list_all_sessions", AsyncMock(return_value=(sessions, True)), ) as list_all_sessions, patch.object( teacher.session_persistence, "list_session_review_statuses", AsyncMock( return_value=( { "analysis-session-2": { "session_id": "analysis-session-2", "status": "closed", "note": "확인 완료", "reviewed_at": "2026-06-27T10:00:00Z", } }, True, ) ), ), patch.object( teacher.session_persistence, "list_session_evaluations", AsyncMock(return_value=({}, True)), ), ): response = await teacher.learner_analysis(learner_id, principal) list_all_sessions.assert_awaited_once_with(principal, include_turn_evaluation=True) self.assertEqual(response.source, "database") self.assertEqual(response.learner_id, learner_id) self.assertEqual(response.learner_label, "이서연") self.assertEqual(response.summary.learner_label, "이서연") self.assertEqual(response.total_sessions, 7) self.assertEqual(response.ended_sessions, 7) self.assertEqual(response.pending_reviews, 6) self.assertEqual(response.closed_reviews, 1) self.assertEqual([session.session_no for session in response.sessions], [1, 2, 3, 4, 5, 6, 7]) self.assertTrue(all(session.learner_label == "이서연" for session in response.sessions)) self.assertEqual([point.session_no for point in response.points], [1, 2, 3, 4, 5, 6, 7]) self.assertEqual(response.sessions[1].review_status, "closed") explore = next(item for item in response.stage_breakdown if item.stage == "탐색") self.assertEqual(explore.sessions, 7) self.assertEqual(explore.turns, 14) async def test_learner_analysis_returns_404_for_out_of_scope_learner(self) -> None: principal = _principal() with patch.object( teacher.session_persistence, "list_all_sessions", AsyncMock(return_value=([], True)), ): with self.assertRaises(HTTPException) as raised: await teacher.learner_analysis("missing-learner", principal) self.assertEqual(raised.exception.status_code, 404) if __name__ == "__main__": unittest.main()