Initial commit
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
"""Unit tests for shellbound (no network required)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from shellbound import modes
|
||||
from shellbound.personas import (
|
||||
AmbiguousPersona,
|
||||
can_expected_mode,
|
||||
default_persona_name,
|
||||
resolve_mode,
|
||||
resolve_name,
|
||||
shellbound_personas,
|
||||
)
|
||||
|
||||
RAW = [
|
||||
{"name": "shellbound_shell", "applies_to_kinds": ["interactive"]},
|
||||
{"name": "shellbound_answer", "applies_to_kinds": ["interactive"]},
|
||||
{"name": "shellbound_creative", "applies_to_kinds": ["interactive"]},
|
||||
{"name": "shellbound_expert", "applies_to_kinds": ["interactive"]},
|
||||
{"name": "shellbound_explain", "applies_to_kinds": ["interactive"]},
|
||||
{"name": "shellbound_poetic", "applies_to_kinds": ["interactive"]},
|
||||
{"name": "engineer", "applies_to_kinds": ["interactive"], "is_default": True},
|
||||
{"name": "orchestrator", "applies_to_kinds": ["coordinator"]},
|
||||
]
|
||||
|
||||
|
||||
def test_shellbound_filter():
|
||||
slugs = {p["name"] for p in shellbound_personas(RAW)}
|
||||
assert slugs == {
|
||||
"shellbound_shell",
|
||||
"shellbound_answer",
|
||||
"shellbound_creative",
|
||||
"shellbound_expert",
|
||||
"shellbound_explain",
|
||||
"shellbound_poetic",
|
||||
}
|
||||
|
||||
|
||||
def test_unique_abbreviations():
|
||||
assert resolve_mode(RAW, "s")["name"] == "shellbound_shell"
|
||||
assert resolve_mode(RAW, "a")["name"] == "shellbound_answer"
|
||||
assert resolve_mode(RAW, "c")["name"] == "shellbound_creative"
|
||||
assert resolve_mode(RAW, "p")["name"] == "shellbound_poetic"
|
||||
assert resolve_mode(RAW, "sh")["name"] == "shellbound_shell"
|
||||
assert resolve_mode(RAW, "expe")["name"] == "shellbound_expert"
|
||||
assert resolve_mode(RAW, "expl")["name"] == "shellbound_explain"
|
||||
assert resolve_mode(RAW, "shellbound_shell")["name"] == "shellbound_shell"
|
||||
assert resolve_mode(RAW, "SHELLBOUND_ANSWER")["name"] == "shellbound_answer"
|
||||
|
||||
|
||||
def test_ambiguous_abbreviation():
|
||||
try:
|
||||
resolve_mode(RAW, "e")
|
||||
except AmbiguousPersona as exc:
|
||||
assert set(exc.candidates) == {"shellbound_expert", "shellbound_explain"}
|
||||
else:
|
||||
raise AssertionError("expected AmbiguousPersona")
|
||||
|
||||
|
||||
def test_no_match_is_query():
|
||||
assert resolve_mode(RAW, "zzz") is None
|
||||
assert can_expected_mode("zzz") is False
|
||||
assert can_expected_mode("s") is True
|
||||
|
||||
|
||||
def test_resolve_name():
|
||||
assert resolve_name(RAW, "engineer")["name"] == "engineer"
|
||||
assert resolve_name(RAW, "shellbound_poetic")["name"] == "shellbound_poetic"
|
||||
try:
|
||||
resolve_name(RAW, "orchestrator") # coordinator kind, not interactive
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("orchestrator should be rejected (non-interactive)")
|
||||
|
||||
|
||||
def test_default_persona():
|
||||
assert default_persona_name(RAW) == "shellbound_answer"
|
||||
|
||||
|
||||
def test_extract_commands_fenced():
|
||||
raw = '```\nls -la\n# hide me\necho hi\n```\n'
|
||||
assert modes.extract_commands(raw) == ["ls -la", "echo hi"]
|
||||
|
||||
|
||||
def test_extract_commands_plain():
|
||||
assert modes.extract_commands("whoami\necho ok") == ["whoami", "echo ok"]
|
||||
assert modes.extract_commands("# comment only") == []
|
||||
|
||||
|
||||
def test_extract_commands_dollar_prompt():
|
||||
assert modes.extract_commands("$ git status") == ["git status"]
|
||||
|
||||
|
||||
def test_minimal_uid():
|
||||
assert modes.minimal_uid("abc12345", ["abc99999", "abc12346"]) == "abc12345"
|
||||
assert modes.minimal_uid("aaa11111", []) == "aaa1"
|
||||
|
||||
|
||||
def test_session_name():
|
||||
assert modes.session_name(" why is the sky blue? ") == "sb: why is the sky blue?"
|
||||
|
||||
|
||||
def test_expert_prompts():
|
||||
task = modes.expert_brief_task("hello")
|
||||
assert task.startswith("Stage 1 - Produce an Expert Brief")
|
||||
assert "USER PROMPT:" in task and "hello" in task
|
||||
assert "prompt writing agent" not in task
|
||||
retry = modes.expert_retry_task("hello", "META")
|
||||
assert retry.startswith("Stage 2 - Adopt the following Expert Persona")
|
||||
assert "EXPERT PERSONA" in retry and "META" in retry
|
||||
assert "USER PROMPT:" in retry and "hello" in retry
|
||||
|
||||
|
||||
def _ws(ws_id, persona, state, updated):
|
||||
ns = type("W", (), {})()
|
||||
ns.ws_id = ws_id
|
||||
ns.persona = persona
|
||||
ns.state = state
|
||||
ns.updated = updated
|
||||
return ns
|
||||
|
||||
|
||||
def test_select_stale_keeps_newest_n():
|
||||
from shellbound.client import select_stale_workspaces
|
||||
|
||||
workstreams = [
|
||||
_ws("aaaa", "shellbound_answer", "idle", "2026-09-05T10:00:00"),
|
||||
_ws("bbbb", "shellbound_answer", "idle", "2026-09-05T11:00:00"),
|
||||
_ws("cccc", "engineer", "idle", "2026-09-05T12:00:00"), # not shellbound_
|
||||
_ws("dddd", "shellbound_shell", "idle", "2026-09-05T09:00:00"),
|
||||
]
|
||||
stale = select_stale_workspaces(workstreams, keep=2)
|
||||
# newest two shellbound_* kept (bbbb, aaaa); only the oldest is stale
|
||||
assert [w.ws_id for w in stale] == ["dddd"]
|
||||
|
||||
|
||||
def test_select_stale_excludes_closed_and_exclude_id():
|
||||
from shellbound.client import select_stale_workspaces
|
||||
|
||||
workstreams = [
|
||||
_ws("aaaa", "shellbound_answer", "closed", "2026-09-05T10:00:00"),
|
||||
_ws("bbbb", "shellbound_answer", "idle", "2026-09-05T11:00:00"),
|
||||
_ws("cccc", "shellbound_shell", "idle", "2026-09-05T09:00:00"),
|
||||
_ws("dddd", "shellbound_shell", "idle", "2026-09-05T08:00:00"),
|
||||
]
|
||||
# bbbb is the newest but excluded (it's the session just used); only
|
||||
# cccc remains inside keep=1, so the stale set is just dddd.
|
||||
stale = select_stale_workspaces(workstreams, keep=1, exclude_id="bbbb")
|
||||
assert [w.ws_id for w in stale] == ["dddd"]
|
||||
|
||||
|
||||
def test_select_stale_keep_zero():
|
||||
from shellbound.client import select_stale_workspaces
|
||||
|
||||
workstreams = [_ws("aaaa", "shellbound_answer", "idle", "2026-09-05T10:00:00")]
|
||||
assert [w.ws_id for w in select_stale_workspaces(workstreams, keep=0)] == ["aaaa"]
|
||||
|
||||
|
||||
def test_config_prunes_node_base_template(tmp_path, monkeypatch):
|
||||
from shellbound import config as c
|
||||
|
||||
cfg_file = tmp_path / "config.json"
|
||||
cfg_file.write_text(
|
||||
json.dumps({"gateway": "https://x:1", "token": "t", "node_base_template": "{gateway}/bad"})
|
||||
)
|
||||
monkeypatch.setattr(c, "CONFIG_FILE", cfg_file)
|
||||
cfg = c.Config.load()
|
||||
assert "node_base_template" not in cfg
|
||||
assert cfg["gateway"] == "https://x:1"
|
||||
assert cfg.keep_workspaces == 5
|
||||
|
||||
|
||||
def test_config_keep_workspaces_environ(tmp_path, monkeypatch):
|
||||
from shellbound import config as c
|
||||
|
||||
monkeypatch.setattr(c, "CONFIG_FILE", tmp_path / "missing.json")
|
||||
monkeypatch.setenv("SHELLBOUND_KEEP_WORKSPACES", "3")
|
||||
assert c.Config.load().keep_workspaces == 3
|
||||
|
||||
|
||||
def test_config_keep_workspaces_edge_values():
|
||||
from shellbound.config import Config
|
||||
|
||||
assert Config({"keep_workspaces": "oops"}).keep_workspaces == 5
|
||||
assert Config({}).keep_workspaces == 5
|
||||
assert Config({"keep_workspaces": 0}).keep_workspaces == 0
|
||||
|
||||
|
||||
def test_normalize_session_and_close():
|
||||
from shellbound.cli import _normalize_session
|
||||
|
||||
assert _normalize_session(["--session", "abc", "hi"]) == ["--session=abc", "hi"]
|
||||
assert _normalize_session(["--session"]) == ["--session=__list__"]
|
||||
assert _normalize_session(["--close", "all"]) == ["--close=all"]
|
||||
assert _normalize_session(["--close", "--debug"]) == ["--close=__all__", "--debug"]
|
||||
|
||||
|
||||
def test_renderer_flush_paragraphs():
|
||||
from shellbound.render import StreamRenderer
|
||||
|
||||
renderer = StreamRenderer(plain=False)
|
||||
renderer.add_content("First paragraph.\n")
|
||||
assert not renderer.flushed # in-flight tail, not yet a block
|
||||
assert renderer.tail.endswith("\n")
|
||||
renderer.add_content("Second.\n\n")
|
||||
assert len(renderer.flushed) == 1 and not renderer.tail
|
||||
|
||||
|
||||
def test_renderer_flush_code_fence():
|
||||
from shellbound.render import StreamRenderer
|
||||
|
||||
renderer = StreamRenderer(plain=False)
|
||||
# blank lines inside an open fence must not flush; the closed fence
|
||||
# becomes a single Markdown block on the trailing blank line.
|
||||
renderer.add_content("```bash\necho a\n\n echo b \n```\n\n")
|
||||
assert len(renderer.flushed) == 1 and not renderer.tail
|
||||
# an unclosed fence cap is force-flushed as raw text
|
||||
renderer.add_content("```python\ndef f():\n")
|
||||
assert len(renderer.flushed) == 1 # still inside the fence, nothing flushed
|
||||
long = "x" * 5000 + "\n"
|
||||
renderer.add_content(long)
|
||||
assert len(renderer.flushed) == 2 and not renderer.tail
|
||||
|
||||
|
||||
def test_renderer_plain_content_streams():
|
||||
from shellbound.render import StreamRenderer
|
||||
|
||||
renderer = StreamRenderer(plain=True)
|
||||
renderer.add_content("just text")
|
||||
assert renderer.content_parts == ["just text"]
|
||||
assert renderer.finish() == "just text"
|
||||
Reference in New Issue
Block a user