Files
FabledScribe/tests/test_mcp_tool_design_systems.py
T
bvandeusen 3da40abcb8
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 38s
feat(design-systems): declare what to write instead, rather than what not to
Milestone #254 step 6, first half (#2295) — and this reframes the task rather
than answering it. The operator's call:

  "in this case we should declare what should be used in place of pure white,
   it's not a prohibition it's what should be used in its place."

None of the three options on the table (a constraints record / the panel reads
both sources / negative token rows) was right, because all three kept the
prohibition as a KIND OF THING. It isn't one. "Pure white is never text" is the
shadow cast by a positive fact — text is Parchment — and a design system that
stores what things ARE has no row for a ban because it never needed one.

So `design_tokens` gains `supersedes`: the literal values this token should be
written instead of. `--color-text-on-action` supersedes `#fff` / `#ffffff`. Same
fact as the rule, stated forwards, and now actionable — a finding can say what
to write rather than only objecting.

It has to be DECLARED, not derived, and that is the crux: `#fff` and Parchment
`#E8E4D8` are different colours, so no value-matching check could ever have
connected them. That mismatch is precisely why the prohibition looked
unrepresentable until it was turned around.

`supersedes` cascades on EMPTINESS rather than on None. A child overriding a
colour says nothing about which literals it replaces, and blanking the family's
declaration there would silently disarm the check for every app that customises
the token — while a child that states its own list replaces it wholesale.

Two things this deliberately does NOT do:

  - It does not feed the drift panel. Superseded literals live in component CSS,
    which `designDrift.ts` cannot see and already documents as a blind spot.
    This is input for the source lint (#2277). Declaring it with nothing
    consuming it yet is honest; wiring it to a panel that cannot check it would
    not be.
  - It does not remove the panel's `prohibited_color` arm yet — that happens
    when the panel is repointed at a resolved system, which needs #2288 first.

The declaration also exposes a missing token: most of the 67 hardcoded
`color: #fff` (#2275) are text on a coloured action button, and the system has
no token for that role at all. Every view hardcodes it. Declaring the token that
was never there is the first real output of the operator's framing.
2026-07-30 21:16:45 -04:00

213 lines
9.3 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, 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"},
]
@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