- 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.
100 lines
3.8 KiB
Python
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()
|