G0~G8 성과·동맹 측정 OS 작업 일괄 고정
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 산출물은 커밋에서 제외했다.
This commit is contained in:
parent
93dd8f82d7
commit
16e791e044
390 changed files with 243188 additions and 499 deletions
192
apps/api/app/test_measurement_routes.py
Normal file
192
apps/api/app/test_measurement_routes.py
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
"""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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue