#!/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())