Drop the rulebook import — a migration, not a product feature (#90)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 19s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 19s
This commit was merged in pull request #90.
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