CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 1m1s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 36s
Step 1 of the Scribe -> Grimoire rename: every capitalised "Scribe" in prose
becomes "Grimoire" ("Fabled Scribe" -> "Fabled Grimoire") across the UI,
the PWA manifest and page title, the MCP instructions and tool docstrings,
hook and skill text, docs and README, with the tests that assert on that
prose moving with it. Old migrations keep their wording.
Identifiers are untouched here: the package, plugin name, MCP server key,
hook file names, env vars, the .scribe marker and FabledScribe repo/image
names move in steps 2 and 3, along with prose that quotes them.
The sweep matched "Scribe" only as a whole word, so describe/subscribe and
friends are untouched. The longer name took the MCP instructions one
character over their 1,600 budget; two phrases are shortened to fit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
222 lines
10 KiB
Python
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 scribe.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("scribe.mcp.tools.design_systems.ds_svc") as svc:
|
|
svc.create_design_system = AsyncMock(return_value=_fake_design_system())
|
|
from scribe.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("scribe.mcp.tools.design_systems.ds_svc") as svc:
|
|
svc.create_design_system = AsyncMock(return_value=_fake_design_system())
|
|
from scribe.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("scribe.mcp.tools.design_systems.ds_svc") as svc:
|
|
svc.create_design_system = AsyncMock(return_value=None)
|
|
from scribe.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("scribe.mcp.tools.design_systems.ds_svc") as svc:
|
|
svc.update_design_system = AsyncMock(return_value=_fake_design_system())
|
|
from scribe.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("scribe.mcp.tools.design_systems.ds_svc") as svc:
|
|
svc.update_design_system = AsyncMock(return_value=_fake_design_system())
|
|
from scribe.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("scribe.mcp.tools.design_systems.ds_svc") as svc:
|
|
svc.update_design_system = AsyncMock(return_value=_fake_design_system())
|
|
from scribe.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("scribe.mcp.tools.design_systems.ds_svc") as svc:
|
|
svc.update_design_system = AsyncMock(
|
|
side_effect=DesignSystemCycle("2 already inherits from 1")
|
|
)
|
|
from scribe.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("scribe.mcp.tools.design_systems.ds_svc") as svc:
|
|
svc.update_token = AsyncMock(return_value=_fake_token())
|
|
from scribe.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("scribe.mcp.tools.design_systems.ds_svc") as svc:
|
|
svc.update_token = AsyncMock(return_value=_fake_token())
|
|
from scribe.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("scribe.mcp.tools.design_systems.ds_svc") as svc:
|
|
svc.update_token = AsyncMock(return_value=_fake_token())
|
|
from scribe.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("scribe.mcp.tools.design_systems.ds_svc") as svc:
|
|
svc.set_project_design_system = AsyncMock(return_value=True)
|
|
from scribe.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 scribe.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("scribe.mcp.tools.design_systems.ds_svc") as svc:
|
|
svc.resolve_design_system = AsyncMock(return_value=resolved)
|
|
from scribe.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("scribe.mcp.tools.design_systems.ds_svc") as svc:
|
|
svc.update_token = AsyncMock(return_value=_fake_token())
|
|
from scribe.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("scribe.mcp.tools.design_systems.ds_svc") as svc:
|
|
svc.update_token = AsyncMock(return_value=_fake_token())
|
|
from scribe.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 scribe.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("scribe.mcp.tools.design_systems.ds_svc.get_design_system",
|
|
AsyncMock(return_value=_fake_design_system())), \
|
|
patch("scribe.mcp.tools.design_systems.ds_svc.list_tokens",
|
|
AsyncMock(return_value=[])), \
|
|
patch("scribe.mcp.tools.design_systems.ds_svc.design_context",
|
|
AsyncMock(return_value={"guidance": merged})):
|
|
out = await get_design_system(2)
|
|
|
|
assert out["resolved_guidance"] == merged
|