Adopt spec v0.2: schema, semantic validator, operating modes; workplans updated
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: sonnet Assistant-Process: 295952@bnt-lap001 Assistant-Session: e93f64ad-516c-46eb-9666-aad8d300c477
This commit is contained in:
parent
b6868a3a58
commit
79996ab249
15 changed files with 1090 additions and 64 deletions
|
|
@ -3,7 +3,6 @@ import shutil
|
|||
import subprocess
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from rtut import pack as P, run as R
|
||||
|
||||
|
|
@ -22,28 +21,6 @@ def test_showcase_is_valid():
|
|||
assert P.load(SRC / "pack.yaml").ok
|
||||
|
||||
|
||||
def test_v02_draft_is_wellformed_and_self_consistent():
|
||||
d = yaml.safe_load((SRC / "pack.v0.2.draft.yaml").read_text())
|
||||
assert d["spec"] == "runbook-pack/v0.2"
|
||||
actors = {a["id"] for a in d["actors"]}
|
||||
dps = {x["id"] for x in d["datapoints"]}
|
||||
steps = {}
|
||||
for st in d["stages"]:
|
||||
for s in st["steps"]:
|
||||
steps[s["id"]] = s
|
||||
assert s["actor"] in actors
|
||||
for u in s.get("uses", []):
|
||||
assert u in dps, u
|
||||
gates = {g["id"] for st in d["stages"] for g in st.get("gates", [])}
|
||||
stages = {st["id"] for st in d["stages"]}
|
||||
for st in d["stages"]:
|
||||
for r in st.get("requires", []):
|
||||
assert r.removeprefix("gate:") in gates | stages
|
||||
assert any(s.get("reversible") is False for s in steps.values())
|
||||
assert any("one_of" in s for s in steps.values())
|
||||
assert not any(k.startswith("secret") for x in d["datapoints"] for k in x)
|
||||
|
||||
|
||||
def test_scripted_playthrough(pk):
|
||||
r = R.create(pk, "tester", {"keeper": "Ada", "weather": "storm", "motto": "Light on"})
|
||||
for s in pk.data["steps"]:
|
||||
|
|
|
|||
106
tests/test_v2.py
Normal file
106
tests/test_v2.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import copy
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from rtut import pack as P, run as R, v2
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
FILE = ROOT / "runbooks/showcase-lighthouse/pack.v0.2.yaml"
|
||||
GOOD = yaml.safe_load(FILE.read_text())
|
||||
|
||||
|
||||
def errs(mutate):
|
||||
d = copy.deepcopy(GOOD)
|
||||
mutate(d)
|
||||
return v2.validate(d)
|
||||
|
||||
|
||||
def step(d, sid):
|
||||
for st in d["stages"]:
|
||||
for s in st.get("steps", []):
|
||||
if s["id"] == sid:
|
||||
return s
|
||||
raise KeyError(sid)
|
||||
|
||||
|
||||
def gate(d, gid):
|
||||
return next(g for st in d["stages"] for g in st.get("gates", []) if g["id"] == gid)
|
||||
|
||||
|
||||
def test_showcase_v02_is_valid_and_loads_through_pack_load():
|
||||
pk = P.load(FILE)
|
||||
assert pk.ok, pk.errors
|
||||
assert pk.spec == "runbook-pack/v0.2"
|
||||
|
||||
|
||||
def test_v02_packs_cannot_be_run_yet():
|
||||
with pytest.raises(R.RunError, match="cannot be run yet"):
|
||||
R.create(P.load(FILE), "tester")
|
||||
|
||||
|
||||
CASES = [
|
||||
("unknown actor", lambda d: step(d, "light-lamp").update(actor="nobody"), "unknown actor"),
|
||||
("unknown datapoint in uses", lambda d: step(d, "light-lamp").update(uses=["nope"]), "uses unknown datapoint"),
|
||||
("placeholder not in uses", lambda d: step(d, "light-lamp").update(uses=["keeper_name"]), "not listed in uses"),
|
||||
("undefined placeholder", lambda d: step(d, "light-lamp").update(command="echo {{ghost}}"), "undefined placeholder"),
|
||||
("requires unknown", lambda d: step(d, "write-logbook").update(requires=["gate:ghost"]), "requires unknown item"),
|
||||
("duplicate id", lambda d: d["stages"][0]["gates"][0].update(id="dusk"), "duplicate id"),
|
||||
("irreversible without phrase", lambda d: (d.pop("confirm_phrase"), None), "confirm_phrase"),
|
||||
("waivable by unknown actor", lambda d: gate(d, "lamp-check").update(waivable={"by": "ghost", "needs_reason": True}), "unknown actor"),
|
||||
("datapoints_ok unknown", lambda d: gate(d, "lamp-check")["checks"].append({"datapoints_ok": ["ghost"]}), "unknown datapoint"),
|
||||
("ask_in unknown stage", lambda d: d["datapoints"][0].update(ask_in="ghost"), "unknown stage"),
|
||||
("artefact produced_by unknown", lambda d: d["artefacts"][0].update(produced_by="ghost"), "unknown step"),
|
||||
("recommended_when bad value", lambda d: step(d, "warn")["one_of"][0].update(recommended_when={"weather": ["hail"]}), "not in values"),
|
||||
("step with one_of and command", lambda d: step(d, "warn").update(command="echo x"), "must not also have its own command"),
|
||||
("secret marker", lambda d: d["framing"].append("token hvs.ABCDEFGHIJKLMN"), "secret-looking"),
|
||||
("exit-0 without command", lambda d: step(d, "write-logbook")["verify"].pop("command"), "needs a command"),
|
||||
("secret datapoint", lambda d: d["datapoints"][0].update(secret=True), "secret"),
|
||||
("gate without goal", lambda d: gate(d, "lamp-check").pop("goal"), "goal"),
|
||||
("waiver must need a reason", lambda d: gate(d, "lamp-check").update(waivable={"by": "harbourmaster", "needs_reason": False}), "needs_reason"),
|
||||
("bad timeout action", lambda d: gate(d, "lamp-check").update(timeout={"seconds": 5, "on_timeout": "explode"}), "on_timeout"),
|
||||
("bad autopilot value", lambda d: step(d, "close-log").update(autopilot="always"), "autopilot"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,fn,frag", CASES, ids=[c[0] for c in CASES])
|
||||
def test_invalid(name, fn, frag):
|
||||
out = errs(fn)
|
||||
assert any(frag in e for e in out), out
|
||||
|
||||
|
||||
def test_cycle_detected():
|
||||
out = errs(lambda d: d["stages"][0].update(requires=["night"]))
|
||||
assert any("cycle" in e for e in out), out
|
||||
|
||||
|
||||
def test_child_requiring_its_parent_is_a_cycle():
|
||||
def m(d):
|
||||
step(d, "read-the-sky").update(requires=["dusk"])
|
||||
assert any("cycle" in e for e in errs(m))
|
||||
|
||||
|
||||
def test_depth_limit():
|
||||
def m(d):
|
||||
inner = {"id": "l4", "title": "Level four", "goal": "too deep", "steps": [copy.deepcopy(step(d, "read-the-sign"))]}
|
||||
inner["steps"][0]["id"] = "deep-step"
|
||||
l3 = {"id": "l3", "title": "Level three", "goal": "three", "stages": [inner]}
|
||||
l2 = {"id": "l2", "title": "Level two", "goal": "two", "stages": [l3]}
|
||||
d["stages"].append({"id": "l1", "title": "Level one", "goal": "one", "stages": [l2]})
|
||||
assert any("nesting deeper" in e for e in errs(m))
|
||||
|
||||
|
||||
def test_three_levels_allowed():
|
||||
def m(d):
|
||||
l3 = {"id": "l3", "title": "Level three", "goal": "three", "steps": [copy.deepcopy(step(d, "read-the-sign"))]}
|
||||
l3["steps"][0]["id"] = "deep-step"
|
||||
l2 = {"id": "l2", "title": "Level two", "goal": "two", "stages": [l3]}
|
||||
d["stages"].append({"id": "l1", "title": "Level one", "goal": "one", "stages": [l2]})
|
||||
assert errs(m) == []
|
||||
|
||||
|
||||
def test_legacy_v02_needs_only_schema():
|
||||
d = {"spec": "runbook-pack/v0.2", "id": "x.y", "title": "Legacy x", "owner": "o",
|
||||
"exercise_status": "unexercised", "engine": "legacy-command", "legacy": {"command": ["true"]}}
|
||||
assert v2.validate(d) == []
|
||||
Loading…
Add table
Add a link
Reference in a new issue