CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 44s
Step 2 of the Scribe -> Grimoire rename. - src/scribe -> src/grimoire, and every import, patch target, module path, hypercorn entry point and `python -m grimoire.restore` with it; pyproject, uv.lock and the frontend package are named grimoire. - Image git.fabledsword.com/bvandeusen/fabledgrimoire; CI's Postgres service runs as grimoire / grimoire_test. - Compose files default the DB user, database and (prod) volume to grimoire, each overridable from .env, so an install created as Scribe keeps its data by setting POSTGRES_USER, POSTGRES_DB and PGDATA_VOLUME. - Backups are written as grimoire-<ts>.tar with format grimoire-backup-tar. Archives named scribe-* with the scribe-backup-tar manifest still restore and are still counted at the destination (folder and S3), with tests. - Exports, the MCP auth realm, the ASGI scope key and the persistent logger carry the new name. Kept on purpose: the secret-sealing key label stays "scribe.secrets.v1", because every stored secret was derived under it. Plugin identity (plugin name, hooks, env vars, .scribe marker, skills) is step 3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
136 lines
5.6 KiB
Python
136 lines
5.6 KiB
Python
"""The starter role set.
|
||
|
||
The premise: a literal gets written when there is no role to reach for. A
|
||
house style with no "text on a filled colour" role collects `color: #fff` at
|
||
every call site that needs one. Naming the roles at creation removes the
|
||
occasion.
|
||
|
||
The tests that matter here are about the BOUNDARY, not the content. Roles ship
|
||
with the product; values never do. A default palette would be one operator's
|
||
taste shipped as product code, and it would be very easy to add by
|
||
accident while "being helpful".
|
||
"""
|
||
import re
|
||
|
||
import pytest
|
||
|
||
from grimoire.services import design_starter_roles as roles
|
||
|
||
|
||
def test_no_role_carries_a_VALUE():
|
||
"""THE rule-115 guard. Every seeded token is a named question with no
|
||
answer. The moment one ships a hex, the product is prescribing an install's
|
||
palette."""
|
||
for row in roles.starter_tokens():
|
||
assert row["value_by_mode"] == {}, f"{row['name']} shipped a value"
|
||
|
||
|
||
def _colour_literals(text: str) -> list[str]:
|
||
"""Hex colours in `text`, NOT counting issue references.
|
||
|
||
`#2275` is four hex-valid digits and also how an issue is cited — so the
|
||
naive pattern flags documentation. A real colour either contains a letter
|
||
a–f or is a full 6/8-digit value; an all-decimal 3- or 4-digit match is an
|
||
issue number.
|
||
"""
|
||
out = []
|
||
for m in re.findall(r"#[0-9a-fA-F]{3,8}\b", text):
|
||
digits = m[1:]
|
||
if len(digits) not in (3, 4, 6, 8):
|
||
continue
|
||
if len(digits) in (6, 8) or any(c in "abcdefABCDEF" for c in digits):
|
||
out.append(m)
|
||
return out
|
||
|
||
|
||
def test_the_module_contains_no_colour_literals_at_all():
|
||
"""Belt and braces on the above, and the stronger claim: not just that
|
||
tokens are blank, but that no palette hides in a comment or a docstring
|
||
waiting to be pasted in. Checks the SOURCE, not the output."""
|
||
import pathlib
|
||
src = pathlib.Path(roles.__file__).read_text()
|
||
hexes = _colour_literals(src)
|
||
assert not hexes, f"colour literals in product code: {hexes}"
|
||
|
||
|
||
def test_the_colour_check_does_not_flag_issue_references():
|
||
"""Pins the exclusion above, because without it this file fails on its own
|
||
citations and the obvious 'fix' is to delete the check."""
|
||
assert _colour_literals("see #2275 and #2349") == []
|
||
assert _colour_literals("color: #fff") == ["#fff"]
|
||
assert _colour_literals("#E8E4D8 on #14171A") == ["#E8E4D8", "#14171A"]
|
||
assert _colour_literals("#000000") == ["#000000"]
|
||
|
||
|
||
def test_every_group_is_individually_selectable():
|
||
"""One flat list, all skippable. An install that wants
|
||
three tokens must be able to get three."""
|
||
only_text = roles.starter_tokens(["text"])
|
||
assert {r["group_name"] for r in only_text} == {"text"}
|
||
assert len(only_text) == 4
|
||
|
||
|
||
def test_empty_selection_yields_nothing_and_is_not_the_same_as_None():
|
||
"""`[]` is a real answer — "none of them" — and must not be read as
|
||
"unspecified, so give me everything". Getting this backwards would seed 40
|
||
rows into a system whose creator explicitly declined."""
|
||
assert roles.starter_tokens([]) == []
|
||
assert len(roles.starter_tokens(None)) > 30
|
||
|
||
|
||
def test_unknown_group_names_are_ignored_not_fatal():
|
||
"""This feeds a checkbox list. A stale name from an older client should not
|
||
fail an otherwise-fine creation."""
|
||
out = roles.starter_tokens(["text", "not-a-real-group"])
|
||
assert {r["group_name"] for r in out} == {"text"}
|
||
|
||
|
||
def test_the_prefix_is_the_installs_choice():
|
||
"""`--fs-` is FabledSword's convention, not the product's. Baking it in
|
||
would put one family's naming into every install."""
|
||
assert all(r["name"].startswith("--ds-") for r in roles.starter_tokens(["text"]))
|
||
custom = roles.starter_tokens(["text"], prefix="--acme-")
|
||
assert all(r["name"].startswith("--acme-") for r in custom)
|
||
assert "--acme-text-primary" in {r["name"] for r in custom}
|
||
|
||
|
||
def test_text_on_action_is_in_the_starter_set():
|
||
"""The specific role whose absence produced 76 literals. It is separate
|
||
from text-primary on purpose: the surfaces it sits on do not change with
|
||
the mode, while the page does — so reusing text-primary there passes in
|
||
dark and fails contrast in light."""
|
||
names = {r["name"] for r in roles.starter_tokens(["text"])}
|
||
assert "--ds-text-on-action" in names
|
||
assert "--ds-text-primary" in names
|
||
|
||
|
||
def test_names_are_valid_custom_properties():
|
||
"""They go straight into a stylesheet; an invalid name is a silent no-op
|
||
rather than an error, which is the worst failure mode available."""
|
||
valid = re.compile(r"^--[A-Za-z0-9_-]+$")
|
||
for row in roles.starter_tokens():
|
||
assert valid.match(row["name"]), row["name"]
|
||
|
||
|
||
def test_no_duplicate_names_across_the_whole_set():
|
||
"""A design system has a partial-unique index on (system, name); a
|
||
duplicate in the starter set would make creation fail at the DB with a
|
||
constraint error rather than anything legible."""
|
||
names = [r["name"] for r in roles.starter_tokens()]
|
||
assert len(names) == len(set(names))
|
||
|
||
|
||
def test_every_role_states_a_purpose():
|
||
"""An unfilled role is only useful if it says what belongs there. "Colour
|
||
1" is a blank with extra steps."""
|
||
for row in roles.starter_tokens():
|
||
assert row["purpose"].strip(), row["name"]
|
||
|
||
|
||
def test_describe_groups_matches_what_starter_tokens_produces():
|
||
"""The catalogue a UI renders and the rows creation writes must not drift —
|
||
a checklist offering a group that seeds nothing is a lie in the UI."""
|
||
described = {g["group"]: g["token_count"] for g in roles.describe_groups()}
|
||
for group, count in described.items():
|
||
assert len(roles.starter_tokens([group])) == count
|