"""교수자 대시보드 집계 테스트.""" 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 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, ) -> 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, 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="내담자 응답", ), ], ) class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase): 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, 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, score="pos", rapport=0.5, technique="reflection", created_at=2_000.0, ), ] principal = _principal() with ( patch.object( teacher.session_persistence, "list_sessions", AsyncMock(return_value=(sessions, True)), ) as list_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)), ), ): response = await teacher.teacher_dashboard(principal) list_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.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(len(response.pending_reviews), 2) 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_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, ) ), ), ): 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_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_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)), ), ): 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, "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_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, 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_sessions", AsyncMock(return_value=(sessions, True)), ) as list_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, ) ), ), ): response = await teacher.learner_analysis(learner_id, principal) list_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.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.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_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()