revert(design-systems): drop the rulebook import — a migration, not a feature
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 44s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 44s
Operator's call, and it corrects a scope error rather than a bug: "this is a path for a user to go from a rulebook to a design system. we don't need to build this path in the app itself ... you should be the one that does the import ... going forward no one else should have to do such a migration." Right. Nobody starting from a design system will ever go rulebook -> system, so the whole path was permanent product code serving a single act on one install. Rule #22: remove it, don't flag it off. Gone from the service, the REST route, the MCP tool, the UI panel, the API client and its tests. There is a second consequence I had missed, and it is the better argument. The parser was WORSE at this than doing it by hand. `propose_tokens` leaves radius steps and type sizes valueless because "Small 4px" is not a hex and nothing here parses it — a limitation I documented carefully and shipped anyway. But that limitation only exists because the importer had to run unattended. Done as work rather than as a feature, those values are just read and written, and the result is a complete design system instead of one with a dozen blanks and a count explaining them. Scaffolding built around my own absence from the loop, when I am the loop. KEPT: `extract_expectations` and `design_expectations` in services/design_rulebook_import.py. The live drift panel still reads them until it is repointed at a resolved design system (#2295), and removing them now would take the /design page's only content with it. They go with that change, not this one.
This commit is contained in:
@@ -136,42 +136,6 @@ export const setProjectDesignSystem = (
|
|||||||
{ design_system_id: designSystemId },
|
{ design_system_id: designSystemId },
|
||||||
);
|
);
|
||||||
|
|
||||||
/** One token an import proposes, with the evidence for it. */
|
|
||||||
export interface ProposedToken {
|
|
||||||
name: string;
|
|
||||||
value_by_mode: Record<string, string>;
|
|
||||||
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<ImportReport>(`/api/design-systems/${designSystemId}/import`, {
|
|
||||||
rulebook_id: rulebookId,
|
|
||||||
apply,
|
|
||||||
});
|
|
||||||
|
|
||||||
export interface StylesheetResult {
|
export interface StylesheetResult {
|
||||||
design_system_id: number;
|
design_system_id: number;
|
||||||
/** The master sheet: purpose tokens only, no element or class rules. */
|
/** The master sheet: purpose tokens only, no element or class rules. */
|
||||||
|
|||||||
@@ -31,17 +31,14 @@ import {
|
|||||||
fetchResolvedTokens,
|
fetchResolvedTokens,
|
||||||
checkSnippets,
|
checkSnippets,
|
||||||
fetchStylesheet,
|
fetchStylesheet,
|
||||||
importFromRulebook,
|
|
||||||
updateDesignSystem,
|
updateDesignSystem,
|
||||||
updateDesignToken,
|
updateDesignToken,
|
||||||
type DesignSystem,
|
type DesignSystem,
|
||||||
type DesignToken,
|
type DesignToken,
|
||||||
type ImportReport,
|
|
||||||
type ResolvedToken,
|
type ResolvedToken,
|
||||||
type SnippetCheck,
|
type SnippetCheck,
|
||||||
type StylesheetResult,
|
type StylesheetResult,
|
||||||
} from "@/api/designSystems";
|
} from "@/api/designSystems";
|
||||||
import { listRulebooks, type Rulebook } from "@/api/rulebooks";
|
|
||||||
import DesignTabs from "@/components/DesignTabs.vue";
|
import DesignTabs from "@/components/DesignTabs.vue";
|
||||||
import { ApiError } from "@/api/client";
|
import { ApiError } from "@/api/client";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
@@ -472,56 +469,6 @@ watch(selectedId, () => {
|
|||||||
snippetCheck.value = null;
|
snippetCheck.value = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- import from a rulebook -------------------------------------------------
|
|
||||||
|
|
||||||
const rulebooks = ref<Rulebook[]>([]);
|
|
||||||
const importRulebookId = ref<number | null>(null);
|
|
||||||
const importReport = ref<ImportReport | null>(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 {
|
function isColourish(value: string): boolean {
|
||||||
return /^(#|rgba?\(|hsla?\(|color-mix\()/.test(value.trim());
|
return /^(#|rgba?\(|hsla?\(|color-mix\()/.test(value.trim());
|
||||||
}
|
}
|
||||||
@@ -836,106 +783,6 @@ function isColourish(value: string): boolean {
|
|||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Import from a rulebook -->
|
|
||||||
<section class="ds-section">
|
|
||||||
<div class="section-head">
|
|
||||||
<h2>Import from a rulebook</h2>
|
|
||||||
<button class="btn-ghost btn-small" @click="showImport = !showImport">
|
|
||||||
{{ showImport ? "Hide" : "Show" }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-if="showImport">
|
|
||||||
<p class="section-note">
|
|
||||||
Reads a rulebook's colour and token declarations and proposes the
|
|
||||||
tokens they add up to — joining "Obsidian #14171A" in one rule to
|
|
||||||
<code>--fs-obsidian</code> in another, since neither alone is a
|
|
||||||
token. Preview first: a rulebook is written aspirationally, so
|
|
||||||
this is a proposal rather than a fact.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p v-if="!rulebooks.length" class="muted">
|
|
||||||
No rulebooks to import from.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div class="field">
|
|
||||||
<label class="field-label" for="import-rulebook">Rulebook</label>
|
|
||||||
<select id="import-rulebook" v-model="importRulebookId" class="input">
|
|
||||||
<option :value="null">Choose one…</option>
|
|
||||||
<option v-for="rb in rulebooks" :key="rb.id" :value="rb.id">{{ rb.title }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row-actions">
|
|
||||||
<button
|
|
||||||
class="btn-ghost" :disabled="importRulebookId === null || importing"
|
|
||||||
@click="runImport(false)"
|
|
||||||
>
|
|
||||||
{{ importing ? "Reading…" : "Preview" }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="importReport && importReport.proposed.length"
|
|
||||||
class="btn-primary" :disabled="importing" @click="runImport(true)"
|
|
||||||
>
|
|
||||||
Import {{ importReport.proposed.length - importReport.skipped.length }} tokens
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-if="importReport">
|
|
||||||
<p class="section-note import-summary">
|
|
||||||
<strong>{{ importReport.proposed.length }}</strong> proposed ·
|
|
||||||
<strong>{{ proposalsWithoutValues }}</strong> with no value the
|
|
||||||
rulebook states in a readable form ·
|
|
||||||
<strong>{{ importReport.skipped.length }}</strong> already defined here
|
|
||||||
<template v-if="importReport.created.length">
|
|
||||||
· <strong>{{ importReport.created.length }}</strong> created
|
|
||||||
</template>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p v-if="!importReport.proposed.length" class="muted">
|
|
||||||
That rulebook names no custom properties, so there is nothing
|
|
||||||
to propose. Most rulebooks aren't design rulebooks.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ul v-else class="token-list">
|
|
||||||
<li v-for="p in importReport.proposed" :key="p.name">
|
|
||||||
<code class="token-name">{{ p.name }}</code>
|
|
||||||
<span class="token-values">
|
|
||||||
<span
|
|
||||||
v-for="(value, mode) in p.value_by_mode" :key="mode" class="mode-chip"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
v-if="isColourish(value)" class="swatch"
|
|
||||||
:style="{ background: value }" aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<code>{{ value }}</code>
|
|
||||||
</span>
|
|
||||||
<span v-if="!Object.keys(p.value_by_mode).length" class="supersedes">
|
|
||||||
needs a value
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span class="token-meta">
|
|
||||||
{{ p.purpose || "" }}
|
|
||||||
<span v-if="p.supersedes.length" class="supersedes">
|
|
||||||
instead of {{ p.supersedes.join(", ") }}
|
|
||||||
</span>
|
|
||||||
<span v-if="p.source_rule_id" class="supersedes">
|
|
||||||
rule #{{ p.source_rule_id }}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="importReport.skipped.includes(p.name)" class="origin-badge"
|
|
||||||
>
|
|
||||||
already defined
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Overrides -->
|
<!-- Overrides -->
|
||||||
<section class="ds-section">
|
<section class="ds-section">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
@@ -1565,10 +1412,6 @@ textarea.input {
|
|||||||
padding: 0.15rem 0;
|
padding: 0.15rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.import-summary {
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.supersedes {
|
.supersedes {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
|||||||
@@ -210,42 +210,6 @@ async def check_snippets_against_design_system(
|
|||||||
return result
|
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 ──────────────────────────────────────────────────────────────
|
# ── Tokens ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def create_design_token(
|
async def create_design_token(
|
||||||
@@ -388,7 +352,6 @@ def register(mcp) -> None:
|
|||||||
delete_design_system,
|
delete_design_system,
|
||||||
get_design_system_stylesheet,
|
get_design_system_stylesheet,
|
||||||
check_snippets_against_design_system,
|
check_snippets_against_design_system,
|
||||||
import_design_system_from_rulebook,
|
|
||||||
create_design_token,
|
create_design_token,
|
||||||
list_design_tokens,
|
list_design_tokens,
|
||||||
update_design_token,
|
update_design_token,
|
||||||
|
|||||||
@@ -156,30 +156,6 @@ async def check_snippets_against_system(design_system_id: int):
|
|||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
@design_systems_bp.post("/design-systems/<int:design_system_id>/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 ──────────────────────────────────────────────────────────────
|
# ── Tokens ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@design_systems_bp.get("/design-systems/<int:design_system_id>/tokens")
|
@design_systems_bp.get("/design-systems/<int:design_system_id>/tokens")
|
||||||
|
|||||||
@@ -230,175 +230,3 @@ async def design_expectations(user_id: int) -> ExpectationSet:
|
|||||||
return ExpectationSet(rulebook_id=rulebook_id)
|
return ExpectationSet(rulebook_id=rulebook_id)
|
||||||
|
|
||||||
return ExpectationSet(rulebook_id=rulebook_id, expectations=extract_expectations(rules))
|
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
|
|
||||||
|
|||||||
@@ -342,77 +342,6 @@ async def set_project_design_system(
|
|||||||
return True
|
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 -------------------------------------------------------
|
# --- the master sheet -------------------------------------------------------
|
||||||
|
|
||||||
async def stylesheet_for_system(
|
async def stylesheet_for_system(
|
||||||
|
|||||||
@@ -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 == []
|
|
||||||
@@ -24,7 +24,7 @@ def test_route_handlers_callable():
|
|||||||
for name in (
|
for name in (
|
||||||
"list_design_systems", "create_design_system", "get_design_system",
|
"list_design_systems", "create_design_system", "get_design_system",
|
||||||
"update_design_system", "delete_design_system", "resolve_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",
|
"check_snippets_against_system",
|
||||||
"list_design_tokens", "create_design_token",
|
"list_design_tokens", "create_design_token",
|
||||||
"update_design_token", "delete_design_token", "set_project_design_system",
|
"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/<int:design_system_id>",
|
"/api/design-systems/<int:design_system_id>",
|
||||||
"/api/design-systems/<int:design_system_id>/resolved",
|
"/api/design-systems/<int:design_system_id>/resolved",
|
||||||
"/api/design-systems/<int:design_system_id>/import",
|
|
||||||
"/api/design-systems/<int:design_system_id>/stylesheet",
|
"/api/design-systems/<int:design_system_id>/stylesheet",
|
||||||
"/api/design-systems/<int:design_system_id>/snippet-check",
|
"/api/design-systems/<int:design_system_id>/snippet-check",
|
||||||
"/api/design-systems/<int:design_system_id>/tokens",
|
"/api/design-systems/<int:design_system_id>/tokens",
|
||||||
@@ -62,7 +61,7 @@ def test_service_functions_take_user_id():
|
|||||||
"create_design_system", "list_design_systems", "get_design_system",
|
"create_design_system", "list_design_systems", "get_design_system",
|
||||||
"update_design_system", "delete_design_system", "resolve_design_system",
|
"update_design_system", "delete_design_system", "resolve_design_system",
|
||||||
"create_token", "list_tokens", "update_token", "delete_token",
|
"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",
|
"stylesheet_for_system", "check_snippets_against_system",
|
||||||
):
|
):
|
||||||
fn = getattr(svc, fn_name)
|
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(tools, name)), f"MCP tool missing: {name}"
|
||||||
assert callable(getattr(routes, name)), f"REST route 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 snippet check is the one verb whose handler names differ between the
|
||||||
# (the tool says what it reads FROM; the route is already under the system),
|
# surfaces (the tool says what it checks AGAINST; the route is already under
|
||||||
# so the loop above can't pair it. It still has to exist on both.
|
# 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)
|
|
||||||
assert callable(tools.check_snippets_against_design_system)
|
assert callable(tools.check_snippets_against_design_system)
|
||||||
assert callable(routes.check_snippets_against_system)
|
assert callable(routes.check_snippets_against_system)
|
||||||
|
|
||||||
|
|||||||
@@ -276,105 +276,6 @@ async def test_create_token_records_the_literals_it_replaces():
|
|||||||
assert captured["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": []}
|
|
||||||
|
|
||||||
|
|
||||||
# --- the master sheet -------------------------------------------------------
|
# --- the master sheet -------------------------------------------------------
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
Reference in New Issue
Block a user