vignette/apps/api/app/test_evaluation_persistence.py
Yun Chan 778e8526d4 세션 평가·라이브코치·교수자 분석 라운드 마감 + 문서 정리 + 코드품질 리팩터
- 누적 작업트리 커밋: 회기 평가 복구·durable 저장, 라이브 코치 이력/근거, 교수자 학생분석, 음성 비언어 메타, PII 마스킹, 운영 티켓/헬스 등
- 문서: 완료 기록 docs/archive/ 냉동 보관, docs/ 단일 인덱스(docs/README.md)+통합 TODO(docs/TODO.md)로 정리
- 리팩터(행위 보존): Stage enum SSOT(taxonomy 소유·state_machine re-export), store recent/masked_turns 중복 제거, speaker_ko_label 단일 헬퍼, _list_sessions N+1 제거(state/turns 배치 + 턴평가 하이드레이션 배치)
- 검증: 백엔드 pytest 352 passed, _list_sessions E2E chromium-single-run 2 passed
2026-07-02 02:50:36 +09:00

690 lines
28 KiB
Python

"""Regression tests for turn-evaluation persistence mapping."""
from __future__ import annotations
from pathlib import Path
import json
import unittest
from unittest.mock import patch
from .deps import Principal, Role
from . import session_persistence
from .routes import sessions
from .services import evaluator, guardrail, session_metrics
from .services import persona as persona_service
from .services import state_machine
from .store import InProcSession
class FakeEvaluationConn:
def __init__(self) -> None:
self.executed: list[tuple[str, tuple[object, ...]]] = []
self.fetchvals: list[tuple[str, tuple[object, ...]]] = []
async def execute(self, query: str, *args: object) -> str:
self.executed.append((query, args))
return "INSERT 0 1"
async def fetchval(self, query: str, *args: object) -> int:
self.fetchvals.append((query, args))
if "app.technique_label_def" in query:
return 101
if "app.client_state_def" in query:
return 202
raise AssertionError(f"unexpected fetchval query: {query}")
class FakeMissingEvaluationConn:
def __init__(self) -> None:
self.fetches: list[tuple[str, tuple[object, ...]]] = []
self.fetchrows: list[tuple[str, tuple[object, ...]]] = []
async def fetch(self, query: str, *args: object) -> list[dict[str, object]]:
self.fetches.append((query, args))
if "FROM app.sessions s" in query:
return [{"id": "11111111-1111-1111-1111-111111111111"}]
if "FROM app.turns" in query:
return []
raise AssertionError(f"unexpected fetch query: {query}")
async def fetchrow(self, query: str, *args: object) -> dict[str, object]:
self.fetchrows.append((query, args))
return {}
class FakeAcquire:
def __init__(self, conn) -> None:
self.conn = conn
async def __aenter__(self) -> FakeEvaluationConn:
return self.conn
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
return None
class EvaluationPersistenceMappingTest(unittest.IsolatedAsyncioTestCase):
def test_session_metrics_prefers_rehydrated_technique_label_ko(self) -> None:
ev = {
"techniques": [
{"code": "empathy", "label_ko": "공감", "label": "legacy empathy"},
{"code": "open_question"},
]
}
self.assertEqual(session_metrics.turn_techniques(ev), ["공감", "open_question"])
def test_fast_evaluator_masks_client_reply_before_prompting(self) -> None:
card = persona_service.P1
state = state_machine.init_state(params=card.openness_params())
ctx = sessions.orchestrator.prepare_turn(
session_id="eval-mask-session",
case_id="eval-mask-case",
card=card,
state=state,
learner_text="오늘 상담에서 집중해 보겠습니다.",
)
messages = evaluator.build_fast_messages(
ctx,
"저는 김서연 씨고 한신대학교 상담심리학과 학생이에요.",
)
blob = "\n".join(message.content for message in messages)
self.assertNotIn("김서연", blob)
self.assertNotIn("한신대학교", blob)
self.assertNotIn("상담심리학과", blob)
self.assertIn("[NAME]", blob)
self.assertIn("[ORG]", blob)
def test_feedback_rows_preserve_review_scalar_contract(self) -> None:
evaluation = {
"loop": "fast",
"turn_seq": 2,
"stage": "탐색",
"appropriateness": "pos",
"appropriateness_note": "정서를 먼저 반영했다.",
"rapport_signal": 0.75,
"theory_mode": "humanistic",
"techniques": [
{
"code": "empathy",
"label_ko": "공감",
"category": "relational",
"rationale": "감정을 명시적으로 반영했다.",
}
],
"client_state_read": [
{
"code": "affect_contact",
"label_ko": "정서 접촉/표현",
"rationale": "내담자가 감정을 언급했다.",
}
],
}
rows = {
row["dimension"]: row
for row in session_persistence._evaluation_feedback_rows(evaluation)
}
self.assertEqual(rows["appropriateness"]["score"], 5.0)
self.assertEqual(rows["appropriateness"]["rationale"], "정서를 먼저 반영했다.")
self.assertEqual(rows["rapport_signal"]["score"], 0.75)
self.assertEqual(rows["theory_mode"]["rationale"], "humanistic")
self.assertEqual(rows["technique:empathy"]["rationale"], "감정을 명시적으로 반영했다.")
self.assertEqual(rows["client_state:affect_contact"]["rationale"], "내담자가 감정을 언급했다.")
def test_alternative_rows_accept_string_and_dict_shapes(self) -> None:
rows = session_persistence._evaluation_alternative_rows(
{
"alternative_utterances": [
"감정을 먼저 반영해 보세요.",
{"text": "조언 전에 의미를 확인해 보세요.", "rationale": "성급한 해결 방지"},
{"suggestion": "침묵을 허용해 보세요."},
{"rationale": "빈 제안은 저장하지 않음"},
]
}
)
self.assertEqual(
rows,
[
{"suggestion": "감정을 먼저 반영해 보세요.", "rationale": None},
{"suggestion": "조언 전에 의미를 확인해 보세요.", "rationale": "성급한 해결 방지"},
{"suggestion": "침묵을 허용해 보세요.", "rationale": None},
],
)
def test_turn_evaluation_rows_mask_pii_before_db_persistence(self) -> None:
class FakeKoRecognizer:
def analyze(self, text: str):
spans = []
for entity_type, value in (
("NAME", "보라별"),
("ORG", "미래학교상담연구랩"),
):
start = text.find(value)
if start >= 0:
spans.append(guardrail.PiiEntitySpan(entity_type, start, start + len(value)))
return spans
guardrail.set_ko_pii_recognizer(FakeKoRecognizer())
self.addCleanup(guardrail.set_ko_pii_recognizer, None)
evaluation = {
"appropriateness": "warn",
"appropriateness_note": "보라별에게 010-1234-5678을 되물었다.",
"techniques": [
{
"code": "reflection",
"rationale": "미래학교상담연구랩 이야기를 바로 조언했다.",
}
],
"client_state_read": [
{
"code": "avoidance",
"rationale": "보라별이 기관 미래학교상담연구랩을 피했다.",
}
],
"intent_deviation": {
"expected": "보라별의 감정을 확인",
"actual": "010-1234-5678 연락처를 재질문",
},
"alternative_utterances": [
{
"text": "보라별님, 미래학교상담연구랩 이야기는 잠시 미뤄도 괜찮아요.",
"rationale": "010-1234-5678 같은 연락처 재확인을 피함",
}
],
}
blob = json.dumps(
{
"feedback": session_persistence._evaluation_feedback_rows(evaluation),
"comments": session_persistence._evaluation_comment_rows(evaluation),
"alternatives": session_persistence._evaluation_alternative_rows(evaluation),
},
ensure_ascii=False,
)
for raw in ("보라별", "미래학교상담연구랩", "010-1234-5678"):
self.assertNotIn(raw, blob)
for masked in ("[NAME]", "[ORG]", "[PHONE]"):
self.assertIn(masked, blob)
def test_turn_from_legacy_row_remasks_raw_text_fallback(self) -> None:
class FakeKoRecognizer:
def analyze(self, text: str):
start = text.find("보라별")
if start < 0:
return []
return [guardrail.PiiEntitySpan("NAME", start, start + len("보라별"))]
guardrail.set_ko_pii_recognizer(FakeKoRecognizer())
self.addCleanup(guardrail.set_ko_pii_recognizer, None)
turn = session_persistence._turn_from_row(
{
"id": "11111111-1111-1111-1111-111111111111",
"seq": 2,
"speaker": "client",
"stage": "explore",
"text": "보라별의 전화는 010-1234-5678입니다.",
"text_masked": "",
"created_at": None,
"visible_to": ("client", "evaluator"),
}
)
self.assertEqual(turn.text, turn.text_masked)
self.assertNotIn("보라별", turn.text)
self.assertNotIn("010-1234-5678", turn.text)
self.assertIn("[NAME]", turn.text)
self.assertIn("[PHONE]", turn.text)
def test_session_evaluation_write_from_result_preserves_payload_shape(self) -> None:
result = evaluator.SessionEvaluation(
session_id="session-1",
stage="explore",
scope="session_end",
turns_evaluated=2,
)
write = session_persistence.SessionEvaluationWrite.from_result(
session_id="session-1",
learner_id="learner-1",
result=result,
)
self.assertEqual(write.status, "ready")
self.assertEqual(write.source, "engine")
self.assertEqual(write.scope, "session_end")
self.assertEqual(write.stage, "explore")
self.assertEqual(write.payload, result.to_dict())
self.assertNotIn("error", write.payload)
self.assertIsNone(write.error)
def test_session_evaluation_write_masks_deep_payload_before_storage(self) -> None:
class FakeKoRecognizer:
def analyze(self, text: str):
spans = []
for entity_type, value in (
("NAME", "보라별"),
("ORG", "미래학교상담연구랩"),
):
start = text.find(value)
if start >= 0:
spans.append(guardrail.PiiEntitySpan(entity_type, start, start + len(value)))
return spans
guardrail.set_ko_pii_recognizer(FakeKoRecognizer())
self.addCleanup(guardrail.set_ko_pii_recognizer, None)
result = evaluator.SessionEvaluation(
session_id="session-privacy",
stage="정리",
scope="session_end",
turns_evaluated=3,
strengths=["보라별의 감정을 반영했다."],
improvements=["010-1234-5678 같은 연락처 재확인은 피한다."],
intent_deviations=[
evaluator.IntentDeviation(
dimension="pacing",
expected="미래학교상담연구랩 이야기를 기다린다",
actual="보라별에게 바로 조언했다",
severity="moderate",
)
],
supervisor_rationale="보라별의 호소를 요약했다.",
supervisor_critique="미래학교상담연구랩과 010-1234-5678을 반복했다.",
alternative_utterances=["보라별님, 지금 감정부터 천천히 볼까요?"],
error="보라별 평가 경고",
)
write = session_persistence.SessionEvaluationWrite.from_result(
session_id="session-privacy",
learner_id="learner-privacy",
result=result,
)
blob = json.dumps({"payload": write.payload, "error": write.error}, ensure_ascii=False)
self.assertEqual(write.status, "error")
for raw in ("보라별", "미래학교상담연구랩", "010-1234-5678"):
self.assertNotIn(raw, blob)
for masked in ("[NAME]", "[ORG]", "[PHONE]"):
self.assertIn(masked, blob)
def test_session_evaluation_write_from_error_preserves_fallback_shape(self) -> None:
write = session_persistence.SessionEvaluationWrite.from_error(
session_id="session-1",
learner_id="learner-1",
scope="session_end",
stage="explore",
error=RuntimeError("engine timeout"),
)
self.assertEqual(write.status, "error")
self.assertEqual(write.source, "engine")
self.assertEqual(write.scope, "session_end")
self.assertEqual(write.stage, "explore")
self.assertEqual(write.payload, {})
self.assertEqual(write.error, "engine timeout")
def test_session_evaluation_write_from_error_names_empty_exception(self) -> None:
write = session_persistence.SessionEvaluationWrite.from_error(
session_id="session-1",
learner_id="learner-1",
scope="session_end",
stage="explore",
error=TimeoutError(),
)
self.assertEqual(write.error, "TimeoutError")
def test_session_evaluation_write_from_error_masks_error_detail(self) -> None:
class FakeKoRecognizer:
def analyze(self, text: str):
start = text.find("보라별")
if start < 0:
return []
return [guardrail.PiiEntitySpan("NAME", start, start + len("보라별"))]
guardrail.set_ko_pii_recognizer(FakeKoRecognizer())
self.addCleanup(guardrail.set_ko_pii_recognizer, None)
write = session_persistence.SessionEvaluationWrite.from_error(
session_id="session-1",
learner_id="learner-1",
scope="session_end",
stage="explore",
error="보라별 처리 중 010-1234-5678 오류",
)
self.assertNotIn("보라별", write.error or "")
self.assertNotIn("010-1234-5678", write.error or "")
self.assertIn("[NAME]", write.error or "")
self.assertIn("[PHONE]", write.error or "")
async def test_save_session_evaluation_remasks_direct_write_payload(self) -> None:
class FakeKoRecognizer:
def analyze(self, text: str):
start = text.find("보라별")
if start < 0:
return []
return [guardrail.PiiEntitySpan("NAME", start, start + len("보라별"))]
guardrail.set_ko_pii_recognizer(FakeKoRecognizer())
self.addCleanup(guardrail.set_ko_pii_recognizer, None)
conn = FakeEvaluationConn()
write = session_persistence.SessionEvaluationWrite(
session_id="11111111-1111-1111-1111-111111111111",
learner_id="22222222-2222-2222-2222-222222222222",
status="ready",
source="engine",
scope="session_end",
stage="정리",
payload={"strengths": ["보라별의 전화 010-1234-5678을 반복했다."]},
error="보라별 direct write error",
)
with (
patch.object(session_persistence, "get_pool", return_value=object()),
patch.object(session_persistence, "acquire", return_value=FakeAcquire(conn)),
):
saved = await session_persistence.save_session_evaluation(write)
self.assertTrue(saved)
_, args = conn.executed[0]
blob = json.dumps({"payload": args[5], "error": args[6]}, ensure_ascii=False)
self.assertNotIn("보라별", blob)
self.assertNotIn("010-1234-5678", blob)
self.assertIn("[NAME]", blob)
self.assertIn("[PHONE]", blob)
def test_rebuild_turn_evaluation_restores_review_shape(self) -> None:
rebuilt = session_persistence._rebuild_turn_evaluations(
[("11111111-1111-1111-1111-111111111111", 2, "탐색")],
feedback_rows=[
{
"turn_id": "11111111-1111-1111-1111-111111111111",
"dimension": "appropriateness",
"score": 1.0,
"rationale": "조언이 너무 빨랐다.",
"top1_score": None,
"loop": "fast",
},
{
"turn_id": "11111111-1111-1111-1111-111111111111",
"dimension": "technique:empathy",
"score": None,
"rationale": "정서 반영이 포함됐다.",
"top1_score": None,
"loop": "fast",
},
{
"turn_id": "11111111-1111-1111-1111-111111111111",
"dimension": "rapport_signal",
"score": -0.4,
"rationale": None,
"top1_score": None,
"loop": "fast",
},
],
technique_rows=[
{
"turn_id": "11111111-1111-1111-1111-111111111111",
"code": "empathy",
"label_ko": "공감",
"category": "relational",
}
],
client_state_rows=[
{
"turn_id": "11111111-1111-1111-1111-111111111111",
"code": "defensive",
"label_ko": "방어",
}
],
comment_rows=[
{
"turn_id": "11111111-1111-1111-1111-111111111111",
"intent_deviation": {
"dimension": "pacing",
"expected": "감정 탐색",
"actual": "해결 조언",
"severity": "moderate",
},
}
],
alternative_rows=[
{
"turn_id": "11111111-1111-1111-1111-111111111111",
"suggestion": "감정을 먼저 반영해 보세요.",
"rationale": None,
}
],
)
ev = rebuilt["11111111-1111-1111-1111-111111111111"]
self.assertEqual(ev["turn_seq"], 2)
self.assertEqual(ev["stage"], "탐색")
self.assertEqual(ev["appropriateness"], "warn")
self.assertEqual(ev["appropriateness_note"], "조언이 너무 빨랐다.")
self.assertEqual(ev["rapport_signal"], -0.4)
self.assertEqual(ev["techniques"][0]["rationale"], "정서 반영이 포함됐다.")
self.assertEqual(ev["client_state_read"][0]["label_ko"], "방어")
self.assertEqual(ev["intent_deviation"]["dimension"], "pacing")
self.assertEqual(ev["alternative_utterances"], ["감정을 먼저 반영해 보세요."])
def test_evaluation_rls_blocks_raw_learner_writes(self) -> None:
root = Path(__file__).resolve().parents[3]
sql = (root / "infra/db/init/04_audit_eval_rls.sql").read_text(encoding="utf-8")
self.assertIn("ALTER TABLE app.feedback_scores ENABLE ROW LEVEL SECURITY", sql)
self.assertIn("ALTER TABLE app.turn_technique ENABLE ROW LEVEL SECURITY", sql)
self.assertIn("ALTER TABLE app.turn_client_state ENABLE ROW LEVEL SECURITY", sql)
self.assertIn("ALTER TABLE app.supervisor_comment ENABLE ROW LEVEL SECURITY", sql)
self.assertIn("ALTER TABLE app.alternative_utterance ENABLE ROW LEVEL SECURITY", sql)
self.assertIn("CREATE POLICY p_feedback_delete", sql)
self.assertIn("CREATE POLICY p_turn_technique_delete", sql)
self.assertIn("CREATE POLICY p_turn_client_state_delete", sql)
self.assertIn("CREATE POLICY p_supervisor_comment_delete", sql)
self.assertIn("CREATE POLICY p_alternative_utterance_delete", sql)
feedback_insert = sql.split("CREATE POLICY p_feedback_insert", 1)[1].split(");", 1)[0]
self.assertNotIn("learner_id = app.current_uid()", feedback_insert)
def test_append_turn_requires_inserted_turn_id(self) -> None:
source = Path(session_persistence.__file__).read_text(encoding="utf-8")
self.assertIn("RETURNING id", source)
self.assertIn("if inserted_turn_id is None:", source)
class EvaluationPersistenceIOTest(unittest.IsolatedAsyncioTestCase):
async def test_missing_session_evaluation_finder_uses_stale_ai_context_query(self) -> None:
conn = FakeMissingEvaluationConn()
sentinel = object()
with (
patch.object(session_persistence, "get_pool", return_value=object()),
patch.object(session_persistence, "acquire", return_value=FakeAcquire(conn)) as acquire,
patch.object(session_persistence, "_session_from_rows", return_value=sentinel),
):
found, durable = await session_persistence.list_sessions_missing_session_evaluation(
older_than_seconds=150.0,
limit=3,
)
self.assertTrue(durable)
self.assertEqual(found, [sentinel])
acquire.assert_called_once_with(ai_context=True, ai_view="evaluator")
self.assertEqual(conn.fetches[0][1], (150.0, 3))
session_query = conn.fetches[0][0]
self.assertIn("s.ended_at IS NOT NULL", session_query)
self.assertIn("se.session_id IS NULL", session_query)
self.assertIn("'client' = ANY(t.visible_to)", session_query)
self.assertIn("ORDER BY s.ended_at ASC", session_query)
self.assertEqual(conn.fetchrows[0][1], ("11111111-1111-1111-1111-111111111111",))
self.assertIn("FROM app.turns", conn.fetches[1][0])
async def test_record_llm_call_audit_inserts_metadata_only(self) -> None:
conn = FakeEvaluationConn()
payload = {
"session_id": "11111111-1111-1111-1111-111111111111",
"provider": "claude_cli",
"model": "sonnet",
"tokens_in": 120,
"tokens_out": 45,
"cost_usd": 0.0123,
"inference_geo": "us",
"latency_ms": 345,
"messages": [{"content": "raw prompt must not be persisted"}],
}
with (
patch.object(session_persistence, "get_pool", return_value=object()),
patch.object(session_persistence, "acquire", return_value=FakeAcquire(conn)) as acquire,
):
ok = await session_persistence.record_llm_call_audit(payload)
self.assertTrue(ok)
acquire.assert_called_once_with(ai_context=True, ai_view="evaluator")
self.assertEqual(len(conn.executed), 1)
query, args = conn.executed[0]
self.assertIn("INSERT INTO audit.llm_call_log", query)
self.assertNotIn("raw prompt", query)
self.assertNotIn("messages", query)
self.assertEqual(args[0], "11111111-1111-1111-1111-111111111111")
self.assertIsNone(args[1])
self.assertEqual(args[2], "claude_cli")
self.assertEqual(args[3], "sonnet")
self.assertEqual(args[4], 120)
self.assertEqual(args[5], 45)
self.assertEqual(args[6], 0.0123)
self.assertEqual(args[7], "us")
self.assertEqual(args[8], 345)
async def test_persist_turn_evaluation_uses_evaluator_context_and_real_fast_tables(self) -> None:
conn = FakeEvaluationConn()
evaluation = {
"loop": "fast",
"turn_seq": 3,
"stage": "탐색",
"appropriateness": "warn",
"appropriateness_note": "해결 제안이 빨랐다.",
"techniques": [
{
"code": "empathy",
"label_ko": "공감",
"category": "relational",
"rationale": "정서 반영.",
}
],
"client_state_read": [
{
"code": "defensive",
"label_ko": "방어",
"rationale": "짧은 회피 반응.",
}
],
"intent_deviation": {
"dimension": "pacing",
"expected": "탐색",
"actual": "조언",
"severity": "minor",
},
"alternative_utterances": ["감정을 먼저 반영해 보세요."],
}
await session_persistence._persist_turn_evaluation(
conn,
"11111111-1111-1111-1111-111111111111",
evaluation,
)
executed_sql = "\n".join(query for query, _ in conn.executed)
self.assertIn("set_config('app.ai_context', '1', true)", executed_sql)
self.assertIn("set_config('app.current_ai_view', 'evaluator', true)", executed_sql)
self.assertIn("INSERT INTO app.feedback_scores", executed_sql)
self.assertIn("INSERT INTO app.turn_technique", executed_sql)
self.assertIn("INSERT INTO app.turn_client_state", executed_sql)
self.assertIn("INSERT INTO app.supervisor_comment", executed_sql)
self.assertIn("DELETE FROM app.alternative_utterance", executed_sql)
self.assertIn("INSERT INTO app.alternative_utterance", executed_sql)
async def test_replace_turn_evaluation_clears_stale_normalized_rows_before_reinsert(self) -> None:
conn = FakeEvaluationConn()
evaluation = {
"loop": "fast",
"turn_seq": 3,
"stage": "탐색",
"appropriateness": "pos",
"techniques": [
{"code": "reflection", "label_ko": "반영", "category": "relational"}
],
"client_state_read": [{"code": "open", "label_ko": "개방"}],
"alternative_utterances": ["조금 더 머물러도 괜찮습니다."],
}
with (
patch.object(session_persistence, "get_pool", return_value=object()),
patch.object(session_persistence, "acquire", return_value=FakeAcquire(conn)) as acquire,
):
ok = await session_persistence.replace_turn_evaluation(
turn_id="11111111-1111-1111-1111-111111111111",
evaluation=evaluation,
)
self.assertTrue(ok)
acquire.assert_called_once_with(ai_context=True, ai_view="evaluator")
executed_sql = "\n".join(query for query, _ in conn.executed)
self.assertLess(
executed_sql.index("DELETE FROM app.feedback_scores"),
executed_sql.index("INSERT INTO app.feedback_scores"),
)
self.assertIn("DELETE FROM app.turn_technique", executed_sql)
self.assertIn("DELETE FROM app.turn_client_state", executed_sql)
self.assertIn("DELETE FROM app.supervisor_comment", executed_sql)
self.assertIn("DELETE FROM app.alternative_utterance", executed_sql)
self.assertIn("INSERT INTO app.turn_technique", executed_sql)
self.assertIn("INSERT INTO app.turn_client_state", executed_sql)
self.assertIn("INSERT INTO app.alternative_utterance", executed_sql)
async def test_route_loader_only_hydrates_when_requested(self) -> None:
principal = Principal(
user_id="00000000-0000-0000-0000-000000000101",
role=Role.LEARNER,
cohort_ids=[],
email="eval-map@hs.ac.kr",
display_name="Eval Map",
)
card = persona_service.P1
sess = InProcSession(
session_id="eval-map-session",
case_id="eval-map-case",
learner_id=principal.user_id,
persona_code=card.code,
theory_mode="humanistic",
persona=card,
state=state_machine.SessionState(
resistance=card.base_resistance(),
ideation_stage=card.ideation_baseline(),
),
)
calls: list[bool] = []
async def fake_load_session(*args, **kwargs):
calls.append(bool(kwargs.get("include_turn_evaluation")))
return sess
with patch.object(sessions.session_persistence, "load_session", fake_load_session):
await sessions._load_session_or_404(sess.session_id, principal)
await sessions._load_session_or_404(
sess.session_id,
principal,
allow_ended=True,
include_turn_evaluation=True,
)
self.assertEqual(calls, [False, True])
if __name__ == "__main__":
unittest.main()