Files
hermes-agent/tests/plugins/test_kanban_workflow_matrix.py
kshitijk4poor 650b294151 fix(kanban): make can_move total over task statuses; stats derives from the workflow
- can_move: `archived` is a valid source (restore). The dashboard PATCH accepts
  archived -> triage/todo/ready (measured live), so the manual table gains that
  row and the matrix test now measures it too. A same-column drop returns False
  instead of raising or meaning "allowed".
- `hermes kanban stats` text output iterated its own hardcoded status list,
  which omitted `review`; it now loops over DEFAULT_WORKFLOW.keys().
- Tests: drop three asserts that could not fail (VALID_STATUSES is
  DEFAULT_STATUSES; BOARD_COLUMNS is built from keys()); add one invariant:
  can_move answers for every value tasks.status can hold.
2026-10-07 11:19:10 +05:30

100 lines
3.8 KiB
Python

"""Manual move matrix: ``DEFAULT_WORKFLOW.manual`` vs. the live ``PATCH /tasks/{id}``.
Every ``src -> dst`` pair is requested through the dashboard API on a parentless task.
The workflow allow-list must agree with what the server accepts, in both directions.
Also covers ``GET /workflow``.
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from hermes_cli import kanban_db as kb
from hermes_cli import kanban_db_connect as kbc
from hermes_cli import kanban_workflow as kw
W = kw.DEFAULT_WORKFLOW
# Accepted moves whose card lands somewhere other than the requested column.
# review -> todo goes through reopen_review_task, which re-gates to ``ready``.
_KNOWN_REDIRECTS = {("review", "todo"): "ready"}
def _load_plugin_router():
plugin_file = Path(__file__).resolve().parents[2] / "plugins" / "kanban" / "dashboard" / "plugin_api.py"
spec = importlib.util.spec_from_file_location("hermes_dashboard_plugin_kanban_workflow_test", plugin_file)
mod = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = mod
spec.loader.exec_module(mod)
return mod.router
@pytest.fixture
def client(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
kb.init_db()
app = FastAPI()
app.include_router(_load_plugin_router(), prefix="/k")
return TestClient(app)
def _task_in(client, src: str) -> str:
tid = client.post("/k/tasks", json={"title": src, "assignee": "a", "triage": src == "triage"}).json()["task"]["id"]
with kbc.connect_closing() as conn:
if src == "todo":
assert client.patch(f"/k/tasks/{tid}", json={"status": "todo"}).status_code == 200
elif src == "running":
assert kb.claim_task(conn, tid) is not None
elif src == "blocked":
assert kb.block_task(conn, tid, reason="x")
elif src == "scheduled":
assert kb.schedule_task(conn, tid, reason="x")
elif src == "review":
assert kb.request_review(conn, tid, summary="s", force=True)
elif src == "done":
assert kb.complete_task(conn, tid, summary="s", force=True)
elif src == kw.ARCHIVED:
assert kb.archive_task(conn, tid)
assert kb.get_task(conn, tid).status == src
return tid
@pytest.mark.parametrize("src", (*W.keys(), kw.ARCHIVED))
def test_manual_moves_match_server(client, src):
mismatches = []
for dst in (*W.keys(), kw.ARCHIVED):
if dst == src:
continue
tid = _task_in(client, src)
# ``done`` needs completion evidence; that is a verb gate, not a transition rule.
body = {"status": dst, **({"summary": "s"} if dst == "done" else {})}
r = client.patch(f"/k/tasks/{tid}", json=body)
with kbc.connect_closing() as conn:
landed = kb.get_task(conn, tid).status
# A refusal must be a domain rejection (400 bad verb / 409 refused transition);
# anything else (5xx, 422) is a bug, never an acceptable "not allowed".
if r.status_code not in (200, 400, 409):
mismatches.append(f"{src}->{dst}: unexpected HTTP {r.status_code}: {r.text[:200]}")
continue
accepted = r.status_code == 200
if accepted != W.can_move(src, dst):
mismatches.append(f"{src}->{dst}: server {r.status_code}, workflow can_move={W.can_move(src, dst)}")
elif accepted and landed != _KNOWN_REDIRECTS.get((src, dst), dst):
mismatches.append(f"{src}->{dst}: landed in {landed}")
assert not mismatches, "\n".join(mismatches)
def test_get_workflow(client):
r = client.get("/k/workflow")
assert r.status_code == 200
assert r.json() == W.to_dict()