vignette/apps/api/app/test_teacher_dashboard.py
2026-07-01 12:34:46 +09:00

373 lines
14 KiB
Python

"""교수자 대시보드 집계 테스트."""
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()