Files
roboco/tests/unit/services/test_cockpit.py
T
6007f47fc9 [ef7b7cb9] Add Company Scorecard to Business Goals tab (#212)
* [0c7a4732] feat(cockpit): add completed_30d and median_lead_time_hours to delivery summary (#207) (#210)

- Extend DeliverySummary schema with completed_30d: int = 0 and
  median_lead_time_hours: float | None = None fields
- Add TaskService.get_delivery_stats_30d() that queries tasks completed
  in the last 30 days and computes statistics.median of lead times
- Update CockpitService.summary() to source both new keys from
  get_delivery_stats_30d() and include them in the delivery dict
- Update tests: mock new method in _patch(), assert new fields in
  test_summary_aggregates, fix test_route_ok_for_ceo dict, add three
  new unit tests for get_delivery_stats_30d (empty, multi, single)

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

* [d2647edf] Frontend: Build CompanyScorecard card on Goals tab (#211)

* [12569f37] Extend CockpitSummary type and build CompanyScorecardCard component (#208)

* [12569f37] feat(cockpit): extend CockpitSummary type with completed_30d and median_lead_time_hours

Add optional delivery.completed_30d (number) and top-level
median_lead_time_hours (number | null, optional) to CockpitSummary
interface in panel/src/lib/api/cockpit.ts so the API shape captures
the new backend fields without breaking existing consumers.

* [12569f37] feat(business): add CompanyScorecardCard component

Create panel/src/components/business/company-scorecard-card.tsx
exporting CompanyScorecardCard. The card fetches /cockpit/summary
via useQuery and renders five always-visible sections:

- Delivery: in_flight, blocked, awaiting_ceo, completed_30d tiles
  (all from API response; no hardcoded numbers)
- Spend: 30d spend + projected monthly; muted 'No budget cap set'
  when cap is null; red/destructive styling only when cap is a
  non-null number AND over_budget is true
- Speed: 'X.Xh median — target: < 24h' when value present;
  'No data yet' when null/undefined; '0h' never rendered
- Two stub Objectives with 'Not tracked yet' label, muted text,
  and dashed-border styling — no fabricated numeric values
- Loading: three grouped Skeleton blocks
- Error: OfflineState with title 'Could not load scorecard data'

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>

* [f1f5cded] Integrate CompanyScorecardCard into GoalsTab and pass quality gate (#209)

* [f1f5cded] feat(cockpit): extend CockpitSummary with completed_30d and median_lead_time_hours

Add optional delivery.completed_30d (number) and top-level
median_lead_time_hours (number | null, optional) to CockpitSummary
interface in panel/src/lib/api/cockpit.ts. Backward compatible.

* [f1f5cded] feat(business): add CompanyScorecardCard component

Create panel/src/components/business/company-scorecard-card.tsx
exporting CompanyScorecardCard. Fetches /cockpit/summary via
useQuery and renders five always-visible sections: Delivery (no
hardcoded numbers), Spend (muted 'No budget cap set' when null;
red only when cap set AND over_budget true), Speed (X.Xh median
or 'No data yet'), two stub Objectives with dashed border and
'Not tracked yet' label. Loading: three skeleton groups. Error:
OfflineState 'Could not load scorecard data'.

* [f1f5cded] feat(goals-tab): integrate CompanyScorecardCard into GoalsTab

Import and render CompanyScorecardCard below the charter form in
goals-tab.tsx. The scorecard fetches its own data independently
so all loading/error states are handled per-card. Both cards are
always rendered in the Goals tab.

* [f1f5cded] fix(scorecard-tests): add vitest framework and CompanyScorecardCard test suite

Install vitest + @testing-library/react + @testing-library/jest-dom +
jsdom + @vitest/coverage-v8 as devDependencies in panel/.

Add panel/vitest.config.ts (jsdom env, @/* alias, coverage on
company-scorecard-card.tsx with 80% threshold).

Add panel/src/test/setup.ts (jest-dom matchers).

Update panel/package.json: add test, test:watch, typecheck scripts.

Update panel/eslint.config.mjs: ignore coverage/ directory to keep
lint clean of generated files.

Write panel/src/components/business/__tests__/company-scorecard-card.test.tsx
with 8 tests covering all 7 AC2 scenarios:
- loading skeleton rendered
- OfflineState on error
- OfflineState when data undefined
- delivery counts from mock data
- spend 'No budget cap set' when cap null
- spend destructive styling when cap non-null and over_budget true
- speed 'No data yet' when lead time null
- speed formatted value when lead time present

pnpm lint: 0 errors  pnpm typecheck: 0 errors
pnpm test: 8/8 pass  coverage: stmts 95% branches 90% fns 91% lines 95%

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>

---------

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>
Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>
2026-06-18 03:33:47 +02:00

216 lines
7.4 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
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),
),
)
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["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,
},
"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)