from __future__ import annotations import unittest from unittest.mock import AsyncMock, patch from uuid import uuid4 from app.deps import Principal, Role from app.services import session_metrics, state_machine from app.services.persona import P1, P2 from app.session_dashboard_projection import ( dashboard_overview, dashboard_persona_progress, dashboard_training_exposure, ) from app.store import InProcSession, TurnRecord def _make_session( session_id: str, session_no: int, learner_id: str, persona_code: str = "P1", ended: bool = True, turns: list[TurnRecord] | None = None, ) -> InProcSession: persona = {P1.code: P1, P2.code: P2}.get(persona_code, P1) state = state_machine.init_state(params=persona.openness_params()) state.stage = state_machine.Stage.EXPLORE sess = InProcSession( session_id=session_id, case_id=session_id, learner_id=learner_id, persona_code=persona.code, theory_mode="humanistic", persona=persona, state=state, session_no=session_no, created_at=1000.0 + session_no * 10.0, ended_at=1000.0 + session_no * 10.0 + 600 if ended else None, ended=ended, turns=[], ) if turns is not None: sess.turns = turns else: sess.turns = [ TurnRecord( turn_seq=1, speaker="counselor", stage=state.stage.value, text="어떤 점이 힘드신가요?", text_masked="어떤 점이 힘드신가요?", evaluation={ "appropriateness": "pos", "appropriateness_note": "공감적 반응", "rapport_signal": 0.6, "techniques": [{"label": "reflection"}], }, created_at=1000.0 + session_no * 10.0 + 5.0, ) ] return sess class DashboardProjectionTDDTests(unittest.TestCase): """Multi-angle TDD tests for metrics projection invariants (REQ-010).""" def test_learner_growth_metrics_excludes_zero_turn_from_ended_sessions(self) -> None: """LearnerGrowthMetrics.ended_sessions must exclude zero-turn sessions.""" learner_id = "00000000-0000-0000-0000-000000000111" # Session 1: completed with turns sess1 = _make_session("sess-1", 1, learner_id, ended=True) # Session 2: zero-turn ended session sess2 = _make_session("sess-2", 2, learner_id, ended=True, turns=[]) # Session 3: active session sess3 = _make_session("sess-3", 3, learner_id, ended=False) growth_list = session_metrics.build_learner_growth( [sess1, sess2, sess3], learner_label=lambda _uid: "홍길동", ) self.assertEqual(len(growth_list), 1) growth = growth_list[0] self.assertEqual(growth.sessions, 3) # Invariant: ended_sessions must equal completed sessions with dialogue (1), not 2 self.assertEqual( growth.ended_sessions, 1, "ended_sessions in LearnerGrowthMetrics must not count 0-turn sessions", ) def test_training_exposure_multi_persona_isolation(self) -> None: """Zero-turn sessions must not inflate exposure count across multiple personas.""" learner_id = "00000000-0000-0000-0000-000000000111" # P1: 1 completed session, 1 zero-turn ended session p1_completed = _make_session("sess-p1-1", 1, learner_id, persona_code="P1", ended=True) p1_zero = _make_session("sess-p1-2", 2, learner_id, persona_code="P1", ended=True, turns=[]) # P2: 1 completed session p2_completed = _make_session("sess-p2-1", 3, learner_id, persona_code="P2", ended=True) exposure = dashboard_training_exposure([p1_completed, p1_zero, p2_completed]) self.assertEqual(exposure.completed_sessions, 2) # Persona progress table check progress_rows = dashboard_persona_progress( [p1_completed, p1_zero, p2_completed], review_ready={}, ) p1_row = next(r for r in progress_rows if r.persona_code == "P1") p2_row = next(r for r in progress_rows if r.persona_code == "P2") self.assertEqual(p1_row.sessions, 2) self.assertEqual(p1_row.completed_sessions, 1) self.assertEqual(p2_row.sessions, 1) self.assertEqual(p2_row.completed_sessions, 1) class TeacherDashboardProjectionTDDTests(unittest.IsolatedAsyncioTestCase): """Teacher dashboard projection alignment for zero-turn sessions.""" async def test_teacher_dashboard_excludes_zero_turn_from_ended_sessions(self) -> None: """Teacher dashboard top-level ended_sessions must exclude zero-turn ended sessions.""" from app.routes import teacher principal = Principal( user_id="teacher-1", role=Role.TEACHER, cohort_ids=["cohort-1"], ) learner_id = "learner-1" sess_normal = _make_session("sess-1", 1, learner_id, ended=True) sess_zero = _make_session("sess-2", 2, learner_id, ended=True, turns=[]) with ( patch.object( teacher.session_persistence, "list_all_sessions", AsyncMock(return_value=([sess_normal, sess_zero], 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_evaluation_repository, "list_session_evaluations", AsyncMock(return_value=({}, True)), ), ): response = await teacher.teacher_dashboard(principal) self.assertEqual( response.ended_sessions, 1, "Teacher dashboard ended_sessions must exclude 0-turn ended sessions", ) self.assertEqual( response.learner_growth[0].ended_sessions, 1, "Learner growth in teacher dashboard must exclude 0-turn ended sessions", ) async def test_teacher_learner_analysis_excludes_zero_turn_from_ended_sessions(self) -> None: """Teacher learner analysis ended_sessions must exclude zero-turn ended sessions.""" from app.routes import teacher principal = Principal( user_id="teacher-1", role=Role.TEACHER, cohort_ids=["cohort-1"], ) learner_id = "learner-1" sess_normal = _make_session("sess-1", 1, learner_id, ended=True) sess_zero = _make_session("sess-2", 2, learner_id, ended=True, turns=[]) with ( patch.object( teacher.session_persistence, "list_all_sessions", AsyncMock(return_value=([sess_normal, sess_zero], True)), ), patch.object( teacher.session_persistence, "list_session_review_statuses", AsyncMock(return_value=({}, True)), ), patch.object( teacher.session_evaluation_repository, "list_session_evaluations", AsyncMock(return_value=({}, True)), ), ): response = await teacher.learner_analysis(learner_id, principal) self.assertEqual( response.ended_sessions, 1, "Teacher learner analysis ended_sessions must exclude 0-turn ended sessions", ) self.assertEqual( response.summary.ended_sessions, 1, "Teacher learner analysis summary.ended_sessions must exclude 0-turn ended sessions", )