Initial Commit
This commit is contained in:
Executable
+161
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for the session task record and project-name validation.
|
||||
|
||||
python3 tests/test_vm_tasks.py
|
||||
|
||||
Pure Python: no VM, no container, no network. These two modules are the
|
||||
ones where a bug is silent rather than loud. A project name that slips
|
||||
through becomes a path, and a task gate that can be talked around gives
|
||||
the model back the freedom to declare itself finished - which is the
|
||||
specific failure the archived sub-agent stack was retired for.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "vm", "vmd"))
|
||||
|
||||
import project # noqa: E402
|
||||
import tasks # noqa: E402
|
||||
|
||||
FAILS: list[str] = []
|
||||
|
||||
|
||||
def check(label: str, condition: bool, detail: str = "") -> None:
|
||||
if condition:
|
||||
print(f"ok {label}")
|
||||
else:
|
||||
FAILS.append(f"{label}: {detail}")
|
||||
print(f"FAIL {label}\n {detail}")
|
||||
|
||||
|
||||
def raises(label: str, fn, exc) -> None:
|
||||
try:
|
||||
fn()
|
||||
except exc:
|
||||
print(f"ok {label}")
|
||||
return
|
||||
except Exception as other: # noqa: BLE001
|
||||
FAILS.append(f"{label}: raised {type(other).__name__}, wanted {exc.__name__}")
|
||||
print(f"FAIL {label}\n raised {type(other).__name__}")
|
||||
return
|
||||
FAILS.append(f"{label}: did not raise")
|
||||
print(f"FAIL {label}\n did not raise")
|
||||
|
||||
|
||||
# --- project names ------------------------------------------------------
|
||||
for good in ("ok-name", "a1", "x9", "a" * 31, "script-tests"):
|
||||
check(f"accepts {good!r}", project.validate(good) == good, "rejected")
|
||||
|
||||
for bad in ("../gitea", "UPPER", "has space", "-lead", "x", "", "a" * 32,
|
||||
"dots.here", "under_score", "sub/dir", "name\n", "base", "trailing-"):
|
||||
raises(f"rejects {bad!r}", lambda b=bad: project.validate(b), project.ProjectError)
|
||||
|
||||
# A rejection the model has to act on must say what to do instead.
|
||||
try:
|
||||
project.validate("../gitea")
|
||||
except project.ProjectError as exc:
|
||||
text = str(exc)
|
||||
check("rejection names the constraint",
|
||||
"lowercase" in text and "repository" in text, text)
|
||||
|
||||
|
||||
# --- the task gate ------------------------------------------------------
|
||||
def fresh() -> tasks.TaskList:
|
||||
t = tasks.TaskList()
|
||||
t.plan(["write the script", "run it", "fix what it reports"])
|
||||
return t
|
||||
|
||||
|
||||
raises("nothing works before plan_tasks",
|
||||
lambda: tasks.TaskList().update(1, "done"), tasks.TaskError)
|
||||
check("finish is refused before plan_tasks",
|
||||
tasks.TaskList().why_not_finished() is not None, "allowed")
|
||||
|
||||
raises("a one-item plan is refused",
|
||||
lambda: tasks.TaskList().plan(["do the thing"]), tasks.TaskError)
|
||||
raises("a 21-item plan is refused",
|
||||
lambda: tasks.TaskList().plan([f"t{i}" for i in range(21)]), tasks.TaskError)
|
||||
raises("planning twice is refused",
|
||||
lambda: fresh().plan(["a", "b"]), tasks.TaskError)
|
||||
|
||||
t = fresh()
|
||||
check("finish refused while tasks are open", t.why_not_finished() is not None, "allowed")
|
||||
check("the refusal lists what is open", "3 of 3 tasks are still open" in (t.why_not_finished() or ""),
|
||||
t.why_not_finished() or "")
|
||||
|
||||
t.update(1, "done")
|
||||
t.update(2, "done")
|
||||
check("still refused with one open", t.why_not_finished() is not None, "allowed")
|
||||
|
||||
raises("blocked without a reason is refused",
|
||||
lambda: t.update(3, "blocked"), tasks.TaskError)
|
||||
raises("dropped without a reason is refused",
|
||||
lambda: t.update(3, "dropped"), tasks.TaskError)
|
||||
raises("an unknown status is refused",
|
||||
lambda: t.update(3, "finished-ish", "x"), tasks.TaskError)
|
||||
raises("an unknown task id is refused",
|
||||
lambda: t.update(99, "done"), tasks.TaskError)
|
||||
|
||||
t.update(3, "blocked", "the package is not in noble")
|
||||
check("finish allowed once everything is closed", t.why_not_finished() is None,
|
||||
t.why_not_finished() or "")
|
||||
check("the reason survives into the table",
|
||||
"the package is not in noble" in t.render(), t.render())
|
||||
|
||||
# --- added tasks stay visible ------------------------------------------
|
||||
t2 = fresh()
|
||||
t2.add("check it on a clean boot", after_id=2)
|
||||
check("an added task is marked as added", "(added mid-session)" in t2.render(), t2.render())
|
||||
check("an added task lands where asked",
|
||||
[x.text for x in t2.tasks][2] == "check it on a clean boot",
|
||||
[x.text for x in t2.tasks])
|
||||
check("an added task is open, so it blocks finish",
|
||||
t2.why_not_finished() is not None, "allowed")
|
||||
|
||||
# --- the status feed ----------------------------------------------------
|
||||
t3 = fresh()
|
||||
check("status line before any progress", t3.status_line() == "0/3 · write the script", t3.status_line())
|
||||
t3.update(1, "done")
|
||||
t3.update(2, "in_progress")
|
||||
check("status line follows the work", t3.status_line() == "1/3 · run it", t3.status_line())
|
||||
|
||||
# --- round trip ---------------------------------------------------------
|
||||
t4 = fresh()
|
||||
t4.update(1, "done")
|
||||
t4.add("extra", None)
|
||||
again = tasks.TaskList.from_json(t4.to_json())
|
||||
check("survives a round trip", again.render() == t4.render(), again.render())
|
||||
check("planned survives a round trip", again.planned is True, again.planned)
|
||||
|
||||
# --- the list is the only thing the watcher can read --------------------
|
||||
#
|
||||
# An agent that plans twelve tasks and then does the work in one long
|
||||
# stretch without touching the list leaves the person watching on "1/12"
|
||||
# for twenty minutes - measured - while the file grows and the status line
|
||||
# says nothing. The work was fine; the report of it was a flat line.
|
||||
#
|
||||
# Nudged in TOOL OUTPUT, because the prompt already asks and asking has not
|
||||
# worked. A nudge and not a refusal: being mid-task is legitimate, and a
|
||||
# tool that blocked on it would be wrong far more often than right.
|
||||
LOOP_SRC = open(os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
"..", "vmagent", "app", "loop.py"), encoding="utf-8").read()
|
||||
check("a run of steps with no task movement is noticed",
|
||||
"TASK_DRIFT_LIMIT" in LOOP_SRC and "since_task" in LOOP_SRC)
|
||||
check("the three task verbs reset it, and nothing else does",
|
||||
'TASK_VERBS = frozenset({"plan_tasks", "update_task", "add_task"})' in LOOP_SRC)
|
||||
after = LOOP_SRC.split("elif (since_task := since_task + 1) >= TASK_DRIFT_LIMIT:")[1]
|
||||
check("it nudges rather than refusing",
|
||||
"text +=" in after[:500] and "raise" not in after[:500],
|
||||
"blocking on this would be wrong more often than right")
|
||||
check("and the counter resets, so it does not fire on every step after",
|
||||
after.lstrip().startswith("since_task = 0"))
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
if FAILS:
|
||||
print(f"FAILED {len(FAILS)}")
|
||||
for f in FAILS:
|
||||
print(" -", f)
|
||||
sys.exit(1)
|
||||
print("all vm task and project-name cases pass")
|
||||
Reference in New Issue
Block a user