mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[W9-3b] Enrich product table with cell mappings + task progress (#530)
Backend: ProductSummaryResponse gains cells: [{team, project_id, project_name}] and progress: {done, active, blocked}. ProductService.progress_for_products does one grouped query over tasks for every distinct project_id any product references, summed per product (monorepo case dedups a project once per product via a seen set). list_all eager-loads cells + each cell's project (selectinload + joinedload) so product_to_summary reads project.name without an N+1. No migration — reads existing tasks.status + product_projects.
Frontend: ProductTable renders a Cells column (team badges + project names, Unmapped when empty) and a Progress column (done/active/blocked counts + a health dot: amber at-risk when blocked>0). Both desktop Table and mobile ResponsiveTableCard variants. Mock products carry the new shape.
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
|
||||
import { ProductTable } from "../product-table";
|
||||
import { Team } from "@/types";
|
||||
import type { ProductSummary } from "@/types";
|
||||
|
||||
const product: ProductSummary = {
|
||||
id: "p1",
|
||||
name: "RoboCo Platform",
|
||||
slug: "roboco-platform",
|
||||
cell_count: 3,
|
||||
cells: [
|
||||
{ team: Team.BACKEND, project_id: "proj-1", project_name: "roboco" },
|
||||
{ team: Team.FRONTEND, project_id: "proj-1", project_name: "roboco" },
|
||||
],
|
||||
progress: { done: 42, active: 5, blocked: 1 },
|
||||
};
|
||||
|
||||
describe("ProductTable", () => {
|
||||
it("renders the product name, cells, and progress", () => {
|
||||
render(<ProductTable products={[product]} isLoading={false} />);
|
||||
expect(screen.getByText("RoboCo Platform")).toBeInTheDocument();
|
||||
expect(screen.getByText("Backend")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("roboco").length).toBe(2);
|
||||
expect(screen.getByText("42 done")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 blocked")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the empty state when there are no products", () => {
|
||||
render(<ProductTable products={[]} isLoading={false} />);
|
||||
expect(screen.getByText("No products found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the unmapped label for a product with no cells", () => {
|
||||
const bare: ProductSummary = {
|
||||
id: "p2",
|
||||
name: "Bare Product",
|
||||
slug: "bare",
|
||||
cell_count: 0,
|
||||
cells: [],
|
||||
progress: { done: 0, active: 0, blocked: 0 },
|
||||
};
|
||||
render(<ProductTable products={[bare]} isLoading={false} />);
|
||||
expect(screen.getAllByText("Unmapped").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("does not show the empty state while loading", () => {
|
||||
render(<ProductTable products={undefined} isLoading={true} />);
|
||||
expect(screen.queryByText("No products found")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -19,14 +19,73 @@ import {
|
||||
} from "@/components/ui/responsive-table";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Boxes, Pencil } from "lucide-react";
|
||||
import type { ProductSummary } from "@/types";
|
||||
import type { ProductSummary, Team } from "@/types";
|
||||
import { EditProductDialog } from "./edit-product-dialog";
|
||||
|
||||
const TEAM_LABELS: Record<Team, string> = {
|
||||
board: "Board",
|
||||
main_pm: "Main PM",
|
||||
backend: "Backend",
|
||||
frontend: "Frontend",
|
||||
ux_ui: "UX/UI",
|
||||
marketing: "Marketing",
|
||||
};
|
||||
|
||||
interface ProductTableProps {
|
||||
products: ProductSummary[] | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function CellsList({ cells }: { cells: ProductSummary["cells"] }) {
|
||||
if (cells.length === 0) {
|
||||
return <span className="text-muted-foreground text-sm">Unmapped</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{cells.map((c) => (
|
||||
<div key={`${c.team}-${c.project_id}`} className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-blue-500/10 text-blue-500 text-xs"
|
||||
>
|
||||
{TEAM_LABELS[c.team] ?? c.team}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs truncate">
|
||||
{c.project_name || "—"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressCell({
|
||||
progress,
|
||||
}: {
|
||||
progress: ProductSummary["progress"];
|
||||
}) {
|
||||
const { done, active, blocked } = progress;
|
||||
const atRisk = blocked > 0;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={
|
||||
"h-2 w-2 rounded-full " +
|
||||
(atRisk ? "bg-amber-500" : done > 0 ? "bg-emerald-500" : "bg-muted")
|
||||
}
|
||||
title={atRisk ? "At risk: blocked tasks" : "Healthy"}
|
||||
/>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-emerald-600 dark:text-emerald-400">{done} done</span>
|
||||
<span className="text-muted-foreground">{active} active</span>
|
||||
{blocked > 0 && (
|
||||
<span className="text-amber-600 dark:text-amber-400">{blocked} blocked</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductTable({ products, isLoading }: ProductTableProps) {
|
||||
const [editingProductId, setEditingProductId] = useState<string | null>(null);
|
||||
|
||||
@@ -59,7 +118,8 @@ export function ProductTable({ products, isLoading }: ProductTableProps) {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead>Cells Mapped</TableHead>
|
||||
<TableHead>Cells</TableHead>
|
||||
<TableHead>Progress</TableHead>
|
||||
<TableHead className="w-[100px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -81,9 +141,10 @@ export function ProductTable({ products, isLoading }: ProductTableProps) {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className="bg-blue-500/10 text-blue-500">
|
||||
{product.cell_count} / 3
|
||||
</Badge>
|
||||
<CellsList cells={product.cells} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ProgressCell progress={product.progress} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -131,10 +192,25 @@ export function ProductTable({ products, isLoading }: ProductTableProps) {
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 divide-y">
|
||||
<ResponsiveTableCardRow label="Cells Mapped">
|
||||
<Badge className="bg-blue-500/10 text-blue-500">
|
||||
{product.cell_count} / 3
|
||||
</Badge>
|
||||
<ResponsiveTableCardRow label="Cells">
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
{product.cells.map((c) => (
|
||||
<span
|
||||
key={`${c.team}-${c.project_id}`}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
{TEAM_LABELS[c.team] ?? c.team}: {c.project_name || "—"}
|
||||
</span>
|
||||
))}
|
||||
{product.cells.length === 0 && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Unmapped
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</ResponsiveTableCardRow>
|
||||
<ResponsiveTableCardRow label="Progress">
|
||||
<ProgressCell progress={product.progress} />
|
||||
</ResponsiveTableCardRow>
|
||||
</div>
|
||||
</ResponsiveTableCard>
|
||||
|
||||
@@ -6,10 +6,40 @@ import type {
|
||||
ProductSummary,
|
||||
ProductUpdate,
|
||||
} from "@/types";
|
||||
import { Team } from "@/types";
|
||||
|
||||
// Mock products — shapes match the real ProductSummaryResponse (cells + progress).
|
||||
function mockProducts(): ProductSummary[] {
|
||||
return [
|
||||
{
|
||||
id: "p-mock-1",
|
||||
name: "RoboCo Platform",
|
||||
slug: "roboco-platform",
|
||||
cell_count: 3,
|
||||
cells: [
|
||||
{ team: Team.BACKEND, project_id: "proj-1", project_name: "roboco" },
|
||||
{ team: Team.FRONTEND, project_id: "proj-1", project_name: "roboco" },
|
||||
{ team: Team.UX_UI, project_id: "proj-1", project_name: "roboco" },
|
||||
],
|
||||
progress: { done: 42, active: 5, blocked: 1 },
|
||||
},
|
||||
{
|
||||
id: "p-mock-2",
|
||||
name: "Docs Site",
|
||||
slug: "docs-site",
|
||||
cell_count: 2,
|
||||
cells: [
|
||||
{ team: Team.FRONTEND, project_id: "proj-2", project_name: "roboco-website" },
|
||||
{ team: Team.BACKEND, project_id: "proj-3", project_name: "docs-api" },
|
||||
],
|
||||
progress: { done: 18, active: 3, blocked: 0 },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const productsApi = {
|
||||
list: async (): Promise<ProductSummary[]> => {
|
||||
if (isMockMode()) return [];
|
||||
if (isMockMode()) return mockProducts();
|
||||
const { data } = await api.get<ProductSummary[]>("/products");
|
||||
return data;
|
||||
},
|
||||
|
||||
@@ -1128,11 +1128,25 @@ export interface Product {
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface ProductCellSummary {
|
||||
team: Team;
|
||||
project_id: string;
|
||||
project_name: string;
|
||||
}
|
||||
|
||||
export interface ProductProgressSummary {
|
||||
done: number;
|
||||
active: number;
|
||||
blocked: number;
|
||||
}
|
||||
|
||||
export interface ProductSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
cell_count: number;
|
||||
cells: ProductCellSummary[];
|
||||
progress: ProductProgressSummary;
|
||||
}
|
||||
|
||||
export interface ProductCreate {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import cast as typing_cast
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, status
|
||||
@@ -10,6 +11,7 @@ from sqlalchemy.exc import IntegrityError
|
||||
from roboco.api.deps import CurrentAgentContext, DbSession, require_pm_or_above
|
||||
from roboco.api.schemas.product import (
|
||||
ProductCreateRequest,
|
||||
ProductProgressSummary,
|
||||
ProductResponse,
|
||||
ProductSummaryResponse,
|
||||
ProductUpdateRequest,
|
||||
@@ -36,7 +38,22 @@ async def list_products(
|
||||
) -> list[ProductSummaryResponse]:
|
||||
service = get_product_service(db)
|
||||
products = await service.list_all(limit=limit, offset=offset)
|
||||
return [product_to_summary(p) for p in products]
|
||||
progress = await service.progress_for_products(products)
|
||||
out: list[ProductSummaryResponse] = []
|
||||
for p in products:
|
||||
pid = typing_cast("UUID", p.id)
|
||||
counts = progress.get(pid, {"done": 0, "active": 0, "blocked": 0})
|
||||
out.append(
|
||||
product_to_summary(
|
||||
p,
|
||||
progress=ProductProgressSummary(
|
||||
done=counts["done"],
|
||||
active=counts["active"],
|
||||
blocked=counts["blocked"],
|
||||
),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@router.post("", response_model=ProductResponse, status_code=status.HTTP_201_CREATED)
|
||||
|
||||
@@ -33,11 +33,40 @@ class ProductResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ProductCellSummary(BaseModel):
|
||||
"""One cell->project mapping for the summary list (carries the project name
|
||||
so the panel can show which repo each cell points at without a second fetch).
|
||||
"""
|
||||
|
||||
team: Team
|
||||
project_id: UUID
|
||||
project_name: str
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ProductProgressSummary(BaseModel):
|
||||
"""Task progress across a product's cell projects.
|
||||
|
||||
done = completed; blocked = blocked; active = every non-terminal,
|
||||
non-cancelled, non-blocked status. Cancelled tasks are excluded (abandoned
|
||||
work is not progress).
|
||||
"""
|
||||
|
||||
done: int = 0
|
||||
active: int = 0
|
||||
blocked: int = 0
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ProductSummaryResponse(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
slug: str
|
||||
cell_count: int = 0
|
||||
cells: list[ProductCellSummary] = Field(default_factory=list)
|
||||
progress: ProductProgressSummary = Field(default_factory=ProductProgressSummary)
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -71,10 +100,22 @@ def product_to_response(product: "ProductTable") -> ProductResponse:
|
||||
)
|
||||
|
||||
|
||||
def product_to_summary(product: "ProductTable") -> ProductSummaryResponse:
|
||||
def product_to_summary(
|
||||
product: "ProductTable",
|
||||
progress: ProductProgressSummary | None = None,
|
||||
) -> ProductSummaryResponse:
|
||||
return ProductSummaryResponse(
|
||||
id=typing_cast("UUID", product.id),
|
||||
name=str(product.name),
|
||||
slug=str(product.slug),
|
||||
cell_count=len(product.cells),
|
||||
cells=[
|
||||
ProductCellSummary(
|
||||
team=c.team,
|
||||
project_id=typing_cast("UUID", c.project_id),
|
||||
project_name=str(c.project.name) if c.project else "",
|
||||
)
|
||||
for c in product.cells
|
||||
],
|
||||
progress=progress or ProductProgressSummary(),
|
||||
)
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
"""ProductService — CRUD + the per-cell project_for routing resolver."""
|
||||
|
||||
from typing import ClassVar
|
||||
from typing import cast as typing_cast
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import case, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from roboco.db.tables import ProductProjectTable, ProductTable
|
||||
from roboco.db.tables import ProductProjectTable, ProductTable, TaskTable
|
||||
from roboco.foundation.identity import Team
|
||||
from roboco.models.base import TaskStatus
|
||||
from roboco.models.product import ProductCellMapping, ProductCreate, ProductUpdate
|
||||
from roboco.services.base import BaseService, ConflictError, NotFoundError
|
||||
|
||||
# Statuses that are NOT active progress: completed (done), cancelled
|
||||
# (abandoned), blocked (its own bucket). Everything else counts as active.
|
||||
_INACTIVE_STATUSES = (
|
||||
TaskStatus.COMPLETED,
|
||||
TaskStatus.CANCELLED,
|
||||
TaskStatus.BLOCKED,
|
||||
)
|
||||
|
||||
|
||||
class ProductService(BaseService):
|
||||
service_name: ClassVar[str] = "product"
|
||||
@@ -78,12 +89,90 @@ class ProductService(BaseService):
|
||||
return True
|
||||
|
||||
async def list_all(self, limit: int = 100, offset: int = 0) -> list[ProductTable]:
|
||||
# Eager-load cells + each cell's project so product_to_summary can read
|
||||
# project.name without an N+1 lazy join per cell.
|
||||
query = (
|
||||
select(ProductTable).order_by(ProductTable.name).limit(limit).offset(offset)
|
||||
select(ProductTable)
|
||||
.options(
|
||||
selectinload(ProductTable.cells).joinedload(ProductProjectTable.project)
|
||||
)
|
||||
.order_by(ProductTable.name)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
result = await self.session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def progress_for_products(
|
||||
self, products: list[ProductTable]
|
||||
) -> dict[UUID, dict[str, int]]:
|
||||
"""Per-product task progress (done/active/blocked) across cell projects.
|
||||
|
||||
One grouped query over tasks for every distinct project_id any product
|
||||
references, summed per product. A product's cells may point several teams
|
||||
at the same project (the monorepo case), so each project is counted once
|
||||
per product. Returns {product_id: {done, active, blocked}}.
|
||||
"""
|
||||
# distinct (product_id, project_id) pairs — dedup the monorepo case.
|
||||
proj_to_products: dict[UUID, list[UUID]] = {}
|
||||
for product in products:
|
||||
seen: set[UUID] = set()
|
||||
for cell in product.cells:
|
||||
pid = typing_cast("UUID", cell.project_id)
|
||||
if pid in seen:
|
||||
continue
|
||||
seen.add(pid)
|
||||
proj_to_products.setdefault(pid, []).append(
|
||||
typing_cast("UUID", product.id)
|
||||
)
|
||||
if not proj_to_products:
|
||||
return {}
|
||||
|
||||
result = await self.session.execute(
|
||||
select(
|
||||
TaskTable.project_id,
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case((TaskTable.status == TaskStatus.COMPLETED, 1), else_=0)
|
||||
),
|
||||
0,
|
||||
).label("done"),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case((TaskTable.status == TaskStatus.BLOCKED, 1), else_=0)
|
||||
),
|
||||
0,
|
||||
).label("blocked"),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case((TaskTable.status.in_(_INACTIVE_STATUSES), 0), else_=1)
|
||||
),
|
||||
0,
|
||||
).label("active"),
|
||||
)
|
||||
.where(TaskTable.project_id.in_(list(proj_to_products.keys())))
|
||||
.group_by(TaskTable.project_id)
|
||||
)
|
||||
per_project: dict[UUID, dict[str, int]] = {}
|
||||
for row in result.fetchall():
|
||||
per_project[typing_cast("UUID", row.project_id)] = {
|
||||
"done": int(row.done or 0),
|
||||
"active": int(row.active or 0),
|
||||
"blocked": int(row.blocked or 0),
|
||||
}
|
||||
|
||||
out: dict[UUID, dict[str, int]] = {}
|
||||
for project_id, product_ids in proj_to_products.items():
|
||||
counts = per_project.get(project_id)
|
||||
if not counts:
|
||||
continue
|
||||
for pid in product_ids:
|
||||
agg = out.setdefault(pid, {"done": 0, "active": 0, "blocked": 0})
|
||||
agg["done"] += counts["done"]
|
||||
agg["active"] += counts["active"]
|
||||
agg["blocked"] += counts["blocked"]
|
||||
return out
|
||||
|
||||
async def project_for(self, product_id: UUID, team: Team | str) -> UUID | None:
|
||||
"""Resolve the Project a given cell maps to within a product.
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Unit tests for ProductService.progress_for_products.
|
||||
|
||||
Mocks the SQLAlchemy AsyncSession.execute() boundary and verifies the
|
||||
per-product aggregation (one grouped query, summed per product, monorepo
|
||||
dedup of the same project across a product's cells).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from roboco.services.product import ProductService
|
||||
|
||||
_PRODUCT_A = UUID("11111111-1111-1111-1111-111111111111")
|
||||
_PRODUCT_B = UUID("22222222-2222-2222-2222-222222222222")
|
||||
_PROJECT_1 = UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
_PROJECT_2 = UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
|
||||
|
||||
|
||||
def _cell(project_id: UUID) -> MagicMock:
|
||||
cell = MagicMock()
|
||||
cell.project_id = project_id
|
||||
return cell
|
||||
|
||||
|
||||
def _product(pid: UUID, project_ids: list[UUID]) -> MagicMock:
|
||||
p = MagicMock()
|
||||
p.id = pid
|
||||
p.cells = [_cell(pid_proj) for pid_proj in project_ids]
|
||||
return p
|
||||
|
||||
|
||||
def _result_fetchall(rows: list[MagicMock]) -> MagicMock:
|
||||
result = MagicMock()
|
||||
result.fetchall = MagicMock(return_value=rows)
|
||||
return result
|
||||
|
||||
|
||||
def _row(project_id: UUID, done: int, active: int, blocked: int) -> MagicMock:
|
||||
row = MagicMock()
|
||||
row.project_id = project_id
|
||||
row.done = done
|
||||
row.active = active
|
||||
row.blocked = blocked
|
||||
return row
|
||||
|
||||
|
||||
class TestProgressForProducts:
|
||||
@pytest.mark.asyncio
|
||||
async def test_sums_per_product_across_its_projects(self) -> None:
|
||||
"""Product A spans projects 1+2; each project's counts are summed."""
|
||||
session = MagicMock()
|
||||
session.execute = AsyncMock(
|
||||
return_value=_result_fetchall(
|
||||
[
|
||||
_row(_PROJECT_1, done=3, active=2, blocked=1),
|
||||
_row(_PROJECT_2, done=5, active=0, blocked=0),
|
||||
]
|
||||
)
|
||||
)
|
||||
svc = ProductService(session)
|
||||
products = [_product(_PRODUCT_A, [_PROJECT_1, _PROJECT_2])]
|
||||
out = await svc.progress_for_products(products)
|
||||
assert out[_PRODUCT_A] == {"done": 8, "active": 2, "blocked": 1}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_monorepo_dedup_counts_project_once_per_product(self) -> None:
|
||||
"""Two cells of the same product pointing at the same project must not
|
||||
double-count that project's tasks for the product."""
|
||||
session = MagicMock()
|
||||
session.execute = AsyncMock(
|
||||
return_value=_result_fetchall(
|
||||
[_row(_PROJECT_1, done=4, active=1, blocked=0)]
|
||||
)
|
||||
)
|
||||
svc = ProductService(session)
|
||||
# Product A has two cells both -> project 1 (monorepo).
|
||||
products = [_product(_PRODUCT_A, [_PROJECT_1, _PROJECT_1])]
|
||||
out = await svc.progress_for_products(products)
|
||||
assert out[_PRODUCT_A] == {"done": 4, "active": 1, "blocked": 0}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shared_project_attributed_to_both_products(self) -> None:
|
||||
"""A project referenced by two products contributes to each once."""
|
||||
session = MagicMock()
|
||||
session.execute = AsyncMock(
|
||||
return_value=_result_fetchall(
|
||||
[_row(_PROJECT_2, done=2, active=1, blocked=0)]
|
||||
)
|
||||
)
|
||||
svc = ProductService(session)
|
||||
products = [
|
||||
_product(_PRODUCT_A, [_PROJECT_1, _PROJECT_2]),
|
||||
_product(_PRODUCT_B, [_PROJECT_2]),
|
||||
]
|
||||
out = await svc.progress_for_products(products)
|
||||
# Project 1 has no row -> contributes 0; project 2 -> both products.
|
||||
assert out[_PRODUCT_A] == {"done": 2, "active": 1, "blocked": 0}
|
||||
assert out[_PRODUCT_B] == {"done": 2, "active": 1, "blocked": 0}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_cells_returns_empty(self) -> None:
|
||||
session = MagicMock()
|
||||
session.execute = AsyncMock()
|
||||
svc = ProductService(session)
|
||||
out = await svc.progress_for_products([_product(_PRODUCT_A, [])])
|
||||
assert out == {}
|
||||
session.execute.assert_not_called()
|
||||
Reference in New Issue
Block a user