One declaration does three jobs: renders the panel, carries the defaults, names
the values that come back. A module writes no DOM and reads no DOM.
Validation refuses rather than repairs -- unknown type, a renderer its type does
not have, a label that is not text, a default outside its own bounds, a when
naming a key the module never declared. A module that is wrong does not get a
best-effort panel; it does not load, and says why once.
Ordering is by declared relation and stage, never priority numbers, and a cycle
drops only the modules in it. Compatibility is by trait, never model name.
The shell hands a module { composer, values, on, shell } and nothing else: no
node, no document, no kit internals. It cannot style anything because it is
never given anything that could.
catalogue/hide.html puts one good module and five broken ones through the real
shell and shows the result: one panel, five absent, no placeholders. The lint
fixtures make the scanner prove it catches each rule, so a clean scan means
something.
First slice: modules/system/appearance. Two settings, an optional ui.js, and
clicking Light actually turns the app light.
209 lines
8.2 KiB
Python
209 lines
8.2 KiB
Python
"""Validation refuses rather than repairs.
|
|
|
|
Every case here is a module that would otherwise load and then behave in a way
|
|
nobody declared -- a panel rendering the wrong control, a headless run with an
|
|
invented default, a row that can never appear.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from core.contract import CONTRACT_VERSION, ModuleSpec
|
|
from core.schema import SchemaError, validate
|
|
|
|
|
|
def announce(**overrides):
|
|
base = {
|
|
"id": "audio_clock", "title": "Audio clock", "mount": "generation.timing",
|
|
"settings": {"enabled": {"type": "bool", "default": True, "label": "On"}},
|
|
}
|
|
base.update(overrides)
|
|
return base
|
|
|
|
|
|
def test_a_valid_declaration_becomes_a_spec():
|
|
spec = validate(announce())
|
|
assert isinstance(spec, ModuleSpec)
|
|
assert spec.id == "audio_clock"
|
|
assert spec.stage == "sampling"
|
|
assert spec.status == "experimental"
|
|
|
|
|
|
def test_defaults_come_from_the_same_declaration_the_panel_renders():
|
|
# This is the whole point of one declaration doing three jobs: a headless
|
|
# run and a rendered panel cannot disagree about a starting value.
|
|
spec = validate(announce(settings={
|
|
"enabled": {"type": "bool", "default": True, "label": "On"},
|
|
"strength": {"type": "float", "default": 0.65, "min": 0, "max": 1, "label": "Strength"},
|
|
}))
|
|
assert spec.defaults() == {"enabled": True, "strength": 0.65}
|
|
|
|
|
|
@pytest.mark.parametrize("missing", ["id", "title", "mount"])
|
|
def test_the_essentials_are_required(missing):
|
|
raw = announce()
|
|
del raw[missing]
|
|
with pytest.raises(SchemaError, match=missing):
|
|
validate(raw)
|
|
|
|
|
|
def test_a_blank_id_is_not_an_id():
|
|
with pytest.raises(SchemaError):
|
|
validate(announce(id=" "))
|
|
|
|
|
|
# --- types and renderers ---------------------------------------------------
|
|
|
|
def test_unknown_type_is_refused():
|
|
with pytest.raises(SchemaError, match="unknown type"):
|
|
validate(announce(settings={"x": {"type": "colour", "default": "#fff", "label": "C"}}))
|
|
|
|
|
|
def test_unknown_ui_hint_is_refused():
|
|
# Accepting it would render something other than what was asked for.
|
|
with pytest.raises(SchemaError, match="not available for a bool"):
|
|
validate(announce(settings={"x": {"type": "bool", "default": True, "label": "X", "ui": "slider"}}))
|
|
|
|
|
|
def test_a_valid_ui_hint_passes():
|
|
spec = validate(announce(settings={"x": {"type": "bool", "default": True, "label": "X", "ui": "toggle"}}))
|
|
assert spec.settings["x"]["ui"] == "toggle"
|
|
|
|
|
|
def test_enum_may_ask_for_the_wheel():
|
|
# How a module gets a radial picker without writing any JavaScript.
|
|
spec = validate(announce(settings={"x": {
|
|
"type": "enum", "default": "a", "label": "X", "ui": "wheel",
|
|
"options": [{"value": "a", "label": "A"}, {"value": "b", "label": "B"}],
|
|
}}))
|
|
assert spec.settings["x"]["ui"] == "wheel"
|
|
|
|
|
|
# --- defaults --------------------------------------------------------------
|
|
|
|
def test_a_setting_without_a_default_is_refused():
|
|
with pytest.raises(SchemaError, match="no default"):
|
|
validate(announce(settings={"x": {"type": "float", "min": 0, "max": 1, "label": "X"}}))
|
|
|
|
|
|
@pytest.mark.parametrize("spec", [
|
|
{"type": "bool", "default": "yes", "label": "X"},
|
|
{"type": "int", "default": 1.5, "label": "X"},
|
|
{"type": "float", "default": "0.5", "label": "X"},
|
|
{"type": "text", "default": 3, "label": "X"},
|
|
{"type": "bool", "default": 1, "label": "X"},
|
|
])
|
|
def test_a_default_of_the_wrong_type_is_refused(spec):
|
|
with pytest.raises(SchemaError):
|
|
validate(announce(settings={"x": spec}))
|
|
|
|
|
|
def test_a_default_outside_its_own_bounds_is_refused():
|
|
with pytest.raises(SchemaError, match="below its min"):
|
|
validate(announce(settings={"x": {"type": "float", "default": -1, "min": 0, "max": 1, "label": "X"}}))
|
|
with pytest.raises(SchemaError, match="above its max"):
|
|
validate(announce(settings={"x": {"type": "float", "default": 9, "min": 0, "max": 1, "label": "X"}}))
|
|
|
|
|
|
def test_inverted_bounds_are_refused():
|
|
with pytest.raises(SchemaError, match="above max"):
|
|
validate(announce(settings={"x": {"type": "int", "default": 0, "min": 5, "max": 1, "label": "X"}}))
|
|
|
|
|
|
def test_a_non_positive_step_is_refused():
|
|
with pytest.raises(SchemaError, match="step"):
|
|
validate(announce(settings={"x": {"type": "float", "default": 0.5, "step": 0, "label": "X"}}))
|
|
|
|
|
|
# --- content is text -------------------------------------------------------
|
|
|
|
@pytest.mark.parametrize("key", ["label", "hint", "unit"])
|
|
def test_content_must_be_text(key):
|
|
# The UI renders these as text and will not interpret anything else.
|
|
with pytest.raises(SchemaError, match=key):
|
|
validate(announce(settings={"x": {"type": "bool", "default": True, key: {"markup": "<b>"}}}))
|
|
|
|
|
|
def test_option_labels_must_be_text_and_values_scalar():
|
|
with pytest.raises(SchemaError, match="label"):
|
|
validate(announce(settings={"x": {"type": "enum", "default": "a", "label": "X",
|
|
"options": [{"value": "a", "label": ["a"]}]}}))
|
|
with pytest.raises(SchemaError, match="scalar"):
|
|
validate(announce(settings={"x": {"type": "enum", "default": "a", "label": "X",
|
|
"options": [{"value": {"a": 1}, "label": "A"}]}}))
|
|
|
|
|
|
# --- enums -----------------------------------------------------------------
|
|
|
|
def test_an_enum_needs_options():
|
|
with pytest.raises(SchemaError, match="no options"):
|
|
validate(announce(settings={"x": {"type": "enum", "default": "a", "label": "X", "options": []}}))
|
|
|
|
|
|
def test_an_enum_default_must_be_one_of_its_options():
|
|
with pytest.raises(SchemaError, match="not one of its options"):
|
|
validate(announce(settings={"x": {"type": "enum", "default": "z", "label": "X",
|
|
"options": [{"value": "a", "label": "A"}]}}))
|
|
|
|
|
|
def test_duplicate_option_values_are_refused():
|
|
with pytest.raises(SchemaError, match="duplicate"):
|
|
validate(announce(settings={"x": {"type": "enum", "default": "a", "label": "X",
|
|
"options": [{"value": "a", "label": "A"}, {"value": "a", "label": "B"}]}}))
|
|
|
|
|
|
# --- conditional visibility ------------------------------------------------
|
|
|
|
def test_when_may_only_name_siblings():
|
|
# A condition on a key that does not exist is always false, so the row would
|
|
# simply never appear -- a typo that reads as a deliberate decision.
|
|
with pytest.raises(SchemaError, match="does not declare"):
|
|
validate(announce(settings={
|
|
"a": {"type": "bool", "default": True, "label": "A"},
|
|
"b": {"type": "bool", "default": True, "label": "B", "when": {"nope": True}},
|
|
}))
|
|
|
|
|
|
def test_when_may_not_name_itself():
|
|
with pytest.raises(SchemaError, match="itself"):
|
|
validate(announce(settings={"a": {"type": "bool", "default": True, "label": "A", "when": {"a": True}}}))
|
|
|
|
|
|
def test_a_valid_when_survives_into_the_spec():
|
|
spec = validate(announce(settings={
|
|
"enabled": {"type": "bool", "default": True, "label": "On"},
|
|
"strength": {"type": "float", "default": 0.5, "label": "S", "when": {"enabled": True}},
|
|
}))
|
|
assert spec.settings["strength"]["when"] == {"enabled": True}
|
|
|
|
|
|
# --- the rest of the announcement ------------------------------------------
|
|
|
|
def test_unknown_stage_is_refused():
|
|
with pytest.raises(SchemaError, match="unknown stage"):
|
|
validate(announce(stage="whenever"))
|
|
|
|
|
|
def test_unknown_status_is_refused():
|
|
with pytest.raises(SchemaError, match="unknown status"):
|
|
validate(announce(status="probably-fine"))
|
|
|
|
|
|
def test_relations_must_be_lists_of_ids():
|
|
with pytest.raises(SchemaError, match="after"):
|
|
validate(announce(after="audio_clock")) # a bare string is a common slip
|
|
with pytest.raises(SchemaError, match="requires"):
|
|
validate(announce(requires=[1, 2]))
|
|
|
|
|
|
def test_the_manifest_carries_what_the_ui_needs():
|
|
spec = validate(announce(requires=["audio_stream"], after=["x"], stage="latent"))
|
|
manifest = spec.to_manifest()
|
|
assert manifest["id"] == "audio_clock"
|
|
assert manifest["requires"] == ["audio_stream"]
|
|
assert manifest["stage"] == "latent"
|
|
assert "source" not in manifest, "the import path is core's business, not the browser's"
|
|
|
|
|
|
def test_the_contract_has_a_version():
|
|
assert isinstance(CONTRACT_VERSION, int)
|