"""교수자 대시보드 집계 테스트.""" from __future__ import annotations import unittest from unittest.mock import AsyncMock, patch from fastapi import HTTPException 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_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()