"""학습자 개인 대시보드 집계 테스트.""" from __future__ import annotations import unittest from unittest.mock import AsyncMock, patch from .deps import Principal, Role from .routes import sessions from .services import state_machine from .services.persona import P1, P2 from .store import InProcSession, TurnRecord def _principal() -> Principal: return Principal( user_id="00000000-0000-0000-0000-000000000501", role=Role.LEARNER, ) def _session( *, session_id: str, session_no: int, persona_code: str, score: str, rapport: float, technique: str, created_at: float, ended: bool = True, ) -> InProcSession: persona = P2 if persona_code == P2.code else P1 state = state_machine.init_state(params=persona.openness_params()) state.stage = state_machine.Stage.EXPLORE return InProcSession( session_id=session_id, case_id=session_id, learner_id=_principal().user_id, persona_code=persona.code, theory_mode="humanistic", persona=persona, state=state, session_no=session_no, created_at=created_at, ended_at=created_at + 600 if ended else None, ended=ended, turns=[ TurnRecord( turn_seq=1, speaker="counselor", stage=state.stage.value, text="상담자 발화", text_masked="상담자 발화", evaluation={ "appropriateness": score, "appropriateness_note": f"{technique} 근거 기반 피드백", "rapport_signal": rapport, "techniques": [{"label": technique}], }, ), TurnRecord( turn_seq=2, speaker="client", stage=state.stage.value, text="내담자 응답", text_masked="내담자 응답", ), ], ) class LearnerDashboardTest(unittest.IsolatedAsyncioTestCase): async def test_dashboard_returns_personalized_growth_from_owned_sessions(self) -> None: principal = _principal() owned_sessions = [ _session( session_id="00000000-0000-0000-0000-00000000b111", session_no=1, persona_code=P1.code, score="neutral", rapport=0.1, technique="reflection", created_at=1_000.0, ), _session( session_id="00000000-0000-0000-0000-00000000b112", session_no=2, persona_code=P1.code, score="pos", rapport=0.5, technique="reflection", created_at=2_000.0, ended=False, ), _session( session_id="00000000-0000-0000-0000-00000000b113", session_no=3, persona_code=P2.code, score="warn", rapport=-0.2, technique="open question", created_at=3_000.0, ), ] async def load_review(session_id: str, _principal_arg: Principal): status = "ready" if session_id.endswith("b111") or session_id.endswith("b113") else None if status is None: return None, True return {"status": status}, True with ( patch.object( sessions.session_persistence, "list_recent_sessions", AsyncMock(return_value=(owned_sessions, True)), ) as list_recent_sessions, patch.object( sessions.session_persistence, "load_session_evaluation", AsyncMock(side_effect=load_review), ), patch.object( sessions.session_persistence, "list_session_archives", AsyncMock(return_value=({}, True)), ) as list_session_archives, ): response = await sessions.learner_dashboard(principal) list_recent_sessions.assert_awaited_once_with(principal, include_turn_evaluation=True) list_session_archives.assert_awaited_once_with( [sess.session_id for sess in owned_sessions], principal, ) self.assertEqual(response.source, "database") self.assertEqual(response.overview.total_sessions, 3) self.assertEqual(response.overview.completed_sessions, 2) self.assertEqual(response.overview.active_sessions, 1) self.assertEqual(response.overview.review_ready_sessions, 2) self.assertEqual(response.overview.archived_sessions, 0) self.assertEqual(response.overview.learner_turns, 3) self.assertEqual(response.overview.client_turns, 3) self.assertEqual(response.growth.first_score, 0.5) self.assertEqual(response.growth.latest_score, 0.25) self.assertEqual(response.growth.trend, "down") self.assertEqual(response.growth.evaluated_sessions, 3) self.assertEqual(response.growth.top_techniques, ["reflection", "open question"]) self.assertEqual([row.persona_code for row in response.persona_progress], [P2.code, P1.code]) self.assertEqual(response.persona_progress[0].review_ready_sessions, 1) self.assertEqual(response.persona_progress[1].sessions, 2) self.assertEqual(response.achievements[0].state, "done") self.assertEqual(response.achievements[1].state, "done") self.assertEqual(len(response.recent_feedback), 3) self.assertIn("근거 기반 피드백", response.recent_feedback[0].note) async def test_dashboard_excludes_failed_fast_loop_evaluations_from_growth_and_feedback(self) -> None: principal = _principal() good_session = _session( session_id="00000000-0000-0000-0000-00000000b211", session_no=1, persona_code=P1.code, score="pos", rapport=0.5, technique="reflection", created_at=1_000.0, ) failed_session = _session( session_id="00000000-0000-0000-0000-00000000b212", session_no=2, persona_code=P1.code, score="neutral", rapport=0.2, technique="failed evaluator", created_at=2_000.0, ) failed_eval = failed_session.turns[0].evaluation assert failed_eval is not None failed_eval["error"] = "engine_error" failed_eval["appropriateness_note"] = "이 문장은 최근 피드백에 노출되면 안 됩니다." failed_eval["techniques"] = [] with ( patch.object( sessions.session_persistence, "list_recent_sessions", AsyncMock(return_value=([good_session, failed_session], True)), ), patch.object( sessions.session_persistence, "load_session_evaluation", AsyncMock(return_value=({"status": "ready"}, True)), ), patch.object( sessions.session_persistence, "list_session_archives", AsyncMock(return_value=({}, True)), ), ): response = await sessions.learner_dashboard(principal) self.assertEqual(response.growth.evaluated_sessions, 1) self.assertEqual(response.growth.first_score, 1.0) self.assertEqual(response.growth.latest_score, 1.0) self.assertEqual(response.growth.avg_score, 1.0) self.assertEqual(len(response.recent_feedback), 1) self.assertIn("reflection 근거 기반 피드백", response.recent_feedback[0].note) self.assertNotIn("노출되면 안 됩니다", response.model_dump_json()) async def test_session_list_marks_archived_rows_from_learner_archive_state(self) -> None: principal = _principal() owned_sessions = [ _session( session_id="00000000-0000-0000-0000-00000000c111", session_no=1, persona_code=P1.code, score="neutral", rapport=0.1, technique="reflection", created_at=1_000.0, ), _session( session_id="00000000-0000-0000-0000-00000000c112", session_no=2, persona_code=P2.code, score="pos", rapport=0.4, technique="summarizing", created_at=2_000.0, ), ] archived_at = 4_000.0 archive_records = { owned_sessions[1].session_id: { "session_id": owned_sessions[1].session_id, "learner_id": principal.user_id, "archived_at": archived_at, "updated_at": archived_at, } } with ( patch.object( sessions.session_persistence, "list_recent_sessions", AsyncMock(return_value=(owned_sessions, True)), ) as list_recent_sessions, patch.object( sessions.session_persistence, "list_session_archives", AsyncMock(return_value=(archive_records, True)), ) as list_session_archives, patch.object(sessions, "_review_ready", AsyncMock(return_value=False)), ): response = await sessions.list_learner_sessions(principal) list_recent_sessions.assert_awaited_once_with(principal) list_session_archives.assert_awaited_once_with( [sess.session_id for sess in owned_sessions], principal, ) self.assertEqual(response.source, "database") self.assertEqual([item.session_id for item in response.sessions], [sess.session_id for sess in owned_sessions]) self.assertEqual([item.archived for item in response.sessions], [False, True]) self.assertIsNone(response.sessions[0].archived_at) self.assertEqual(response.sessions[1].archived_at, sessions._iso(archived_at)) async def test_dashboard_counts_archived_sessions_outside_review_ready_queue(self) -> None: principal = _principal() owned_sessions = [ _session( session_id="00000000-0000-0000-0000-00000000d111", session_no=1, persona_code=P1.code, score="neutral", rapport=0.1, technique="reflection", created_at=1_000.0, ), _session( session_id="00000000-0000-0000-0000-00000000d112", session_no=2, persona_code=P1.code, score="pos", rapport=0.5, technique="reflection", created_at=2_000.0, ended=False, ), _session( session_id="00000000-0000-0000-0000-00000000d113", session_no=3, persona_code=P2.code, score="warn", rapport=-0.2, technique="open question", created_at=3_000.0, ), ] archive_records = { owned_sessions[2].session_id: { "session_id": owned_sessions[2].session_id, "learner_id": principal.user_id, "archived_at": 4_000.0, "updated_at": 4_000.0, } } async def load_review(session_id: str, _principal_arg: Principal): status = "ready" if session_id.endswith("d111") or session_id.endswith("d113") else None if status is None: return None, True return {"status": status}, True with ( patch.object( sessions.session_persistence, "list_recent_sessions", AsyncMock(return_value=(owned_sessions, True)), ), patch.object( sessions.session_persistence, "list_session_archives", AsyncMock(return_value=(archive_records, True)), ) as list_session_archives, patch.object( sessions.session_persistence, "load_session_evaluation", AsyncMock(side_effect=load_review), ), ): response = await sessions.learner_dashboard(principal) list_session_archives.assert_awaited_once_with( [sess.session_id for sess in owned_sessions], principal, ) self.assertEqual(response.overview.total_sessions, 3) self.assertEqual(response.overview.archived_sessions, 1) self.assertEqual(response.overview.review_ready_sessions, 1) self.assertEqual(response.persona_progress[0].persona_code, P2.code) self.assertEqual(response.persona_progress[0].review_ready_sessions, 0) async def test_archive_session_uses_owned_load_and_sets_learner_archive_state(self) -> None: principal = _principal() sess = _session( session_id="00000000-0000-0000-0000-00000000e111", session_no=1, persona_code=P1.code, score="pos", rapport=0.3, technique="reflection", created_at=1_000.0, ) archived_at = 4_000.0 with ( patch.object( sessions.session_persistence, "load_session", AsyncMock(return_value=sess), ) as load_session, patch.object( sessions.session_persistence, "set_session_archived", AsyncMock( return_value=( { "session_id": sess.session_id, "learner_id": principal.user_id, "archived_at": archived_at, "updated_at": archived_at, }, True, ) ), ) as set_session_archived, patch.object( sessions.session_persistence, "load_session_evaluation", AsyncMock(return_value=({"status": "ready"}, True)), ), ): response = await sessions.archive_session(sess.session_id, principal) load_session.assert_awaited_once_with( sess.session_id, principal, allow_ended=True, include_turn_evaluation=False, ) set_session_archived.assert_awaited_once_with( session_id=sess.session_id, learner_id=principal.user_id, archived=True, ) self.assertEqual(response.source, "database") self.assertTrue(response.archived) self.assertEqual(response.archived_at, sessions._iso(archived_at)) self.assertTrue(response.session.archived) self.assertTrue(response.session.review_ready) async def test_restore_session_uses_owned_load_and_clears_learner_archive_state(self) -> None: principal = _principal() sess = _session( session_id="00000000-0000-0000-0000-00000000e112", session_no=1, persona_code=P2.code, score="pos", rapport=0.3, technique="reflection", created_at=1_000.0, ) with ( patch.object( sessions.session_persistence, "load_session", AsyncMock(return_value=sess), ) as load_session, patch.object( sessions.session_persistence, "set_session_archived", AsyncMock(return_value=(None, True)), ) as set_session_archived, patch.object( sessions.session_persistence, "load_session_evaluation", AsyncMock(return_value=(None, True)), ), ): response = await sessions.restore_archived_session(sess.session_id, principal) load_session.assert_awaited_once_with( sess.session_id, principal, allow_ended=True, include_turn_evaluation=False, ) set_session_archived.assert_awaited_once_with( session_id=sess.session_id, learner_id=principal.user_id, archived=False, ) self.assertEqual(response.source, "database") self.assertFalse(response.archived) self.assertIsNone(response.archived_at) self.assertFalse(response.session.archived) self.assertIsNone(response.session.archived_at) if __name__ == "__main__": unittest.main()