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