RED QUEEN BUILD PHASE A2: Mission-State- und Work-Package-State-Machine. Minimaler Missions-Metadata-Layer (missions.db) auf nativer Hermes-Kanban-Basis (KanbanMirror best effort). Deterministische Validierung, Idempotenz, STATE_ERROR fail-closed, Restart-Persistenz. 61 deterministische Tests PASS. Keine autonome Orchestrierung/Loops/Cron.
396 lines
14 KiB
Python
396 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Red Queen — A2 Testsuite (deterministisch, isoliert).
|
|
|
|
Nutzt ausschliesslich temporaere DBs:
|
|
* missions.db -> temp Pfad (pro Testlauf neu)
|
|
* Kanban-Mirror -> eigener temp `HERMES_KANBAN_DB` Pfad (NICHT /opt/data/kanban.db)
|
|
|
|
Lauf:
|
|
python3 test_a2.py
|
|
Exit-Code 0 = alle Tests gruen; 1 = mindestens ein Fehler.
|
|
|
|
Deckt A2 §18 ab: create/read/valid+invalid transition, wp create/dependency/
|
|
block/complete, mission complete, idempotency, unknown id, unknown state,
|
|
RESTART-PERSISTENCE, state consistency, negative-tests (§13), corruption (§14).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
_HERE = Path(__file__).resolve().parent
|
|
sys.path.insert(0, str(_HERE))
|
|
|
|
from rq_mission import MissionStore, RqError # noqa: E402
|
|
|
|
PASS = 0
|
|
FAIL = 0
|
|
FAILURES = []
|
|
|
|
|
|
def check(name: str, cond: bool, extra: str = ""):
|
|
global PASS, FAIL
|
|
if cond:
|
|
PASS += 1
|
|
print(f" [PASS] {name}")
|
|
else:
|
|
FAIL += 1
|
|
FAILURES.append(name)
|
|
print(f" [FAIL] {name} {extra}")
|
|
|
|
|
|
def expect_err(name: str, fn, code: str, fragment: str = ""):
|
|
try:
|
|
fn()
|
|
except RqError as e:
|
|
ok = e.code == code and (not fragment or fragment in e.message)
|
|
check(name, ok, f"got code={e.code} msg={e.message!r}")
|
|
return e
|
|
except Exception as e: # noqa
|
|
check(name, False, f"unexpected {type(e).__name__}: {e}")
|
|
return None
|
|
check(name, False, "no error raised")
|
|
return None
|
|
|
|
|
|
def fresh_store():
|
|
d = tempfile.mkdtemp(prefix="a2test_")
|
|
return MissionStore(str(Path(d) / "missions.db")), d
|
|
|
|
|
|
def _drive_wp_to_done(s, wid):
|
|
for t in ("READY", "IN_PROGRESS", "CHECKING"):
|
|
s.wp_transition(wid, t)
|
|
s.wp_complete(wid)
|
|
|
|
|
|
def _drive_mission_to_review(s, mid):
|
|
for t in ("PLANNING", "READY", "RUNNING", "REVIEW"):
|
|
s.mission_transition(mid, t)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Tests
|
|
# --------------------------------------------------------------------------- #
|
|
def test_mission_create_read():
|
|
print("\n== create/read ==")
|
|
s, _ = fresh_store()
|
|
m = s.mission_create("A2 mission", goal="g", scope="sc",
|
|
constraints="c", acceptance_criteria="ac")
|
|
check("mission_create returns CREATED", m["state"] == "CREATED", m)
|
|
check("mission id prefix", m["id"].startswith("RQ-MISSION-"), m["id"])
|
|
r = s.mission_read(m["id"])
|
|
check("mission_read roundtrip", r["id"] == m["id"] and r["state"] == "CREATED")
|
|
check("mission_read metadata", r["goal"] == "g" and r["acceptance_criteria"] == "ac")
|
|
|
|
|
|
def test_mission_id_sequence():
|
|
print("\n== mission id sequence (Max+1) ==")
|
|
s, _ = fresh_store()
|
|
ids = [int(s.mission_create("a")["id"].split("-")[-1]) for _ in range(3)]
|
|
check("ids monotonic increasing", ids[0] < ids[1] < ids[2], ids)
|
|
|
|
|
|
def test_valid_mission_transitions():
|
|
print("\n== valid mission transitions ==")
|
|
s, _ = fresh_store()
|
|
m = s.mission_create("v")
|
|
mid = m["id"]
|
|
seq = ["PLANNING", "READY", "RUNNING", "PAUSED", "RUNNING", "REVIEW"]
|
|
st = "CREATED"
|
|
for t in seq:
|
|
r = s.mission_transition(mid, t)
|
|
check(f"{st}->{t}", r["state"] == t, r)
|
|
st = t
|
|
|
|
|
|
def test_invalid_mission_transition():
|
|
print("\n== invalid mission transition ==")
|
|
s, _ = fresh_store()
|
|
m = s.mission_create("x")
|
|
mid = m["id"]
|
|
expect_err("CREATED->RUNNING rejected", lambda: s.mission_transition(mid, "RUNNING"),
|
|
"INVALID_TRANSITION")
|
|
check("state unchanged", s.mission_state(mid)["state"] == "CREATED")
|
|
|
|
|
|
def test_wp_transition_flow():
|
|
print("\n== wp happy-path to DONE ==")
|
|
s, _ = fresh_store()
|
|
m = s.mission_create("wpf")
|
|
w = s.wp_create(m["id"], "w")
|
|
for t in ("READY", "IN_PROGRESS", "CHECKING"):
|
|
s.wp_transition(w["id"], t)
|
|
s.wp_complete(w["id"])
|
|
check("wp ends DONE", s.wp_state(w["id"])["state"] == "DONE")
|
|
r = s.wp_complete(w["id"])
|
|
check("wp DONE->DONE idempotent", r["idempotent"] is True, r)
|
|
|
|
|
|
def test_wp_requires_checking_to_done():
|
|
print("\n== wp cannot skip CHECKING to DONE ==")
|
|
s, _ = fresh_store()
|
|
m = s.mission_create("c")
|
|
w = s.wp_create(m["id"], "w")
|
|
expect_err("READY->DONE rejected", lambda: s.wp_transition(w["id"], "DONE"),
|
|
"INVALID_TRANSITION")
|
|
check("wp still TODO", s.wp_state(w["id"])["state"] == "TODO")
|
|
|
|
|
|
def test_dependency_gate():
|
|
print("\n== dependency gate ==")
|
|
s, _ = fresh_store()
|
|
m = s.mission_create("dep")
|
|
wc = s.wp_create(m["id"], "child")
|
|
wp = s.wp_create(m["id"], "parent")
|
|
s.wp_dependency(m["id"], wc["id"], wp["id"])
|
|
for st in ("READY", "IN_PROGRESS", "CHECKING"):
|
|
s.wp_transition(wc["id"], st)
|
|
expect_err("child DONE while parent open", lambda: s.wp_complete(wc["id"]),
|
|
"WP_OPEN_DEPENDENCY")
|
|
check("child not DONE", s.wp_state(wc["id"])["state"] == "CHECKING")
|
|
|
|
|
|
def test_mission_complete_requires_done_wps():
|
|
print("\n== mission complete requires done WPs ==")
|
|
s, _ = fresh_store()
|
|
m = s.mission_create("mc")
|
|
w = s.wp_create(m["id"], "w")
|
|
_drive_mission_to_review(s, m["id"])
|
|
expect_err("complete with open WP", lambda: s.mission_complete(m["id"]),
|
|
"MISSION_INCOMPLETE_WPS")
|
|
check("mission stays REVIEW", s.mission_state(m["id"])["state"] == "REVIEW")
|
|
_drive_wp_to_done(s, w["id"])
|
|
r = s.mission_complete(m["id"])
|
|
check("mission complete after WPs done", r["state"] == "COMPLETED", r)
|
|
|
|
|
|
def test_idempotency():
|
|
print("\n== idempotency ==")
|
|
s, _ = fresh_store()
|
|
m = s.mission_create("idem")
|
|
mid = m["id"]
|
|
m2 = s.mission_create("idem2", mission_id=mid)
|
|
check("mission explicit-id idempotent", m2["id"] == mid, m2)
|
|
w = s.wp_create(mid, "w", wp_id=f"{mid}-WP-1")
|
|
w2 = s.wp_create(mid, "w", wp_id=f"{mid}-WP-1")
|
|
check("wp explicit-id idempotent", w2["id"] == w["id"], w2)
|
|
wa = s.wp_create(mid, "wa")
|
|
wb = s.wp_create(mid, "wb")
|
|
s.wp_dependency(mid, wa["id"], wb["id"])
|
|
s.wp_dependency(mid, wa["id"], wb["id"]) # duplicate
|
|
rd = s.mission_read(mid)
|
|
count = sum(1 for d in rd["dependencies"]
|
|
if d["wp_id"] == wa["id"] and d["depends_on"] == wb["id"])
|
|
check("double dependency no dup row", count == 1, rd["dependencies"])
|
|
|
|
|
|
def test_unknown_id_and_state():
|
|
print("\n== unknown id/state ==")
|
|
s, _ = fresh_store()
|
|
expect_err("mission_read unknown", lambda: s.mission_read("RQ-NOPE"), "UNKNOWN_MISSION")
|
|
expect_err("mission_transition unknown", lambda: s.mission_transition("RQ-NOPE", "PLANNING"),
|
|
"UNKNOWN_MISSION")
|
|
expect_err("wp_read unknown", lambda: s.wp_read("RQ-NOPE-WP-1"), "UNKNOWN_WP")
|
|
expect_err("unknown mission state", lambda: s.mission_transition("RQ-NOPE", "NOPE"),
|
|
"UNKNOWN_STATE")
|
|
m = s.mission_create("u")
|
|
expect_err("unknown wp state", lambda: s.wp_transition("RQ-NOPE", "NOPE"), "UNKNOWN_STATE")
|
|
|
|
|
|
def test_restart_persistence():
|
|
print("\n== restart-persistence ==")
|
|
d = tempfile.mkdtemp(prefix="a2r_")
|
|
mdb = str(Path(d) / "missions.db")
|
|
s1 = MissionStore(mdb)
|
|
m = s1.mission_create("restart")
|
|
mid = m["id"]
|
|
w1 = s1.wp_create(mid, "w1")
|
|
w2 = s1.wp_create(mid, "w2")
|
|
s1.wp_dependency(mid, w1["id"], w2["id"])
|
|
_drive_wp_to_done(s1, w2["id"])
|
|
_drive_wp_to_done(s1, w1["id"])
|
|
_drive_mission_to_review(s1, mid)
|
|
s1.mission_complete(mid)
|
|
|
|
# "Restart": neues Store-Objekt, gleiche DB
|
|
s2 = MissionStore(mdb)
|
|
r = s2.mission_read(mid)
|
|
check("restart mission state identical", r["state"] == "COMPLETED", r["state"])
|
|
check("restart has 2 WPs", len(r["work_packages"]) == 2, r["work_packages"])
|
|
check("restart all WPs DONE", all(x["state"] == "DONE" for x in r["work_packages"]))
|
|
check("restart dependency intact",
|
|
any(x["wp_id"] == w1["id"] and x["depends_on"] == w2["id"] for x in r["dependencies"]),
|
|
r["dependencies"])
|
|
c2 = s2.mission_complete(mid)
|
|
check("restart re-complete idempotent", c2["idempotent"] is True, c2)
|
|
w3 = s2.wp_create(mid, "w3")
|
|
check("wp id continues after restart", w3["id"] == f"{mid}-WP-003", w3["id"])
|
|
|
|
|
|
def test_fail_closed_no_mutation():
|
|
print("\n== fail-closed: no mutation on rejection ==")
|
|
s, _ = fresh_store()
|
|
m = s.mission_create("fc")
|
|
mid = m["id"]
|
|
w = s.wp_create(mid, "w")
|
|
expect_err("invalid mission transition", lambda: s.mission_transition(mid, "RUNNING"),
|
|
"INVALID_TRANSITION")
|
|
check("mission unchanged", s.mission_state(mid)["state"] == "CREATED")
|
|
expect_err("invalid wp transition", lambda: s.wp_transition(w["id"], "DONE"),
|
|
"INVALID_TRANSITION")
|
|
check("wp unchanged", s.wp_state(w["id"])["state"] == "TODO")
|
|
try:
|
|
s.mission_transition(mid, "RUNNING")
|
|
except RqError as e:
|
|
d = e.to_dict()
|
|
check("error code machine-readable", d["code"] == "INVALID_TRANSITION", d)
|
|
check("error has detail", "current" in d["detail"], d)
|
|
|
|
|
|
def test_state_error_corrupt_state():
|
|
print("\n== corruption: STATE_ERROR fail-closed ==")
|
|
s, d = fresh_store()
|
|
m = s.mission_create("corrupt")
|
|
mid = m["id"]
|
|
w = s.wp_create(mid, "w")
|
|
wid = w["id"]
|
|
db = str(Path(d) / "missions.db")
|
|
# Korrupten Mission-State direkt in DB setzen
|
|
with sqlite3.connect(db) as c:
|
|
c.execute("UPDATE missions SET state='GARBAGE' WHERE id=?", (mid,))
|
|
e = expect_err("corrupt mission -> STATE_ERROR",
|
|
lambda: s.mission_transition(mid, "PLANNING"), "STATE_ERROR")
|
|
if e is not None:
|
|
check("STATE_ERROR detail has current",
|
|
e.to_dict().get("detail", {}).get("current") == "GARBAGE", e.to_dict())
|
|
check("mission state unchanged (still corrupt, no mutation)",
|
|
s.mission_state(mid)["state"] == "GARBAGE")
|
|
# Korrupten WP-State setzen
|
|
with sqlite3.connect(db) as c:
|
|
c.execute("UPDATE work_packages SET state='JUNK' WHERE id=?", (wid,))
|
|
expect_err("corrupt wp -> STATE_ERROR", lambda: s.wp_transition(wid, "DONE"), "STATE_ERROR")
|
|
check("wp state unchanged (no mutation)", s.wp_state(wid)["state"] == "JUNK")
|
|
|
|
|
|
def test_mirror_isolation():
|
|
print("\n== kanban mirror isolated ==")
|
|
d = tempfile.mkdtemp(prefix="a2mirror_")
|
|
kdb = str(Path(d) / "kanban_test.db")
|
|
s = MissionStore(str(Path(d) / "missions.db"), mirror_db=kdb)
|
|
m = s.mission_create("mirror")
|
|
w = s.wp_create(m["id"], "wmirror")
|
|
check("wp has kanban_task_id", bool(w["kanban_task_id"]), w)
|
|
for st in ("READY", "IN_PROGRESS", "CHECKING"):
|
|
s.wp_transition(w["id"], st)
|
|
s.wp_complete(w["id"])
|
|
check("wp DONE", s.wp_state(w["id"])["state"] == "DONE")
|
|
check("test kanban db created", Path(kdb).exists(), kdb)
|
|
check("productive kanban.db untouched", kdb != "/opt/data/kanban.db")
|
|
|
|
|
|
def test_negative_completed_mission_rejects():
|
|
print("\n== negative: completed cannot go active ==")
|
|
s, _ = fresh_store()
|
|
m = s.mission_create("neg")
|
|
mid = m["id"]
|
|
w = s.wp_create(mid, "w")
|
|
_drive_mission_to_review(s, mid)
|
|
_drive_wp_to_done(s, w["id"])
|
|
s.mission_complete(mid)
|
|
expect_err("COMPLETED->RUNNING rejected", lambda: s.mission_transition(mid, "RUNNING"),
|
|
"INVALID_TRANSITION")
|
|
r = s.mission_transition(mid, "COMPLETED")
|
|
check("COMPLETED->COMPLETED idempotent", r["idempotent"] is True, r)
|
|
|
|
|
|
def test_wp_block_flow():
|
|
print("\n== wp block/undo ==")
|
|
s, _ = fresh_store()
|
|
m = s.mission_create("blk")
|
|
w = s.wp_create(m["id"], "w")
|
|
s.wp_transition(w["id"], "READY")
|
|
s.wp_transition(w["id"], "IN_PROGRESS")
|
|
s.wp_block(w["id"], "missing asset")
|
|
check("wp BLOCKED", s.wp_state(w["id"])["state"] == "BLOCKED")
|
|
s.wp_transition(w["id"], "IN_PROGRESS") # BLOCKED->IN_PROGRESS
|
|
check("wp resumed IN_PROGRESS", s.wp_state(w["id"])["state"] == "IN_PROGRESS")
|
|
|
|
|
|
def test_mission_pause_block_resume():
|
|
print("\n== mission pause/block/resume ==")
|
|
s, _ = fresh_store()
|
|
m = s.mission_create("mpb")
|
|
mid = m["id"]
|
|
s.mission_transition(mid, "PLANNING")
|
|
s.mission_transition(mid, "READY")
|
|
s.mission_transition(mid, "RUNNING")
|
|
s.mission_pause(mid)
|
|
check("mission PAUSED", s.mission_state(mid)["state"] == "PAUSED")
|
|
s.mission_resume(mid)
|
|
check("mission RUNNING (resume)", s.mission_state(mid)["state"] == "RUNNING")
|
|
s.mission_block(mid)
|
|
check("mission BLOCKED", s.mission_state(mid)["state"] == "BLOCKED")
|
|
s.mission_resume(mid) # BLOCKED->RUNNING
|
|
check("mission RUNNING (block->resume)", s.mission_state(mid)["state"] == "RUNNING")
|
|
|
|
|
|
def test_self_dependency_rejected():
|
|
print("\n== self/foreign dependency rejected ==")
|
|
s, _ = fresh_store()
|
|
m = s.mission_create("sd")
|
|
w = s.wp_create(m["id"], "w")
|
|
expect_err("self dependency", lambda: s.wp_dependency(m["id"], w["id"], w["id"]),
|
|
"INVALID_DEPENDENCY")
|
|
m2 = s.mission_create("sd2")
|
|
w2 = s.wp_create(m2["id"], "w2")
|
|
expect_err("cross-mission dependency", lambda: s.wp_dependency(m["id"], w["id"], w2["id"]),
|
|
"INVALID_DEPENDENCY")
|
|
|
|
|
|
ALL_TESTS = [
|
|
test_mission_create_read,
|
|
test_mission_id_sequence,
|
|
test_valid_mission_transitions,
|
|
test_invalid_mission_transition,
|
|
test_wp_transition_flow,
|
|
test_wp_requires_checking_to_done,
|
|
test_dependency_gate,
|
|
test_mission_complete_requires_done_wps,
|
|
test_idempotency,
|
|
test_unknown_id_and_state,
|
|
test_restart_persistence,
|
|
test_fail_closed_no_mutation,
|
|
test_state_error_corrupt_state,
|
|
test_mirror_isolation,
|
|
test_negative_completed_mission_rejects,
|
|
test_wp_block_flow,
|
|
test_mission_pause_block_resume,
|
|
test_self_dependency_rejected,
|
|
]
|
|
|
|
|
|
def main():
|
|
for fn in ALL_TESTS:
|
|
try:
|
|
fn()
|
|
except Exception as e: # noqa
|
|
global FAIL, FAILURES
|
|
FAIL += 1
|
|
FAILURES.append(fn.__name__)
|
|
print(f" [ERROR] {fn.__name__}: {type(e).__name__}: {e}")
|
|
print("\n" + "=" * 60)
|
|
print(f"PASS={PASS} FAIL={FAIL}")
|
|
if FAIL:
|
|
print("FAILURES:", FAILURES)
|
|
return 1
|
|
print("ALL TESTS PASSED")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|