trading-system-docs/a2/test_a2.py
Red Queen dbbbb1dddc feat(a2): persistent mission state & state machine
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.
2026-08-24 20:18:32 +00:00

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())