Files
FabledScribe/tests/test_design_starter_roles.py
T
bvandeusenandClaude Opus 5 22f907c44d
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Failing after 46s
CI & Build / Build & push image (push) Skipped
feat(design): offer starter token ROLES at creation, never values
A literal gets written into a stylesheet when there is no role to reach for.
This codebase demonstrated it: the house style had no "text on a filled colour"
role, so 76 call sites wrote a pure-white literal — not out of defiance, but
because nothing existed to write instead. The correction was not a better ban
list; it was declaring the missing role (#2275, #2349).

So the useful moment is creation. A system whose roles are named on day one
never presents the occasion.

Ten groups, ~40 roles: surface, text, action, semantic, border, accent, radius,
space, motion, state. Operator's call was one flat list, every group
individually skippable — presets keyed to app shape (web / CLI / docs) were
rejected because they need the product to hold opinions about app categories,
and a wrong category is worse than a list someone prunes once.

TWO BOUNDARIES THIS HAS TO HOLD, both rule #115:

- The ROLES ship; the VALUES never do. Every seeded token has an empty
  value_by_mode, so a fresh system is a set of named, deliberately-unanswered
  questions. A test asserts no hex appears anywhere in the module — not just
  that tokens are blank, but that no palette hides in a comment waiting to be
  pasted in.
- The PREFIX is the install's. `--fs-` is FabledSword's convention, not the
  product's; the default is a neutral `--ds-` and callers pass their own.

Valueless roles are already legible downstream — render_stylesheet emits them
as commented-out declarations and stylesheet_for_system reports them under
`valueless` (#2299) — so "declared but undecided" reads correctly with nothing
new built.

Both surfaces, per rule #33: MCP gains starter_role_groups/token_prefix plus
list_starter_role_groups(); REST gains the same on POST plus
GET /api/design-systems/starter-roles. The parity enumeration is extended
rather than loosened.

Note create_design_system treats None and [] alike (seed nothing), while
starter_tokens treats None as "all". Deliberate: creation must never write 40
rows into a system whose caller never asked, and the everything-checked default
belongs in the UI where the operator can see it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-03 11:37:20 -04:00

137 lines
5.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""The starter role set (#2349).
The premise: a literal gets written when there is no role to reach for. This
codebase demonstrated it — the house style had no "text on a filled colour"
role, so 76 call sites wrote `color: #fff` (#2275). Naming the roles at
creation removes the occasion.
The tests that matter here are about the BOUNDARY, not the content. Roles ship
with the product; values never do. A default palette would be one operator's
taste shipped as product code (rule #115), and it would be very easy to add by
accident while "being helpful".
"""
import re
import pytest
from scribe.services import design_starter_roles as roles
def test_no_role_carries_a_VALUE():
"""THE rule-115 guard. Every seeded token is a named question with no
answer. The moment one ships a hex, the product is prescribing an install's
palette."""
for row in roles.starter_tokens():
assert row["value_by_mode"] == {}, f"{row['name']} shipped a value"
def _colour_literals(text: str) -> list[str]:
"""Hex colours in `text`, NOT counting issue references.
`#2275` is four hex-valid digits and also how this codebase cites an issue —
so the naive pattern flags its own documentation, which is the third time
that has happened here (#2353). A real colour either contains a letter
af or is a full 6/8-digit value; an all-decimal 3- or 4-digit match is an
issue number.
"""
out = []
for m in re.findall(r"#[0-9a-fA-F]{3,8}\b", text):
digits = m[1:]
if len(digits) not in (3, 4, 6, 8):
continue
if len(digits) in (6, 8) or any(c in "abcdefABCDEF" for c in digits):
out.append(m)
return out
def test_the_module_contains_no_colour_literals_at_all():
"""Belt and braces on the above, and the stronger claim: not just that
tokens are blank, but that no palette hides in a comment or a docstring
waiting to be pasted in. Checks the SOURCE, not the output."""
import pathlib
src = pathlib.Path(roles.__file__).read_text()
hexes = _colour_literals(src)
assert not hexes, f"colour literals in product code: {hexes}"
def test_the_colour_check_does_not_flag_issue_references():
"""Pins the exclusion above, because without it this file fails on its own
citations and the obvious 'fix' is to delete the check."""
assert _colour_literals("see #2275 and #2349") == []
assert _colour_literals("color: #fff") == ["#fff"]
assert _colour_literals("#E8E4D8 on #14171A") == ["#E8E4D8", "#14171A"]
assert _colour_literals("#000000") == ["#000000"]
def test_every_group_is_individually_selectable():
"""Operator's call: one flat list, all skippable. An install that wants
three tokens must be able to get three."""
only_text = roles.starter_tokens(["text"])
assert {r["group_name"] for r in only_text} == {"text"}
assert len(only_text) == 4
def test_empty_selection_yields_nothing_and_is_not_the_same_as_None():
"""`[]` is a real answer — "none of them" — and must not be read as
"unspecified, so give me everything". Getting this backwards would seed 40
rows into a system whose creator explicitly declined."""
assert roles.starter_tokens([]) == []
assert len(roles.starter_tokens(None)) > 30
def test_unknown_group_names_are_ignored_not_fatal():
"""This feeds a checkbox list. A stale name from an older client should not
fail an otherwise-fine creation."""
out = roles.starter_tokens(["text", "not-a-real-group"])
assert {r["group_name"] for r in out} == {"text"}
def test_the_prefix_is_the_installs_choice():
"""`--fs-` is FabledSword's convention, not the product's. Baking it in
would put one family's naming into every install."""
assert all(r["name"].startswith("--ds-") for r in roles.starter_tokens(["text"]))
custom = roles.starter_tokens(["text"], prefix="--acme-")
assert all(r["name"].startswith("--acme-") for r in custom)
assert "--acme-text-primary" in {r["name"] for r in custom}
def test_text_on_action_is_in_the_starter_set():
"""The specific role whose absence produced 76 literals. It is separate
from text-primary on purpose: the surfaces it sits on do not change with
the mode, while the page does — so reusing text-primary there passes in
dark and fails contrast in light (#2275)."""
names = {r["name"] for r in roles.starter_tokens(["text"])}
assert "--ds-text-on-action" in names
assert "--ds-text-primary" in names
def test_names_are_valid_custom_properties():
"""They go straight into a stylesheet; an invalid name is a silent no-op
rather than an error, which is the worst failure mode available."""
valid = re.compile(r"^--[A-Za-z0-9_-]+$")
for row in roles.starter_tokens():
assert valid.match(row["name"]), row["name"]
def test_no_duplicate_names_across_the_whole_set():
"""A design system has a partial-unique index on (system, name); a
duplicate in the starter set would make creation fail at the DB with a
constraint error rather than anything legible."""
names = [r["name"] for r in roles.starter_tokens()]
assert len(names) == len(set(names))
def test_every_role_states_a_purpose():
"""An unfilled role is only useful if it says what belongs there. "Colour
1" is a blank with extra steps."""
for row in roles.starter_tokens():
assert row["purpose"].strip(), row["name"]
def test_describe_groups_matches_what_starter_tokens_produces():
"""The catalogue a UI renders and the rows creation writes must not drift —
a checklist offering a group that seeds nothing is a lie in the UI."""
described = {g["group"]: g["token_count"] for g in roles.describe_groups()}
for group, count in described.items():
assert len(roles.starter_tokens([group])) == count