CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 24s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Failing after 36s
CI & Build / Build & push image (push) Skipped
Second pass over the tests/ ledger after bbee0d0. fake_record(**attrs) is the
one MagicMock-with-real-attributes builder (to_dict mirrors them; the
note-2109 hazard documented once); fake_note/fake_task/fake_snippet/
fake_project/fake_milestone/fake_system/fake_rulebook/fake_topic/fake_rule
carry each model's ordinary defaults on top of it, replacing 14 per-file
factories (two rulebook trios in tool-vs-service wordings, _fake_task, _fake_ms,
_fake_project, _plan_note, _fake_snippet, two _snippet adapters now one-liners
over fake_snippet). FakeMCP replaces the five closure-over-a-list registrar
fakes (+ _Recorder); loc() and design_token_stub() replace the paired _loc /
_token / _T stand-ins; every hand-built async_session mock (9 helper defs and
14 inline copies) now starts from make_mock_session(). Call sites rewritten by
AST with each file's former defaults made explicit, so behaviour is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
200 lines
9.0 KiB
Python
200 lines
9.0 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="Scribe", 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="Scribe", 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 #2077
|
|
recorded for update_snippet."""
|
|
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
|