v0.2 walk-through UI: guided flow, data view, overview, modes, overrides, typed phrase; retry confirmation; visual pass
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
f8ce4717a8
commit
f070999aa2
12 changed files with 1285 additions and 53 deletions
|
|
@ -343,3 +343,86 @@ def test_pushed_items_are_a_distinct_state_that_lets_the_run_continue_but_never_
|
|||
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")
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ def test_create_project_from_template_and_view_it(psrv):
|
|||
assert ">planned<" in body and ">rehearsal<" in body
|
||||
assert "target:north-light" in body # provenance of the fog weather
|
||||
assert "Still needed" in body and "name='a_host'" in body # hello needs a host
|
||||
assert "cannot be run in this UI yet" in body # the v0.2 member
|
||||
assert "waiting for rtut.example-hello" in body # the v0.2 member waits for its predecessor
|
||||
|
||||
|
||||
def test_needed_attributes_can_be_filled_in_and_are_validated(psrv):
|
||||
|
|
@ -114,8 +114,8 @@ def test_start_a_run_from_a_project_uses_its_attributes_and_links_back(psrv):
|
|||
_, _, body = req(psrv, "GET", "/project/harbour")
|
||||
assert ">applying<" in body and "latest run" in body
|
||||
st, loc2, _ = req(psrv, "POST", "/project/harbour/run", {"_t": t, "pack": "rtut.showcase-lighthouse-v2", "operator": "bernd"})
|
||||
assert st == 303 # the engine accepts it; the UI just cannot walk v0.2 runs yet
|
||||
assert "cannot walk v0.2 runs yet" in req(psrv, "GET", loc2.split("#")[0])[2]
|
||||
assert st == 303 # a v0.2 member can be started too, in either mode
|
||||
assert "Guided flow" in req(psrv, "GET", loc2.split("#")[0])[2]
|
||||
|
||||
|
||||
def test_attribute_table_shows_state_source_modified_and_problems(psrv):
|
||||
|
|
@ -172,27 +172,10 @@ def test_bad_project_id_and_duplicate_are_refused(psrv):
|
|||
assert st == 400 and "environment" in body
|
||||
|
||||
|
||||
def test_v02_run_shows_a_guard_instead_of_crashing(psrv):
|
||||
def test_v02_run_is_walkable_in_the_ui(psrv):
|
||||
reg = psrv.app.registry()
|
||||
pack = reg.packs["rtut.showcase-lighthouse-v2"]
|
||||
r = run2.create(pack, "bernd", {"logdir": "/tmp/rtut-ui-v2"})
|
||||
i = psrv.app.pack_index("rtut.showcase-lighthouse-v2")
|
||||
st, _, body = req(psrv, "GET", f"/run/{i}/{r.data['run_id']}")
|
||||
assert st == 200 and "cannot walk v0.2 runs yet" in body
|
||||
|
||||
|
||||
def test_front_page_lists_projects_by_their_own_name_and_shows_the_template_as_origin(psrv):
|
||||
make_project(psrv)
|
||||
_, _, body = req(psrv, "GET", "/")
|
||||
projects = body.split("<h2>Project templates</h2>")[0]
|
||||
assert "Harbour watch" in projects and "Our own harbour, two lights." in projects
|
||||
assert "from template <a href='/template/lighthouse-watch'>lighthouse-watch</a>" in projects
|
||||
assert "The night watch is played through" not in projects and "A watch at a lighthouse" not in projects
|
||||
assert "A watch at a lighthouse" in body.split("<h2>Project templates</h2>")[1] # the template keeps its own listing
|
||||
|
||||
|
||||
def test_creating_a_project_needs_a_name(psrv):
|
||||
t = psrv.app.token
|
||||
st, _, body = req(psrv, "POST", "/template/lighthouse-watch/new", {"_t": t, "project_id": "nameless", "environment": "rehearsal", "title": "x"})
|
||||
assert st == 400 and "name" in body
|
||||
assert not (psrv.repo / "projects/nameless").exists()
|
||||
assert st == 200 and "Guided flow" in body and "Keep in mind" in body
|
||||
|
|
|
|||
343
tests/test_ui_v2.py
Normal file
343
tests/test_ui_v2.py
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
import http.client
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import pytest
|
||||
|
||||
from rtut import run as R1, run2 as R, ui
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
PHRASE = "I understand this cannot be undone"
|
||||
_scratch = []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def s2(tmp_path):
|
||||
d = tmp_path / "runbooks" / "lh"; d.mkdir(parents=True)
|
||||
shutil.copy(ROOT / "runbooks/showcase-lighthouse/pack.v0.2.yaml", d / "pack.yaml")
|
||||
s = ui.serve([tmp_path], 0)
|
||||
threading.Thread(target=s.serve_forever, daemon=True).start()
|
||||
yield s
|
||||
s.shutdown()
|
||||
while _scratch:
|
||||
shutil.rmtree(_scratch.pop(), ignore_errors=True)
|
||||
|
||||
|
||||
def req(s, method, path, form=None, host="127.0.0.1"):
|
||||
c = http.client.HTTPConnection("127.0.0.1", s.server_address[1])
|
||||
body = urlencode(form) if form is not None else None
|
||||
c.request(method, path, body, {"Host": host, "Content-Type": "application/x-www-form-urlencoded"})
|
||||
r = c.getresponse()
|
||||
return r.status, r.getheader("Location"), r.read().decode()
|
||||
|
||||
|
||||
def post(s, path, **form):
|
||||
return req(s, "POST", path, {"_t": s.app.token, **form})
|
||||
|
||||
|
||||
def new_run(s, mode="manual", **extra):
|
||||
logdir = f"/tmp/rtut-uv2-{uuid.uuid4().hex[:8]}"
|
||||
_scratch.append(logdir)
|
||||
st, loc, _ = post(s, "/run/new/0", operator="bernd", mode=mode, p_logdir=logdir, **extra)
|
||||
assert st == 303, loc
|
||||
return loc.split("#")[0], logdir
|
||||
|
||||
|
||||
def page(s, base, query=""):
|
||||
st, _, body = req(s, "GET", base + query)
|
||||
assert st == 200
|
||||
return body
|
||||
|
||||
|
||||
def do_step(s, base, sid, alt=None, phrase=None, run_cmd=True, cmd_of=None):
|
||||
"""What an operator does through the UI: start, do the work, check, mark done."""
|
||||
form = {}
|
||||
if alt: form["alt"] = alt
|
||||
if phrase: form["phrase"] = phrase
|
||||
assert post(s, f"{base}/{sid}/start", **form)[0] == 303
|
||||
run = R1.load(s.app.packs[0], base.rsplit("/", 1)[1])
|
||||
cmd = R.command_for(run, sid)
|
||||
if cmd and run_cmd:
|
||||
subprocess.run(cmd, shell=True, check=True, capture_output=True)
|
||||
eff = R.step_def(run, sid)
|
||||
manual = (eff["verify"].get("expect") or "manual") == "manual" and not eff["verify"].get("datapoints_ok")
|
||||
assert (post(s, f"{base}/{sid}/verify", ok="1") if manual else post(s, f"{base}/{sid}/verify", yes="1"))[0] == 303
|
||||
assert post(s, f"{base}/{sid}/done")[0] == 303
|
||||
|
||||
|
||||
def pass_gate(s, base, gid):
|
||||
run = R1.load(s.app.packs[0], base.rsplit("/", 1)[1])
|
||||
for i, ck in enumerate(R.flow(run).gates[gid]["checks"]):
|
||||
exp = ck.get("expect") or (None if ck.get("datapoints_ok") else "manual")
|
||||
assert (post(s, f"{base}/gate/{gid}/check", idx=str(i), ok="1") if exp == "manual" else post(s, f"{base}/gate/{gid}/check", idx=str(i), yes="1"))[0] == 303
|
||||
|
||||
|
||||
# ---- starting -----------------------------------------------------------------------------------------------
|
||||
|
||||
def test_v02_pack_is_listed_and_has_a_start_form_with_modes_and_preauthorization(s2):
|
||||
_, _, body = req(s2, "GET", "/")
|
||||
assert "/pack/0" in body and "cannot be run yet" not in body
|
||||
body = page(s2, "/pack/0")
|
||||
assert "Keep in mind" in body and "Start a run" in body
|
||||
assert "name='p_keeper_name'" in body and "<span class=chip>actor: keeper</span>" in body # datapoints with their dimension tags
|
||||
assert "name=mode value=manual checked" in body and "name=mode value=auto-confirm" in body
|
||||
assert "Pre-authorize irreversible steps" in body and "name='pre_close-log'" in body and "manual-only" in body
|
||||
assert "name='p_lamp_lit'" not in body # produced datapoints are not questions
|
||||
|
||||
|
||||
def test_flow_view_structure_framing_first_next_action_and_parallel_items(s2):
|
||||
base, _ = new_run(s2)
|
||||
body = page(s2, base)
|
||||
assert "Guided flow" in body and "Data" in body and "Overview" in body
|
||||
assert body.index("Keep in mind") < body.index("Dusk - prepare") # framing comes first, not last
|
||||
assert "Manual mode." in body and "Hand over to auto-confirm" in body
|
||||
assert "<b>Next:</b> <a href='#item-read-the-sign'>Read the poem over the door</a>" in body and "(recommended)" in body
|
||||
assert "Also possible in parallel" in body and "#item-light-lamp" in body and "#item-read-the-sky" in body
|
||||
assert "<b>Goal:</b> The lamp is lit" in body
|
||||
assert "Questions for this stage" in body and "name='d_keeper_name'" in body
|
||||
assert "Gate: The lamp burns steadily" in body and "quality goal: safe-navigation" in body and "blocking" in body
|
||||
assert "1. Read the poem over the door" in body and "[Lighthouse keeper · the-lighthouse]" in body
|
||||
assert "<button type=button data-copy>" not in body # nothing started: no command, no Copy
|
||||
|
||||
|
||||
def test_blocked_step_says_why_and_offers_an_override_dialog_with_the_downstream_risk(s2):
|
||||
base, _ = new_run(s2)
|
||||
body = page(s2, base)
|
||||
assert "Waiting for: gate lamp-check is not complete" in body
|
||||
assert "Start anyway (override)" in body and "Downstream risk:" in body and "name=override_reason" in body and "name=acknowledge_risk" in body
|
||||
|
||||
|
||||
def test_starting_and_running_a_parallel_step_shows_command_and_copy_only_then(s2):
|
||||
base, _ = new_run(s2, p_weather="fog")
|
||||
assert post(s2, f"{base}/light-lamp/start")[0] == 303
|
||||
body = page(s2, base)
|
||||
assert "<b>Continue:</b> <a href='#item-light-lamp'>Light the lamp</a>" in body
|
||||
assert "<pre>echo "Ada lights the paraffin lamp."</pre><button type=button data-copy>" in body
|
||||
assert "Yes, it is" in body and "Mark failed" in body and "id=current" in body
|
||||
|
||||
|
||||
# ---- alternatives, irreversible steps, overrides, gates ------------------------------------------------------------
|
||||
|
||||
def open_night(s, base):
|
||||
for sid in ("read-the-sign", "light-lamp", "read-the-sky"):
|
||||
do_step(s, base, sid)
|
||||
pass_gate(s, base, "lamp-check")
|
||||
|
||||
|
||||
def test_alternatives_are_recommended_by_datapoint_and_can_be_overridden_by_the_operator(s2):
|
||||
base, _ = new_run(s2, p_weather="fog")
|
||||
open_night(s2, base)
|
||||
body = page(s2, base)
|
||||
assert "<b>Choose one:</b>" in body and "value='sound-horn' checked" in body and "Sound the fog horn <span class=note>(recommended)</span>" in body
|
||||
assert post(s2, f"{base}/warn/start", alt="wave-flags")[0] == 303
|
||||
body = page(s2, base)
|
||||
assert "Signal with flags" in body and "signals with flags" in body
|
||||
|
||||
|
||||
def test_irreversible_step_needs_the_typed_phrase_and_shows_it(s2):
|
||||
base, _ = new_run(s2, p_weather="fog")
|
||||
open_night(s2, base)
|
||||
do_step(s2, base, "warn"); do_step(s2, base, "write-logbook")
|
||||
body = page(s2, base)
|
||||
assert "irreversible" in body and f"<code>{PHRASE}</code>" in body and "name=phrase" in body
|
||||
st, _, err = post(s2, f"{base}/close-log/start")
|
||||
assert st == 400 and "confirmation phrase" in err
|
||||
assert post(s2, f"{base}/close-log/start", phrase="yes please")[0] == 400
|
||||
assert post(s2, f"{base}/close-log/start", phrase=PHRASE)[0] == 303
|
||||
assert "Continue:" in page(s2, base)
|
||||
|
||||
|
||||
def test_override_needs_reason_and_acknowledgement_and_is_recorded_and_shown(s2):
|
||||
base, _ = new_run(s2)
|
||||
st, _, err = post(s2, f"{base}/warn/start", alt="sound-horn")
|
||||
assert st == 400 and "Risk:" in err
|
||||
st, _, err = post(s2, f"{base}/warn/start", alt="sound-horn", override_reason="just to see")
|
||||
assert st == 400 and "acknowledge" in err
|
||||
assert post(s2, f"{base}/warn/start", alt="sound-horn", override_reason="just to see", acknowledge_risk="1")[0] == 303
|
||||
body = page(s2, base)
|
||||
assert "1 deviation(s) recorded" in body and "will never count towards maturity" in body
|
||||
ov = page(s2, base, "?view=overview")
|
||||
assert "<h2>Deviations</h2>" in ov and ">override<" in ov and "just to see" in ov and "warn" in ov
|
||||
|
||||
|
||||
def test_gate_checks_waiver_and_state_through_the_ui(s2):
|
||||
base, _ = new_run(s2)
|
||||
for sid in ("read-the-sign", "light-lamp", "read-the-sky"):
|
||||
do_step(s2, base, sid)
|
||||
body = page(s2, base)
|
||||
assert "Yes, it is" in body and "Not yet" in body and "datapoints ok: lamp_lit" in body
|
||||
assert post(s2, f"{base}/gate/lamp-check/check", idx="0", ok="0")[0] == 303
|
||||
assert post(s2, f"{base}/gate/lamp-check/check", idx="1", yes="1")[0] == 303
|
||||
body = page(s2, base)
|
||||
assert "Waive this gate (declared waivable by Harbour master)" in body and "Ships may steer by a light that is not burning." in body
|
||||
st, _, err = post(s2, f"{base}/gate/lamp-check/waive", reason=" ")
|
||||
assert st == 400 and "reason" in err
|
||||
assert post(s2, f"{base}/gate/lamp-check/waive", reason="harbour master accepts")[0] == 303
|
||||
body = page(s2, base)
|
||||
assert "Waived: harbour master accepts" in body and "1 deviation(s)" in body
|
||||
assert "Start this step" in body # the night can begin after a waiver
|
||||
|
||||
|
||||
def test_a_gate_that_is_not_waivable_offers_no_waiver(s2):
|
||||
base, _ = new_run(s2)
|
||||
assert "Waive this gate" in page(s2, base).split("Gate: Nothing left behind")[0] # lamp-check is waivable
|
||||
assert "Waive this gate" not in page(s2, base).split("Gate: Nothing left behind")[1] # night-on-record is not
|
||||
assert post(s2, f"{base}/gate/night-on-record/waive", reason="x")[0] == 400
|
||||
|
||||
|
||||
# ---- retry policy -----------------------------------------------------------------------------------------------------
|
||||
|
||||
def test_retry_of_non_idempotent_step_asks_for_the_partial_application_confirmation(s2):
|
||||
base, _ = new_run(s2, p_weather="fog")
|
||||
open_night(s2, base)
|
||||
do_step(s2, base, "warn")
|
||||
assert post(s2, f"{base}/write-logbook/start")[0] == 303
|
||||
assert post(s2, f"{base}/write-logbook/fail", note="disk full")[0] == 303
|
||||
body = page(s2, base)
|
||||
assert "name=acknowledge_partial" in body and "did not partly apply" in body
|
||||
st, _, err = post(s2, f"{base}/write-logbook/retry")
|
||||
assert st == 400 and "did not partly apply" in err
|
||||
assert post(s2, f"{base}/write-logbook/retry", acknowledge_partial="1")[0] == 303
|
||||
# an idempotent step needs no confirmation
|
||||
base2, _ = new_run(s2)
|
||||
post(s2, f"{base2}/read-the-sign/start"); post(s2, f"{base2}/read-the-sign/fail", note="")
|
||||
body2 = page(s2, base2)
|
||||
assert "Failed: (no comment)" in body2 and "name=acknowledge_partial" not in body2 # idempotent: free retry
|
||||
assert post(s2, f"{base2}/read-the-sign/retry")[0] == 303
|
||||
|
||||
|
||||
# ---- data view --------------------------------------------------------------------------------------------------------
|
||||
|
||||
def test_data_view_groups_filters_and_edits_datapoints_independently_of_the_flow(s2):
|
||||
base, logdir = new_run(s2)
|
||||
body = page(s2, base, "?view=data")
|
||||
assert "independent of the flow" in body and "<b>Group by</b>" in body
|
||||
assert "href='?view=data&group=actor'" in body
|
||||
assert "Scratch folder" in body and "actor: keeper" in body
|
||||
grouped = page(s2, base, "?view=data&group=actor")
|
||||
assert "<h3>actor: keeper</h3>" in grouped and "<h3>actor: (none)</h3>" in grouped
|
||||
only = page(s2, base, "?view=data&filter=kind:identity")
|
||||
assert "Keeper's name" in only and "Motto" in only and "Scratch folder" not in only and "clear filter" in only
|
||||
assert "Where it lives" in body and f"{logdir}/logbook.txt" in body # artefact location resolved
|
||||
assert "<h2>Actors</h2>" in body and "Harbour master" in body and "approve-custody" not in body
|
||||
# editing here needs no step to be running
|
||||
st, loc, _ = post(s2, f"{base}/data", d_weather="storm", next=f"{base}?view=data")
|
||||
assert st == 303 and loc == f"{base}?view=data"
|
||||
assert "selected>storm" in page(s2, base, "?view=data")
|
||||
st, _, err = post(s2, f"{base}/data", d_weather="hail")
|
||||
assert st == 400 and "weather" in err
|
||||
st, loc, _ = post(s2, f"{base}/data", d_motto="Hold fast", next="https://evil.example/x")
|
||||
assert st == 303 and loc == base # an unsafe next is ignored
|
||||
st, _, err = post(s2, f"{base}/data", d_motto="<script>alert(1)</script>")
|
||||
assert st == 400 and "<script>alert(1)" not in err
|
||||
|
||||
|
||||
def test_produced_datapoints_are_shown_not_editable_and_state_changes_are_visible(s2):
|
||||
base, _ = new_run(s2)
|
||||
body = page(s2, base, "?view=data")
|
||||
assert "name='d_lamp_lit'" not in body and ">nil<" in body and "produced by light-lamp" in body
|
||||
do_step(s2, base, "light-lamp")
|
||||
body = page(s2, base, "?view=data")
|
||||
assert ">ok<" in body and "confirmed by light-lamp" in body
|
||||
|
||||
|
||||
# ---- overview -----------------------------------------------------------------------------------------------------------
|
||||
|
||||
def test_overview_shows_progress_stages_gates_datapoints_and_no_deviations(s2):
|
||||
base, _ = new_run(s2)
|
||||
body = page(s2, base, "?view=overview")
|
||||
assert "0 of 8 done" in page(s2, base) and "<h2>Stages</h2>" in body
|
||||
assert "Dusk - prepare" in body and "Night watch - give warning" in body and "Dawn - close the night" in body
|
||||
assert "0/3" in body and "<h2>Datapoints</h2>" in body and ">set<" in body and ">nil<" in body
|
||||
assert "None. A run without deviations can count towards maturity." in body
|
||||
do_step(s2, base, "light-lamp")
|
||||
assert "1 of 8 done" in page(s2, base)
|
||||
|
||||
|
||||
# ---- auto-confirm -----------------------------------------------------------------------------------------------------
|
||||
|
||||
def test_auto_confirm_mode_confirms_waits_and_can_be_taken_over(s2):
|
||||
base, _ = new_run(s2, mode="auto-confirm", p_weather="fog")
|
||||
body = page(s2, base)
|
||||
assert "Auto-confirm mode." in body and "never runs commands" in body and "Run until it waits" in body and "Take over (manual)" in body
|
||||
assert "Overrides are made in manual mode" in body # blocked items cannot be overridden here
|
||||
assert "Start anyway" not in body
|
||||
assert post(s2, f"{base}/tick", n="50")[0] == 303
|
||||
body = page(s2, base)
|
||||
assert "Last: waiting write-logbook" in body or "Last: awaiting-operator" in body
|
||||
run = R1.load(s2.app.packs[0], base.rsplit("/", 1)[1])
|
||||
assert run.data["steps"]["read-the-sign"]["state"] == "done" and run.data["steps"]["warn"]["state"] == "done"
|
||||
assert run.data["steps"]["read-the-sign"]["verification"].get("auto_confirmed")
|
||||
assert post(s2, f"{base}/mode", to="manual")[0] == 303
|
||||
body = page(s2, base)
|
||||
assert "Manual mode." in body and "Hand over to auto-confirm" in body
|
||||
assert "auto-confirm → manual" in page(s2, base, "?view=overview")
|
||||
assert post(s2, f"{base}/mode", to="manual")[0] == 400 # already manual
|
||||
|
||||
|
||||
def test_auto_confirm_stop_is_shown_and_never_confirms_manual_only_steps(s2):
|
||||
base, logdir = new_run(s2, mode="auto-confirm", p_weather="fog")
|
||||
post(s2, f"{base}/tick", n="50") # the engine confirms what it can, then waits for the work
|
||||
# the operator does the work between ticks
|
||||
run = R1.load(s2.app.packs[0], base.rsplit("/", 1)[1])
|
||||
subprocess.run(R.command_for(run, "write-logbook"), shell=True, check=True, capture_output=True)
|
||||
assert post(s2, f"{base}/tick", n="50")[0] == 303
|
||||
body = page(s2, base)
|
||||
assert "Last: awaiting-operator close-log" in body and "step is manual-only" in body
|
||||
assert "Manual-only: auto-confirm will not start this step" in body
|
||||
|
||||
|
||||
# ---- captures, feedback, closing ------------------------------------------------------------------------------------
|
||||
|
||||
def test_captures_and_pasted_output_verification_work_in_v02_runs(s2):
|
||||
base, _ = new_run(s2, p_weather="fog")
|
||||
post(s2, f"{base}/read-the-sky/start")
|
||||
body = page(s2, base)
|
||||
assert "Capture command and output" in body and "Check against this output" in body
|
||||
assert post(s2, f"{base}/read-the-sky/capture", command="echo x", output="Weather tonight - fog.")[0] == 303
|
||||
assert post(s2, f"{base}/read-the-sky/verify-pasted", output="something else")[0] == 303
|
||||
assert "Check not passed" in page(s2, base)
|
||||
assert post(s2, f"{base}/read-the-sky/verify-pasted", output="Weather tonight - fog.")[0] == 303
|
||||
assert "Check passed" in page(s2, base)
|
||||
st, _, err = post(s2, f"{base}/read-the-sky/capture", output="hvs.ABCDEFGHIJKLMN")
|
||||
assert st == 400 and "secret-looking" in err
|
||||
|
||||
|
||||
def test_finishing_a_completed_run_feedback_and_the_read_only_result(s2):
|
||||
base, logdir = new_run(s2, p_weather="fog")
|
||||
open_night(s2, base)
|
||||
do_step(s2, base, "warn"); do_step(s2, base, "write-logbook")
|
||||
do_step(s2, base, "close-log", phrase=PHRASE)
|
||||
pass_gate(s2, base, "night-on-record")
|
||||
body = page(s2, base)
|
||||
assert "Everything is done." in body and "Finish: succeeded" in body and "How was this runbook?" in body
|
||||
assert post(s2, f"{base}/feedback", rating="good", comment="clear")[0] == 303
|
||||
assert "Back to the runbook list" in page(s2, base)
|
||||
assert post(s2, f"{base}/finalize", outcome="succeeded", note="smooth")[0] == 303
|
||||
body = page(s2, base)
|
||||
assert "Run finished: succeeded" in body and "Start this step" not in body
|
||||
assert post(s2, f"{base}/read-the-sign/start")[0] == 400
|
||||
|
||||
|
||||
def test_abandoning_and_failed_outcome_rules(s2):
|
||||
base, _ = new_run(s2)
|
||||
body = page(s2, base)
|
||||
assert "Abandon run" in body and "Finish: succeeded" not in body and "Finish: failed" not in body
|
||||
assert post(s2, f"{base}/finalize", outcome="succeeded")[0] == 400
|
||||
assert post(s2, f"{base}/finalize", outcome="abandoned")[0] == 303
|
||||
assert "Run finished: abandoned" in page(s2, base)
|
||||
|
||||
|
||||
# ---- safety --------------------------------------------------------------------------------------------------------------
|
||||
|
||||
def test_csrf_host_and_unknown_actions(s2):
|
||||
base, _ = new_run(s2)
|
||||
assert req(s2, "POST", f"{base}/mode", {"to": "manual"})[0] == 403
|
||||
assert req(s2, "GET", base, host="evil.example")[0] == 403
|
||||
assert post(s2, f"{base}/read-the-sign/explode")[0] == 400
|
||||
assert post(s2, f"{base}/nope/start")[0] == 400
|
||||
assert req(s2, "GET", base + "?view=<script>")[0] == 200 # an unknown view falls back to the flow
|
||||
Loading…
Add table
Add a link
Reference in a new issue