feat(design-systems): import a design system out of a rulebook's prose
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 21s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Successful in 38s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 21s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Successful in 38s
Milestone #254 step 3 (#2288). Reuses #251's prose extractor as the reader and adds the part that makes it an import rather than a list of claims. **The join is the whole trick.** A rulebook states a design system in two places and neither half is a token: one rule names the colours ("Obsidian #14171A (page bg, deepest surface)"), another names the custom properties (`--fs-obsidian/iron/slate`). The import pairs them on the word — `--fs-obsidian` ends with `obsidian` — which is the only reason it produces something usable instead of seventy empty names. The parenthetical becomes the token's purpose, which is the field a bare hex could never carry. **Prohibitions arrive as replacements, per the operator's reframe.** Rule 52 declares Parchment and forbids pure white in one breath, so the import emits "write --fs-parchment instead of #ffffff" — the same fact stated forwards. It attaches to the FIRST token that rule supplied a value for, not to every token of that rule, because claiming Vellum is also the replacement for white would be putting words in the rulebook's mouth. **A token the rulebook names but states no readable value for is still proposed, with an empty value.** Radius steps and type sizes are prose ("Small 4px") and nothing here parses them; inventing a parse per shape would be guessing. The name is real and the value needs a human, so the proposal says exactly that — and the UI leads with the COUNT of those, because an import that hid them would look more complete than it is. Preview is the default on both surfaces and in the UI. An import is a proposal: rulebooks are written aspirationally and some of what they describe was never built, so every entry carries the rule id and the sentence it came from and a reviewer can check the claim rather than trust it. Existing token names are never overwritten. A value already in the record was put there deliberately — most likely correcting this importer — so a re-run fills gaps and lists the rest as skipped, which also makes it safe to repeat. Colours the rulebook names but never exposes as a custom property produce no token: it never asked for one, and inventing a name would put something in the record no rule sanctions.
This commit is contained in:
@@ -274,3 +274,102 @@ async def test_create_token_records_the_literals_it_replaces():
|
||||
value_by_mode={"base": "#e8e4d8"}, supersedes=["#fff", "#ffffff"],
|
||||
)
|
||||
assert captured["supersedes"] == ["#fff", "#ffffff"]
|
||||
|
||||
|
||||
# --- import from a rulebook (step 3) ----------------------------------------
|
||||
|
||||
def _proposal(name, value=None):
|
||||
from scribe.services.design_rulebook_import import ProposedToken
|
||||
return ProposedToken(name=name, value_by_mode={"base": value} if value else {})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_denied_without_write_on_the_system():
|
||||
with patch("scribe.services.design_systems.access") as acc:
|
||||
acc.can_write_design_system = AsyncMock(return_value=False)
|
||||
from scribe.services.design_systems import import_from_rulebook
|
||||
assert await import_from_rulebook(1, 3, 9) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preview_proposes_without_creating_anything():
|
||||
"""apply=False is the default because an import is a PROPOSAL. A preview
|
||||
that quietly wrote would make the review step decorative."""
|
||||
created = AsyncMock()
|
||||
with patch("scribe.services.design_systems.access") as acc, \
|
||||
patch("scribe.services.design_systems.create_token", created), \
|
||||
patch("scribe.services.design_systems.list_tokens", AsyncMock(return_value=[])), \
|
||||
patch("scribe.services.rulebooks.list_rules", AsyncMock(return_value=[object()])), \
|
||||
patch("scribe.services.design_rulebook_import.propose_tokens",
|
||||
MagicMock(return_value=[_proposal("--fs-obsidian", "#14171a")])):
|
||||
acc.can_write_design_system = AsyncMock(return_value=True)
|
||||
from scribe.services.design_systems import import_from_rulebook
|
||||
report = await import_from_rulebook(1, 3, 9, apply=False)
|
||||
|
||||
assert [p["name"] for p in report["proposed"]] == ["--fs-obsidian"]
|
||||
assert report["created"] == []
|
||||
created.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_never_overwrites_a_token_the_system_already_defines():
|
||||
"""LOAD-BEARING for re-running it. A value already in the record was put
|
||||
there deliberately — very likely correcting this importer — and a second run
|
||||
must fill gaps rather than undo the correction."""
|
||||
existing = MagicMock()
|
||||
existing.name = "--fs-obsidian"
|
||||
created_token = MagicMock()
|
||||
created_token.to_dict.return_value = {"name": "--fs-iron"}
|
||||
|
||||
with patch("scribe.services.design_systems.access") as acc, \
|
||||
patch("scribe.services.design_systems.create_token",
|
||||
AsyncMock(return_value=created_token)) as create, \
|
||||
patch("scribe.services.design_systems.list_tokens",
|
||||
AsyncMock(return_value=[existing])), \
|
||||
patch("scribe.services.rulebooks.list_rules", AsyncMock(return_value=[object()])), \
|
||||
patch("scribe.services.design_rulebook_import.propose_tokens",
|
||||
MagicMock(return_value=[
|
||||
_proposal("--fs-obsidian", "#000000"),
|
||||
_proposal("--fs-iron", "#1e2228"),
|
||||
])):
|
||||
acc.can_write_design_system = AsyncMock(return_value=True)
|
||||
from scribe.services.design_systems import import_from_rulebook
|
||||
report = await import_from_rulebook(1, 3, 9, apply=True)
|
||||
|
||||
assert report["skipped"] == ["--fs-obsidian"]
|
||||
assert [c["name"] for c in report["created"]] == ["--fs-iron"]
|
||||
assert create.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_valueless_proposal_is_still_created():
|
||||
"""The rulebook names it, so its absence from the system is itself a
|
||||
finding. A named token with a blank value says "this exists and needs
|
||||
deciding"; silence says nothing at all."""
|
||||
token = MagicMock()
|
||||
token.to_dict.return_value = {"name": "--fs-radius-sm"}
|
||||
with patch("scribe.services.design_systems.access") as acc, \
|
||||
patch("scribe.services.design_systems.create_token",
|
||||
AsyncMock(return_value=token)) as create, \
|
||||
patch("scribe.services.design_systems.list_tokens", AsyncMock(return_value=[])), \
|
||||
patch("scribe.services.rulebooks.list_rules", AsyncMock(return_value=[object()])), \
|
||||
patch("scribe.services.design_rulebook_import.propose_tokens",
|
||||
MagicMock(return_value=[_proposal("--fs-radius-sm")])):
|
||||
acc.can_write_design_system = AsyncMock(return_value=True)
|
||||
from scribe.services.design_systems import import_from_rulebook
|
||||
report = await import_from_rulebook(1, 3, 9, apply=True)
|
||||
|
||||
assert len(report["created"]) == 1
|
||||
assert create.await_args.kwargs["value_by_mode"] == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_unreadable_or_empty_rulebook_reports_nothing_rather_than_failing():
|
||||
"""An install can point this at any rulebook, and most rulebooks are not
|
||||
design rulebooks (rule #115)."""
|
||||
with patch("scribe.services.design_systems.access") as acc, \
|
||||
patch("scribe.services.rulebooks.list_rules", AsyncMock(return_value=[])):
|
||||
acc.can_write_design_system = AsyncMock(return_value=True)
|
||||
from scribe.services.design_systems import import_from_rulebook
|
||||
report = await import_from_rulebook(1, 3, 9, apply=True)
|
||||
assert report == {"rulebook_id": 9, "proposed": [], "created": [], "skipped": []}
|
||||
|
||||
Reference in New Issue
Block a user