"""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, MagicMock, patch import pytest from scribe.mcp._context import _user_id_ctx from scribe.services.design_systems import DesignSystemCycle @pytest.fixture(autouse=True) def _bind_user(): token = _user_id_ctx.set(7) yield _user_id_ctx.reset(token) def _fake_system(): s = MagicMock() s.to_dict.return_value = {"id": 1, "title": "FabledSword", "parent_id": None} return s def _fake_token(): t = MagicMock() t.to_dict.return_value = {"id": 9, "name": "--fs-obsidian"} return t # --- 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_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_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_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_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_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 class _T: def __init__(self, name, value_by_mode): self.name, self.value_by_mode = name, value_by_mode self.group_name = self.purpose = None self.order_index = 0 resolved = resolve_tokens( 2, {1: None, 2: 1}, {1: [_T("--fs-accent", {"base": "#6b2118"})], 2: [_T("--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"}, ]