Lock and seal closed runs; feedback with the closing form; Home headlines link to their sections; drop the back link
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
b7edb8f023
commit
711c4e1149
13 changed files with 381 additions and 42 deletions
|
|
@ -42,16 +42,17 @@ def test_finalize_rules(pk):
|
|||
R.finalize(r, "abandoned") # already finished
|
||||
|
||||
|
||||
def test_finished_run_is_read_only_but_takes_feedback(pk):
|
||||
def test_finished_run_is_read_only_and_feedback_is_locked_too(pk):
|
||||
r = R.create(pk, "bernd"); R.finalize(r, "abandoned")
|
||||
for call in (lambda: R.start(r, "read-the-sign"), lambda: R.add_capture(r, "read-the-sign", "x")):
|
||||
with pytest.raises(R.RunError, match="finished"):
|
||||
call()
|
||||
R.add_feedback(r, "poor", "gave up: unclear") # feedback stays possible
|
||||
with pytest.raises(R.RunError, match="closed and locked"):
|
||||
R.add_feedback(r, "poor", "gave up: unclear") # feedback goes in before closing, or with the closing form
|
||||
|
||||
|
||||
def test_scorecard_counts_every_outcome_and_stale_open_runs(pk):
|
||||
ok = R.create(pk, "a"); play_all(ok); R.finalize(ok, "succeeded"); R.add_feedback(ok, "good")
|
||||
ok = R.create(pk, "a"); play_all(ok); R.finalize(ok, "succeeded", rating="good")
|
||||
bad = R.create(pk, "b"); R.start(bad, "read-the-sign"); R.fail(bad, "read-the-sign", "x"); R.finalize(bad, "failed")
|
||||
gone = R.create(pk, "c"); R.finalize(gone, "abandoned")
|
||||
stalled = R.create(pk, "d")
|
||||
|
|
@ -151,3 +152,72 @@ def test_unfinished_run_elapsed_runs_to_now_and_no_average_without_succeeded_run
|
|||
t = R.run_times(r, now=datetime.now(timezone.utc) + timedelta(seconds=90))
|
||||
assert not t["finished_run"] and 89 <= t["elapsed"] <= 91
|
||||
assert R.scorecard(pk)["avg_seconds"] is None and R.scorecard(pk)["timed_runs"] == 0
|
||||
|
||||
|
||||
# ---- a closed run is locked and sealed --------------------------------------------------------------------------
|
||||
|
||||
def test_closing_records_feedback_with_the_outcome_and_validates_before_changing_anything(pk):
|
||||
r = R.create(pk, "bernd")
|
||||
with pytest.raises(R.RunError, match="rating must be"):
|
||||
R.finalize(r, "abandoned", rating="great")
|
||||
assert not r.data.get("outcome") and "seal" not in r.data # a refused close changed nothing
|
||||
R.finalize(r, "abandoned", "gave up", rating="poor", comment="step 2 was unclear")
|
||||
assert R.latest_feedback(r)["comment"] == "step 2 was unclear" and r.data["outcome"] == "abandoned"
|
||||
assert R.load(pk, r.data["run_id"]).data["feedback"][0]["rating"] == "poor"
|
||||
|
||||
|
||||
def test_a_closed_record_cannot_be_saved_with_changes_but_the_exercise_marker_is_allowed(pk):
|
||||
r = R.create(pk, "bernd"); R.finalize(r, "abandoned")
|
||||
r2 = R.load(pk, r.data["run_id"])
|
||||
r2.data["steps"]["read-the-sign"]["state"] = "done"
|
||||
with pytest.raises(R.RunError, match="closed and locked"):
|
||||
r2.save()
|
||||
r3 = R.load(pk, r.data["run_id"])
|
||||
r3.data["operator"] = "someone-else"
|
||||
with pytest.raises(R.RunError, match="operator"):
|
||||
r3.save()
|
||||
assert R.load(pk, r.data["run_id"]).data["operator"] == "bernd" # nothing reached the disk
|
||||
r4 = R.load(pk, r.data["run_id"])
|
||||
r4.data["exercise_recorded"] = "x"
|
||||
r4.save(allow=("exercise_recorded",)) # the one permitted later update
|
||||
assert R.load(pk, r.data["run_id"]).data["exercise_recorded"] == "x" and R.seal_state(R.load(pk, r.data["run_id"])) == "sealed"
|
||||
|
||||
|
||||
def test_closing_seals_the_file_read_only_and_tampering_is_detected(pk):
|
||||
import json, stat
|
||||
r = R.create(pk, "bernd")
|
||||
assert R.seal_state(r) == "open" and r.path.stat().st_mode & stat.S_IWUSR
|
||||
R.finalize(r, "abandoned")
|
||||
assert R.seal_state(r) == "sealed" and not (r.path.stat().st_mode & (stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH))
|
||||
assert len(r.data["seal"]) == 64
|
||||
r.path.chmod(0o644) # someone edits the file by hand
|
||||
data = json.loads(r.path.read_text()); data["operator"] = "forged"
|
||||
r.path.write_text(json.dumps(data))
|
||||
assert R.seal_state(R.load(pk, r.data["run_id"])) == "broken"
|
||||
|
||||
|
||||
def test_a_run_closed_before_sealing_existed_is_reported_as_unsealed_not_broken(pk):
|
||||
import json
|
||||
r = R.create(pk, "bernd"); R.finalize(r, "abandoned")
|
||||
r.path.chmod(0o644)
|
||||
data = json.loads(r.path.read_text()); data.pop("seal"); r.path.write_text(json.dumps(data))
|
||||
assert R.seal_state(R.load(pk, r.data["run_id"])) == "unsealed"
|
||||
|
||||
|
||||
def test_a_tampered_run_no_longer_counts_as_maturity_evidence(pk):
|
||||
import json
|
||||
from rtut import maturity as M
|
||||
M.add_review(pk, "alice")
|
||||
r = R.create(pk, "bob", {"logdir": "/tmp/rtut-lock-mat"}, {"project": "lab", "environment": "rehearsal"})
|
||||
for s in pk.data["steps"]:
|
||||
sid = s["id"]; R.start(r, sid)
|
||||
cmd = R.command_for(r, sid)
|
||||
if cmd:
|
||||
subprocess.run(cmd, shell=True, check=True, capture_output=True)
|
||||
R.verify(r, sid, confirm=True, manual_ok=True if s["verify"]["expect"] == "manual" else None)
|
||||
R.complete(r, sid, {n: "1" for n in s.get("evidence", [])})
|
||||
R.finalize(r, "succeeded")
|
||||
assert M.maturity(pk)["evidence"]["rehearsal_runs"] == 1
|
||||
r.path.chmod(0o644)
|
||||
data = json.loads(r.path.read_text()); data["operator"] = "forged"; r.path.write_text(json.dumps(data))
|
||||
assert M.maturity(pk)["evidence"]["clean_runs"] == 0 # changed after closing: not evidence
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue