vignette/apps/api/app/test_teacher_dashboard.py
Yun Chan a0311c5957
Some checks failed
API contract / OpenAPI type drift (push) Failing after 3m27s
회기 무발화 0턴 분리, 자기예측 락 불변식 및 TDD 회귀 검증 완료
2026-09-08 23:28:06 +09:00

721 lines
26 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
class _ListSessionsConn:
def __init__(self) -> None:
self.query = ""
self.args: tuple[object, ...] = ()
async def fetch(self, query: str, *args: object) -> list[object]:
self.query = query
self.args = args
return []
async def execute(self, _query: str, *args: object) -> None:
return None
class _AcquireCtx:
def __init__(self, conn: _ListSessionsConn) -> None:
self.conn = conn
async def __aenter__(self) -> _ListSessionsConn:
return self.conn
async def __aexit__(self, exc_type, exc, tb) -> bool:
return False
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,
learner_label: str | None = None,
with_turns: bool = True,
) -> 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,
learner_label=learner_label,
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="내담자 응답",
),
]
if with_turns
else [],
)
class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
async def test_list_all_sessions_has_no_recent_limit_for_teacher_summaries(self) -> None:
principal = _principal()
conn = _ListSessionsConn()
with (
patch.object(teacher.session_persistence, "get_pool"),
patch.object(
teacher.session_persistence,
"acquire",
return_value=_AcquireCtx(conn),
),
):
sessions, durable = await teacher.session_persistence.list_all_sessions(principal)
self.assertEqual(sessions, [])
self.assertTrue(durable)
self.assertNotIn("LIMIT", conn.query)
self.assertEqual(conn.args, ())
async def test_list_recent_sessions_keeps_recent_limit_for_general_lists(self) -> None:
principal = _principal()
conn = _ListSessionsConn()
with (
patch.object(teacher.session_persistence, "get_pool"),
patch.object(
teacher.session_persistence,
"acquire",
return_value=_AcquireCtx(conn),
),
):
sessions, durable = await teacher.session_persistence.list_recent_sessions(principal)
self.assertEqual(sessions, [])
self.assertTrue(durable)
self.assertIn("LIMIT $1", conn.query)
self.assertEqual(conn.args, (100,))
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,
learner_label="김민수",
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,
learner_label="김민수",
score="pos",
rapport=0.5,
technique="reflection",
created_at=2_000.0,
),
]
principal = _principal()
with (
patch.object(
teacher.session_persistence,
"list_all_sessions",
AsyncMock(return_value=(sessions, True)),
) as list_all_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)),
),
patch.object(
teacher.session_evaluation_repository,
"list_session_evaluations",
AsyncMock(return_value=({}, True)),
),
):
response = await teacher.teacher_dashboard(principal)
list_all_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.learner_label, "김민수")
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(response.recent_sessions[0].learner_label, "김민수")
self.assertEqual(len(response.pending_reviews), 2)
async def test_dashboard_does_not_truncate_student_analysis_list(self) -> None:
sessions = [
_session(
session_id=f"many-learners-session-{idx:02d}",
session_no=1,
learner_id=f"many-learner-{idx:02d}",
learner_label=f"학생 {idx:02d}",
score="pos",
rapport=0.2,
technique="reflection",
created_at=1_000.0 + idx,
)
for idx in range(13)
]
principal = _principal()
with (
patch.object(
teacher.session_persistence,
"list_all_sessions",
AsyncMock(return_value=(sessions, 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.total_learners, 13)
self.assertEqual(len(response.learner_growth), 13)
self.assertEqual(
{item.learner_id for item in response.learner_growth},
{f"many-learner-{idx:02d}" for idx in range(13)},
)
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_all_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,
)
),
),
patch.object(
teacher.session_evaluation_repository,
"list_session_evaluations",
AsyncMock(return_value=({}, 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_excludes_empty_ended_sessions_from_pending_queue(self) -> None:
principal = _principal()
empty_session = _session(
session_id="00000000-0000-0000-0000-00000000b411",
session_no=1,
learner_id="00000000-0000-0000-0000-000000000333",
learner_label="하린",
score="neutral",
rapport=0.1,
technique="reflection",
created_at=1_000.0,
with_turns=False,
)
reviewable_session = _session(
session_id="00000000-0000-0000-0000-00000000b412",
session_no=2,
learner_id="00000000-0000-0000-0000-000000000333",
learner_label="하린",
score="pos",
rapport=0.5,
technique="reflection",
created_at=2_000.0,
)
with (
patch.object(
teacher.session_persistence,
"list_all_sessions",
AsyncMock(return_value=([empty_session, reviewable_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=({}, True)),
),
patch.object(
teacher.session_evaluation_repository,
"list_session_evaluations",
AsyncMock(return_value=({}, True)),
),
):
response = await teacher.teacher_dashboard(principal)
self.assertEqual([item.session_id for item in response.pending_reviews], [reviewable_session.session_id])
empty_summary = next(
item for item in response.recent_sessions if item.session_id == empty_session.session_id
)
self.assertEqual(empty_summary.turn_count, 0)
self.assertEqual(empty_summary.supervisor_state, "기록 대기")
self.assertFalse(empty_summary.review_ready)
async def test_dashboard_surfaces_session_evaluation_failure(self) -> None:
principal = _principal()
sess = _session(
session_id="00000000-0000-0000-0000-00000000b311",
session_no=6,
learner_id="00000000-0000-0000-0000-000000000333",
learner_label="하린",
score="neutral",
rapport=0.1,
technique="reflection",
created_at=1_000.0,
)
evaluation_record = {
"status": "error",
"source": "engine",
"scope": "session_end",
"stage": "정리",
"payload": {},
"error": "session evaluation timeout after 45s",
"updated_at": "2026-07-01T00:00:00Z",
}
with (
patch.object(
teacher.session_persistence,
"list_all_sessions",
AsyncMock(return_value=([sess], 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=({sess.session_id: evaluation_record}, True)),
),
):
response = await teacher.teacher_dashboard(principal)
failed = response.pending_reviews[0]
self.assertEqual(failed.learner_label, "하린")
self.assertEqual(failed.evaluation_status, "error")
self.assertFalse(failed.review_ready)
self.assertEqual(failed.supervisor_state, "평가 실패")
self.assertEqual(
failed.evaluation_error,
"AI 평가가 제한 시간 안에 끝나지 않았습니다. 다시 시도할 수 있습니다.",
)
self.assertNotIn("session evaluation timeout after 45s", response.model_dump_json())
async def test_dashboard_marks_stale_missing_session_evaluation_as_failed(self) -> None:
principal = _principal()
sess = _session(
session_id="00000000-0000-0000-0000-00000000b411",
session_no=7,
learner_id="00000000-0000-0000-0000-000000000333",
learner_label="하린",
score="neutral",
rapport=0.1,
technique="reflection",
created_at=1_000.0,
)
with (
patch.object(
teacher.session_persistence,
"list_all_sessions",
AsyncMock(return_value=([sess], 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)
failed = response.pending_reviews[0]
self.assertEqual(failed.learner_label, "하린")
self.assertEqual(failed.evaluation_status, "error")
self.assertFalse(failed.review_ready)
self.assertEqual(failed.supervisor_state, "평가 실패")
self.assertEqual(
failed.evaluation_error,
"회기말 평가 기록이 아직 저장되지 않았습니다. 다시 시도할 수 있습니다.",
)
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_all_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)),
),
patch.object(
teacher.session_evaluation_repository,
"list_session_evaluations",
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_evaluation_repository,
"load_session_evaluation",
AsyncMock(return_value=({"status": "ready"}, True)),
),
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_teacher_cannot_close_review_before_ai_session_evaluation_ready(self) -> None:
principal = _principal()
sess = _session(
session_id="00000000-0000-0000-0000-00000000c211",
session_no=2,
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_evaluation_repository,
"load_session_evaluation",
AsyncMock(return_value=({"status": "error", "error": "timeout"}, True)),
),
patch.object(
teacher.session_persistence,
"save_session_review_status",
AsyncMock(),
) as save_status,
):
with self.assertRaises(HTTPException) as raised:
await teacher.update_session_review_status(
sess.session_id,
teacher.TeacherSessionReviewStatusRequest(status="closed", note="완료"),
principal,
)
self.assertEqual(raised.exception.status_code, 409)
self.assertIn("session evaluation must be ready", raised.exception.detail)
save_status.assert_not_awaited()
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,
learner_label="이서연",
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_all_sessions",
AsyncMock(return_value=(sessions, True)),
) as list_all_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,
)
),
),
patch.object(
teacher.session_evaluation_repository,
"list_session_evaluations",
AsyncMock(return_value=({}, True)),
),
):
response = await teacher.learner_analysis(learner_id, principal)
list_all_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.learner_label, "이서연")
self.assertEqual(response.summary.learner_label, "이서연")
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.assertTrue(all(session.learner_label == "이서연" for session in response.sessions))
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_all_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()