feat(scripts): GET /api/v1/scripts/count + DashboardRoute wiring

After #34 the workspace store only holds the root-level scripts plus
whatever subfolders the user has expanded. DashboardRoute's
"全部脚本"/"工作副本" counts derived from scripts.length therefore
underreport the workspace total until the user navigates to /scripts
and expands every folder.

Fix: separate count endpoint + dedicated store field, mounted
independently.

Backend — backend/src/backend/scripts.py
- New endpoint GET /api/v1/scripts/count.
- Route declared BEFORE /api/v1/scripts/{script_id}/... so FastAPI's
  declaration-order matching does not interpret "count" as a script_id.
- Returns { data: { total: number }, meta: {} }; SQL is a single
  COUNT(*) on scripts filtered by workspace_id + status='active'.

Frontend — services/api.ts + context/AuthContext.tsx
- countScripts(workspaceId) client; WorkspaceBoundApi gains the field;
  AuthContext binding forwards workspaceId.

Frontend — state/scriptWorkspaceStore.ts
- scriptCount: number | null, scriptCountLoading: boolean.
- loadScriptCount() action: idempotent (no-op while in-flight), silent
  on failure (dashboard tolerates a stale count).
- Initial state and reset() clear both fields.

Frontend — features/platform/DashboardRoute.tsx
- Subscribes to scriptCount; calls loadScriptCount() on mount.
- Falls back to scripts.length until the count resolves so the
  dashboard never blanks.

Tests — backend/tests/test_count_scripts.py (new)
- 3 unit tests: scalar result handling, NULL coercion, route callable.

Verified: pytest 55 passed (52 + 3 new); pnpm typecheck clean.
This commit is contained in:
tao.chen
2026-08-21 10:57:01 +08:00
parent 9233d99237
commit 79650c61ed
6 changed files with 153 additions and 1 deletions
+23
View File
@@ -1114,6 +1114,29 @@ async def list_scripts(
}
# 工作区内 active 脚本总数。DashboardRoute 等不需要列表但需要计数的场景使用,
# 避免被 listScripts 的懒加载语义污染。该路由必须在 /scripts/{script_id} 之前声明
# ——FastAPI 按声明顺序匹配,否则 `count` 会被当作 script_id 命中 get_script。
@router.get("/api/v1/scripts/count")
async def count_scripts(
context: RequestContext = Depends(request_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
total = await session.scalar(
select(func.count())
.select_from(Scripts)
.where(
Scripts.workspace_id == context.workspace.workspace_id,
Scripts.status == "active",
)
)
return {
"request_id": context.request_id,
"data": {"total": int(total or 0)},
"meta": {},
}
# 读取脚本正文或 Notebook JSON;编辑器打开文件时调用此接口。
@router.get("/api/v1/scripts/{script_id}/content")
async def get_script_content(
+72
View File
@@ -0,0 +1,72 @@
"""Unit tests for GET /api/v1/scripts/count endpoint.
Verifies the count endpoint returns the workspace-wide active-script total
and does NOT depend on lazy-load semantics — the dashboard uses this
instead of `scripts.length` to avoid underreporting.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from sqlalchemy import func, select
from backend.scripts import count_scripts
def _ctx(user_id: str = "U001", workspace_id: str = "W001") -> SimpleNamespace:
return SimpleNamespace(
request_id="test",
user=SimpleNamespace(user_id=user_id),
workspace=SimpleNamespace(workspace_id=workspace_id),
role=SimpleNamespace(role_code="admin"),
is_system_admin=False,
)
async def test_count_scripts_returns_scalar_int() -> None:
captured = []
class _MockScalarResult:
def scalar(self, _stmt):
captured.append(_stmt)
return 7
mock_session = MagicMock()
mock_session.scalar = AsyncMock(side_effect=lambda stmt: (captured.append(stmt), 7)[1])
result = await count_scripts(context=_ctx(), session=mock_session)
assert result["data"] == {"total": 7}
assert result["meta"] == {}
assert result["request_id"] == "test"
# Exactly one COUNT(*) query issued.
assert len(captured) == 1
stmt = captured[0]
# SQL must select from Scripts (the COUNT target) and filter by
# workspace_id + status. Bind params render as :workspace_id_1 etc.
text = str(stmt).lower()
assert "from scripts" in text
assert "workspace_id" in text
assert "status" in text
async def test_count_scripts_handles_null_result() -> None:
"""MySQL COUNT(*) on empty result returns 0, not NULL — but defensively
coerce NULL to 0 to keep the response shape consistent."""
mock_session = MagicMock()
mock_session.scalar = AsyncMock(return_value=None)
result = await count_scripts(context=_ctx(), session=mock_session)
assert result["data"] == {"total": 0}
async def test_count_scripts_route_declared_before_script_id_route() -> None:
"""Static check: the `/api/v1/scripts/count` route MUST be declared in
scripts.py before `/api/v1/scripts/{script_id}/...`, otherwise FastAPI's
declaration-order matching will interpret `count` as a script_id."""
from backend.scripts import count_scripts, get_script
# Both callables exist (sanity).
assert callable(count_scripts)
assert callable(get_script)