8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본 폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을 git 이력으로 고정하는 것이 목적이다. - contracts/routes/services: measurement, outcome_trajectory, rupture_repair, deliberate_practice, calibration_transfer, supervision_research, multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트 - infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행) - apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가 - docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG - scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트 engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
192 lines
7.1 KiB
Python
192 lines
7.1 KiB
Python
"""Alliance Pulse HTTP boundary tests."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import unittest
|
|
from unittest.mock import AsyncMock, patch
|
|
from uuid import UUID
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from .contracts.measurement import AllianceScores
|
|
from .deps import Principal, Role
|
|
from .routes import measurements
|
|
|
|
|
|
SESSION_ID = UUID("00000000-0000-0000-0000-00000000a111")
|
|
PULSE_ID = UUID("00000000-0000-0000-0000-00000000a222")
|
|
|
|
|
|
def _principal(role: Role) -> Principal:
|
|
return Principal(
|
|
user_id="00000000-0000-0000-0000-00000000a333",
|
|
role=role,
|
|
cohort_ids=["e2e-hanshin"],
|
|
)
|
|
|
|
|
|
class AllianceMeasurementRouteTest(unittest.IsolatedAsyncioTestCase):
|
|
async def test_create_locks_before_scheduling_agents(self) -> None:
|
|
order: list[str] = []
|
|
|
|
async def lock(**_kwargs: object) -> measurements.alliance_measurement.LockedPulseResult:
|
|
order.append("locked")
|
|
return measurements.alliance_measurement.LockedPulseResult(
|
|
pulse_id=PULSE_ID,
|
|
idempotent_replay=False,
|
|
)
|
|
|
|
def schedule(pulse_id: UUID) -> None:
|
|
self.assertEqual(pulse_id, PULSE_ID)
|
|
order.append("scheduled")
|
|
|
|
with (
|
|
patch.object(measurements.alliance_measurement, "create_locked_pulse", lock),
|
|
patch.object(measurements.alliance_measurement, "schedule_alliance_agents", schedule),
|
|
):
|
|
response = await measurements.create_alliance_pulse(
|
|
SESSION_ID,
|
|
measurements.AlliancePulseCreateRequest(
|
|
checkpoint="post",
|
|
scores=AllianceScores(goal=0.4, task=0.6, bond=0.8),
|
|
),
|
|
_principal(Role.LEARNER),
|
|
)
|
|
|
|
self.assertEqual(response.pulse_id, PULSE_ID)
|
|
self.assertFalse(response.idempotent_replay)
|
|
self.assertEqual(order, ["locked", "scheduled"])
|
|
|
|
async def test_identical_replay_returns_same_id_without_rescheduling(self) -> None:
|
|
schedule = unittest.mock.Mock()
|
|
with (
|
|
patch.object(
|
|
measurements.alliance_measurement,
|
|
"create_locked_pulse",
|
|
AsyncMock(
|
|
return_value=measurements.alliance_measurement.LockedPulseResult(
|
|
pulse_id=PULSE_ID,
|
|
idempotent_replay=True,
|
|
)
|
|
),
|
|
),
|
|
patch.object(measurements.alliance_measurement, "schedule_alliance_agents", schedule),
|
|
):
|
|
response = await measurements.create_alliance_pulse(
|
|
SESSION_ID,
|
|
measurements.AlliancePulseCreateRequest(
|
|
checkpoint="post",
|
|
scores=AllianceScores(goal=0.4, task=0.6, bond=0.8),
|
|
),
|
|
_principal(Role.LEARNER),
|
|
)
|
|
|
|
self.assertEqual(response.pulse_id, PULSE_ID)
|
|
self.assertTrue(response.idempotent_replay)
|
|
schedule.assert_not_called()
|
|
|
|
async def test_create_conflict_is_409_and_does_not_schedule(self) -> None:
|
|
schedule = unittest.mock.Mock()
|
|
with (
|
|
patch.object(
|
|
measurements.alliance_measurement,
|
|
"create_locked_pulse",
|
|
AsyncMock(
|
|
side_effect=measurements.alliance_measurement.AlliancePulseConflictError(
|
|
"post alliance pulse is already locked"
|
|
)
|
|
),
|
|
),
|
|
patch.object(measurements.alliance_measurement, "schedule_alliance_agents", schedule),
|
|
):
|
|
with self.assertRaises(HTTPException) as raised:
|
|
await measurements.create_alliance_pulse(
|
|
SESSION_ID,
|
|
measurements.AlliancePulseCreateRequest(
|
|
checkpoint="post",
|
|
scores=AllianceScores(goal=0.4, task=0.6, bond=0.8),
|
|
),
|
|
_principal(Role.LEARNER),
|
|
)
|
|
|
|
self.assertEqual(raised.exception.status_code, 409)
|
|
schedule.assert_not_called()
|
|
|
|
async def test_list_preserves_three_dimensions_and_perspective(self) -> None:
|
|
service_items = [
|
|
{
|
|
"pulse_id": str(PULSE_ID),
|
|
"checkpoint": "post",
|
|
"status": "ready",
|
|
"learner_locked_at": "2026-08-06T10:00:00+00:00",
|
|
"revealed_at": "2026-08-06T10:00:01+00:00",
|
|
"error_code": None,
|
|
"self_scores": {"goal": 0.4, "task": 0.6, "bond": 0.8},
|
|
"measurements": [
|
|
{
|
|
"measurement_id": f"00000000-0000-0000-0000-00000000a22{index}",
|
|
"dimension": dimension,
|
|
"perspective": "learner_self_report",
|
|
"source_kind": "learner_reported",
|
|
"value": value,
|
|
"confidence": None,
|
|
"status": "ready",
|
|
"error_code": None,
|
|
"rationale": None,
|
|
"evidence": [],
|
|
"created_at": "2026-08-06T10:00:00+00:00",
|
|
}
|
|
for index, (dimension, value) in enumerate(
|
|
(("goal", 0.4), ("task", 0.6), ("bond", 0.8)),
|
|
start=3,
|
|
)
|
|
],
|
|
}
|
|
]
|
|
with patch.object(
|
|
measurements.alliance_measurement,
|
|
"list_alliance_pulses",
|
|
AsyncMock(return_value=service_items),
|
|
):
|
|
response = await measurements.get_alliance_pulses(
|
|
SESSION_ID,
|
|
_principal(Role.LEARNER),
|
|
)
|
|
|
|
self.assertEqual([item.dimension for item in response.items[0].measurements], ["goal", "task", "bond"])
|
|
self.assertTrue(
|
|
all(
|
|
item.perspective == "learner_self_report"
|
|
for item in response.items[0].measurements
|
|
)
|
|
)
|
|
|
|
async def test_supervisor_state_error_is_422(self) -> None:
|
|
with patch.object(
|
|
measurements.alliance_measurement,
|
|
"add_supervisor_rating",
|
|
AsyncMock(
|
|
side_effect=measurements.alliance_measurement.AlliancePulseStateError(
|
|
"supervisor rating requires transcript evidence"
|
|
)
|
|
),
|
|
):
|
|
with self.assertRaises(HTTPException) as raised:
|
|
await measurements.create_supervisor_alliance_rating(
|
|
SESSION_ID,
|
|
PULSE_ID,
|
|
measurements.SupervisorAllianceRatingRequest(
|
|
scores=AllianceScores(goal=0.5, task=0.5, bond=0.5),
|
|
evidence_turn_ids=(
|
|
UUID("00000000-0000-0000-0000-00000000a444"),
|
|
),
|
|
note="근거 장면 확인",
|
|
),
|
|
_principal(Role.TEACHER),
|
|
)
|
|
|
|
self.assertEqual(raised.exception.status_code, 422)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|