mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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>
240 lines
8.2 KiB
Python
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)
|