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:
Yun Chan 2026-08-08 01:30:53 +09:00
parent 93dd8f82d7
commit 16e791e044
390 changed files with 243188 additions and 499 deletions

View 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()