/ tests / hermes_cli / test_kanban_cli.py
test_kanban_cli.py
  1  """Tests for the kanban CLI surface (hermes_cli.kanban)."""
  2  
  3  from __future__ import annotations
  4  
  5  import argparse
  6  import json
  7  import os
  8  from pathlib import Path
  9  
 10  import pytest
 11  
 12  from hermes_cli import kanban as kc
 13  from hermes_cli import kanban_db as kb
 14  
 15  
 16  @pytest.fixture
 17  def kanban_home(tmp_path, monkeypatch):
 18      home = tmp_path / ".hermes"
 19      home.mkdir()
 20      monkeypatch.setenv("HERMES_HOME", str(home))
 21      monkeypatch.setattr(Path, "home", lambda: tmp_path)
 22      kb.init_db()
 23      return home
 24  
 25  
 26  # ---------------------------------------------------------------------------
 27  # Workspace flag parsing
 28  # ---------------------------------------------------------------------------
 29  
 30  @pytest.mark.parametrize(
 31      "value,expected",
 32      [
 33          ("scratch",              ("scratch", None)),
 34          ("worktree",              ("worktree", None)),
 35          ("dir:/tmp/work",         ("dir", "/tmp/work")),
 36      ],
 37  )
 38  def test_parse_workspace_flag_valid(value, expected):
 39      assert kc._parse_workspace_flag(value) == expected
 40  
 41  
 42  def test_parse_workspace_flag_expands_user():
 43      kind, path = kc._parse_workspace_flag("dir:~/vault")
 44      assert kind == "dir"
 45      assert path.endswith("/vault")
 46      assert not path.startswith("~")
 47  
 48  
 49  @pytest.mark.parametrize("bad", ["cloud", "dir:", "", "worktree:/x"])
 50  def test_parse_workspace_flag_rejects(bad):
 51      if not bad:
 52          # Empty -> defaults; not an error.
 53          assert kc._parse_workspace_flag(bad) == ("scratch", None)
 54          return
 55      with pytest.raises(argparse.ArgumentTypeError):
 56          kc._parse_workspace_flag(bad)
 57  
 58  
 59  # ---------------------------------------------------------------------------
 60  # run_slash smoke tests (end-to-end via the same entry both CLI and gateway use)
 61  # ---------------------------------------------------------------------------
 62  
 63  def test_run_slash_no_args_shows_usage(kanban_home):
 64      out = kc.run_slash("")
 65      assert "kanban" in out.lower()
 66      assert "create" in out.lower() or "subcommand" in out.lower() or "action" in out.lower()
 67  
 68  
 69  def test_run_slash_create_and_list(kanban_home):
 70      out = kc.run_slash("create 'ship feature' --assignee alice")
 71      assert "Created" in out
 72      out = kc.run_slash("list")
 73      assert "ship feature" in out
 74      assert "alice" in out
 75  
 76  
 77  def test_run_slash_create_with_parent_and_cascade(kanban_home):
 78      # Parent then child via --parent
 79      out1 = kc.run_slash("create 'parent' --assignee alice")
 80      # Extract the "t_xxxx" id from "Created t_xxxx (ready, ...)"
 81      import re
 82      m = re.search(r"(t_[a-f0-9]+)", out1)
 83      assert m
 84      p = m.group(1)
 85      out2 = kc.run_slash(f"create 'child' --assignee bob --parent {p}")
 86      assert "todo" in out2  # child starts as todo
 87  
 88      # Complete parent; list should promote child to ready
 89      kc.run_slash(f"complete {p}")
 90      # Explicit filter: child should now be ready (was todo before complete).
 91      ready_list = kc.run_slash("list --status ready")
 92      assert "child" in ready_list
 93  
 94  
 95  def test_run_slash_show_includes_comments(kanban_home):
 96      out = kc.run_slash("create 'x'")
 97      import re
 98      tid = re.search(r"(t_[a-f0-9]+)", out).group(1)
 99      kc.run_slash(f"comment {tid} 'source is paywalled'")
100      show = kc.run_slash(f"show {tid}")
101      assert "source is paywalled" in show
102  
103  
104  def test_run_slash_block_unblock_cycle(kanban_home):
105      out = kc.run_slash("create 'x' --assignee alice")
106      import re
107      tid = re.search(r"(t_[a-f0-9]+)", out).group(1)
108      # Claim first so block() finds it running
109      kc.run_slash(f"claim {tid}")
110      assert "Blocked" in kc.run_slash(f"block {tid} 'need decision'")
111      assert "Unblocked" in kc.run_slash(f"unblock {tid}")
112  
113  
114  def test_run_slash_json_output(kanban_home):
115      out = kc.run_slash("create 'jsontask' --assignee alice --json")
116      payload = json.loads(out)
117      assert payload["title"] == "jsontask"
118      assert payload["assignee"] == "alice"
119      assert payload["status"] == "ready"
120  
121  
122  def test_run_slash_dispatch_dry_run_counts(kanban_home):
123      kc.run_slash("create 'a' --assignee alice")
124      kc.run_slash("create 'b' --assignee bob")
125      out = kc.run_slash("dispatch --dry-run")
126      assert "Spawned:" in out
127  
128  
129  def test_run_slash_context_output_format(kanban_home):
130      out = kc.run_slash("create 'tech spec' --assignee alice --body 'write an RFC'")
131      import re
132      tid = re.search(r"(t_[a-f0-9]+)", out).group(1)
133      kc.run_slash(f"comment {tid} 'remember to include performance section'")
134      ctx = kc.run_slash(f"context {tid}")
135      assert "tech spec" in ctx
136      assert "write an RFC" in ctx
137      assert "performance section" in ctx
138  
139  
140  def test_run_slash_tenant_filter(kanban_home):
141      kc.run_slash("create 'biz-a task' --tenant biz-a --assignee alice")
142      kc.run_slash("create 'biz-b task' --tenant biz-b --assignee alice")
143      a = kc.run_slash("list --tenant biz-a")
144      b = kc.run_slash("list --tenant biz-b")
145      assert "biz-a task" in a and "biz-b task" not in a
146      assert "biz-b task" in b and "biz-a task" not in b
147  
148  
149  def test_run_slash_usage_error_returns_message(kanban_home):
150      # Missing required argument for create
151      out = kc.run_slash("create")
152      assert "usage" in out.lower() or "error" in out.lower()
153  
154  
155  def test_run_slash_assign_reassigns(kanban_home):
156      out = kc.run_slash("create 'x' --assignee alice")
157      import re
158      tid = re.search(r"(t_[a-f0-9]+)", out).group(1)
159      assert "Assigned" in kc.run_slash(f"assign {tid} bob")
160      show = kc.run_slash(f"show {tid}")
161      assert "bob" in show
162  
163  
164  def test_run_slash_link_unlink(kanban_home):
165      a = kc.run_slash("create 'a'")
166      b = kc.run_slash("create 'b'")
167      import re
168      ta = re.search(r"(t_[a-f0-9]+)", a).group(1)
169      tb = re.search(r"(t_[a-f0-9]+)", b).group(1)
170      assert "Linked" in kc.run_slash(f"link {ta} {tb}")
171      # After link, b is todo
172      show = kc.run_slash(f"show {tb}")
173      assert "todo" in show
174      assert "Unlinked" in kc.run_slash(f"unlink {ta} {tb}")
175  
176  
177  # ---------------------------------------------------------------------------
178  # Integration with the COMMAND_REGISTRY
179  # ---------------------------------------------------------------------------
180  
181  def test_kanban_is_resolvable():
182      from hermes_cli.commands import resolve_command
183  
184      cmd = resolve_command("kanban")
185      assert cmd is not None
186      assert cmd.name == "kanban"
187  
188  
189  def test_kanban_bypasses_active_session_guard():
190      from hermes_cli.commands import should_bypass_active_session
191  
192      assert should_bypass_active_session("kanban")
193  
194  
195  def test_kanban_in_autocomplete_table():
196      from hermes_cli.commands import COMMANDS, SUBCOMMANDS
197  
198      assert "/kanban" in COMMANDS
199      subs = SUBCOMMANDS.get("/kanban") or []
200      assert "create" in subs
201      assert "dispatch" in subs
202  
203  
204  def test_kanban_not_gateway_only():
205      # kanban is available in BOTH CLI and gateway surfaces.
206      from hermes_cli.commands import COMMAND_REGISTRY
207  
208      cmd = next(c for c in COMMAND_REGISTRY if c.name == "kanban")
209      assert not cmd.cli_only
210      assert not cmd.gateway_only