Files
roboco/tests/unit/services/test_cockpit.py
T
80ccf415cb feat(cockpit): expose first_pass_yield and a real escaped-defects metric (#709)
The Company Scorecard renders three charter objectives but the cockpit
summary only ever carried one of the metrics, so two cards read "No data
yet" permanently.

first_pass_yield is a pass-through — MetricsService.get_org_scorecard()
already computes it on the same 30d/org scope the rest of the delivery block
uses, and CockpitService.summary simply never forwarded it.

escaped_defects is new. The obvious definition — a blocker finding opened on
a task that already reached a terminal state — is unimplementable: every
producer of a task_review_findings row fires as part of a bounce whose
transition requires a non-terminal task, so it would read zero forever, and a
permanently-green card is the same fabrication the panel change removes.

What it counts instead: a blocker still at 'addressed', never 'verified', on
a task that has since completed. That is reachable because
stamp_addressed_verified only bulk-verifies rows matching its OWN origin, so
a blocker raised by one origin and never re-confirmed by that origin survives
to completion on the developer's word alone.

docs/map/metrics-observability.md documents what a zero actually means: the
one reachable trigger is a PM-origin blocker on a task escalated to the CEO
rather than completed by the PM, since escalate_to_ceo carries no
findings-resolved precondition and ceo_approve verifies only ceo-origin rows.
It also records that the count is per-finding over a rolling 30-day window,
which is not the same unit as the charter's "per release".

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-26 19:11:27 +02:00

240 lines
8.2 KiB
Python

"""roboco.services.cockpit + route — read-only company summary (mocked deps)."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from fastapi import HTTPException
from roboco.api.routes import cockpit as croute
from roboco.models import AgentRole
from roboco.models.permissions import AgentContext
from roboco.services import cockpit as cm
from roboco.services.cockpit import CockpitService
from roboco.services.strategy_engine import StrategyObservation
from roboco.services.task import TaskService
_IN_PROGRESS = 2
_CLAIMED = 1
_BLOCKED = 3
_BUDGET = 100.0
_SPEND_30D = 150.0
_COMPLETED_30D = 5
_MEDIAN_LEAD_TIME = 12.5
_FIRST_PASS_YIELD = 0.92
_ESCAPED_DEFECTS = 2
def _agent(role: AgentRole) -> AgentContext:
return AgentContext(agent_id=uuid4(), role=role, team=None)
def _patch(monkeypatch: pytest.MonkeyPatch) -> None:
goals = {
"north_star": "Win the market",
"objectives": [{"metric": "NPS"}],
"operating_policy": {"monthly_budget_cap": _BUDGET},
}
monkeypatch.setattr(
cm,
"get_company_goals_service",
lambda _s: MagicMock(get=AsyncMock(return_value=goals)),
)
counts = {
"in_progress": _IN_PROGRESS,
"claimed": _CLAIMED,
"blocked": _BLOCKED,
"awaiting_ceo_approval": 1,
}
delivery_stats = {
"completed_30d": _COMPLETED_30D,
"median_lead_time_hours": _MEDIAN_LEAD_TIME,
}
monkeypatch.setattr(
cm,
"get_task_service",
lambda _s: MagicMock(
count_by_status=AsyncMock(return_value=counts),
get_delivery_stats_30d=AsyncMock(return_value=delivery_stats),
),
)
monkeypatch.setattr(
cm,
"get_metrics_service",
lambda _s: MagicMock(
get_org_scorecard=AsyncMock(
return_value=MagicMock(first_pass_yield=_FIRST_PASS_YIELD)
)
),
)
monkeypatch.setattr(
cm,
"ReviewFindingsRepository",
lambda _s: MagicMock(
escaped_defects_since=AsyncMock(
return_value=[(uuid4(), "qa"), (uuid4(), "pr_gate")]
)
),
)
usage = MagicMock(
get_summary=AsyncMock(return_value={"total_cost_usd": _SPEND_30D}),
get_projection=AsyncMock(return_value={"projected_monthly_cost_usd": 200.0}),
)
monkeypatch.setattr(cm, "get_usage_service", lambda _s: usage)
monkeypatch.setattr(
cm,
"get_strategy_engine",
lambda _s: MagicMock(
assess=AsyncMock(
return_value=[StrategyObservation(kind="idle", summary="s", detail="d")]
)
),
)
proposed = MagicMock()
proposed.status = "proposed"
done = MagicMock()
done.status = "provisioned"
monkeypatch.setattr(
cm,
"get_pitch_service",
lambda _s: MagicMock(list_pitches=AsyncMock(return_value=[proposed, done])),
)
@pytest.mark.asyncio
async def test_summary_aggregates(monkeypatch: pytest.MonkeyPatch) -> None:
_patch(monkeypatch)
out = await CockpitService(MagicMock()).summary()
assert out["basis"] == "proxy"
assert out["north_star"] == "Win the market"
assert out["delivery"]["in_flight"] == _IN_PROGRESS + _CLAIMED
assert out["delivery"]["blocked"] == _BLOCKED
assert out["delivery"]["completed_30d"] == _COMPLETED_30D
assert out["delivery"]["median_lead_time_hours"] == _MEDIAN_LEAD_TIME
assert out["delivery"]["first_pass_yield"] == _FIRST_PASS_YIELD
assert out["delivery"]["escaped_defects"] == _ESCAPED_DEFECTS
assert out["spend"]["spend_30d_usd"] == _SPEND_30D
assert out["spend"]["over_budget"] is True
assert out["pending_pitches"] == 1
assert out["signals"][0]["kind"] == "idle"
@pytest.mark.asyncio
async def test_route_forbidden_for_developer() -> None:
with pytest.raises(HTTPException) as exc:
await croute.cockpit_summary(MagicMock(), _agent(AgentRole.DEVELOPER))
assert exc.value.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_route_ok_for_ceo(monkeypatch: pytest.MonkeyPatch) -> None:
summary: dict[str, Any] = {
"basis": "proxy",
"north_star": "Win",
"objectives": [],
"delivery": {
"task_counts": {},
"in_flight": 0,
"blocked": 0,
"awaiting_ceo": 0,
"completed_30d": 0,
"median_lead_time_hours": None,
"first_pass_yield": None,
"escaped_defects": 0,
},
"spend": {
"spend_30d_usd": 0.0,
"projected_monthly_usd": None,
"monthly_budget_cap_usd": None,
"over_budget": False,
},
"pending_pitches": 0,
"signals": [],
}
svc = MagicMock(summary=AsyncMock(return_value=summary))
monkeypatch.setattr(croute, "get_cockpit_service", lambda _db: svc)
resp = await croute.cockpit_summary(MagicMock(), _agent(AgentRole.CEO))
assert resp.basis == "proxy"
assert resp.spend.over_budget is False
@pytest.mark.asyncio
async def test_signals_returns_only_strategy_signals(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# The lightweight slice returns ONLY the strategy signals — none of the
# summary fan-out (goals / spend / counts / pitches).
_patch(monkeypatch)
out = await CockpitService(MagicMock()).signals()
assert list(out.keys()) == ["signals"]
assert out["signals"][0]["kind"] == "idle"
assert out["signals"][0]["summary"] == "s"
@pytest.mark.asyncio
async def test_signals_route_forbidden_for_developer() -> None:
with pytest.raises(HTTPException) as exc:
await croute.cockpit_signals(MagicMock(), _agent(AgentRole.DEVELOPER))
assert exc.value.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_signals_route_ok_for_ceo(monkeypatch: pytest.MonkeyPatch) -> None:
svc = MagicMock(
signals=AsyncMock(
return_value={"signals": [{"kind": "idle", "summary": "s", "detail": "d"}]}
)
)
monkeypatch.setattr(croute, "get_cockpit_service", lambda _db: svc)
resp = await croute.cockpit_signals(MagicMock(), _agent(AgentRole.CEO))
assert resp.signals[0].kind == "idle"
@pytest.mark.asyncio
async def test_get_delivery_stats_30d_no_tasks() -> None:
"""When no completed tasks exist in the 30d window, returns zeros and None."""
session = MagicMock()
execute_result = MagicMock()
execute_result.all.return_value = []
session.execute = AsyncMock(return_value=execute_result)
stats = await TaskService(session).get_delivery_stats_30d()
assert stats["completed_30d"] == 0
assert stats["median_lead_time_hours"] is None
@pytest.mark.asyncio
async def test_get_delivery_stats_30d_with_tasks() -> None:
"""With completed tasks, returns count and median lead time in hours."""
now = datetime.now(UTC)
# Three tasks with lead times of 2h, 4h, 6h → median = 4h
rows = [
MagicMock(created_at=now - timedelta(hours=2), completed_at=now),
MagicMock(created_at=now - timedelta(hours=4), completed_at=now),
MagicMock(created_at=now - timedelta(hours=6), completed_at=now),
]
session = MagicMock()
execute_result = MagicMock()
execute_result.all.return_value = rows
session.execute = AsyncMock(return_value=execute_result)
stats = await TaskService(session).get_delivery_stats_30d()
assert stats["completed_30d"] == len(rows)
assert stats["median_lead_time_hours"] == pytest.approx(4.0, abs=0.01)
@pytest.mark.asyncio
async def test_get_delivery_stats_30d_single_task() -> None:
"""With a single task, median equals that task's lead time."""
now = datetime.now(UTC)
rows = [MagicMock(created_at=now - timedelta(hours=10), completed_at=now)]
session = MagicMock()
execute_result = MagicMock()
execute_result.all.return_value = rows
session.execute = AsyncMock(return_value=execute_result)
stats = await TaskService(session).get_delivery_stats_30d()
assert stats["completed_30d"] == 1
assert stats["median_lead_time_hours"] == pytest.approx(10.0, abs=0.01)