diff --git a/frontend/src/api/designSystems.ts b/frontend/src/api/designSystems.ts index f9333fe..9bae46d 100644 --- a/frontend/src/api/designSystems.ts +++ b/frontend/src/api/designSystems.ts @@ -136,42 +136,6 @@ export const setProjectDesignSystem = ( { design_system_id: designSystemId }, ); -/** One token an import proposes, with the evidence for it. */ -export interface ProposedToken { - name: string; - value_by_mode: Record; - group_name: string | null; - purpose: string | null; - supersedes: string[]; - source_rule_id: number | null; - source_rule_title: string; - source_context: string; -} - -export interface ImportReport { - rulebook_id: number; - proposed: ProposedToken[]; - created: DesignToken[]; - /** Proposals whose name the system already defines. Never overwritten. */ - skipped: string[]; -} - -/** Seed a design system from a rulebook that describes one in prose. - * - * Defaults to a PREVIEW: an import is a proposal, since rulebooks are written - * aspirationally and some of what they describe was never built. Pass - * `apply: true` to write. Existing token names are never overwritten, so a - * second run fills gaps and reports the rest. */ -export const importFromRulebook = ( - designSystemId: number, - rulebookId: number, - apply = false, -) => - apiPost(`/api/design-systems/${designSystemId}/import`, { - rulebook_id: rulebookId, - apply, - }); - export interface StylesheetResult { design_system_id: number; /** The master sheet: purpose tokens only, no element or class rules. */ diff --git a/frontend/src/views/DesignSystemsView.vue b/frontend/src/views/DesignSystemsView.vue index 81c85f3..c3885f9 100644 --- a/frontend/src/views/DesignSystemsView.vue +++ b/frontend/src/views/DesignSystemsView.vue @@ -31,17 +31,14 @@ import { fetchResolvedTokens, checkSnippets, fetchStylesheet, - importFromRulebook, updateDesignSystem, updateDesignToken, type DesignSystem, type DesignToken, - type ImportReport, type ResolvedToken, type SnippetCheck, type StylesheetResult, } from "@/api/designSystems"; -import { listRulebooks, type Rulebook } from "@/api/rulebooks"; import DesignTabs from "@/components/DesignTabs.vue"; import { ApiError } from "@/api/client"; import { useToastStore } from "@/stores/toast"; @@ -472,56 +469,6 @@ watch(selectedId, () => { snippetCheck.value = null; }); -// --- import from a rulebook ------------------------------------------------- - -const rulebooks = ref([]); -const importRulebookId = ref(null); -const importReport = ref(null); -const importing = ref(false); -const showImport = ref(false); - -async function loadRulebooks() { - try { - rulebooks.value = await listRulebooks(); - } catch { - rulebooks.value = []; - } -} -onMounted(loadRulebooks); - -/** Preview, then apply — never one step. - * - * A rulebook is prose written aspirationally, so an import is a proposal and - * the operator has to be able to read it before it becomes records. Applying - * from an unread preview is still one click; applying without one is not - * possible, which is the point. */ -async function runImport(apply: boolean) { - if (selectedId.value === null || importRulebookId.value === null || importing.value) return; - importing.value = true; - try { - const report = await importFromRulebook(selectedId.value, importRulebookId.value, apply); - importReport.value = report; - if (apply) { - await loadDetail(selectedId.value); - toast.show(`Imported ${report.created.length} tokens`); - } - } catch { - toast.show("Import failed", "error"); - } finally { - importing.value = false; - } -} - -/** Proposals the rulebook names but states no readable value for. - * - * Surfaced as a count rather than buried: the rulebook writes radius steps and - * type sizes as prose ("Small 4px"), which nothing parses, so these arrive as - * names awaiting a value. That is an honest gap, and hiding it would make the - * import look more complete than it is. */ -const proposalsWithoutValues = computed( - () => importReport.value?.proposed.filter((p) => !Object.keys(p.value_by_mode).length).length ?? 0, -); - function isColourish(value: string): boolean { return /^(#|rgba?\(|hsla?\(|color-mix\()/.test(value.trim()); } @@ -836,106 +783,6 @@ function isColourish(value: string): boolean { - -
-
-

Import from a rulebook

- -
- - -
-
@@ -1565,10 +1412,6 @@ textarea.input { padding: 0.15rem 0; } -.import-summary { - margin-top: 0.75rem; -} - .supersedes { font-size: 0.75rem; color: var(--color-text-muted); diff --git a/src/scribe/mcp/tools/design_systems.py b/src/scribe/mcp/tools/design_systems.py index f8777bb..7043a34 100644 --- a/src/scribe/mcp/tools/design_systems.py +++ b/src/scribe/mcp/tools/design_systems.py @@ -210,42 +210,6 @@ async def check_snippets_against_design_system( return result -async def import_design_system_from_rulebook( - design_system_id: int, - rulebook_id: int, - apply: bool = False, -) -> dict: - """Seed a design system from a rulebook that describes one in prose. - - Reads the rulebook's colour and token declarations and proposes the tokens - they add up to — joining "Obsidian #14171A" in one rule to `--fs-obsidian` - in another, since neither alone is a token. - - Defaults to a PREVIEW. An import is a proposal: rulebooks are written - aspirationally and some of what they describe was never built, so read - `proposed` before setting apply=True. Every entry carries the rule and - sentence it came from so the claim can be checked rather than trusted. - - Existing token names are never overwritten — a second run fills gaps and - lists the rest under `skipped`, so it is safe to repeat. - - Args: - design_system_id: The system to seed. - rulebook_id: The rulebook to read. - apply: False (default) previews; True writes the tokens. - """ - uid = current_user_id() - report = await ds_svc.import_from_rulebook( - uid, design_system_id, rulebook_id, apply=apply, - ) - if report is None: - raise ValueError( - f"design system {design_system_id} not writable, or rulebook " - f"{rulebook_id} not readable" - ) - return report - - # ── Tokens ────────────────────────────────────────────────────────────── async def create_design_token( @@ -388,7 +352,6 @@ def register(mcp) -> None: delete_design_system, get_design_system_stylesheet, check_snippets_against_design_system, - import_design_system_from_rulebook, create_design_token, list_design_tokens, update_design_token, diff --git a/src/scribe/routes/design_systems.py b/src/scribe/routes/design_systems.py index 4c0e015..6487ed5 100644 --- a/src/scribe/routes/design_systems.py +++ b/src/scribe/routes/design_systems.py @@ -156,30 +156,6 @@ async def check_snippets_against_system(design_system_id: int): return jsonify(result) -@design_systems_bp.post("/design-systems//import") -@login_required -async def import_design_system(design_system_id: int): - """Seed a design system from a rulebook's prose. - - `{"rulebook_id": N}` previews; add `"apply": true` to write. Preview is the - default because an import is a PROPOSAL — rulebooks are written - aspirationally and some of what they describe was never built. - - Existing token names are never overwritten, so a second run fills gaps and - reports the rest rather than undoing corrections. - """ - data = await request.get_json() or {} - rulebook_id = data.get("rulebook_id") - if not isinstance(rulebook_id, int) or rulebook_id <= 0: - return jsonify({"error": "rulebook_id is required"}), 400 - report = await ds_svc.import_from_rulebook( - _uid(), design_system_id, rulebook_id, apply=bool(data.get("apply")), - ) - if report is None: - return _not_found("design system or rulebook") - return jsonify(report) - - # ── Tokens ────────────────────────────────────────────────────────────── @design_systems_bp.get("/design-systems//tokens") diff --git a/src/scribe/services/design_rulebook_import.py b/src/scribe/services/design_rulebook_import.py index 34573c5..7f637fd 100644 --- a/src/scribe/services/design_rulebook_import.py +++ b/src/scribe/services/design_rulebook_import.py @@ -230,175 +230,3 @@ async def design_expectations(user_id: int) -> ExpectationSet: return ExpectationSet(rulebook_id=rulebook_id) return ExpectationSet(rulebook_id=rulebook_id, expectations=extract_expectations(rules)) - - -# --------------------------------------------------------------------------- -# Import — turning a rulebook into a PROPOSED design system (milestone #254 step 3) -# --------------------------------------------------------------------------- -# -# The extraction above answers "what claims does this rulebook make?", which is -# what a drift panel needs. Seeding a design system needs a different shape: -# tokens with names AND values, which the rulebook states in two separate -# places. Rule 51 names the colours ("Obsidian #14171A (page bg, deepest -# surface)"); rule 72 names the custom properties (`--fs-obsidian/iron/...`). -# Neither alone is a token. -# -# So the import joins them on the WORD: `--fs-obsidian` ends with `obsidian`, -# and a colour called Obsidian was declared elsewhere. That join is mechanical -# and it is the only reason an import produces something usable rather than 70 -# empty names. -# -# AN IMPORT IS A PROPOSAL, NOT A TRUTH. Rulebooks are written aspirationally and -# some of what they describe was never built. Every proposed token therefore -# carries the rule and sentence it came from, so a reviewer can check the claim -# rather than trust it. - -# "Obsidian #14171A (page bg, deepest surface)" — a capitalised name, a hex, and -# an optional parenthetical saying what it is for. -_NAMED_COLOUR = re.compile( - r"\b([A-Z][A-Za-z]*(?:\s+[A-Z][A-Za-z]*)?)\s+(#[0-9a-fA-F]{3,8})\b" - r"(?:\s*\(([^)]{0,80})\))?" -) - - -@dataclass -class ProposedToken: - """One token an import suggests, with the evidence for it. - - `value_by_mode` is empty when the rulebook names the token but states no - value this can read — radius steps, type sizes and durations are prose - (`Small 4px`), not hex, and inventing a parse for each would be guessing. - An empty value is the honest output: the name is real, the value needs a - human. Reporting how many landed that way is part of the result. - """ - - name: str - value_by_mode: dict[str, str] = field(default_factory=dict) - group_name: str | None = None - purpose: str | None = None - supersedes: list[str] = field(default_factory=list) - source_rule_id: int | None = None - source_rule_title: str = "" - source_context: str = "" - - def as_dict(self) -> dict: - return { - "name": self.name, - "value_by_mode": self.value_by_mode, - "group_name": self.group_name, - "purpose": self.purpose, - "supersedes": self.supersedes, - "source_rule_id": self.source_rule_id, - "source_rule_title": self.source_rule_title, - "source_context": self.source_context, - } - - -@dataclass -class _NamedColour: - value: str - purpose: str | None - rule_id: int - rule_title: str - context: str - - -def _group_from_name(name: str) -> str | None: - """`--fs-radius-sm` -> "radius"; `--fs-obsidian` -> None. - - A family name has a middle segment; a flat one does not. Structural rather - than a lookup table, so it works on a naming scheme this code has never - seen — which rule #115 requires, since the prefix is each install's own. - """ - parts = [p for p in name.lstrip("-").split("-") if p] - return parts[1] if len(parts) >= 3 else None - - -def _named_colours(rules: list[Rule]) -> dict[str, _NamedColour]: - """Every `Name #hex (purpose)` a rulebook declares, keyed by lowercased name. - - First declaration wins, matching `extract_expectations` — the rule that - introduces a colour is the one worth citing. - """ - out: dict[str, _NamedColour] = {} - for rule in rules: - text = " ".join(filter(None, [rule.statement or "", rule.how_to_apply or ""])) - for sentence in _SENTENCE_SPLIT.split(text): - if not sentence.strip() or _is_negated(sentence): - continue - for match in _NAMED_COLOUR.finditer(sentence): - label, raw_hex, purpose = match.groups() - value = normalize_hex(raw_hex) - key = label.strip().lower() - if not value or key in out: - continue - out[key] = _NamedColour( - value=value, - purpose=(purpose or "").strip() or None, - rule_id=int(rule.id), - rule_title=rule.title, - context=sentence.strip(), - ) - return out - - -def _prohibitions_by_rule(rules: list[Rule]) -> dict[int, list[str]]: - """Forbidden colours, grouped by the rule that forbids them.""" - out: dict[int, list[str]] = {} - for expectation in extract_expectations(rules): - if expectation.kind == "prohibited_color": - out.setdefault(expectation.rule_id, []).append(expectation.value) - return out - - -def propose_tokens(rules: list[Rule]) -> list[ProposedToken]: - """Turn a rulebook into the design system it is describing. - - One proposal per custom-property NAME the rulebook declares, valued from the - named colour whose word matches the token's last segment. - - Prohibitions attach as `supersedes` on the first token drawn from the SAME - rule that forbids them. Rule 52 declares Parchment/Vellum/Ash and forbids - pure white in one breath, so pure white becomes "write --fs-parchment - instead" — the positive form of what the rule was saying. Guessing which - token inherits the prohibition is acceptable precisely because this is a - proposal a human reviews; guessing silently would not be, which is why every - entry carries its source sentence. - """ - colours = _named_colours(rules) - prohibited = _prohibitions_by_rule(rules) - claimed_prohibitions: set[int] = set() - - proposals: list[ProposedToken] = [] - seen: set[str] = set() - - for expectation in extract_expectations(rules): - if expectation.kind != "token" or expectation.value in seen: - continue - seen.add(expectation.value) - - suffix = expectation.value.rsplit("-", 1)[-1].lower() - colour = colours.get(suffix) - - proposal = ProposedToken( - name=expectation.value, - value_by_mode={"base": colour.value} if colour else {}, - group_name=_group_from_name(expectation.value), - purpose=colour.purpose if colour else None, - source_rule_id=colour.rule_id if colour else expectation.rule_id, - source_rule_title=colour.rule_title if colour else expectation.rule_title, - source_context=colour.context if colour else expectation.context, - ) - - # The prohibition rides on the first token that rule supplied a value - # for — its primary. Attaching it to every token of that rule would - # claim the rulebook said something it didn't. - if colour and colour.rule_id not in claimed_prohibitions: - forbidden = prohibited.get(colour.rule_id) - if forbidden: - proposal.supersedes = list(forbidden) - claimed_prohibitions.add(colour.rule_id) - - proposals.append(proposal) - - return proposals diff --git a/src/scribe/services/design_systems.py b/src/scribe/services/design_systems.py index de6a1af..9f8b278 100644 --- a/src/scribe/services/design_systems.py +++ b/src/scribe/services/design_systems.py @@ -342,77 +342,6 @@ async def set_project_design_system( return True -# --- import from a rulebook ------------------------------------------------- - -async def import_from_rulebook( - user_id: int, - design_system_id: int, - rulebook_id: int, - apply: bool = False, -) -> dict | None: - """Propose (and optionally create) tokens for a system from a rulebook. - - Returns None when the caller may not write the system or read the rulebook. - Otherwise a report with three lists, and the split between them is the whole - point of running it with `apply=False` first: - - proposed — everything the rulebook describes, each entry carrying the - rule and sentence it came from - created — what was actually written (empty unless `apply`) - skipped — proposals whose name the system already defines - - **Existing tokens are never overwritten.** An import is a proposal built by - reading prose; a value already in the record was put there deliberately, and - a re-run must not undo an operator's correction. That also makes the whole - operation safe to repeat — it fills gaps and reports the rest. - - Tokens with no value are still created when `apply` is set. The rulebook - names them, so their absence from the system is itself a finding, and a - named token with a blank value says "this exists and needs deciding" where - silence says nothing at all. - """ - if not await access.can_write_design_system(user_id, design_system_id): - return None - - from scribe.services import rulebooks as rulebooks_svc - from scribe.services.design_rulebook_import import propose_tokens - - rules = await rulebooks_svc.list_rules(user_id, rulebook_id=rulebook_id) - if not rules: - return {"rulebook_id": rulebook_id, "proposed": [], "created": [], "skipped": []} - - proposals = propose_tokens(rules) - existing = {t.name for t in await list_tokens(user_id, design_system_id)} - - created: list[dict] = [] - skipped: list[str] = [] - for index, proposal in enumerate(proposals): - if proposal.name in existing: - skipped.append(proposal.name) - continue - if not apply: - continue - token = await create_token( - user_id, - design_system_id=design_system_id, - name=proposal.name, - value_by_mode=proposal.value_by_mode, - group_name=proposal.group_name, - purpose=proposal.purpose, - supersedes=proposal.supersedes, - order_index=index, - ) - if token is not None: - created.append(token.to_dict()) - - return { - "rulebook_id": rulebook_id, - "proposed": [p.as_dict() for p in proposals], - "created": created, - "skipped": skipped, - } - - # --- the master sheet ------------------------------------------------------- async def stylesheet_for_system( diff --git a/tests/test_design_rulebook_propose.py b/tests/test_design_rulebook_propose.py deleted file mode 100644 index 4197f33..0000000 --- a/tests/test_design_rulebook_propose.py +++ /dev/null @@ -1,145 +0,0 @@ -"""Rulebook prose -> a PROPOSED design system (milestone #254 step 3). - -The extraction tested in test_design_rulebook_import.py answers "what claims does -this rulebook make". This answers a harder question — "what design system is it -describing" — which needs the two halves joined: one rule names the colours, -another names the custom properties, and neither alone is a token. - -Rule text is representative rather than copied from this operator's rulebook -(rule #115): a test that only passes against their exact wording would be -testing the instance. -""" -from types import SimpleNamespace - -from scribe.services.design_rulebook_import import propose_tokens - - -def _rule(rule_id, title, statement, how_to_apply=None): - return SimpleNamespace( - id=rule_id, title=title, statement=statement, how_to_apply=how_to_apply - ) - - -SURFACES = _rule( - 51, "Universal surfaces", - "Obsidian #14171A (page bg, deepest surface), Iron #1E2228 (cards), " - "Slate #2C313A (hovered surfaces).", -) -TEXT = _rule( - 52, "Text palette", - "Text tokens: Parchment #E8E4D8 (primary text), Vellum #C2BFB4 (secondary). " - "Pure white #FFFFFF is NEVER used as text color.", -) -PROPERTIES = _rule( - 72, "CSS custom properties", - "Expose the system as custom properties: surfaces " - "(--fs-obsidian/iron/slate), text (--fs-parchment/vellum), and radius " - "(--fs-radius-sm/md/lg).", -) - - -def _by_name(proposals): - return {p.name: p for p in proposals} - - -# --- the join --------------------------------------------------------------- - -def test_a_token_takes_its_value_from_the_colour_of_the_same_name(): - """THE mechanism. `--fs-obsidian` and "Obsidian #14171A" are declared in - different rules and neither is a token on its own. Joining them on the word - is the only reason an import produces something usable instead of a list of - empty names.""" - proposals = _by_name(propose_tokens([SURFACES, PROPERTIES])) - assert proposals["--fs-obsidian"].value_by_mode == {"base": "#14171a"} - assert proposals["--fs-iron"].value_by_mode == {"base": "#1e2228"} - - -def test_the_parenthetical_becomes_the_tokens_purpose(): - """Rulebooks say what a colour is FOR right beside its value, and that is - the field a bare hex can never carry.""" - proposals = _by_name(propose_tokens([SURFACES, PROPERTIES])) - assert proposals["--fs-obsidian"].purpose == "page bg, deepest surface" - - -def test_a_token_with_no_matching_colour_is_proposed_with_no_value(): - """HONEST OUTPUT, not a failure. The rulebook states radius steps as prose - ("Small 4px"), which nothing here parses. The name is real and the value - needs a human — proposing the name with an empty value says exactly that, - where dropping it would hide a token the rulebook asked for.""" - proposals = _by_name(propose_tokens([SURFACES, PROPERTIES])) - assert proposals["--fs-radius-sm"].value_by_mode == {} - assert "--fs-radius-lg" in proposals - - -def test_every_proposal_carries_the_rule_and_sentence_it_came_from(): - """An import is a proposal a human reviews, and a claim you cannot trace is - a claim you have to take on faith.""" - obsidian = _by_name(propose_tokens([SURFACES, PROPERTIES]))["--fs-obsidian"] - assert obsidian.source_rule_id == 51 - assert obsidian.source_rule_title == "Universal surfaces" - assert "Obsidian #14171A" in obsidian.source_context - - -# --- prohibitions become replacements --------------------------------------- - -def test_a_prohibition_becomes_supersedes_on_that_rules_primary_token(): - """The reframe, end to end. Rule 52 declares Parchment and forbids pure - white in one breath; the import turns that into "write --fs-parchment - instead of #ffffff" — the same fact, stated forwards, and actionable.""" - proposals = _by_name(propose_tokens([TEXT, PROPERTIES])) - assert proposals["--fs-parchment"].supersedes == ["#ffffff"] - - -def test_a_prohibition_attaches_to_one_token_not_every_token_of_its_rule(): - """Rule 52 declares two colours. Attaching the prohibition to both would - claim the rulebook said something it didn't — that Vellum is also the - replacement for white.""" - proposals = _by_name(propose_tokens([TEXT, PROPERTIES])) - assert proposals["--fs-vellum"].supersedes == [] - - -def test_a_forbidden_colour_never_becomes_a_token_value(): - """Sentence-scoped negation carried through to the import: #FFFFFF appears - in rule 52 as a hex, and a naive read would make it Parchment's value.""" - proposals = propose_tokens([TEXT, PROPERTIES]) - for proposal in proposals: - assert proposal.value_by_mode.get("base") != "#ffffff" - - -# --- grouping --------------------------------------------------------------- - -def test_a_family_token_is_grouped_by_its_middle_segment(): - """`--fs-radius-sm` -> "radius". Structural, so it works on a naming scheme - this code has never seen — the prefix is each install's own (rule #115).""" - proposals = _by_name(propose_tokens([SURFACES, PROPERTIES])) - assert proposals["--fs-radius-sm"].group_name == "radius" - - -def test_a_flat_token_is_left_ungrouped_rather_than_guessed_at(): - proposals = _by_name(propose_tokens([SURFACES, PROPERTIES])) - assert proposals["--fs-obsidian"].group_name is None - - -# --- shape ------------------------------------------------------------------ - -def test_each_token_name_is_proposed_exactly_once(): - """The slash shorthand expands and rules repeat colours; neither may produce - a duplicate, since two live rows with one name is the duplicate-definition - bug the unique index exists to refuse.""" - names = [p.name for p in propose_tokens([SURFACES, TEXT, PROPERTIES])] - assert len(names) == len(set(names)) - - -def test_a_rulebook_that_names_no_tokens_proposes_nothing(): - """Most rulebooks are not design rulebooks. That has to be an empty result - rather than an error — an install can point this at anything.""" - unrelated = _rule(1, "Branching", "Work happens on the dev branch.") - assert propose_tokens([unrelated]) == [] - - -def test_colours_declared_without_a_token_name_are_not_invented_into_tokens(): - """A rulebook naming a colour it never exposes as a custom property has not - asked for a token, and inventing a name for it would put a token in the - record that no rule sanctions.""" - proposals = propose_tokens([SURFACES]) - assert proposals == [] diff --git a/tests/test_routes_design_systems.py b/tests/test_routes_design_systems.py index 190dd4e..8b0b54a 100644 --- a/tests/test_routes_design_systems.py +++ b/tests/test_routes_design_systems.py @@ -24,7 +24,7 @@ def test_route_handlers_callable(): for name in ( "list_design_systems", "create_design_system", "get_design_system", "update_design_system", "delete_design_system", "resolve_design_system", - "import_design_system", "get_design_system_stylesheet", + "get_design_system_stylesheet", "check_snippets_against_system", "list_design_tokens", "create_design_token", "update_design_token", "delete_design_token", "set_project_design_system", @@ -46,7 +46,6 @@ def test_every_endpoint_is_reachable_on_the_app(): "/api/design-systems", "/api/design-systems/", "/api/design-systems//resolved", - "/api/design-systems//import", "/api/design-systems//stylesheet", "/api/design-systems//snippet-check", "/api/design-systems//tokens", @@ -62,7 +61,7 @@ def test_service_functions_take_user_id(): "create_design_system", "list_design_systems", "get_design_system", "update_design_system", "delete_design_system", "resolve_design_system", "create_token", "list_tokens", "update_token", "delete_token", - "set_project_design_system", "import_from_rulebook", + "set_project_design_system", "stylesheet_for_system", "check_snippets_against_system", ): fn = getattr(svc, fn_name) @@ -91,11 +90,9 @@ def test_agent_and_web_surfaces_stay_at_parity(): assert callable(getattr(tools, name)), f"MCP tool missing: {name}" assert callable(getattr(routes, name)), f"REST route missing: {name}" - # Import is the one verb whose handler names differ between the surfaces - # (the tool says what it reads FROM; the route is already under the system), - # so the loop above can't pair it. It still has to exist on both. - assert callable(tools.import_design_system_from_rulebook) - assert callable(routes.import_design_system) + # The snippet check is the one verb whose handler names differ between the + # surfaces (the tool says what it checks AGAINST; the route is already under + # the system), so the loop above can't pair it. It still has to exist on both. assert callable(tools.check_snippets_against_design_system) assert callable(routes.check_snippets_against_system) diff --git a/tests/test_services_design_systems.py b/tests/test_services_design_systems.py index 387781a..83f0339 100644 --- a/tests/test_services_design_systems.py +++ b/tests/test_services_design_systems.py @@ -276,105 +276,6 @@ async def test_create_token_records_the_literals_it_replaces(): 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": []} - - # --- the master sheet ------------------------------------------------------- @pytest.mark.asyncio