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
464 lines
23 KiB
Python
464 lines
23 KiB
Python
import copy
|
|
import subprocess
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
from rtut import pack as P, run as R1, run2 as R
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
SRC = yaml.safe_load((ROOT / "runbooks/showcase-lighthouse-v2/pack.yaml").read_text())
|
|
PHRASE = "I understand this cannot be undone"
|
|
|
|
|
|
def mkpack(tmp_path, mutate=None):
|
|
d = copy.deepcopy(SRC)
|
|
if mutate:
|
|
mutate(d)
|
|
f = tmp_path / "runbooks" / "lh" / "pack.yaml"
|
|
f.parent.mkdir(parents=True, exist_ok=True)
|
|
f.write_text(yaml.safe_dump(d, sort_keys=False))
|
|
pk = P.load(f)
|
|
assert pk.ok, pk.errors
|
|
return pk
|
|
|
|
|
|
_SCRATCH: list[str] = []
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _cleanup_scratch():
|
|
yield
|
|
import shutil
|
|
while _SCRATCH:
|
|
shutil.rmtree(_SCRATCH.pop(), ignore_errors=True)
|
|
|
|
|
|
def new(tmp_path, mutate=None, **kw):
|
|
pk = mkpack(tmp_path, mutate)
|
|
logdir = f"/tmp/rtut-{uuid.uuid4().hex[:10]}"
|
|
_SCRATCH.append(logdir)
|
|
vals = {"logdir": logdir, **kw.pop("values", {})}
|
|
return R.create(pk, "tester", vals, **kw)
|
|
|
|
|
|
def do_step(r, sid, alt=None, phrase=None, manual=True, run_cmd=True, evidence=None):
|
|
"""What an operator does: start, run the command, check, mark done."""
|
|
R.start(r, sid, alt=alt, phrase=phrase)
|
|
cmd = R.command_for(r, sid)
|
|
if cmd and run_cmd:
|
|
subprocess.run(cmd, shell=True, check=True, capture_output=True)
|
|
R.verify(r, sid, confirm=True, manual_ok=manual)
|
|
R.complete(r, sid, evidence)
|
|
|
|
|
|
def pass_gate(r, gid):
|
|
for i, ck in enumerate(R.flow(r).gates[gid]["checks"]):
|
|
R.gate_check(r, gid, i, confirm=True, manual_ok=True)
|
|
|
|
|
|
def counts_towards_maturity(r) -> bool:
|
|
"""Would this run be clean maturity evidence? (succeeded, no deviation, with an environment)"""
|
|
from rtut import maturity as M
|
|
r.data["outcome"] = "succeeded"
|
|
r.data["context"] = {"project": "p", "environment": "rehearsal"}
|
|
return M._evidence(r.pack, r.data.get("pack_digest"), [r])["clean_runs"] == 1
|
|
|
|
|
|
# ---- datapoints -----------------------------------------------------------------------------------
|
|
|
|
def test_create_sets_datapoint_states(tmp_path):
|
|
r = new(tmp_path)
|
|
dp = r.data["datapoints"]
|
|
assert dp["keeper_name"]["state"] == "set" and dp["keeper_name"]["value"] == "Ada"
|
|
assert dp["lamp_lit"]["state"] == "nil"
|
|
assert r.data["params"]["weather"] == "fog"
|
|
|
|
|
|
def test_invalid_datapoint_is_err_and_not_stored(tmp_path):
|
|
r = new(tmp_path)
|
|
assert R.set_datapoint(r, "weather", "hail") == "err"
|
|
assert "value" not in r.data["datapoints"]["weather"] and "weather" not in r.data["params"]
|
|
assert R.set_datapoint(r, "weather", "storm") == "set"
|
|
assert R.set_datapoint(r, "logdir", "/etc") == "err"
|
|
assert R.set_datapoint(r, "motto", 'x"; rm -rf /') == "err"
|
|
with pytest.raises(R.RunError):
|
|
R.create(mkpack(tmp_path), "t", {"weather": "hail"})
|
|
|
|
|
|
# ---- readiness, recommendation, parallelism, alternatives ---------------------------------------------
|
|
|
|
def test_recommended_next_is_first_ready_in_canonical_order_and_others_are_parallel(tmp_path):
|
|
r = new(tmp_path)
|
|
na = R.next_actions(r)
|
|
assert na["recommended"] == "read-the-sign"
|
|
assert na["ready"] == ["read-the-sign", "light-lamp", "read-the-sky"] # parallel, canonical order
|
|
blocked = {b["id"]: b["unmet"] for b in na["blocked"]}
|
|
assert "lamp-check" in blocked and "warn" in blocked and "close-log" in blocked
|
|
R.start(r, "light-lamp") # any ready item may be taken
|
|
assert R.next_actions(r)["recommended"] == "light-lamp" # a running item comes first
|
|
|
|
|
|
def test_alternatives_recommended_by_datapoint_and_explicit_choice(tmp_path):
|
|
for weather, alt in (("fog", "sound-horn"), ("clear", "wave-flags"), ("storm", "shelter")):
|
|
r = new(tmp_path, values={"weather": weather})
|
|
assert R.recommended_alt(r, "warn") == alt
|
|
r = new(tmp_path)
|
|
for s in ("read-the-sign", "light-lamp", "read-the-sky"):
|
|
do_step(r, s)
|
|
pass_gate(r, "lamp-check")
|
|
with pytest.raises(R.RunError, match="choose one of"):
|
|
R.start(r, "warn", alt="dance")
|
|
R.start(r, "warn", alt="wave-flags") # operator may override the recommendation
|
|
assert R.command_for(r, "warn").startswith("echo") and "signals with flags" in R.command_for(r, "warn")
|
|
assert r.data["steps"]["warn"]["alternative"] == "wave-flags"
|
|
|
|
|
|
def test_datapoint_ok_via_confirmed_by_and_gate_flow(tmp_path):
|
|
r = new(tmp_path)
|
|
assert R.unmet(r, "lamp-check") # stage steps not done yet
|
|
do_step(r, "light-lamp")
|
|
dp = r.data["datapoints"]["lamp_lit"]
|
|
assert dp["state"] == "ok" and dp["how"] # produced, then confirmed by the step's own check
|
|
for s in ("read-the-sign", "read-the-sky"):
|
|
do_step(r, s)
|
|
assert R.unmet(r, "lamp-check") == []
|
|
R.gate_check(r, "lamp-check", 0, manual_ok=True)
|
|
assert r.data["gates"]["lamp-check"]["state"] == "open" # not all checks evaluated yet
|
|
R.gate_check(r, "lamp-check", 1) # datapoints_ok is automatic
|
|
assert r.data["gates"]["lamp-check"]["state"] == "passed"
|
|
assert "warn" in R.next_actions(r)["ready"]
|
|
|
|
|
|
def test_failed_step_marks_produced_datapoint_err_and_retry_clears_it(tmp_path):
|
|
r = new(tmp_path)
|
|
R.start(r, "light-lamp"); R.fail(r, "light-lamp", "no oil")
|
|
assert r.data["datapoints"]["lamp_lit"]["state"] == "err"
|
|
R.retry(r, "light-lamp")
|
|
assert r.data["datapoints"]["lamp_lit"]["state"] == "nil"
|
|
|
|
|
|
# ---- overrides, waivers, deviations ------------------------------------------------------------------
|
|
|
|
def test_override_needs_reason_and_acknowledged_risk_and_is_recorded(tmp_path):
|
|
r = new(tmp_path)
|
|
with pytest.raises(R.RunError, match="Risk:"):
|
|
R.start(r, "warn", alt="sound-horn") # gate not passed: names unmet + risk
|
|
with pytest.raises(R.RunError, match="acknowledge"):
|
|
R.start(r, "warn", alt="sound-horn", override_reason="need to see the horn text")
|
|
R.start(r, "warn", alt="sound-horn", override_reason="need to see the horn text", acknowledge_risk=True)
|
|
dev = r.data["deviations"][0]
|
|
assert dev["kind"] == "override" and dev["item"] == "warn" and dev["mode"] == "manual"
|
|
assert any("lamp-check" in u for u in dev["unmet"]) and dev["risk_acknowledged"] and dev["reason"]
|
|
assert "not complete" in dev["unmet"][0]
|
|
|
|
|
|
def test_waiver_only_where_declared_with_reason_and_recorded(tmp_path):
|
|
r = new(tmp_path)
|
|
for s in ("read-the-sign", "light-lamp", "read-the-sky"):
|
|
do_step(r, s)
|
|
R.gate_check(r, "lamp-check", 0, manual_ok=False)
|
|
R.gate_check(r, "lamp-check", 1)
|
|
assert r.data["gates"]["lamp-check"]["state"] == "failed"
|
|
with pytest.raises(R.RunError, match="reason"):
|
|
R.waive(r, "lamp-check", " ")
|
|
R.waive(r, "lamp-check", "harbour master accepts the risk")
|
|
assert r.data["gates"]["lamp-check"]["state"] == "waived"
|
|
assert r.data["deviations"][-1]["kind"] == "waiver" and "check 0 not passed" in r.data["deviations"][-1]["unmet"]
|
|
assert "warn" in R.next_actions(r)["ready"] # a waived gate unblocks dependents
|
|
with pytest.raises(R.RunError, match="not waivable"):
|
|
R.waive(r, "night-on-record", "try")
|
|
|
|
|
|
def test_deviations_keep_a_run_out_of_maturity(tmp_path):
|
|
r = new(tmp_path)
|
|
R.start(r, "warn", alt="sound-horn", override_reason="probe", acknowledge_risk=True)
|
|
assert r.data["deviations"] and not counts_towards_maturity(r)
|
|
assert counts_towards_maturity(new(tmp_path)) # the same without a deviation would count
|
|
|
|
|
|
# ---- confirmation styles ------------------------------------------------------------------------------
|
|
|
|
def test_irreversible_step_needs_the_typed_phrase(tmp_path):
|
|
r = new(tmp_path)
|
|
assert R.confirmation_style(r, "read-the-sign") == "confirm" and R.confirmation_style(r, "close-log") == "phrase"
|
|
for s in ("read-the-sign", "light-lamp", "read-the-sky"):
|
|
do_step(r, s)
|
|
pass_gate(r, "lamp-check")
|
|
do_step(r, "warn")
|
|
do_step(r, "write-logbook")
|
|
with pytest.raises(R.RunError, match="confirmation phrase"):
|
|
R.start(r, "close-log")
|
|
with pytest.raises(R.RunError, match="confirmation phrase"):
|
|
R.start(r, "close-log", phrase="yes")
|
|
R.start(r, "close-log", phrase=PHRASE)
|
|
assert r.data["steps"]["close-log"]["authorized_by"] == "tester"
|
|
|
|
|
|
def test_preauthorization_only_for_irreversible_steps(tmp_path):
|
|
with pytest.raises(R.RunError, match="only irreversible"):
|
|
new(tmp_path, mode="auto-confirm", preauthorize=["light-lamp"])
|
|
|
|
|
|
# ---- auto-confirm ------------------------------------------------------------------------------------------------
|
|
|
|
def drive(r, ticks=60, now=None, operator=None):
|
|
log = []
|
|
for _ in range(ticks):
|
|
out = R.auto_confirm_tick(r, now)
|
|
log.append(out)
|
|
if out["action"] in ("finished", "stopped", "awaiting-operator", "waiting"):
|
|
break
|
|
if out["action"] == "started" and operator:
|
|
operator(out["item"]) # the operator runs the step's command while auto-confirm confirms
|
|
return log
|
|
|
|
|
|
def test_auto_confirm_confirms_when_preconditions_are_met_and_waits_for_the_operator_on_manual_only(tmp_path):
|
|
r = new(tmp_path, mode="auto-confirm")
|
|
|
|
def operator(sid):
|
|
cmd = R.command_for(r, sid)
|
|
if cmd:
|
|
subprocess.run(cmd, shell=True, check=True, capture_output=True)
|
|
|
|
log = drive(r, operator=operator)
|
|
assert log[-1] == {"action": "awaiting-operator", "item": "close-log", "detail": "step is manual-only"}
|
|
done = [x["item"] for x in log if x["action"] == "completed"]
|
|
assert done[:3] == ["read-the-sign", "light-lamp", "read-the-sky"] and "lamp-check" in done and "write-logbook" in done
|
|
assert r.data["steps"]["warn"]["alternative"] == "sound-horn" # recommended by the fog datapoint
|
|
assert r.data["gates"]["lamp-check"]["state"] == "passed"
|
|
assert r.data["steps"]["read-the-sign"]["verification"].get("auto_confirmed")
|
|
assert r.data["deviations"] == []
|
|
R.start(r, "close-log", phrase=PHRASE) # the operator takes over the last step
|
|
subprocess.run(R.command_for(r, "close-log"), shell=True, check=True, capture_output=True)
|
|
R.verify(r, "close-log", confirm=True); R.complete(r, "close-log")
|
|
assert R.auto_confirm_tick(r)["action"] == "completed" # gate night-on-record
|
|
assert R.auto_confirm_tick(r)["action"] == "finished"
|
|
R1.finalize(r, "succeeded")
|
|
assert r.data["outcome"] == "succeeded" and not r.data["deviations"]
|
|
|
|
|
|
def test_auto_confirm_timeout_stop(tmp_path):
|
|
def m(d):
|
|
d["timeout"] = {"seconds": 30, "on_timeout": "stop"}
|
|
r = new(tmp_path, m, mode="auto-confirm")
|
|
log = drive(r) # write-logbook's file check cannot pass: nobody ran its command
|
|
assert log[-1]["action"] == "waiting" and log[-1]["item"] == "write-logbook"
|
|
late = datetime.now(timezone.utc) + timedelta(seconds=31)
|
|
out = R.auto_confirm_tick(r, late)
|
|
assert out["action"] == "stopped" and "timed out after 30s" in out["detail"]
|
|
assert R.auto_confirm_tick(r)["action"] == "stopped" # stays stopped
|
|
assert r.data["deviations"] == [] # stopping is not a deviation
|
|
|
|
|
|
def test_auto_confirm_push_through_records_deviation_and_moves_on(tmp_path):
|
|
def m(d):
|
|
d["timeout"] = {"seconds": 30, "on_timeout": "push-through"}
|
|
r = new(tmp_path, m, mode="auto-confirm")
|
|
drive(r)
|
|
late = datetime.now(timezone.utc) + timedelta(seconds=31)
|
|
out = R.auto_confirm_tick(r, late)
|
|
assert out["action"] == "push-through" and out["item"] == "write-logbook"
|
|
assert r.data["steps"]["write-logbook"]["state"] == "pushed" # its own state: not done, not failed
|
|
dev = r.data["deviations"][0]
|
|
assert dev["kind"] == "push-through" and dev["mode"] == "auto-confirm" and dev["risk_acknowledged"] is False
|
|
nxt = R.auto_confirm_tick(r, late) # continues to the manual-only step
|
|
assert nxt["item"] == "close-log" and nxt["action"] == "awaiting-operator"
|
|
assert not counts_towards_maturity(r) # a pushed-through run is not evidence
|
|
|
|
|
|
def test_auto_confirm_gate_push_through_and_stop(tmp_path):
|
|
def m(d):
|
|
d["stages"][0]["gates"][0]["checks"].append({"done_when": "always fails", "command": "false", "expect": "exit-0"})
|
|
d["stages"][0]["gates"][0]["timeout"] = {"seconds": 10, "on_timeout": "push-through"}
|
|
r = new(tmp_path, m, mode="auto-confirm")
|
|
drive(r)
|
|
assert r.data["gates"]["lamp-check"]["state"] == "failed" # all checks evaluated, one failing
|
|
out = R.auto_confirm_tick(r, datetime.now(timezone.utc) + timedelta(seconds=11))
|
|
assert out["action"] == "push-through" and r.data["gates"]["lamp-check"]["state"] == "pushed" # not "waived": nobody approved it
|
|
assert r.data["deviations"][0]["item"] == "lamp-check" and r.data["deviations"][0]["kind"] == "push-through"
|
|
|
|
|
|
def test_auto_confirm_never_confirms_irreversible_steps_unless_preauthorized(tmp_path):
|
|
def m(d):
|
|
for st in d["stages"]:
|
|
for s in st.get("steps", []):
|
|
s.pop("auto_confirm", None) # allow automation of close-log
|
|
r = new(tmp_path, m, mode="auto-confirm")
|
|
drive(r, operator=lambda sid: (subprocess.run(R.command_for(r, sid), shell=True, check=True, capture_output=True) if R.command_for(r, sid) else None))
|
|
last = R.auto_confirm_tick(r)
|
|
assert last["action"] == "awaiting-operator" and "not pre-authorized" in last["detail"]
|
|
r2 = new(tmp_path, m, mode="auto-confirm", preauthorize=["close-log"])
|
|
drive(r2, operator=lambda sid: (subprocess.run(R.command_for(r2, sid), shell=True, check=True, capture_output=True) if R.command_for(r2, sid) else None))
|
|
assert r2.data["steps"]["close-log"]["state"] in ("running", "done")
|
|
assert r2.data["steps"]["close-log"]["authorized_by"] == "tester"
|
|
|
|
|
|
def test_manual_mode_rejects_auto_confirm_and_auto_confirm_rejects_overrides(tmp_path):
|
|
with pytest.raises(R.RunError, match="auto-confirm run"):
|
|
R.auto_confirm_tick(new(tmp_path))
|
|
r = new(tmp_path, mode="auto-confirm")
|
|
with pytest.raises(R.RunError, match="manual mode"):
|
|
R.start(r, "warn", alt="sound-horn", override_reason="x", acknowledge_risk=True)
|
|
|
|
|
|
def test_finished_v02_run_is_read_only_and_scores_count_it(tmp_path):
|
|
r = new(tmp_path)
|
|
R1.finalize(r, "abandoned")
|
|
with pytest.raises(R.RunError, match="finished"):
|
|
R.start(r, "read-the-sign")
|
|
with pytest.raises(R.RunError, match="finished"):
|
|
R.set_datapoint(r, "weather", "clear")
|
|
assert R1.scorecard(r.pack)["abandoned"] == 1
|
|
assert R1.load(r.pack, r.data["run_id"]).data["spec"] == "runbook-run/v0.2" # reloads through the shared loader
|
|
|
|
|
|
def test_pushed_items_are_a_distinct_state_that_lets_the_run_continue_but_never_succeed(tmp_path):
|
|
def m(d):
|
|
d["timeout"] = {"seconds": 5, "on_timeout": "push-through"}
|
|
light = next(s for st in d["stages"] for s in st.get("steps", []) if s["id"] == "light-lamp")
|
|
light["verify"] = {"done_when": "never true here", "command": "false", "expect": "exit-0"}
|
|
d["stages"][0]["gates"][0]["timeout"] = {"seconds": 5, "on_timeout": "push-through"} # the gate's own timeout wins
|
|
r = new(tmp_path, m, mode="auto-confirm")
|
|
late = datetime.now(timezone.utc) + timedelta(seconds=6)
|
|
seen = []
|
|
for _ in range(40):
|
|
out = R.auto_confirm_tick(r, late)
|
|
seen.append(out["action"])
|
|
if out["action"] in ("stopped", "awaiting-operator", "finished"):
|
|
break
|
|
late = late + timedelta(seconds=6)
|
|
assert r.data["steps"]["light-lamp"]["state"] == "pushed"
|
|
dp = r.data["datapoints"]["lamp_lit"]
|
|
assert dp["state"] == "pushed" and "value" not in dp and "error" not in dp # not set, not err
|
|
assert "lamp_lit" not in r.data["params"] # never usable as a value
|
|
assert R.is_complete(r, "light-lamp") # dependents may proceed
|
|
assert r.data["gates"]["lamp-check"]["state"] == "pushed" # datapoints_ok needs 'ok', so the gate was pushed too
|
|
kinds = [d["kind"] for d in r.data["deviations"]]
|
|
assert kinds.count("push-through") >= 2 and "override" not in kinds
|
|
with pytest.raises(R1.RunError, match="succeeds only when every step is done"):
|
|
R1.finalize(r, "succeeded")
|
|
R1.finalize(r, "failed", "pushed through to collect problems") # a clean-completion failure, finishable as failed
|
|
assert r.data["outcome"] == "failed"
|
|
|
|
|
|
# ---- retry policy, mode switch, views ------------------------------------------------------------------------------
|
|
|
|
def test_retry_of_a_non_idempotent_step_needs_the_partial_application_confirmation(tmp_path):
|
|
r = new(tmp_path)
|
|
for s in ("read-the-sign", "light-lamp", "read-the-sky"):
|
|
do_step(r, s)
|
|
pass_gate(r, "lamp-check")
|
|
do_step(r, "warn")
|
|
R.start(r, "write-logbook"); R.fail(r, "write-logbook", "disk full halfway") # non-idempotent: appends
|
|
with pytest.raises(R.RunError, match="did not partly apply"):
|
|
R.retry(r, "write-logbook")
|
|
R.retry(r, "write-logbook", acknowledge_partial=True)
|
|
assert r.data["steps"]["write-logbook"]["retries"][0]["acknowledged_partial"] is True
|
|
R.start(r, "read-the-sign") if False else None
|
|
r2 = new(tmp_path)
|
|
R.start(r2, "read-the-sign"); R.fail(r2, "read-the-sign", "x") # idempotent: free retry
|
|
R.retry(r2, "read-the-sign")
|
|
assert "retries" not in r2.data["steps"]["read-the-sign"]
|
|
|
|
|
|
def test_undeclared_idempotency_also_needs_the_confirmation(tmp_path):
|
|
def m(d):
|
|
step(d, "read-the-sign").pop("idempotent")
|
|
r = new(tmp_path, m)
|
|
R.start(r, "read-the-sign"); R.fail(r, "read-the-sign", "x")
|
|
with pytest.raises(R.RunError, match="not declared idempotent"):
|
|
R.retry(r, "read-the-sign")
|
|
|
|
|
|
def step(d, sid):
|
|
return next(s for st in d["stages"] for s in st.get("steps", []) if s["id"] == sid)
|
|
|
|
|
|
def test_switch_mode_records_history_and_clears_a_stop(tmp_path):
|
|
r = new(tmp_path, mode="auto-confirm")
|
|
R._stop(r, "x", "why")
|
|
assert r.data["auto_confirm"]["stopped"]
|
|
R.switch_mode(r, "manual")
|
|
assert r.data["mode"] == "manual" and r.data["mode_history"][0] == {**r.data["mode_history"][0], "from": "auto-confirm", "to": "manual"}
|
|
with pytest.raises(R.RunError, match="already in manual"):
|
|
R.switch_mode(r, "manual")
|
|
R.switch_mode(r, "auto-confirm")
|
|
assert "stopped" not in r.data["auto_confirm"] and len(r.data["mode_history"]) == 2
|
|
|
|
|
|
def test_overview_counts_progress_pushed_and_datapoint_states(tmp_path):
|
|
r = new(tmp_path)
|
|
ov = R.overview(r)
|
|
assert ov["progress"] == {"done": 0, "pushed": 0, "total": 8} and [s["id"] for s in ov["stages"]] == ["dusk", "night", "dawn"]
|
|
assert ov["datapoints"]["set"] == 5 and ov["datapoints"]["nil"] == 1
|
|
do_step(r, "light-lamp")
|
|
ov = R.overview(r)
|
|
assert ov["progress"]["done"] == 1 and ov["stages"][0]["steps_done"] == 1 and ov["datapoints"]["ok"] == 1
|
|
assert ov["stages"][1]["unmet"] and not ov["stages"][0]["complete"]
|
|
|
|
|
|
def test_datapoint_rows_group_and_filter_by_dimension(tmp_path):
|
|
r = new(tmp_path)
|
|
assert "actor" in R.dimension_keys(r) and "kind" in R.dimension_keys(r)
|
|
rows = R.datapoint_rows(r)
|
|
assert list(rows) == [""] and len(rows[""]) == 6
|
|
by_actor = R.datapoint_rows(r, group="actor")
|
|
assert set(by_actor) == {"keeper", "(none)"} and {x["id"] for x in by_actor["keeper"]} >= {"keeper_name", "lamp_fuel", "motto", "lamp_lit"}
|
|
only = R.datapoint_rows(r, flt="kind:identity")
|
|
assert {x["id"] for x in only[""]} == {"keeper_name", "motto"}
|
|
lamp = next(x for x in rows[""] if x["id"] == "lamp_lit")
|
|
assert lamp["editable"] is False and lamp["produced_by"] == "light-lamp"
|
|
|
|
|
|
def test_artefact_rows_resolve_location_from_a_datapoint(tmp_path):
|
|
r = new(tmp_path, values={"logdir": "/tmp/rtut-art-one"})
|
|
a = R.artefact_rows(r)[0]
|
|
assert a["id"] == "logbook-file" and a["location"] == "/tmp/rtut-art-one/logbook.txt" and a["produced_state"] == "todo"
|
|
|
|
|
|
def test_captures_and_pasted_verification_work_for_v02_runs(tmp_path):
|
|
r = new(tmp_path)
|
|
R.start(r, "read-the-sky")
|
|
R1.add_capture(r, "read-the-sky", "Weather tonight - fog.", "echo ...")
|
|
res = R1.verify_pasted(r, "read-the-sky", "Weather tonight - fog.")
|
|
assert res["passed"] and r.data["steps"]["read-the-sky"]["captures"][0]["output"].startswith("Weather")
|
|
|
|
|
|
# ---- changing datapoints after steps used them ------------------------------------------------------------------------
|
|
|
|
def test_changing_an_unused_datapoint_is_free_and_a_used_one_is_a_recorded_deviation(tmp_path):
|
|
r = new(tmp_path, values={"weather": "fog"})
|
|
assert R.set_datapoint(r, "weather", "clear") == "set" and r.data["deviations"] == [] # nobody used it yet
|
|
do_step(r, "read-the-sky") # uses weather
|
|
assert R.datapoint_users(r, "weather") == ["read-the-sky"]
|
|
with pytest.raises(R.RunError, match="already used by read-the-sky"):
|
|
R.set_datapoint(r, "weather", "storm")
|
|
with pytest.raises(R.RunError, match="acknowledge"):
|
|
R.set_datapoint(r, "weather", "storm", override_reason="the forecast changed")
|
|
assert r.data["params"]["weather"] == "clear" # refused: nothing changed
|
|
R.set_datapoint(r, "weather", "storm", override_reason="the forecast changed", acknowledge_risk=True)
|
|
assert r.data["params"]["weather"] == "storm"
|
|
dev = r.data["deviations"][0]
|
|
assert dev["kind"] == "changed-after-use" and dev["item"] == "weather" and "read-the-sky" in dev["unmet"][0] and "'clear'" in dev["unmet"][0]
|
|
assert dev["reason"] == "the forecast changed" and dev["risk_acknowledged"] is True
|
|
assert R.set_datapoint(r, "weather", "storm") == "set" and len(r.data["deviations"]) == 1 # the same value is not a change
|
|
|
|
|
|
def test_used_datapoints_cannot_be_changed_in_auto_confirm_mode(tmp_path):
|
|
r = new(tmp_path, mode="auto-confirm")
|
|
R.auto_confirm_tick(r); R.auto_confirm_tick(r)
|
|
r.data["steps"]["read-the-sky"]["state"] = "done"
|
|
with pytest.raises(R.RunError, match="take over"):
|
|
R.set_datapoint(r, "weather", "clear", override_reason="x", acknowledge_risk=True)
|
|
|
|
|
|
def test_a_run_that_changed_a_used_value_never_counts_towards_maturity(tmp_path):
|
|
r = new(tmp_path)
|
|
do_step(r, "read-the-sky")
|
|
R.set_datapoint(r, "weather", "clear", override_reason="changed my mind", acknowledge_risk=True)
|
|
assert r.data["deviations"] and not counts_towards_maturity(r)
|