feat: 添加测试框架及功能测试用例

test: 实现BDD测试框架及功能测试
docs: 添加测试配置文件及文档
refactor: 重构命令处理逻辑以支持测试
This commit is contained in:
Yuyao Huang (Sam)
2026-03-29 04:24:27 +08:00
parent 6cf2143987
commit 8dab229aaf
22 changed files with 906 additions and 0 deletions
View File
+183
View File
@@ -0,0 +1,183 @@
"""
Shared Given/Then step definitions used across all feature files.
"""
from __future__ import annotations
from pytest_bdd import given, then, parsers
# ── Given: user identity ─────────────────────────────────────────────────────
@given(parsers.parse('user "{user_id}" is sending commands'))
def set_user(user_id, pytestconfig):
from orchestrator.tools import set_current_user
set_current_user(user_id)
pytestconfig._test_user_id = user_id
@given(parsers.parse('the current chat_id is "{chat_id}"'))
def set_chat(chat_id):
from orchestrator.tools import set_current_chat
set_current_chat(chat_id)
# ── Given: session setup ─────────────────────────────────────────────────────
@given(parsers.parse('user has session "{conv_id}" in "{cwd}"'))
def add_session(conv_id, cwd, pytestconfig, tmp_path):
from agent.manager import manager, Session
user_id = getattr(pytestconfig, "_test_user_id", "user_abc123")
session = Session(conv_id=conv_id, cwd=str(tmp_path / conv_id), owner_id=user_id, cc_timeout=50.0)
(tmp_path / conv_id).mkdir(exist_ok=True)
manager._sessions[conv_id] = session
@given(parsers.parse('session "{conv_id}" in "{cwd}" belongs to user "{owner}"'))
def add_foreign_session(conv_id, cwd, owner, tmp_path):
from agent.manager import manager, Session
session = Session(conv_id=conv_id, cwd=str(tmp_path / conv_id), owner_id=owner, cc_timeout=50.0)
(tmp_path / conv_id).mkdir(exist_ok=True)
manager._sessions[conv_id] = session
@given(parsers.parse('active session is "{conv_id}"'))
def set_active_session(conv_id, pytestconfig):
from orchestrator.agent import agent
user_id = getattr(pytestconfig, "_test_user_id", "user_abc123")
agent._active_conv[user_id] = conv_id
@given(parsers.parse('active session is "{conv_id}" which does not exist'))
def set_ghost_active_session(conv_id, pytestconfig):
from orchestrator.agent import agent
user_id = getattr(pytestconfig, "_test_user_id", "user_abc123")
agent._active_conv[user_id] = conv_id
# intentionally NOT added to manager._sessions
@given(parsers.parse('no active session for user "{user_id}"'))
def ensure_no_active_session(user_id):
from orchestrator.agent import agent
agent._active_conv[user_id] = None
# ── Given: mode toggles ──────────────────────────────────────────────────────
@given(parsers.parse('direct mode is enabled for user "{user_id}"'))
def enable_direct_mode(user_id):
from orchestrator.agent import agent
agent._passthrough[user_id] = True
# ── Given: mocks ─────────────────────────────────────────────────────────────
@given(parsers.parse('run_claude returns "{output}"'))
def set_run_claude_return(output, mock_run_claude):
mock_run_claude.return_value = output
# ── Given: config ────────────────────────────────────────────────────────────
@given("ROUTER_MODE is disabled")
def disable_router_mode(monkeypatch):
import config
monkeypatch.setattr(config, "ROUTER_MODE", False)
# ── Then: reply assertions ───────────────────────────────────────────────────
@then(parsers.parse('reply contains "{text}"'))
def reply_contains(text, pytestconfig):
reply = getattr(pytestconfig, "_reply", None)
assert text in (reply or ""), \
f"Expected {text!r} in reply, got: {reply!r}"
@then(parsers.parse('reply does not contain "{text}"'))
def reply_not_contains(text, pytestconfig):
reply = getattr(pytestconfig, "_reply", None)
assert text not in (reply or ""), \
f"Expected {text!r} NOT in reply, got: {reply!r}"
@then("reply is not empty")
def reply_not_empty(pytestconfig):
reply = getattr(pytestconfig, "_reply", None)
assert reply and reply.strip(), \
f"Expected non-empty reply, got: {reply!r}"
@then("text reply is empty")
def reply_is_empty(pytestconfig):
reply = getattr(pytestconfig, "_reply", None)
assert reply == "", \
f"Expected empty reply, got: {reply!r}"
@then("command is not handled")
def command_not_handled(pytestconfig):
reply = getattr(pytestconfig, "_reply", None)
assert reply is None
# ── Then: session state ──────────────────────────────────────────────────────
@then(parsers.parse('session manager has {count:d} session for user "{user_id}"'))
@then(parsers.parse('session manager has {count:d} sessions for user "{user_id}"'))
def check_session_count(count, user_id):
from agent.manager import manager
sessions = manager.list_sessions(user_id=user_id)
assert len(sessions) == count, \
f"Expected {count} sessions, got {len(sessions)}: {sessions}"
@then(parsers.parse('active session for user "{user_id}" is "{conv_id}"'))
def check_active_session(user_id, conv_id):
from orchestrator.agent import agent
assert agent._active_conv.get(user_id) == conv_id
@then(parsers.parse('active session for user "{user_id}" is None'))
def check_no_active_session(user_id):
from orchestrator.agent import agent
assert agent._active_conv.get(user_id) is None
# ── Then: mode state ─────────────────────────────────────────────────────────
@then(parsers.parse('passthrough mode is enabled for user "{user_id}"'))
def check_passthrough_on(user_id):
from orchestrator.agent import agent
assert agent._passthrough.get(user_id) is True
@then(parsers.parse('passthrough mode is disabled for user "{user_id}"'))
def check_passthrough_off(user_id):
from orchestrator.agent import agent
assert agent._passthrough.get(user_id) is False
# ── Then: Feishu output ──────────────────────────────────────────────────────
@then(parsers.parse('a sessions card is sent to chat "{chat_id}"'))
def check_card_sent(chat_id, feishu_calls):
cards = feishu_calls["cards"]
assert any(c["receive_id"] == chat_id for c in cards), \
f"No card sent to {chat_id!r}, captured: {cards}"
# ── Then: scheduler ──────────────────────────────────────────────────────────
@then(parsers.parse('scheduler has {count:d} pending job'))
@then(parsers.parse('scheduler has {count:d} pending jobs'))
def check_scheduler_jobs(count):
from agent.scheduler import scheduler
assert len(scheduler._jobs) == count, \
f"Expected {count} jobs, got {len(scheduler._jobs)}"
# ── Then: run_claude ─────────────────────────────────────────────────────────
@then("run_claude was called")
def check_run_claude_called(mock_run_claude):
assert mock_run_claude.call_count >= 1, "Expected run_claude to be called"
+76
View File
@@ -0,0 +1,76 @@
"""
Step definitions for agent routing and passthrough features.
"""
from __future__ import annotations
from pytest_bdd import scenarios, given, when, then, parsers
from tests.step_defs.common_steps import * # noqa: F401,F403 — import shared steps
scenarios(
"../features/agent/routing.feature",
"../features/agent/passthrough.feature",
)
# ── Given: agent-specific setup ──────────────────────────────────────────────
@given(parsers.parse('user "{user_id}" is in smart mode'))
def set_smart_mode(user_id, pytestconfig):
from orchestrator.agent import agent
from orchestrator.tools import set_current_user
set_current_user(user_id)
agent._passthrough[user_id] = False
pytestconfig._test_user_id = user_id
@given(parsers.parse('user has active session "{conv_id}" in "{cwd}"'))
def add_and_activate_session(conv_id, cwd, pytestconfig, tmp_path):
from agent.manager import manager, Session
from orchestrator.agent import agent
user_id = getattr(pytestconfig, "_test_user_id", "user_abc123")
session = Session(conv_id=conv_id, cwd=str(tmp_path / conv_id), owner_id=user_id, cc_timeout=50.0)
(tmp_path / conv_id).mkdir(exist_ok=True)
manager._sessions[conv_id] = session
agent._active_conv[user_id] = conv_id
@given(parsers.parse('vcr cassette "{cassette_name}"'))
def set_vcr_cassette(cassette_name, pytestconfig):
pytestconfig._vcr_cassette = cassette_name
# ── When: send message through agent ─────────────────────────────────────────
@when(parsers.parse('user sends agent message "{text}"'))
def send_agent_message(text, pytestconfig, mock_run_claude, feishu_calls):
import asyncio
from orchestrator.agent import agent
from tests.conftest import make_vcr_cassette
user_id = getattr(pytestconfig, "_test_user_id", "user_abc123")
cassette_name = getattr(pytestconfig, "_vcr_cassette", None)
loop = asyncio.get_event_loop()
if cassette_name:
with make_vcr_cassette(cassette_name):
reply = loop.run_until_complete(agent.run(user_id, text))
else:
reply = loop.run_until_complete(agent.run(user_id, text))
pytestconfig._reply = reply
# ── Then: agent-specific assertions ─────────────────────────────────────────
@then(parsers.parse('agent created a session for user "{user_id}"'))
def check_session_created(user_id):
from orchestrator.agent import agent
assert agent._active_conv.get(user_id) is not None, \
f"Expected active session to be set for {user_id}"
@then(parsers.parse('no session is created for user "{user_id}"'))
def check_no_session(user_id):
from orchestrator.agent import agent
assert agent._active_conv.get(user_id) is None, \
f"Expected no active session for {user_id}, got {agent._active_conv.get(user_id)}"
+78
View File
@@ -0,0 +1,78 @@
"""
Step definitions for all slash command features.
"""
from __future__ import annotations
import time
from pytest_bdd import scenarios, given, when, then, parsers
from tests.step_defs.common_steps import * # noqa: F401,F403 — import shared steps
scenarios(
"../features/commands/new.feature",
"../features/commands/status.feature",
"../features/commands/switch.feature",
"../features/commands/close.feature",
"../features/commands/direct_smart.feature",
"../features/commands/shell.feature",
"../features/commands/remind.feature",
"../features/commands/tasks.feature",
"../features/commands/nodes.feature",
"../features/commands/help.feature",
)
# ── When: send slash command ─────────────────────────────────────────────────
@when(parsers.parse('user sends "{text}"'))
def send_command(text, pytestconfig, feishu_calls, mock_run_claude):
import asyncio
from bot.commands import handle_command
user_id = getattr(pytestconfig, "_test_user_id", "user_abc123")
reply = asyncio.get_event_loop().run_until_complete(handle_command(user_id, text))
pytestconfig._reply = reply
# ── Given: task runner state ─────────────────────────────────────────────────
@given(parsers.parse('there is a running task "{task_id}" described as "{desc}"'))
def add_running_task(task_id, desc):
from agent.task_runner import task_runner, BackgroundTask, TaskStatus
task = BackgroundTask(
task_id=task_id,
description=desc,
started_at=time.time(),
status=TaskStatus.RUNNING,
)
task_runner._tasks[task_id] = task
@given(parsers.parse('there is a completed task "{task_id}" described as "{desc}"'))
def add_completed_task(task_id, desc):
from agent.task_runner import task_runner, BackgroundTask, TaskStatus
now = time.time()
task = BackgroundTask(
task_id=task_id,
description=desc,
started_at=now - 5,
status=TaskStatus.COMPLETED,
completed_at=now,
result="success",
)
task_runner._tasks[task_id] = task
@given(parsers.parse('there is a failed task "{task_id}" described as "{desc}"'))
def add_failed_task(task_id, desc):
from agent.task_runner import task_runner, BackgroundTask, TaskStatus
now = time.time()
task = BackgroundTask(
task_id=task_id,
description=desc,
started_at=now - 3,
status=TaskStatus.FAILED,
completed_at=now,
error="subprocess failed",
)
task_runner._tasks[task_id] = task