Design systems as records — the stylesheet Scribe holds, plus two live bug fixes #88
@@ -0,0 +1,55 @@
|
||||
"""design_tokens.supersedes — the literals a token should be used instead of
|
||||
|
||||
Revision ID: 0073
|
||||
Revises: 0072
|
||||
Create Date: 2026-07-30
|
||||
|
||||
Records what a prohibition was actually trying to say.
|
||||
|
||||
A design rulebook writes "pure white #FFFFFF is NEVER used as text color". That
|
||||
sentence has no row in a table of tokens, because a design system stores what
|
||||
things ARE — which looked like a gap in the model and was really a sentence
|
||||
written backwards. The positive fact is "text is Parchment", and the useful
|
||||
record is the mapping from the literal someone would otherwise write to the
|
||||
token they should write instead.
|
||||
|
||||
So `supersedes` is a JSONB array of literal values, e.g. `["#fff", "#ffffff"]`
|
||||
on a text-on-action token. A finding built from it can say what to write, not
|
||||
merely what not to.
|
||||
|
||||
It must be DECLARED rather than derived. `#fff` and Parchment `#E8E4D8` are
|
||||
different colours, so no value-matching rule could ever have connected them —
|
||||
which is exactly why the prohibition felt unrepresentable until it was turned
|
||||
around.
|
||||
|
||||
Consumed by the source lint that reads component CSS, not by the drift panel:
|
||||
these literals are in the components, which the panel cannot see.
|
||||
|
||||
NOT NULL with a `'[]'` default, matching `value_by_mode` — a nullable JSONB
|
||||
column has two empty states and every reader has to test for both.
|
||||
|
||||
Downgrade drops the column; the declarations are lost, which costs the lint its
|
||||
input and nothing else.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
|
||||
revision = "0073"
|
||||
down_revision = "0072"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"design_tokens",
|
||||
sa.Column(
|
||||
"supersedes", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("design_tokens", "supersedes")
|
||||
@@ -26,6 +26,13 @@ export interface DesignToken {
|
||||
value_by_mode: Record<string, string>;
|
||||
group_name: string | null;
|
||||
purpose: string | null;
|
||||
/** Literal values this token should be used INSTEAD OF, e.g. ["#fff"].
|
||||
*
|
||||
* How a design system records what a prohibition was trying to say: not
|
||||
* "white is banned" but "write this token instead". Declared rather than
|
||||
* inferred, because a superseded literal and the token's own value are
|
||||
* usually different values and nothing could connect them by matching. */
|
||||
supersedes: string[];
|
||||
order_index: number;
|
||||
}
|
||||
|
||||
@@ -49,6 +56,7 @@ export interface ResolvedToken {
|
||||
name: string;
|
||||
group_name: string | null;
|
||||
purpose: string | null;
|
||||
supersedes: string[];
|
||||
order_index: number;
|
||||
value_by_mode: Record<string, string>;
|
||||
origin_by_mode: Record<string, number>;
|
||||
@@ -93,6 +101,7 @@ export const createDesignToken = (
|
||||
value_by_mode?: Record<string, string>;
|
||||
group_name?: string | null;
|
||||
purpose?: string | null;
|
||||
supersedes?: string[];
|
||||
order_index?: number;
|
||||
},
|
||||
) => apiPost<DesignToken>(`/api/design-systems/${designSystemId}/tokens`, body);
|
||||
|
||||
@@ -245,6 +245,10 @@ const tokenGroup = ref("");
|
||||
const tokenPurpose = ref("");
|
||||
/** Mode/value pairs, edited as a list so a token can carry any number of modes. */
|
||||
const tokenModes = ref<{ mode: string; value: string }[]>([{ mode: "base", value: "" }]);
|
||||
/** Literals this token should be written instead of, comma-separated in the
|
||||
* form. Kept as one text field rather than a repeater: it is a short list of
|
||||
* literals, and a row-per-value UI would cost more than it explains. */
|
||||
const tokenSupersedes = ref("");
|
||||
const savingToken = ref(false);
|
||||
const editingTokenId = ref<number | null>(null);
|
||||
|
||||
@@ -253,6 +257,7 @@ function resetTokenForm() {
|
||||
tokenGroup.value = "";
|
||||
tokenPurpose.value = "";
|
||||
tokenModes.value = [{ mode: "base", value: "" }];
|
||||
tokenSupersedes.value = "";
|
||||
editingTokenId.value = null;
|
||||
showAddToken.value = false;
|
||||
}
|
||||
@@ -262,6 +267,7 @@ function startEditToken(token: DesignToken) {
|
||||
tokenName.value = token.name;
|
||||
tokenGroup.value = token.group_name ?? "";
|
||||
tokenPurpose.value = token.purpose ?? "";
|
||||
tokenSupersedes.value = (token.supersedes ?? []).join(", ");
|
||||
const entries = Object.entries(token.value_by_mode);
|
||||
tokenModes.value = entries.length
|
||||
? entries.map(([mode, value]) => ({ mode, value }))
|
||||
@@ -288,6 +294,10 @@ async function submitToken() {
|
||||
value_by_mode: collectModes(),
|
||||
group_name: tokenGroup.value.trim() || null,
|
||||
purpose: tokenPurpose.value.trim() || null,
|
||||
supersedes: tokenSupersedes.value
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean),
|
||||
};
|
||||
if (editingTokenId.value !== null) {
|
||||
await updateDesignToken(editingTokenId.value, body);
|
||||
@@ -555,6 +565,20 @@ function isColourish(value: string): boolean {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="token-supersedes">Use instead of</label>
|
||||
<input
|
||||
id="token-supersedes" v-model="tokenSupersedes" class="input mono" type="text"
|
||||
placeholder="#fff, #ffffff"
|
||||
/>
|
||||
<p class="field-hint">
|
||||
Literal values that should be written as this token instead —
|
||||
comma separated. This is where a rule like "pure white is never
|
||||
text" belongs: not as a prohibition, but as the thing to write
|
||||
in its place. Checked by the source lint, not by this page.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<fieldset class="modes">
|
||||
<legend class="field-label">Values by mode</legend>
|
||||
<p class="field-hint">
|
||||
@@ -613,7 +637,12 @@ function isColourish(value: string): boolean {
|
||||
<code>{{ value }}</code>
|
||||
</span>
|
||||
</span>
|
||||
<span class="token-meta">{{ token.purpose || token.group_name || "" }}</span>
|
||||
<span class="token-meta">
|
||||
{{ token.purpose || token.group_name || "" }}
|
||||
<span v-if="token.supersedes.length" class="supersedes">
|
||||
instead of {{ token.supersedes.join(", ") }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="token-actions">
|
||||
<button class="btn-ghost btn-small" @click="startEditToken(token)">Edit</button>
|
||||
<button class="btn-ghost btn-small" @click="removeToken(token)">
|
||||
@@ -647,6 +676,9 @@ function isColourish(value: string): boolean {
|
||||
<div class="resolved-head">
|
||||
<code class="token-name">{{ token.name }}</code>
|
||||
<span v-if="token.purpose" class="token-meta">{{ token.purpose }}</span>
|
||||
<span v-if="token.supersedes.length" class="supersedes">
|
||||
instead of {{ token.supersedes.join(", ") }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- One badge only when every mode agrees on its source. -->
|
||||
@@ -1034,6 +1066,12 @@ function isColourish(value: string): boolean {
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.supersedes {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.mode-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -147,6 +147,7 @@ async def create_design_token(
|
||||
value_by_mode: dict | None = None,
|
||||
group_name: str = "",
|
||||
purpose: str = "",
|
||||
supersedes: list | None = None,
|
||||
order_index: int = 0,
|
||||
) -> dict:
|
||||
"""Add a token to a design system.
|
||||
@@ -162,6 +163,12 @@ async def create_design_token(
|
||||
group_name: Free-text grouping — "surface", "text", "radius", whatever
|
||||
this system's own vocabulary is.
|
||||
purpose: What the token is for, e.g. "page bg, deepest surface".
|
||||
supersedes: Literal values this token should be used INSTEAD OF, e.g.
|
||||
["#fff", "#ffffff"]. This is how a design system records what a
|
||||
prohibition was trying to say — not "white is banned" but "write
|
||||
this token instead". Declare it rather than expecting it to be
|
||||
inferred: a superseded literal and the token's own value are
|
||||
usually different values, so nothing can connect them by matching.
|
||||
order_index: Display position within its group.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
@@ -172,6 +179,7 @@ async def create_design_token(
|
||||
value_by_mode=value_by_mode,
|
||||
group_name=group_name or None,
|
||||
purpose=purpose or None,
|
||||
supersedes=supersedes,
|
||||
order_index=order_index,
|
||||
)
|
||||
if token is None:
|
||||
@@ -192,12 +200,14 @@ async def update_design_token(
|
||||
value_by_mode: dict | None = None,
|
||||
group_name: str = "",
|
||||
purpose: str = "",
|
||||
supersedes: list | None = None,
|
||||
order_index: int = -1,
|
||||
) -> dict:
|
||||
"""Update a token. Empty/None args leave a field unchanged.
|
||||
|
||||
`value_by_mode` REPLACES the whole map rather than merging into it, so send
|
||||
every mode you want the token to keep.
|
||||
`value_by_mode` and `supersedes` REPLACE their whole value rather than
|
||||
merging into it, so send every entry you want the token to keep. Pass `[]`
|
||||
to clear `supersedes` entirely.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
fields: dict = {}
|
||||
@@ -209,6 +219,10 @@ async def update_design_token(
|
||||
fields["group_name"] = group_name
|
||||
if purpose:
|
||||
fields["purpose"] = purpose
|
||||
# `is not None`, not truthiness: `[]` is a meaningful edit (drop every
|
||||
# superseded literal) and would otherwise be unreachable.
|
||||
if supersedes is not None:
|
||||
fields["supersedes"] = supersedes
|
||||
if order_index >= 0:
|
||||
fields["order_index"] = order_index
|
||||
token = await ds_svc.update_token(uid, token_id, **fields)
|
||||
|
||||
@@ -109,6 +109,24 @@ class DesignToken(Base, TimestampMixin, SoftDeleteMixin):
|
||||
# vocabulary, and a whitelist would bake one install's kit into the schema.
|
||||
group_name: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
purpose: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# Literal values this token should be used INSTEAD OF, e.g. ["#fff",
|
||||
# "#ffffff"] on a text-on-action token.
|
||||
#
|
||||
# This is how a design system records the thing a prohibition was trying to
|
||||
# say. "Pure white is never text" is the shadow of a positive fact — text is
|
||||
# Parchment — and a system that stores what things ARE has no row for a ban.
|
||||
# Recording the replacement keeps the check and makes it actionable: a
|
||||
# finding can name what to write instead of merely objecting.
|
||||
#
|
||||
# It has to be DECLARED rather than inferred, because the superseded literal
|
||||
# and the token's own value are usually different colours (#fff is not
|
||||
# #E8E4D8). No value-matching rule could ever connect them.
|
||||
#
|
||||
# Consumed by the source lint (#2277), not by the drift panel: these
|
||||
# literals live in component CSS, which the panel cannot see and says so.
|
||||
supersedes: Mapped[list] = mapped_column(
|
||||
JSONB, nullable=False, default=list, server_default=text("'[]'::jsonb")
|
||||
)
|
||||
order_index: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
@@ -119,6 +137,7 @@ class DesignToken(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"value_by_mode": self.value_by_mode or {},
|
||||
"group_name": self.group_name,
|
||||
"purpose": self.purpose,
|
||||
"supersedes": self.supersedes or [],
|
||||
"order_index": self.order_index,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
|
||||
@@ -140,6 +140,7 @@ async def create_design_token(design_system_id: int):
|
||||
value_by_mode=data.get("value_by_mode"),
|
||||
group_name=data.get("group_name") or None,
|
||||
purpose=data.get("purpose") or None,
|
||||
supersedes=data.get("supersedes"),
|
||||
order_index=data.get("order_index") or 0,
|
||||
)
|
||||
if token is None:
|
||||
@@ -153,7 +154,10 @@ async def update_design_token(token_id: int):
|
||||
data = await request.get_json() or {}
|
||||
fields = {
|
||||
k: v for k, v in data.items()
|
||||
if k in ("name", "value_by_mode", "group_name", "purpose", "order_index")
|
||||
if k in (
|
||||
"name", "value_by_mode", "group_name", "purpose", "supersedes",
|
||||
"order_index",
|
||||
)
|
||||
}
|
||||
token = await ds_svc.update_token(_uid(), token_id, **fields)
|
||||
if token is None:
|
||||
|
||||
@@ -97,6 +97,7 @@ class ResolvedToken:
|
||||
contributions: dict[str, tuple[Contribution, ...]]
|
||||
group_name: str | None
|
||||
purpose: str | None
|
||||
supersedes: tuple[str, ...]
|
||||
order_index: int
|
||||
|
||||
@property
|
||||
@@ -132,6 +133,7 @@ class ResolvedToken:
|
||||
"name": self.name,
|
||||
"group_name": self.group_name,
|
||||
"purpose": self.purpose,
|
||||
"supersedes": list(self.supersedes),
|
||||
"order_index": self.order_index,
|
||||
"value_by_mode": self.value_by_mode,
|
||||
"origin_by_mode": self.origin_by_mode,
|
||||
@@ -206,7 +208,11 @@ def resolve_tokens(
|
||||
)
|
||||
|
||||
meta = metadata.setdefault(
|
||||
token.name, {"group_name": None, "purpose": None, "order_index": None}
|
||||
token.name,
|
||||
{
|
||||
"group_name": None, "purpose": None,
|
||||
"supersedes": None, "order_index": None,
|
||||
},
|
||||
)
|
||||
for field in ("group_name", "purpose"):
|
||||
if meta[field] is None:
|
||||
@@ -218,6 +224,13 @@ def resolve_tokens(
|
||||
# change that only touched a colour.
|
||||
if not meta["order_index"]:
|
||||
meta["order_index"] = getattr(token, "order_index", 0) or None
|
||||
# `supersedes` cascades on EMPTINESS, not on None: a child that
|
||||
# overrides a colour and says nothing about which literals it
|
||||
# replaces should keep the family's declaration, and an empty list
|
||||
# is what "said nothing" looks like once the column is NOT NULL.
|
||||
# A child that states its own list replaces the whole thing.
|
||||
if not meta["supersedes"]:
|
||||
meta["supersedes"] = tuple(getattr(token, "supersedes", None) or ()) or None
|
||||
|
||||
resolved = [
|
||||
ResolvedToken(
|
||||
@@ -227,6 +240,7 @@ def resolve_tokens(
|
||||
},
|
||||
group_name=metadata[name]["group_name"],
|
||||
purpose=metadata[name]["purpose"],
|
||||
supersedes=metadata[name]["supersedes"] or (),
|
||||
order_index=metadata[name]["order_index"] or 0,
|
||||
)
|
||||
for name, per_mode in contributions.items()
|
||||
|
||||
@@ -183,6 +183,7 @@ async def create_token(
|
||||
value_by_mode: dict | None = None,
|
||||
group_name: str | None = None,
|
||||
purpose: str | None = None,
|
||||
supersedes: list | None = None,
|
||||
order_index: int = 0,
|
||||
) -> DesignToken | None:
|
||||
if not await access.can_write_design_system(user_id, design_system_id):
|
||||
@@ -197,6 +198,10 @@ async def create_token(
|
||||
value_by_mode=value_by_mode or {},
|
||||
group_name=group_name,
|
||||
purpose=purpose,
|
||||
# `or []` for the same reason as value_by_mode above: the column is
|
||||
# NOT NULL so absence has one spelling, and None would store JSON
|
||||
# null instead of an empty array.
|
||||
supersedes=supersedes or [],
|
||||
order_index=order_index,
|
||||
)
|
||||
session.add(token)
|
||||
@@ -270,7 +275,10 @@ async def resolve_design_system(
|
||||
async def update_token(
|
||||
user_id: int, token_id: int, **fields: object
|
||||
) -> DesignToken | None:
|
||||
allowed = {"name", "value_by_mode", "group_name", "purpose", "order_index"}
|
||||
allowed = {
|
||||
"name", "value_by_mode", "group_name", "purpose", "supersedes",
|
||||
"order_index",
|
||||
}
|
||||
async with async_session() as session:
|
||||
token = await session.get(DesignToken, token_id)
|
||||
if token is None or token.deleted_at is not None:
|
||||
|
||||
@@ -102,10 +102,11 @@ def test_the_guard_survives_a_hierarchy_that_is_already_corrupt():
|
||||
# because resolve_tokens is pure and duck-typed — which is the whole reason it
|
||||
# lives here rather than inside the service.
|
||||
|
||||
def _token(name, value_by_mode, group_name=None, purpose=None, order_index=0):
|
||||
def _token(name, value_by_mode, group_name=None, purpose=None, order_index=0,
|
||||
supersedes=None):
|
||||
return SimpleNamespace(
|
||||
name=name, value_by_mode=value_by_mode, group_name=group_name,
|
||||
purpose=purpose, order_index=order_index,
|
||||
purpose=purpose, order_index=order_index, supersedes=supersedes or [],
|
||||
)
|
||||
|
||||
|
||||
@@ -310,3 +311,79 @@ def test_resolution_terminates_on_a_corrupt_hierarchy():
|
||||
assert set(resolved) == {"--fs-a", "--fs-b"}
|
||||
# Each system contributes exactly once, not endlessly.
|
||||
assert len(resolved["--fs-a"].contributions["base"]) == 1
|
||||
|
||||
|
||||
# --- supersedes -------------------------------------------------------------
|
||||
#
|
||||
# The declaration that replaces a prohibition. A design system stores what things
|
||||
# ARE, so "pure white is never text" has no row — but "write this token instead
|
||||
# of #fff" does, and it is the same fact stated forwards.
|
||||
|
||||
def test_supersedes_is_inherited_when_the_override_is_silent_about_it():
|
||||
"""LOAD-BEARING. 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."""
|
||||
resolved = _by_name(resolve_tokens(
|
||||
APP, PARENTS,
|
||||
{
|
||||
FAMILY: [_token("--fs-text", {"base": "#e8e4d8"}, supersedes=["#fff", "#ffffff"])],
|
||||
APP: [_token("--fs-text", {"base": "#f0ece0"})],
|
||||
},
|
||||
))
|
||||
text = resolved["--fs-text"]
|
||||
assert text.supersedes == ("#fff", "#ffffff")
|
||||
assert text.value_by_mode == {"base": "#f0ece0"} # the value still overrode
|
||||
|
||||
|
||||
def test_an_override_that_states_its_own_supersedes_replaces_the_list():
|
||||
"""Whole-list replacement, not a merge — an app that means "only #fff" must
|
||||
be able to say so without inheriting entries it deliberately dropped."""
|
||||
resolved = _by_name(resolve_tokens(
|
||||
APP, PARENTS,
|
||||
{
|
||||
FAMILY: [_token("--fs-text", {"base": "a"}, supersedes=["#fff", "#ffffff"])],
|
||||
APP: [_token("--fs-text", {"base": "b"}, supersedes=["#fff"])],
|
||||
},
|
||||
))
|
||||
assert resolved["--fs-text"].supersedes == ("#fff",)
|
||||
|
||||
|
||||
def test_a_token_that_supersedes_nothing_resolves_to_an_empty_tuple():
|
||||
"""Most tokens replace nothing. That has to be an empty sequence rather than
|
||||
None, so no caller has to test for two kinds of nothing."""
|
||||
resolved = _by_name(resolve_tokens(
|
||||
FAMILY, PARENTS, {FAMILY: [_token("--fs-radius-md", {"base": "8px"})]},
|
||||
))
|
||||
assert resolved["--fs-radius-md"].supersedes == ()
|
||||
|
||||
|
||||
def test_supersedes_survives_serialisation_as_a_list():
|
||||
resolved = _by_name(resolve_tokens(
|
||||
FAMILY, PARENTS,
|
||||
{FAMILY: [_token("--fs-text", {"base": "#e8e4d8"}, supersedes=["#fff"])]},
|
||||
))
|
||||
assert resolved["--fs-text"].to_dict()["supersedes"] == ["#fff"]
|
||||
|
||||
|
||||
def test_the_superseded_literal_need_not_match_the_tokens_own_value():
|
||||
"""The whole reason this is DECLARED rather than derived. `#fff` and
|
||||
Parchment are different colours, so a value-matching rule could never have
|
||||
connected them — which is why the prohibition looked unrepresentable until
|
||||
it was turned around."""
|
||||
resolved = _by_name(resolve_tokens(
|
||||
FAMILY, PARENTS,
|
||||
{FAMILY: [_token("--fs-text", {"base": "#e8e4d8"}, supersedes=["#fff"])]},
|
||||
))
|
||||
text = resolved["--fs-text"]
|
||||
assert text.value_by_mode["base"] not in text.supersedes
|
||||
|
||||
|
||||
def test_rows_without_a_supersedes_attribute_at_all_still_resolve():
|
||||
"""Duck-typed input: a caller passing rows from before the column existed
|
||||
must not crash the cascade."""
|
||||
legacy = SimpleNamespace(
|
||||
name="--fs-x", value_by_mode={"base": "a"},
|
||||
group_name=None, purpose=None, order_index=0,
|
||||
)
|
||||
resolved = _by_name(resolve_tokens(FAMILY, PARENTS, {FAMILY: [legacy]}))
|
||||
assert resolved["--fs-x"].supersedes == ()
|
||||
|
||||
@@ -189,3 +189,24 @@ async def test_resolve_returns_serialised_tokens_with_their_provenance():
|
||||
{"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
|
||||
|
||||
@@ -236,3 +236,41 @@ async def test_resolve_scopes_the_hierarchy_to_the_systems_OWNER_not_the_caller(
|
||||
|
||||
assert result == [] # empty chain, not None
|
||||
assert parent_map.await_args.args[1] == owner # NOT `caller`
|
||||
|
||||
|
||||
# --- supersedes (step 6's reframe) ------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_token_supersedes_defaults_to_an_empty_list_not_json_null():
|
||||
"""Same NOT NULL reasoning as value_by_mode: absence gets one spelling."""
|
||||
mock_session = _make_mock_session()
|
||||
captured = {}
|
||||
mock_session.add = MagicMock(
|
||||
side_effect=lambda obj: captured.update(supersedes=obj.supersedes)
|
||||
)
|
||||
with patch("scribe.services.design_systems.async_session") as mock_cls, \
|
||||
patch("scribe.services.design_systems.access") as acc:
|
||||
acc.can_write_design_system = AsyncMock(return_value=True)
|
||||
mock_cls.return_value = mock_session
|
||||
from scribe.services.design_systems import create_token
|
||||
await create_token(user_id=1, design_system_id=3, name="--fs-x", supersedes=None)
|
||||
assert captured["supersedes"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_token_records_the_literals_it_replaces():
|
||||
mock_session = _make_mock_session()
|
||||
captured = {}
|
||||
mock_session.add = MagicMock(
|
||||
side_effect=lambda obj: captured.update(supersedes=obj.supersedes)
|
||||
)
|
||||
with patch("scribe.services.design_systems.async_session") as mock_cls, \
|
||||
patch("scribe.services.design_systems.access") as acc:
|
||||
acc.can_write_design_system = AsyncMock(return_value=True)
|
||||
mock_cls.return_value = mock_session
|
||||
from scribe.services.design_systems import create_token
|
||||
await create_token(
|
||||
user_id=1, design_system_id=3, name="--fs-text-on-action",
|
||||
value_by_mode={"base": "#e8e4d8"}, supersedes=["#fff", "#ffffff"],
|
||||
)
|
||||
assert captured["supersedes"] == ["#fff", "#ffffff"]
|
||||
|
||||
Reference in New Issue
Block a user