Files
FabledGrimoire/tests/test_mcp_tool_design_systems.py
T
bvandeusenandClaude Opus 5.5 0f5ef0be63
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 44s
rename(547): the server is grimoire — package, image, DB defaults, backups
Step 2 of the Scribe -> Grimoire rename.

- src/scribe -> src/grimoire, and every import, patch target, module path,
  hypercorn entry point and `python -m grimoire.restore` with it; pyproject,
  uv.lock and the frontend package are named grimoire.
- Image git.fabledsword.com/bvandeusen/fabledgrimoire; CI's Postgres service
  runs as grimoire / grimoire_test.
- Compose files default the DB user, database and (prod) volume to grimoire,
  each overridable from .env, so an install created as Scribe keeps its data
  by setting POSTGRES_USER, POSTGRES_DB and PGDATA_VOLUME.
- Backups are written as grimoire-<ts>.tar with format grimoire-backup-tar.
  Archives named scribe-* with the scribe-backup-tar manifest still restore
  and are still counted at the destination (folder and S3), with tests.
- Exports, the MCP auth realm, the ASGI scope key and the persistent logger
  carry the new name.

Kept on purpose: the secret-sealing key label stays "scribe.secrets.v1",
because every stored secret was derived under it.

Plugin identity (plugin name, hooks, env vars, .scribe marker, skills) is
step 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:50:45 -04:00

222 lines
10 KiB
Python

"""MCP design-system tools — the sentinel translations, mostly.
The tools are thin wrappers, so the only logic worth testing is where the MCP
calling convention meets the service's: an agent cannot omit an argument, so
"leave unchanged", "clear" and "set" have to be encoded in the value. Getting
that mapping wrong is silent — the call succeeds and changes the wrong thing.
"""
from unittest.mock import AsyncMock, patch
import pytest
from grimoire.services.design_systems import DesignSystemCycle
from tests.helpers import design_token_stub, fake_record
pytestmark = pytest.mark.usefixtures("_bind_user")
def _fake_design_system():
return fake_record(id=1, title="FabledSword", parent_id=None)
def _fake_token():
return fake_record(id=9, name="--fs-obsidian")
# --- create -----------------------------------------------------------------
@pytest.mark.asyncio
async def test_creating_without_a_parent_passes_none_not_zero():
"""0 is the "omitted" sentinel, and it must not reach the service as a
system id — there is no system 0, so the create would fail an ACL check for
a record that cannot exist."""
with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc:
svc.create_design_system = AsyncMock(return_value=_fake_design_system())
from grimoire.mcp.tools.design_systems import create_design_system
await create_design_system(title="FabledSword")
assert svc.create_design_system.await_args.kwargs["parent_id"] is None
@pytest.mark.asyncio
async def test_creating_with_a_parent_passes_it_through():
with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc:
svc.create_design_system = AsyncMock(return_value=_fake_design_system())
from grimoire.mcp.tools.design_systems import create_design_system
await create_design_system(title="Grimoire", parent_id=4)
assert svc.create_design_system.await_args.kwargs["parent_id"] == 4
@pytest.mark.asyncio
async def test_create_raises_when_the_parent_is_not_writable():
with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc:
svc.create_design_system = AsyncMock(return_value=None)
from grimoire.mcp.tools.design_systems import create_design_system
with pytest.raises(ValueError):
await create_design_system(title="Grimoire", parent_id=4)
# --- the three-state parent -------------------------------------------------
@pytest.mark.asyncio
async def test_update_with_parent_id_zero_leaves_the_parent_alone():
"""The common case — renaming a system must not silently re-root it."""
with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc:
svc.update_design_system = AsyncMock(return_value=_fake_design_system())
from grimoire.mcp.tools.design_systems import update_design_system
await update_design_system(design_system_id=1, title="Renamed")
fields = svc.update_design_system.await_args.kwargs
assert "parent_id" not in fields
assert fields["title"] == "Renamed"
@pytest.mark.asyncio
async def test_update_with_parent_id_minus_one_clears_it():
"""-1 means "make this a family system". It has to arrive at the service as
None, which is the value the service reads as "become a root" — where
omitting the key means "leave alone"."""
with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc:
svc.update_design_system = AsyncMock(return_value=_fake_design_system())
from grimoire.mcp.tools.design_systems import update_design_system
await update_design_system(design_system_id=1, parent_id=-1)
assert svc.update_design_system.await_args.kwargs["parent_id"] is None
@pytest.mark.asyncio
async def test_update_with_a_positive_parent_id_sets_it():
with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc:
svc.update_design_system = AsyncMock(return_value=_fake_design_system())
from grimoire.mcp.tools.design_systems import update_design_system
await update_design_system(design_system_id=1, parent_id=4)
assert svc.update_design_system.await_args.kwargs["parent_id"] == 4
@pytest.mark.asyncio
async def test_a_cycle_surfaces_as_a_usable_error_not_a_not_found():
"""The service raises so this layer can keep the two apart. An agent told
"not found" would retry the same call; one told what the loop is can fix it."""
with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc:
svc.update_design_system = AsyncMock(
side_effect=DesignSystemCycle("2 already inherits from 1")
)
from grimoire.mcp.tools.design_systems import update_design_system
with pytest.raises(ValueError, match="already inherits"):
await update_design_system(design_system_id=1, parent_id=2)
# --- tokens -----------------------------------------------------------------
@pytest.mark.asyncio
async def test_update_token_treats_order_index_minus_one_as_unchanged():
"""0 is a VALID order_index, so it cannot double as the omitted sentinel —
the same reason the systems tools use -1."""
with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc:
svc.update_token = AsyncMock(return_value=_fake_token())
from grimoire.mcp.tools.design_systems import update_design_token
await update_design_token(token_id=9, purpose="page bg")
fields = svc.update_token.await_args.kwargs
assert "order_index" not in fields
assert fields["purpose"] == "page bg"
@pytest.mark.asyncio
async def test_update_token_accepts_order_index_zero():
with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc:
svc.update_token = AsyncMock(return_value=_fake_token())
from grimoire.mcp.tools.design_systems import update_design_token
await update_design_token(token_id=9, order_index=0)
assert svc.update_token.await_args.kwargs["order_index"] == 0
@pytest.mark.asyncio
async def test_update_token_can_set_an_empty_value_map():
"""`value_by_mode={}` is meaningful — it strips every mode from a token. The
guard is `is not None`, not truthiness, or that edit would be unreachable."""
with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc:
svc.update_token = AsyncMock(return_value=_fake_token())
from grimoire.mcp.tools.design_systems import update_design_token
await update_design_token(token_id=9, value_by_mode={})
assert svc.update_token.await_args.kwargs["value_by_mode"] == {}
# --- the project pointer ----------------------------------------------------
@pytest.mark.asyncio
async def test_clearing_a_projects_design_system_passes_none():
with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc:
svc.set_project_design_system = AsyncMock(return_value=True)
from grimoire.mcp.tools.design_systems import set_project_design_system
result = await set_project_design_system(project_id=2, design_system_id=-1)
assert svc.set_project_design_system.await_args.args[2] is None
assert result["design_system_id"] is None
@pytest.mark.asyncio
async def test_resolve_returns_serialised_tokens_with_their_provenance():
"""The payload has to carry the shadowed entries, not just the winner —
dropping them at the serialisation boundary would discard the one thing
resolution was built to preserve."""
from grimoire.services.design_cascade import resolve_tokens
resolved = resolve_tokens(
2, {1: None, 2: 1},
{1: [design_token_stub("--fs-accent", {"base": "#6b2118"})],
2: [design_token_stub("--fs-accent", {"base": "#5b4a8a"})]},
)
with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc:
svc.resolve_design_system = AsyncMock(return_value=resolved)
from grimoire.mcp.tools.design_systems import resolve_design_system
payload = await resolve_design_system(design_system_id=2)
token = payload["tokens"][0]
assert token["value_by_mode"] == {"base": "#5b4a8a"}
assert token["origin_by_mode"] == {"base": 2}
assert token["contributions"]["base"] == [
{"system_id": 2, "value": "#5b4a8a"},
{"system_id": 1, "value": "#6b2118"},
]
@pytest.mark.asyncio
async def test_update_token_can_clear_supersedes_with_an_empty_list():
"""`[]` means "this token replaces nothing after all" — a real edit. Guarded
on `is not None` so it isn't swallowed as "unchanged", the same trap
update_snippet guards against."""
with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc:
svc.update_token = AsyncMock(return_value=_fake_token())
from grimoire.mcp.tools.design_systems import update_design_token
await update_design_token(token_id=9, supersedes=[])
assert svc.update_token.await_args.kwargs["supersedes"] == []
@pytest.mark.asyncio
async def test_update_token_leaves_supersedes_alone_when_omitted():
with patch("grimoire.mcp.tools.design_systems.ds_svc") as svc:
svc.update_token = AsyncMock(return_value=_fake_token())
from grimoire.mcp.tools.design_systems import update_design_token
await update_design_token(token_id=9, purpose="text on action surfaces")
assert "supersedes" not in svc.update_token.await_args.kwargs
# --- get: the guidance a UI session builds from -----------------------------
@pytest.mark.asyncio
async def test_get_design_system_carries_the_chain_merged_guidance():
"""enter_project carries only a summary and points here. The
system's own `guidance` field is only its departures; a session building
UI needs the house style too, so the merged form rides alongside."""
from grimoire.mcp.tools.design_systems import get_design_system
merged = [{"design_system_id": 1, "title": "House", "guidance": "house style"},
{"design_system_id": 2, "title": "App", "guidance": "departures"}]
with patch("grimoire.mcp.tools.design_systems.ds_svc.get_design_system",
AsyncMock(return_value=_fake_design_system())), \
patch("grimoire.mcp.tools.design_systems.ds_svc.list_tokens",
AsyncMock(return_value=[])), \
patch("grimoire.mcp.tools.design_systems.ds_svc.design_context",
AsyncMock(return_value={"guidance": merged})):
out = await get_design_system(2)
assert out["resolved_guidance"] == merged