CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 38s
Milestone #254 step 6, first half (#2295) — and this reframes the task rather than answering it. The operator's call: "in this case we should declare what should be used in place of pure white, it's not a prohibition it's what should be used in its place." None of the three options on the table (a constraints record / the panel reads both sources / negative token rows) was right, because all three kept the prohibition as a KIND OF THING. It isn't one. "Pure white is never text" is the shadow cast by a positive fact — text is Parchment — and a design system that stores what things ARE has no row for a ban because it never needed one. So `design_tokens` gains `supersedes`: the literal values this token should be written instead of. `--color-text-on-action` supersedes `#fff` / `#ffffff`. Same fact as the rule, stated forwards, and now actionable — a finding can say what to write rather than only objecting. It has to be DECLARED, not derived, and that is the crux: `#fff` and Parchment `#E8E4D8` are different colours, so no value-matching check could ever have connected them. That mismatch is precisely why the prohibition looked unrepresentable until it was turned around. `supersedes` cascades on EMPTINESS rather than on None. A child overriding a colour says nothing about which literals it replaces, and blanking the family's declaration there would silently disarm the check for every app that customises the token — while a child that states its own list replaces it wholesale. Two things this deliberately does NOT do: - It does not feed the drift panel. Superseded literals live in component CSS, which `designDrift.ts` cannot see and already documents as a blind spot. This is input for the source lint (#2277). Declaring it with nothing consuming it yet is honest; wiring it to a panel that cannot check it would not be. - It does not remove the panel's `prohibited_color` arm yet — that happens when the panel is repointed at a resolved system, which needs #2288 first. The declaration also exposes a missing token: most of the 67 hardcoded `color: #fff` (#2275) are text on a coloured action button, and the system has no token for that role at all. Every view hardcodes it. Declaring the token that was never there is the first real output of the operator's framing.
390 lines
15 KiB
Python
390 lines
15 KiB
Python
"""The parent chain: walking it, and refusing to close it (milestone #254 step 1).
|
|
|
|
Pure functions over a `{id: parent_id}` literal, so a whole hierarchy is one line
|
|
of setup and no database is involved. That is the reason the cascade lives in its
|
|
own import-free module — see services/design_cascade.py.
|
|
"""
|
|
from types import SimpleNamespace
|
|
|
|
from scribe.services.design_cascade import ancestry, resolve_tokens, would_cycle
|
|
|
|
|
|
# --- ancestry ---------------------------------------------------------------
|
|
|
|
def test_ancestry_returns_the_chain_deepest_first():
|
|
"""Deepest first because that is the order resolution consumes it in: the
|
|
system being resolved wins over its parent, which wins over the root."""
|
|
parents = {1: None, 2: 1, 3: 2}
|
|
assert ancestry(3, parents) == [3, 2, 1]
|
|
|
|
|
|
def test_a_root_is_its_own_whole_chain():
|
|
"""A family system has no parent, and that is an ordinary state — not an
|
|
incomplete one. It must resolve to exactly itself."""
|
|
assert ancestry(1, {1: None}) == [1]
|
|
|
|
|
|
def test_a_missing_parent_truncates_rather_than_raising():
|
|
"""A parent that was soft-deleted (or filtered out of the caller's scope) is
|
|
a shorter chain, not a failed request. The alternative — raising — would make
|
|
one deleted system break every descendant's rendering."""
|
|
assert ancestry(3, {3: 2}) == [3, 2]
|
|
|
|
|
|
def test_a_system_absent_from_the_map_still_yields_itself():
|
|
assert ancestry(9, {}) == [9]
|
|
|
|
|
|
def test_ancestry_terminates_on_a_cycle_instead_of_hanging():
|
|
"""LOAD-BEARING, and the reason a visited-set exists even though writes are
|
|
guarded. A loop introduced by a direct DB edit or a future bug must degrade
|
|
to a truncated chain: truncation shows up in the result, a hang shows up as
|
|
an outage. Every id appears exactly once."""
|
|
parents = {1: 3, 2: 1, 3: 2}
|
|
chain = ancestry(1, parents)
|
|
assert chain == [1, 3, 2]
|
|
assert len(chain) == len(set(chain))
|
|
|
|
|
|
def test_ancestry_terminates_on_a_self_parent():
|
|
assert ancestry(1, {1: 1}) == [1]
|
|
|
|
|
|
# --- would_cycle ------------------------------------------------------------
|
|
|
|
def test_clearing_the_parent_never_cycles():
|
|
"""None means "make this a root", which is always safe."""
|
|
assert would_cycle(2, None, {1: None, 2: 1}) is False
|
|
|
|
|
|
def test_a_system_cannot_be_its_own_parent():
|
|
assert would_cycle(1, 1, {1: None}) is True
|
|
|
|
|
|
def test_an_ordinary_reparent_is_allowed():
|
|
"""family <- app is the shape the whole model exists for; it must not trip
|
|
the guard."""
|
|
assert would_cycle(2, 1, {1: None, 2: None}) is False
|
|
|
|
|
|
def test_a_direct_swap_is_refused():
|
|
"""A -> B, then B -> A. The two-system case, and the one a UI produces first
|
|
because both systems are on screen together."""
|
|
parents = {1: None, 2: 1}
|
|
assert would_cycle(1, 2, parents) is True
|
|
|
|
|
|
def test_an_indirect_loop_is_refused():
|
|
"""Three deep: root <- mid <- leaf, then root's parent set to leaf. Catching
|
|
this is what makes the check a chain walk rather than a parent comparison."""
|
|
parents = {1: None, 2: 1, 3: 2}
|
|
assert would_cycle(1, 3, parents) is True
|
|
|
|
|
|
def test_reparenting_onto_a_sibling_subtree_is_allowed():
|
|
"""Two branches off one root. Moving one under the other is legitimate — the
|
|
guard must refuse loops, not reorganisation."""
|
|
parents = {1: None, 2: 1, 3: 1}
|
|
assert would_cycle(3, 2, parents) is False
|
|
|
|
|
|
def test_the_guard_survives_a_hierarchy_that_is_already_corrupt():
|
|
"""If a cycle somehow already exists, the guard still has to answer rather
|
|
than spin — the write path is exactly where such a hierarchy gets repaired."""
|
|
parents = {1: 2, 2: 1, 3: None}
|
|
assert would_cycle(3, 1, parents) is False
|
|
assert would_cycle(1, 2, parents) is True
|
|
|
|
|
|
# --- resolution -------------------------------------------------------------
|
|
#
|
|
# The cascade proper (milestone #254 step 2). Hierarchies are stated as literals
|
|
# because resolve_tokens is pure and duck-typed — which is the whole reason it
|
|
# lives here rather than inside the service.
|
|
|
|
def _token(name, value_by_mode, group_name=None, purpose=None, order_index=0,
|
|
supersedes=None):
|
|
return SimpleNamespace(
|
|
name=name, value_by_mode=value_by_mode, group_name=group_name,
|
|
purpose=purpose, order_index=order_index, supersedes=supersedes or [],
|
|
)
|
|
|
|
|
|
# A family (1) and an app inheriting from it (2) — the shape the model exists for.
|
|
FAMILY, APP = 1, 2
|
|
PARENTS = {FAMILY: None, APP: FAMILY}
|
|
|
|
|
|
def _by_name(tokens):
|
|
return {t.name: t for t in tokens}
|
|
|
|
|
|
def test_a_system_with_no_tokens_of_its_own_inherits_the_whole_family_set():
|
|
"""The correct answer for an app that has not departed from the family yet —
|
|
and the state every app system starts in."""
|
|
resolved = resolve_tokens(
|
|
APP, PARENTS,
|
|
{FAMILY: [_token("--fs-obsidian", {"base": "#14171a"})], APP: []},
|
|
)
|
|
assert [t.name for t in resolved] == ["--fs-obsidian"]
|
|
assert resolved[0].value_by_mode == {"base": "#14171a"}
|
|
assert resolved[0].origin_by_mode == {"base": FAMILY}
|
|
|
|
|
|
def test_the_deepest_system_wins_and_says_what_it_overrode():
|
|
"""Provenance is the point of the whole model: the effective set is just a
|
|
list without it, and "where does this app depart from the family?" becomes a
|
|
diff someone has to compute."""
|
|
resolved = _by_name(resolve_tokens(
|
|
APP, PARENTS,
|
|
{
|
|
FAMILY: [_token("--fs-accent", {"base": "#6b2118"})],
|
|
APP: [_token("--fs-accent", {"base": "#5b4a8a"})],
|
|
},
|
|
))
|
|
accent = resolved["--fs-accent"]
|
|
assert accent.value_by_mode == {"base": "#5b4a8a"}
|
|
assert accent.origin_by_mode == {"base": APP}
|
|
# The shadowed ancestor is still there, behind the winner.
|
|
assert [c.system_id for c in accent.contributions["base"]] == [APP, FAMILY]
|
|
assert accent.contributions["base"][1].value == "#6b2118"
|
|
|
|
|
|
def test_overriding_one_mode_leaves_the_others_inherited():
|
|
"""LOAD-BEARING, and the argument that decided the storage shape. An accent
|
|
deepened for light backgrounds while dark is left alone must own `base` and
|
|
still inherit `dark` — which is why merging is per (name, MODE) and why the
|
|
value column is a map rather than a pair of columns."""
|
|
resolved = _by_name(resolve_tokens(
|
|
APP, PARENTS,
|
|
{
|
|
FAMILY: [_token("--fs-accent", {"base": "#34a877", "dark": "#34a877"})],
|
|
APP: [_token("--fs-accent", {"base": "#15803d"})],
|
|
},
|
|
))
|
|
accent = resolved["--fs-accent"]
|
|
assert accent.value_by_mode == {"base": "#15803d", "dark": "#34a877"}
|
|
assert accent.origin_by_mode == {"base": APP, "dark": FAMILY}
|
|
|
|
|
|
def test_a_token_only_the_app_defines_is_not_an_override():
|
|
"""Introducing a token and overriding one are different acts, and a UI that
|
|
labelled both "overridden here" would misdescribe the first."""
|
|
resolved = _by_name(resolve_tokens(
|
|
APP, PARENTS,
|
|
{FAMILY: [], APP: [_token("--fs-editor-caret", {"base": "#5b4a8a"})]},
|
|
))
|
|
caret = resolved["--fs-editor-caret"]
|
|
assert caret.origin_by_mode == {"base": APP}
|
|
assert caret.is_overridden_in(APP) is False
|
|
|
|
|
|
def test_is_overridden_in_is_true_only_for_the_system_that_shadowed():
|
|
resolved = _by_name(resolve_tokens(
|
|
APP, PARENTS,
|
|
{
|
|
FAMILY: [_token("--fs-accent", {"base": "#6b2118"})],
|
|
APP: [_token("--fs-accent", {"base": "#5b4a8a"})],
|
|
},
|
|
))
|
|
accent = resolved["--fs-accent"]
|
|
assert accent.is_overridden_in(APP) is True
|
|
assert accent.is_overridden_in(FAMILY) is False
|
|
|
|
|
|
def test_three_levels_stack_nearest_first():
|
|
"""A chain deeper than family->app, to prove the walk isn't a single hop."""
|
|
parents = {1: None, 2: 1, 3: 2}
|
|
resolved = _by_name(resolve_tokens(
|
|
3, parents,
|
|
{
|
|
1: [_token("--fs-bg", {"base": "a"})],
|
|
2: [_token("--fs-bg", {"base": "b"})],
|
|
3: [_token("--fs-bg", {"base": "c"})],
|
|
},
|
|
))
|
|
bg = resolved["--fs-bg"]
|
|
assert bg.value_by_mode == {"base": "c"}
|
|
assert [c.value for c in bg.contributions["base"]] == ["c", "b", "a"]
|
|
|
|
|
|
def test_resolving_the_family_itself_ignores_its_children():
|
|
"""Inheritance runs one way. A family system resolved on its own must not
|
|
pick up an app's overrides — otherwise every app would restyle the house."""
|
|
resolved = _by_name(resolve_tokens(
|
|
FAMILY, PARENTS,
|
|
{
|
|
FAMILY: [_token("--fs-accent", {"base": "#6b2118"})],
|
|
APP: [_token("--fs-accent", {"base": "#5b4a8a"})],
|
|
},
|
|
))
|
|
assert resolved["--fs-accent"].value_by_mode == {"base": "#6b2118"}
|
|
|
|
|
|
def test_value_for_falls_back_to_the_base_mode():
|
|
"""A token that isn't mode-dependent carries only `base`, and asking it for
|
|
"dark" must yield that rather than nothing — the read rule the storage shape
|
|
implies."""
|
|
resolved = _by_name(resolve_tokens(
|
|
FAMILY, PARENTS, {FAMILY: [_token("--fs-radius-md", {"base": "8px"})]},
|
|
))
|
|
radius = resolved["--fs-radius-md"]
|
|
assert radius.value_for("dark") == "8px"
|
|
assert radius.value_for("base") == "8px"
|
|
|
|
|
|
def test_value_for_prefers_an_explicit_mode_over_the_fallback():
|
|
resolved = _by_name(resolve_tokens(
|
|
FAMILY, PARENTS,
|
|
{FAMILY: [_token("--fs-bg", {"base": "#f7f5ef", "dark": "#14171a"})]},
|
|
))
|
|
assert resolved["--fs-bg"].value_for("dark") == "#14171a"
|
|
|
|
|
|
def test_metadata_is_inherited_when_the_override_leaves_it_blank():
|
|
"""A child overriding a colour routinely says nothing about what the token is
|
|
FOR. Inheriting the family's description beats blanking it — the override was
|
|
about the value, not the meaning."""
|
|
resolved = _by_name(resolve_tokens(
|
|
APP, PARENTS,
|
|
{
|
|
FAMILY: [_token(
|
|
"--fs-obsidian", {"base": "#14171a"},
|
|
group_name="surface", purpose="page bg, deepest surface",
|
|
)],
|
|
APP: [_token("--fs-obsidian", {"base": "#101317"})],
|
|
},
|
|
))
|
|
obsidian = resolved["--fs-obsidian"]
|
|
assert obsidian.group_name == "surface"
|
|
assert obsidian.purpose == "page bg, deepest surface"
|
|
assert obsidian.value_by_mode == {"base": "#101317"} # the value still won
|
|
|
|
|
|
def test_an_override_that_states_metadata_wins_it_too():
|
|
resolved = _by_name(resolve_tokens(
|
|
APP, PARENTS,
|
|
{
|
|
FAMILY: [_token("--fs-x", {"base": "a"}, purpose="family says")],
|
|
APP: [_token("--fs-x", {"base": "b"}, purpose="app says")],
|
|
},
|
|
))
|
|
assert resolved["--fs-x"].purpose == "app says"
|
|
|
|
|
|
def test_an_override_at_default_order_keeps_the_familys_position():
|
|
"""order_index 0 is the column DEFAULT, so it reads as unstated. Treating it
|
|
as "first" would let a colour-only override drag its token to the top of the
|
|
group — a visible reshuffle nobody asked for."""
|
|
resolved = _by_name(resolve_tokens(
|
|
APP, PARENTS,
|
|
{
|
|
FAMILY: [_token("--fs-x", {"base": "a"}, order_index=7)],
|
|
APP: [_token("--fs-x", {"base": "b"})],
|
|
},
|
|
))
|
|
assert resolved["--fs-x"].order_index == 7
|
|
|
|
|
|
def test_the_effective_set_is_ordered_by_group_then_position_with_ungrouped_last():
|
|
resolved = resolve_tokens(
|
|
FAMILY, PARENTS,
|
|
{FAMILY: [
|
|
_token("--fs-z", {"base": "1"}), # ungrouped
|
|
_token("--fs-b", {"base": "2"}, group_name="text", order_index=1),
|
|
_token("--fs-a", {"base": "3"}, group_name="surface", order_index=2),
|
|
_token("--fs-c", {"base": "4"}, group_name="surface", order_index=1),
|
|
]},
|
|
)
|
|
assert [t.name for t in resolved] == ["--fs-c", "--fs-a", "--fs-b", "--fs-z"]
|
|
|
|
|
|
def test_resolution_terminates_on_a_corrupt_hierarchy():
|
|
"""Resolution inherits ancestry's visited-set. A loop reaching this function
|
|
must produce a truncated set, not a hung request — the defensive half of the
|
|
cycle guard, exercised end to end."""
|
|
parents = {1: 2, 2: 1}
|
|
resolved = _by_name(resolve_tokens(
|
|
1, parents,
|
|
{1: [_token("--fs-a", {"base": "one"})], 2: [_token("--fs-b", {"base": "two"})]},
|
|
))
|
|
assert set(resolved) == {"--fs-a", "--fs-b"}
|
|
# Each system contributes exactly once, not endlessly.
|
|
assert len(resolved["--fs-a"].contributions["base"]) == 1
|
|
|
|
|
|
# --- supersedes -------------------------------------------------------------
|
|
#
|
|
# The declaration that replaces a prohibition. A design system stores what things
|
|
# ARE, so "pure white is never text" has no row — but "write this token instead
|
|
# of #fff" does, and it is the same fact stated forwards.
|
|
|
|
def test_supersedes_is_inherited_when_the_override_is_silent_about_it():
|
|
"""LOAD-BEARING. A child overriding a colour says nothing about which
|
|
literals it replaces, and blanking the family's declaration there would
|
|
silently disarm the check for every app that customises the token."""
|
|
resolved = _by_name(resolve_tokens(
|
|
APP, PARENTS,
|
|
{
|
|
FAMILY: [_token("--fs-text", {"base": "#e8e4d8"}, supersedes=["#fff", "#ffffff"])],
|
|
APP: [_token("--fs-text", {"base": "#f0ece0"})],
|
|
},
|
|
))
|
|
text = resolved["--fs-text"]
|
|
assert text.supersedes == ("#fff", "#ffffff")
|
|
assert text.value_by_mode == {"base": "#f0ece0"} # the value still overrode
|
|
|
|
|
|
def test_an_override_that_states_its_own_supersedes_replaces_the_list():
|
|
"""Whole-list replacement, not a merge — an app that means "only #fff" must
|
|
be able to say so without inheriting entries it deliberately dropped."""
|
|
resolved = _by_name(resolve_tokens(
|
|
APP, PARENTS,
|
|
{
|
|
FAMILY: [_token("--fs-text", {"base": "a"}, supersedes=["#fff", "#ffffff"])],
|
|
APP: [_token("--fs-text", {"base": "b"}, supersedes=["#fff"])],
|
|
},
|
|
))
|
|
assert resolved["--fs-text"].supersedes == ("#fff",)
|
|
|
|
|
|
def test_a_token_that_supersedes_nothing_resolves_to_an_empty_tuple():
|
|
"""Most tokens replace nothing. That has to be an empty sequence rather than
|
|
None, so no caller has to test for two kinds of nothing."""
|
|
resolved = _by_name(resolve_tokens(
|
|
FAMILY, PARENTS, {FAMILY: [_token("--fs-radius-md", {"base": "8px"})]},
|
|
))
|
|
assert resolved["--fs-radius-md"].supersedes == ()
|
|
|
|
|
|
def test_supersedes_survives_serialisation_as_a_list():
|
|
resolved = _by_name(resolve_tokens(
|
|
FAMILY, PARENTS,
|
|
{FAMILY: [_token("--fs-text", {"base": "#e8e4d8"}, supersedes=["#fff"])]},
|
|
))
|
|
assert resolved["--fs-text"].to_dict()["supersedes"] == ["#fff"]
|
|
|
|
|
|
def test_the_superseded_literal_need_not_match_the_tokens_own_value():
|
|
"""The whole reason this is DECLARED rather than derived. `#fff` and
|
|
Parchment are different colours, so a value-matching rule could never have
|
|
connected them — which is why the prohibition looked unrepresentable until
|
|
it was turned around."""
|
|
resolved = _by_name(resolve_tokens(
|
|
FAMILY, PARENTS,
|
|
{FAMILY: [_token("--fs-text", {"base": "#e8e4d8"}, supersedes=["#fff"])]},
|
|
))
|
|
text = resolved["--fs-text"]
|
|
assert text.value_by_mode["base"] not in text.supersedes
|
|
|
|
|
|
def test_rows_without_a_supersedes_attribute_at_all_still_resolve():
|
|
"""Duck-typed input: a caller passing rows from before the column existed
|
|
must not crash the cascade."""
|
|
legacy = SimpleNamespace(
|
|
name="--fs-x", value_by_mode={"base": "a"},
|
|
group_name=None, purpose=None, order_index=0,
|
|
)
|
|
resolved = _by_name(resolve_tokens(FAMILY, PARENTS, {FAMILY: [legacy]}))
|
|
assert resolved["--fs-x"].supersedes == ()
|