feat: rebuild AI commands and migrate config to TOML
This commit is contained in:
96
tests/test_agent_tools.py
Normal file
96
tests/test_agent_tools.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from micro_agents_tools import FilesystemTools, PythonTools
|
||||
|
||||
from app.agent_tools import AgentToolManager, TelegramTools
|
||||
|
||||
|
||||
class AgentToolPolicyTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_filesystem_and_python_tools_are_owner_only(self) -> None:
|
||||
telegram_tools = TelegramTools(
|
||||
SimpleNamespace(),
|
||||
99,
|
||||
base_dir=Path.cwd(),
|
||||
).tools()
|
||||
manager = AgentToolManager(
|
||||
[*FilesystemTools().tools(), *PythonTools().tools(), *telegram_tools]
|
||||
)
|
||||
|
||||
guest_names = {
|
||||
tool.name for tool in await manager.available_tools({"is_owner": False})
|
||||
}
|
||||
owner_names = {
|
||||
tool.name for tool in await manager.available_tools({"is_owner": True})
|
||||
}
|
||||
|
||||
self.assertEqual(guest_names, {"send_message", "send_image", "send_file"})
|
||||
self.assertIn("read_file", owner_names)
|
||||
self.assertIn("write_file", owner_names)
|
||||
self.assertIn("execute_python", owner_names)
|
||||
|
||||
|
||||
class TelegramToolsTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_sends_text_to_current_chat_as_reply_to_command(self) -> None:
|
||||
client = SimpleNamespace(
|
||||
send_message=AsyncMock(return_value=SimpleNamespace(id=123))
|
||||
)
|
||||
tools = TelegramTools(
|
||||
client,
|
||||
99,
|
||||
base_dir=Path.cwd(),
|
||||
reply_to_message_id=7,
|
||||
)
|
||||
|
||||
result = await tools.send_message("hello")
|
||||
|
||||
client.send_message.assert_awaited_once_with(
|
||||
99,
|
||||
"hello",
|
||||
reply_to_message_id=7,
|
||||
)
|
||||
self.assertIn("message_id=123", result)
|
||||
|
||||
async def test_sends_image_and_file_from_workspace(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
base_dir = Path(temp_dir)
|
||||
image = base_dir / "photo.jpg"
|
||||
document = base_dir / "report.txt"
|
||||
image.write_bytes(b"image")
|
||||
document.write_text("report", encoding="utf-8")
|
||||
client = SimpleNamespace(
|
||||
send_photo=AsyncMock(return_value=SimpleNamespace(id=1)),
|
||||
send_document=AsyncMock(return_value=SimpleNamespace(id=2)),
|
||||
)
|
||||
tools = TelegramTools(client, 99, base_dir=base_dir)
|
||||
|
||||
await tools.send_image("photo.jpg", "caption")
|
||||
await tools.send_file("report.txt")
|
||||
|
||||
client.send_photo.assert_awaited_once_with(
|
||||
99,
|
||||
str(image),
|
||||
caption="caption",
|
||||
reply_to_message_id=None,
|
||||
)
|
||||
client.send_document.assert_awaited_once_with(
|
||||
99,
|
||||
str(document),
|
||||
caption=None,
|
||||
reply_to_message_id=None,
|
||||
)
|
||||
|
||||
async def test_rejects_path_outside_workspace(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
tools = TelegramTools(SimpleNamespace(), 99, base_dir=Path(temp_dir))
|
||||
with self.assertRaisesRegex(ValueError, "Absolute paths"):
|
||||
await tools.send_file(str(Path(temp_dir).resolve() / "secret.txt"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
306
tests/test_ai_command.py
Normal file
306
tests/test_ai_command.py
Normal file
@@ -0,0 +1,306 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from app.agentic import (
|
||||
AgentSessionHandle,
|
||||
AgentSessionStore,
|
||||
AgentSyntaxError,
|
||||
_build_context_messages,
|
||||
_download_message_media,
|
||||
render_prompt_template,
|
||||
parse_agent_invocation,
|
||||
run_agent_turn,
|
||||
strip_prompt_comments,
|
||||
)
|
||||
from app.commands.builtins import ai, command_registry
|
||||
from app.config import AiConfig, AiProviderConfig
|
||||
|
||||
|
||||
class AgentInvocationTests(unittest.TestCase):
|
||||
def test_prompt_comments_are_removed_without_breaking_urls(self) -> None:
|
||||
value = strip_prompt_comments(
|
||||
"// first\nkeep https://example.com // trailing\n/* block\ncomment */\nlast"
|
||||
)
|
||||
|
||||
self.assertNotIn("first", value)
|
||||
self.assertNotIn("trailing", value)
|
||||
self.assertNotIn("block", value)
|
||||
self.assertIn("https://example.com", value)
|
||||
self.assertIn("last", value)
|
||||
|
||||
def test_prompt_template_replaces_nested_values_and_text(self) -> None:
|
||||
rendered = render_prompt_template(
|
||||
"caller={{caller_user.id}} owner={owner.name} text={{text}}",
|
||||
{
|
||||
"caller_user": {"id": 42},
|
||||
"owner": {"name": "Owner"},
|
||||
"text": "old",
|
||||
"source_text": "new",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(rendered, "caller=42 owner=Owner text=new")
|
||||
|
||||
def test_parses_context_provider_and_thinking_flags(self) -> None:
|
||||
invocation = parse_agent_invocation("12 .ds .th explain this", AiConfig())
|
||||
|
||||
self.assertEqual(invocation.context_messages, 12)
|
||||
self.assertTrue(invocation.use_deepseek)
|
||||
self.assertTrue(invocation.thinking)
|
||||
self.assertEqual(invocation.prompt, "explain this")
|
||||
|
||||
def test_ignores_unknown_leading_dot_arguments(self) -> None:
|
||||
invocation = parse_agent_invocation(".unknown .ds prompt", AiConfig())
|
||||
|
||||
self.assertTrue(invocation.use_deepseek)
|
||||
self.assertEqual(invocation.prompt, "prompt")
|
||||
|
||||
def test_rejects_missing_prompt(self) -> None:
|
||||
with self.assertRaises(AgentSyntaxError):
|
||||
parse_agent_invocation("10 .ds .th", AiConfig())
|
||||
|
||||
def test_rejects_context_count_above_configured_limit(self) -> None:
|
||||
with self.assertRaises(AgentSyntaxError):
|
||||
parse_agent_invocation("101 prompt", AiConfig(max_context_messages=100))
|
||||
|
||||
|
||||
class AgentContextTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_reply_is_injected_once_and_history_is_chronological(self) -> None:
|
||||
older = SimpleNamespace(id=1, chat=SimpleNamespace(id=99), text="older")
|
||||
newer = SimpleNamespace(id=2, chat=SimpleNamespace(id=99), text="newer")
|
||||
command = SimpleNamespace(
|
||||
id=3,
|
||||
chat=SimpleNamespace(id=99),
|
||||
reply_to_message=older,
|
||||
)
|
||||
|
||||
async def history():
|
||||
yield newer
|
||||
yield older
|
||||
|
||||
client = SimpleNamespace(get_chat_history=lambda *args, **kwargs: history())
|
||||
invocation = parse_agent_invocation("2 prompt", AiConfig())
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
messages = await _build_context_messages(
|
||||
client,
|
||||
command,
|
||||
invocation,
|
||||
AiConfig(),
|
||||
Path(temp_dir),
|
||||
user_template="wrapped={{message.is_reply}} {{text}}",
|
||||
template_context={},
|
||||
)
|
||||
|
||||
self.assertEqual(len(messages), 2)
|
||||
self.assertIn("wrapped=true", messages[0].message())
|
||||
self.assertIn("older", messages[0].message())
|
||||
self.assertIn("newer", messages[1].message())
|
||||
|
||||
async def test_media_over_limit_is_not_downloaded(self) -> None:
|
||||
message = SimpleNamespace(
|
||||
id=1,
|
||||
document=SimpleNamespace(file_size=6, mime_type="text/plain"),
|
||||
)
|
||||
client = SimpleNamespace(download_media=AsyncMock())
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
media = await _download_message_media(client, message, Path(temp_dir), 5)
|
||||
|
||||
self.assertEqual(media, [])
|
||||
client.download_media.assert_not_awaited()
|
||||
|
||||
async def test_media_within_limit_is_attached(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
path = Path(temp_dir) / "note.txt"
|
||||
path.write_bytes(b"hello")
|
||||
message = SimpleNamespace(
|
||||
id=1,
|
||||
document=SimpleNamespace(
|
||||
file_size=5,
|
||||
mime_type="text/plain",
|
||||
file_name="note.txt",
|
||||
),
|
||||
)
|
||||
client = SimpleNamespace(download_media=AsyncMock(return_value=str(path)))
|
||||
|
||||
media = await _download_message_media(client, message, Path(temp_dir), 5)
|
||||
|
||||
self.assertEqual(len(media), 1)
|
||||
self.assertEqual(media[0].data, b"hello")
|
||||
self.assertEqual(media[0].kind, "file")
|
||||
|
||||
async def test_deepseek_and_reasoning_are_passed_to_agent(self) -> None:
|
||||
config = AiConfig(
|
||||
glm=AiProviderConfig("https://glm.example", "glm-key", "glm-model"),
|
||||
deepseek=AiProviderConfig("https://ds.example", "ds-key", "ds-model"),
|
||||
brave_api_key="brave-key",
|
||||
)
|
||||
invocation = parse_agent_invocation(".ds .th prompt", config)
|
||||
command = SimpleNamespace(
|
||||
id=3,
|
||||
chat=SimpleNamespace(id=99),
|
||||
from_user=SimpleNamespace(id=42),
|
||||
reply_to_message=None,
|
||||
)
|
||||
account = SimpleNamespace(
|
||||
me=SimpleNamespace(id=7, full_name="Owner", username="owner"),
|
||||
config=SimpleNamespace(name="personal", session="personal"),
|
||||
)
|
||||
final_message = SimpleNamespace(message=lambda: "answer")
|
||||
session = SimpleNamespace(
|
||||
run=AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
succeeded=True,
|
||||
final_message=final_message,
|
||||
error=None,
|
||||
)
|
||||
)
|
||||
)
|
||||
agent = MagicMock()
|
||||
agent.create_session.return_value = session
|
||||
|
||||
with (
|
||||
patch("app.agentic.OpenAIBasedLLM") as llm_class,
|
||||
patch("app.agentic.Aggregator", return_value=agent) as aggregator_class,
|
||||
patch("app.agentic.BraveSearchTools") as brave_tools,
|
||||
patch("app.agentic._build_context_messages", AsyncMock(return_value=[])),
|
||||
):
|
||||
brave_tools.return_value.tools.return_value = []
|
||||
result = await run_agent_turn(
|
||||
SimpleNamespace(),
|
||||
command,
|
||||
account,
|
||||
AgentSessionStore(),
|
||||
invocation,
|
||||
config,
|
||||
)
|
||||
|
||||
self.assertEqual(result.answer, "answer")
|
||||
llm_class.assert_called_once_with(
|
||||
api_url="https://ds.example",
|
||||
api_key="ds-key",
|
||||
model="ds-model",
|
||||
)
|
||||
self.assertTrue(aggregator_class.call_args.kwargs["thinking"])
|
||||
brave_tools.assert_called_once_with(api_key="brave-key", timeout_seconds=10.0)
|
||||
result.handle.close()
|
||||
|
||||
async def test_reply_to_ai_response_reuses_existing_session(self) -> None:
|
||||
workspace = MagicMock()
|
||||
final_message = SimpleNamespace(message=lambda: "continued")
|
||||
session = SimpleNamespace(
|
||||
context={"old": True},
|
||||
run=AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
succeeded=True,
|
||||
final_message=final_message,
|
||||
error=None,
|
||||
)
|
||||
),
|
||||
)
|
||||
handle = AgentSessionHandle(session, workspace, set())
|
||||
store = AgentSessionStore()
|
||||
ai_response = SimpleNamespace(id=50, chat=SimpleNamespace(id=99))
|
||||
store.register_response(handle, ai_response)
|
||||
command = SimpleNamespace(
|
||||
id=51,
|
||||
chat=SimpleNamespace(id=99),
|
||||
from_user=SimpleNamespace(id=7, full_name="Owner"),
|
||||
reply_to_message=ai_response,
|
||||
)
|
||||
account = SimpleNamespace(
|
||||
me=SimpleNamespace(id=7, full_name="Owner"),
|
||||
config=SimpleNamespace(name="personal", session="personal"),
|
||||
)
|
||||
|
||||
result = await run_agent_turn(
|
||||
SimpleNamespace(),
|
||||
command,
|
||||
account,
|
||||
store,
|
||||
parse_agent_invocation("continue", AiConfig()),
|
||||
AiConfig(),
|
||||
)
|
||||
|
||||
self.assertIs(result.handle, handle)
|
||||
self.assertEqual(result.answer, "continued")
|
||||
session.run.assert_awaited_once_with("continue")
|
||||
self.assertTrue(session.context["is_owner"])
|
||||
store.clear()
|
||||
workspace.cleanup.assert_called_once()
|
||||
|
||||
|
||||
class AiCommandTests(unittest.IsolatedAsyncioTestCase):
|
||||
def _ctx(self, args: str = "prompt") -> SimpleNamespace:
|
||||
sent = SimpleNamespace(id=124, chat=SimpleNamespace(id=99))
|
||||
message = SimpleNamespace(
|
||||
id=123,
|
||||
react=AsyncMock(),
|
||||
reply_text=AsyncMock(return_value=sent),
|
||||
)
|
||||
session_store = MagicMock()
|
||||
return SimpleNamespace(
|
||||
args=args,
|
||||
message=message,
|
||||
client=SimpleNamespace(),
|
||||
account=SimpleNamespace(
|
||||
me=SimpleNamespace(id=7, full_name="Owner"),
|
||||
config=SimpleNamespace(name="personal", session="personal"),
|
||||
ai_sessions=session_store,
|
||||
),
|
||||
manager=SimpleNamespace(config=SimpleNamespace(ai=AiConfig())),
|
||||
reply=message.reply_text,
|
||||
)
|
||||
|
||||
def test_ai_is_registered_as_shared(self) -> None:
|
||||
definition = command_registry.resolve("ai")
|
||||
|
||||
self.assertIsNotNone(definition)
|
||||
self.assertTrue(definition.is_shared)
|
||||
self.assertEqual(definition.guest_prefix, "..")
|
||||
|
||||
async def test_success_replies_with_agent_answer(self) -> None:
|
||||
ctx = self._ctx()
|
||||
handle = MagicMock()
|
||||
with patch(
|
||||
"app.commands.builtins.run_agent_turn",
|
||||
AsyncMock(return_value=SimpleNamespace(answer="answer", handle=handle)),
|
||||
):
|
||||
await ai(ctx)
|
||||
|
||||
ctx.reply.assert_awaited_once_with("answer")
|
||||
ctx.account.ai_sessions.register_response.assert_called_once_with(
|
||||
handle,
|
||||
ctx.reply.return_value,
|
||||
)
|
||||
ctx.message.react.assert_not_awaited()
|
||||
|
||||
async def test_invalid_syntax_only_dislikes(self) -> None:
|
||||
ctx = self._ctx(".ds .th")
|
||||
|
||||
with self.assertLogs("app.commands.builtins", level="ERROR"):
|
||||
await ai(ctx)
|
||||
|
||||
ctx.message.react.assert_awaited_once_with("👎")
|
||||
ctx.reply.assert_not_awaited()
|
||||
|
||||
async def test_agent_error_only_dislikes(self) -> None:
|
||||
ctx = self._ctx()
|
||||
with patch(
|
||||
"app.commands.builtins.run_agent_turn",
|
||||
AsyncMock(side_effect=RuntimeError("API failed")),
|
||||
):
|
||||
with self.assertLogs("app.commands.builtins", level="ERROR"):
|
||||
await ai(ctx)
|
||||
|
||||
ctx.message.react.assert_awaited_once_with("👎")
|
||||
ctx.reply.assert_not_awaited()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,92 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
|
||||
from app.ai.service import AIService
|
||||
from app.commands import CommandRegistry
|
||||
from app.config import AIConfig, AIModelConfig, AIPromptConfig, AIProviderConfig
|
||||
|
||||
|
||||
def _tool_response(call_id: str) -> SimpleNamespace:
|
||||
call = SimpleNamespace(
|
||||
id=call_id,
|
||||
function=SimpleNamespace(
|
||||
name="temporary_command_create",
|
||||
arguments=json.dumps({"name": "test", "response": "ok"}),
|
||||
),
|
||||
)
|
||||
return SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
finish_reason="tool_calls",
|
||||
message=SimpleNamespace(content=None, tool_calls=[call]),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class _FakeCompletions:
|
||||
def __init__(self, responses: list[SimpleNamespace]) -> None:
|
||||
self.responses = responses
|
||||
self.requests: list[dict] = []
|
||||
|
||||
async def create(self, **kwargs: object) -> SimpleNamespace:
|
||||
self.requests.append(dict(kwargs))
|
||||
return self.responses.pop(0)
|
||||
|
||||
|
||||
class AIToolLoopTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_suppresses_duplicate_tool_call_and_returns_final_answer(self) -> None:
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
config = AIConfig(
|
||||
default_model="test",
|
||||
context_messages=0,
|
||||
prompt=AIPromptConfig(root=root),
|
||||
providers={"provider": AIProviderConfig("provider", "key", "https://example.test")},
|
||||
models={"test": AIModelConfig("test", "provider", "model")},
|
||||
workspace_dir=root / "workspace",
|
||||
)
|
||||
registry = CommandRegistry()
|
||||
service = AIService(config, registry)
|
||||
completions = _FakeCompletions(
|
||||
[
|
||||
_tool_response("one"),
|
||||
_tool_response("two"),
|
||||
SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
finish_reason="stop",
|
||||
message=SimpleNamespace(content="Created .test", tool_calls=[]),
|
||||
)
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
service._clients["provider"] = SimpleNamespace(
|
||||
chat=SimpleNamespace(completions=completions)
|
||||
)
|
||||
ctx = SimpleNamespace(
|
||||
is_owner=True,
|
||||
actor=None,
|
||||
message=SimpleNamespace(chat=SimpleNamespace(id=1), id=1, text=".ai test"),
|
||||
args="test",
|
||||
replied_message=None,
|
||||
replied_user=None,
|
||||
)
|
||||
|
||||
response = await service.generate(ctx, SimpleNamespace(context_messages=0, model_flags=[], prompt="create test"))
|
||||
service.close()
|
||||
|
||||
self.assertEqual(response.text, "Created .test")
|
||||
self.assertIsNotNone(registry.resolve("test"))
|
||||
self.assertEqual(len(completions.requests), 3)
|
||||
self.assertIn("already been performed", completions.requests[2]["messages"][-1]["content"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,22 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
import unittest
|
||||
|
||||
from app.ai.transcript import AITranscriptLogger
|
||||
|
||||
|
||||
class AITranscriptLoggerTests(unittest.TestCase):
|
||||
def test_writes_jsonl_events(self) -> None:
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
logger = AITranscriptLogger(Path(temp_dir))
|
||||
logger.record("request", payload={"model": "test", "messages": []})
|
||||
logger.record("response", payload={"choices": [{"message": {"content": "ok"}}]})
|
||||
|
||||
events = [json.loads(line) for line in logger.path.read_text(encoding="utf-8").splitlines()]
|
||||
logger.close()
|
||||
|
||||
self.assertEqual([event["event"] for event in events], ["request", "response"])
|
||||
self.assertEqual(events[1]["payload"]["choices"][0]["message"]["content"], "ok")
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.config import _parse_github_webhook_config
|
||||
from app.config import _parse_ai_config, _parse_elevenlabs_config, _parse_github_webhook_config
|
||||
|
||||
|
||||
class ConfigGitHubWebhookTests(unittest.TestCase):
|
||||
@@ -58,5 +58,68 @@ class ConfigGitHubWebhookTests(unittest.TestCase):
|
||||
self.assertIsNone(config.secret)
|
||||
|
||||
|
||||
class ConfigElevenLabsTests(unittest.TestCase):
|
||||
def test_elevenlabs_config_parses_values(self) -> None:
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
config = _parse_elevenlabs_config(
|
||||
{
|
||||
"api_key": "config-key",
|
||||
"model_id": "scribe_v2",
|
||||
"language_code": "ru",
|
||||
"request_timeout_seconds": 120,
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(config.api_key, "config-key")
|
||||
self.assertEqual(config.model_id, "scribe_v2")
|
||||
self.assertEqual(config.language_code, "ru")
|
||||
self.assertEqual(config.request_timeout_seconds, 120)
|
||||
|
||||
def test_elevenlabs_env_overrides(self) -> None:
|
||||
env = {
|
||||
"ELEVENLABS_API_KEY": "env-key",
|
||||
"ELEVENLABS_MODEL_ID": "scribe_v2",
|
||||
"ELEVENLABS_LANGUAGE_CODE": "en",
|
||||
"ELEVENLABS_REQUEST_TIMEOUT_SECONDS": "45",
|
||||
}
|
||||
with patch.dict("os.environ", env, clear=True):
|
||||
config = _parse_elevenlabs_config({"api_key": "config-key"})
|
||||
|
||||
self.assertEqual(config.api_key, "env-key")
|
||||
self.assertEqual(config.language_code, "en")
|
||||
self.assertEqual(config.request_timeout_seconds, 45)
|
||||
|
||||
|
||||
class ConfigAiTests(unittest.TestCase):
|
||||
def test_ai_config_parses_both_providers(self) -> None:
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
config = _parse_ai_config(
|
||||
{
|
||||
"glm": {"api_key": "glm-key", "model": "glm-model"},
|
||||
"deepseek": {"api_key": "ds-key", "model": "ds-model"},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(config.glm.api_key, "glm-key")
|
||||
self.assertEqual(config.glm.model, "glm-model")
|
||||
self.assertEqual(config.deepseek.api_key, "ds-key")
|
||||
self.assertEqual(config.deepseek.model, "ds-model")
|
||||
|
||||
def test_ai_provider_env_overrides(self) -> None:
|
||||
env = {
|
||||
"GLM_API_KEY": "env-glm",
|
||||
"GLM_MODEL": "env-glm-model",
|
||||
"DEEPSEEK_API_KEY": "env-ds",
|
||||
"DEEPSEEK_MODEL": "env-ds-model",
|
||||
}
|
||||
with patch.dict("os.environ", env, clear=True):
|
||||
config = _parse_ai_config({})
|
||||
|
||||
self.assertEqual(config.glm.api_key, "env-glm")
|
||||
self.assertEqual(config.glm.model, "env-glm-model")
|
||||
self.assertEqual(config.deepseek.api_key, "env-ds")
|
||||
self.assertEqual(config.deepseek.model, "env-ds-model")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
44
tests/test_config_toml.py
Normal file
44
tests/test_config_toml.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.config import load_app_config
|
||||
|
||||
|
||||
class TomlConfigTests(unittest.TestCase):
|
||||
def test_loads_default_toml_config(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
(root / "config.toml").write_text(
|
||||
"""
|
||||
api_id = 123456
|
||||
api_hash = "hash"
|
||||
session_dir = "sessions"
|
||||
|
||||
[[accounts]]
|
||||
name = "personal"
|
||||
session = "personal"
|
||||
|
||||
[elevenlabs]
|
||||
api_key = "test-key"
|
||||
model_id = "scribe_v2"
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
config = load_app_config(root)
|
||||
|
||||
self.assertEqual(config.api_id, 123456)
|
||||
self.assertEqual(config.accounts[0].session, "personal")
|
||||
self.assertEqual(config.elevenlabs.api_key, "test-key")
|
||||
self.assertEqual(config.session_dir, root / "sessions")
|
||||
self.assertEqual(config.ai.prompt_dir, root / "prompts")
|
||||
self.assertEqual(config.ai.base_dir, root)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,104 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.config import _parse_ai_config
|
||||
|
||||
|
||||
def _raw_ai_config() -> dict:
|
||||
return {
|
||||
"default_model": "test",
|
||||
"context_messages": 1,
|
||||
"prompt": {"root": "prompts/ai"},
|
||||
"providers": {
|
||||
"test": {
|
||||
"api_key": "model-key",
|
||||
"base_url": "https://example.test/v1",
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
"test": {
|
||||
"provider": "test",
|
||||
"model": "test-model",
|
||||
}
|
||||
},
|
||||
"web_search": {
|
||||
"api_key": "config-key",
|
||||
"count": 99,
|
||||
"safesearch": "strict",
|
||||
},
|
||||
"page_extract": {
|
||||
"max_chars": 5000,
|
||||
"render_dynamic": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class ConfigWebSearchTests(unittest.TestCase):
|
||||
def test_web_search_config_clamps_count(self) -> None:
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
config = _parse_ai_config(_raw_ai_config(), root=Path.cwd())
|
||||
|
||||
assert config is not None
|
||||
self.assertEqual(config.web_search.api_key, "config-key")
|
||||
self.assertEqual(config.web_search.count, 20)
|
||||
self.assertEqual(config.web_search.safesearch, "strict")
|
||||
self.assertEqual(config.page_extract.max_chars, 5000)
|
||||
self.assertFalse(config.page_extract.render_dynamic)
|
||||
|
||||
def test_web_search_env_overrides(self) -> None:
|
||||
env = {
|
||||
"BRAVE_API_KEY": "env-key",
|
||||
"BRAVE_SEARCH_COUNT": "3",
|
||||
"BRAVE_SEARCH_COUNTRY": "pl",
|
||||
"BRAVE_SEARCH_LANG": "pl",
|
||||
"BRAVE_SEARCH_UI_LANG": "pl-PL",
|
||||
"BRAVE_SEARCH_SAFESEARCH": "off",
|
||||
"PAGE_EXTRACT_MAX_CHARS": "9000",
|
||||
"PAGE_EXTRACT_RENDER_DYNAMIC": "true",
|
||||
}
|
||||
with patch.dict("os.environ", env, clear=True):
|
||||
config = _parse_ai_config(_raw_ai_config(), root=Path.cwd())
|
||||
|
||||
assert config is not None
|
||||
self.assertEqual(config.web_search.api_key, "env-key")
|
||||
self.assertEqual(config.web_search.count, 3)
|
||||
self.assertEqual(config.web_search.country, "PL")
|
||||
self.assertEqual(config.web_search.search_lang, "pl")
|
||||
self.assertEqual(config.web_search.ui_lang, "pl-PL")
|
||||
self.assertEqual(config.web_search.safesearch, "off")
|
||||
self.assertEqual(config.page_extract.max_chars, 9000)
|
||||
self.assertTrue(config.page_extract.render_dynamic)
|
||||
|
||||
def test_web_search_accepts_brave_search_api_key_env(self) -> None:
|
||||
env = {"BRAVE_SEARCH_API_KEY": "search-env-key"}
|
||||
with patch.dict("os.environ", env, clear=True):
|
||||
config = _parse_ai_config(_raw_ai_config(), root=Path.cwd())
|
||||
|
||||
assert config is not None
|
||||
self.assertEqual(config.web_search.api_key, "search-env-key")
|
||||
|
||||
def test_brave_api_key_takes_precedence_over_search_alias(self) -> None:
|
||||
env = {
|
||||
"BRAVE_API_KEY": "primary-key",
|
||||
"BRAVE_SEARCH_API_KEY": "alias-key",
|
||||
}
|
||||
with patch.dict("os.environ", env, clear=True):
|
||||
config = _parse_ai_config(_raw_ai_config(), root=Path.cwd())
|
||||
|
||||
assert config is not None
|
||||
self.assertEqual(config.web_search.api_key, "primary-key")
|
||||
|
||||
def test_web_search_strips_bearer_prefix(self) -> None:
|
||||
env = {"BRAVE_SEARCH_API_KEY": " Bearer token-value "}
|
||||
with patch.dict("os.environ", env, clear=True):
|
||||
config = _parse_ai_config(_raw_ai_config(), root=Path.cwd())
|
||||
|
||||
assert config is not None
|
||||
self.assertEqual(config.web_search.api_key, "token-value")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,45 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from app.ai.page_extract import extract_readable_text, normalize_url, validate_public_url
|
||||
|
||||
|
||||
class PageExtractTests(unittest.TestCase):
|
||||
def test_extract_readable_text_skips_scripts_and_styles(self) -> None:
|
||||
title, text = extract_readable_text(
|
||||
"""
|
||||
<html>
|
||||
<head>
|
||||
<title>Example Page</title>
|
||||
<style>.hidden { display: none; }</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Hello</h1>
|
||||
<script>alert('nope')</script>
|
||||
<p>Readable text.</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(title, "Example Page")
|
||||
self.assertIn("Hello", text)
|
||||
self.assertIn("Readable text.", text)
|
||||
self.assertNotIn("alert", text)
|
||||
self.assertNotIn("display", text)
|
||||
|
||||
def test_normalize_url_rejects_non_web_schemes(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "http and https"):
|
||||
normalize_url("file:///etc/passwd")
|
||||
|
||||
def test_validate_public_url_blocks_localhost(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "blocked"):
|
||||
validate_public_url("http://127.0.0.1:8000", block_private_hosts=True)
|
||||
|
||||
def test_validate_public_url_can_allow_private_hosts(self) -> None:
|
||||
validate_public_url("http://127.0.0.1:8000", block_private_hosts=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,47 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
import unittest
|
||||
|
||||
from app.ai.prompts import PromptBook
|
||||
|
||||
|
||||
class PromptBookTests(unittest.TestCase):
|
||||
def test_renders_builtin_time_placeholders(self) -> None:
|
||||
prompt_book = PromptBook(Path("."))
|
||||
rendered = prompt_book._render_template(
|
||||
(
|
||||
"{{current_date}} {{current_time}} {{current_datetime}} "
|
||||
"{{current_weekday}} {{timezone}} {{utc_date}} {{utc_time}} "
|
||||
"{{utc_datetime}} {{unix_timestamp}}"
|
||||
),
|
||||
now=datetime(2026, 5, 19, 12, 34, 56, tzinfo=UTC),
|
||||
)
|
||||
|
||||
self.assertIn("2026-05-19", rendered)
|
||||
self.assertIn("2026-05-19T12:34:56+00:00", rendered)
|
||||
self.assertIn("Tuesday", rendered)
|
||||
self.assertIn("1779194096", rendered)
|
||||
|
||||
def test_unknown_placeholders_are_left_unchanged(self) -> None:
|
||||
prompt_book = PromptBook(Path("."))
|
||||
|
||||
self.assertEqual(
|
||||
prompt_book._render_template("Hello {{unknown_value}}"),
|
||||
"Hello {{unknown_value}}",
|
||||
)
|
||||
|
||||
def test_load_system_prompt_renders_files(self) -> None:
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
(root / "system.md").write_text("Today is {{current_date}}", encoding="utf-8")
|
||||
|
||||
rendered = PromptBook(root).load_system_prompt()
|
||||
|
||||
self.assertNotIn("{{current_date}}", rendered)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,31 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from app.commands import CommandRegistry, TemporaryCommandStore
|
||||
|
||||
|
||||
class TemporaryCommandStoreTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_created_command_is_owner_only_and_replies_from_memory(self) -> None:
|
||||
registry = CommandRegistry()
|
||||
store = TemporaryCommandStore(registry)
|
||||
|
||||
message = store.create("test-command", "Temporary response", "Test command")
|
||||
command = registry.resolve("test-command")
|
||||
|
||||
self.assertIn(".test-command", message)
|
||||
self.assertIsNotNone(command)
|
||||
self.assertFalse(command.is_shared)
|
||||
ctx = SimpleNamespace(reply=AsyncMock())
|
||||
await command.handler(ctx)
|
||||
ctx.reply.assert_awaited_once_with("Temporary response")
|
||||
|
||||
def test_rejects_existing_command_name(self) -> None:
|
||||
registry = CommandRegistry()
|
||||
store = TemporaryCommandStore(registry)
|
||||
store.create("test", "First")
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "already exists"):
|
||||
store.create("test", "Second")
|
||||
94
tests/test_txt_command.py
Normal file
94
tests/test_txt_command.py
Normal file
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from pyrogram.enums import MessageEntityType
|
||||
|
||||
from app.commands.builtins import command_registry, txt
|
||||
from app.transcription import TranscriptionError
|
||||
|
||||
|
||||
class TxtCommandTests(unittest.IsolatedAsyncioTestCase):
|
||||
def test_txt_is_registered_as_shared(self) -> None:
|
||||
definition = command_registry.resolve("txt")
|
||||
|
||||
self.assertIsNotNone(definition)
|
||||
self.assertTrue(definition.is_shared)
|
||||
self.assertEqual(definition.guest_prefix, "..")
|
||||
|
||||
def _ctx(self, *, reply=None, args: str = ""):
|
||||
message = SimpleNamespace(
|
||||
id=123,
|
||||
reply_to_message=reply,
|
||||
react=AsyncMock(),
|
||||
reply_text=AsyncMock(),
|
||||
)
|
||||
return SimpleNamespace(
|
||||
args=args,
|
||||
message=message,
|
||||
replied_message=reply,
|
||||
client=SimpleNamespace(download_media=AsyncMock(return_value="downloaded.ogg")),
|
||||
manager=SimpleNamespace(
|
||||
config=SimpleNamespace(elevenlabs=SimpleNamespace())
|
||||
),
|
||||
reply=message.reply_text,
|
||||
)
|
||||
|
||||
async def test_invalid_usage_only_reacts_with_dislike(self) -> None:
|
||||
ctx = self._ctx()
|
||||
|
||||
await txt(ctx)
|
||||
|
||||
ctx.message.react.assert_awaited_once_with("👎")
|
||||
ctx.reply.assert_not_awaited()
|
||||
ctx.client.download_media.assert_not_awaited()
|
||||
|
||||
async def test_api_error_only_reacts_with_dislike(self) -> None:
|
||||
media = SimpleNamespace(mime_type="audio/ogg")
|
||||
reply = SimpleNamespace(voice=media, video_note=None, video=None)
|
||||
ctx = self._ctx(reply=reply)
|
||||
|
||||
with patch(
|
||||
"app.commands.builtins.transcribe_file",
|
||||
AsyncMock(side_effect=TranscriptionError("API failed")),
|
||||
):
|
||||
with self.assertLogs("app.commands.builtins", level="ERROR"):
|
||||
await txt(ctx)
|
||||
|
||||
ctx.message.react.assert_awaited_once_with("👎")
|
||||
ctx.reply.assert_not_awaited()
|
||||
|
||||
async def test_success_replies_with_expandable_blockquote(self) -> None:
|
||||
media = SimpleNamespace(mime_type="video/mp4")
|
||||
reply = SimpleNamespace(voice=None, video_note=media, video=None)
|
||||
ctx = self._ctx(reply=reply)
|
||||
|
||||
with patch(
|
||||
"app.commands.builtins.transcribe_file",
|
||||
AsyncMock(return_value="Расшифрованный текст"),
|
||||
) as transcribe:
|
||||
await txt(ctx)
|
||||
|
||||
ctx.message.react.assert_not_awaited()
|
||||
ctx.reply.assert_awaited_once()
|
||||
self.assertEqual(ctx.reply.await_args.args[0], "Расшифрованный текст")
|
||||
entity = ctx.reply.await_args.kwargs["entities"][0]
|
||||
self.assertEqual(entity.type, MessageEntityType.BLOCKQUOTE)
|
||||
self.assertTrue(entity.expandable)
|
||||
transcribe.assert_awaited_once()
|
||||
|
||||
async def test_arguments_make_otherwise_valid_command_invalid(self) -> None:
|
||||
media = SimpleNamespace(mime_type="video/mp4")
|
||||
reply = SimpleNamespace(voice=None, video_note=None, video=media)
|
||||
ctx = self._ctx(reply=reply, args="unexpected")
|
||||
|
||||
await txt(ctx)
|
||||
|
||||
ctx.message.react.assert_awaited_once_with("👎")
|
||||
ctx.reply.assert_not_awaited()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,199 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
import httpx
|
||||
|
||||
from app.ai.web_search import (
|
||||
_format_error_response,
|
||||
BraveWebSearchClient,
|
||||
build_query,
|
||||
format_results,
|
||||
parse_response,
|
||||
)
|
||||
from app.config import AIWebSearchConfig
|
||||
|
||||
|
||||
class WebSearchTests(unittest.IsolatedAsyncioTestCase):
|
||||
def test_build_query_clamps_and_defaults(self) -> None:
|
||||
config = AIWebSearchConfig(api_key="key", count=5)
|
||||
|
||||
query = build_query(
|
||||
config,
|
||||
{
|
||||
"query": " python web framework ",
|
||||
"count": 99,
|
||||
"offset": -1,
|
||||
"freshness": "pw",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(query.query, "python web framework")
|
||||
self.assertEqual(query.count, 20)
|
||||
self.assertEqual(query.offset, 0)
|
||||
self.assertEqual(query.freshness, "pw")
|
||||
self.assertEqual(query.safesearch, "moderate")
|
||||
|
||||
def test_build_query_rejects_invalid_freshness(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "freshness"):
|
||||
build_query(AIWebSearchConfig(), {"query": "news", "freshness": "yesterday"})
|
||||
|
||||
def test_build_query_normalizes_unsupported_brave_country_and_ui_lang(self) -> None:
|
||||
config = AIWebSearchConfig(
|
||||
api_key="key",
|
||||
country="UA",
|
||||
search_lang="ru",
|
||||
ui_lang="ru-UA",
|
||||
)
|
||||
|
||||
query = build_query(config, {"query": "crimea news"})
|
||||
|
||||
self.assertEqual(query.country, "ALL")
|
||||
self.assertEqual(query.search_lang, "ru")
|
||||
self.assertEqual(query.ui_lang, "ru-RU")
|
||||
|
||||
def test_build_query_falls_back_unknown_ui_lang_to_english(self) -> None:
|
||||
config = AIWebSearchConfig(api_key="key", ui_lang="zz-ZZ")
|
||||
|
||||
query = build_query(config, {"query": "python"})
|
||||
|
||||
self.assertEqual(query.ui_lang, "en-US")
|
||||
|
||||
def test_parse_and_format_results(self) -> None:
|
||||
response = parse_response(
|
||||
"openai",
|
||||
{
|
||||
"query": {"altered": "open ai", "more_results_available": True},
|
||||
"web": {
|
||||
"results": [
|
||||
{
|
||||
"title": "OpenAI",
|
||||
"url": "https://openai.com",
|
||||
"description": "AI research and products.",
|
||||
"age": "May 18, 2026",
|
||||
"extra_snippets": ["Models and APIs.", ""],
|
||||
},
|
||||
{"title": "", "url": ""},
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.results), 1)
|
||||
self.assertTrue(response.more_results_available)
|
||||
|
||||
formatted = format_results(response)
|
||||
self.assertIn("Search results for: openai", formatted)
|
||||
self.assertIn("https://openai.com", formatted)
|
||||
self.assertIn("Models and APIs.", formatted)
|
||||
|
||||
def test_format_empty_results(self) -> None:
|
||||
formatted = format_results(parse_response("nothing", {"web": {"results": []}}))
|
||||
self.assertIn("No results found.", formatted)
|
||||
|
||||
def test_format_error_response_uses_brave_detail(self) -> None:
|
||||
request = httpx.Request("GET", "https://api.search.brave.com/res/v1/web/search")
|
||||
response = httpx.Response(
|
||||
429,
|
||||
request=request,
|
||||
json={"error": {"detail": "Rate limit exceeded"}},
|
||||
)
|
||||
|
||||
self.assertIn("Rate limit exceeded", _format_error_response(response))
|
||||
|
||||
async def test_search_sends_subscription_token_header(self) -> None:
|
||||
seen_headers: httpx.Headers | None = None
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal seen_headers
|
||||
seen_headers = request.headers
|
||||
return httpx.Response(
|
||||
200,
|
||||
request=request,
|
||||
json={
|
||||
"query": {"original": "python"},
|
||||
"web": {"results": []},
|
||||
},
|
||||
)
|
||||
|
||||
client = BraveWebSearchClient(
|
||||
AIWebSearchConfig(api_key="test-key"),
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
|
||||
await client.search({"query": "python"})
|
||||
|
||||
assert seen_headers is not None
|
||||
self.assertEqual(seen_headers["x-subscription-token"], "test-key")
|
||||
|
||||
async def test_search_logs_success_response_summary(self) -> None:
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
request=request,
|
||||
json={
|
||||
"query": {"original": "python"},
|
||||
"web": {
|
||||
"results": [
|
||||
{
|
||||
"title": "Python",
|
||||
"url": "https://www.python.org",
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
client = BraveWebSearchClient(
|
||||
AIWebSearchConfig(api_key="test-key"),
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
|
||||
with self.assertLogs("app.ai.web_search", level="INFO") as captured:
|
||||
await client.search({"query": "python"})
|
||||
|
||||
logs = "\n".join(captured.output)
|
||||
self.assertIn("Brave web search response status=200", logs)
|
||||
self.assertIn("results=1", logs)
|
||||
|
||||
async def test_search_logs_error_response_body(self) -> None:
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
422,
|
||||
request=request,
|
||||
json={
|
||||
"error": {
|
||||
"code": "VALIDATION",
|
||||
"detail": "Unable to validate request parameter(s)",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
client = BraveWebSearchClient(
|
||||
AIWebSearchConfig(api_key="test-key"),
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
|
||||
with self.assertLogs("app.ai.web_search", level="WARNING") as captured:
|
||||
with self.assertRaisesRegex(ValueError, "Unable to validate"):
|
||||
await client.search({"query": "python"})
|
||||
|
||||
logs = "\n".join(captured.output)
|
||||
self.assertIn("Brave web search error status=422", logs)
|
||||
self.assertIn("VALIDATION", logs)
|
||||
|
||||
async def test_search_rejects_missing_subscription_token_before_request(self) -> None:
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
self.fail("request should not be sent without a subscription token")
|
||||
|
||||
client = BraveWebSearchClient(
|
||||
AIWebSearchConfig(api_key=""),
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "BRAVE_API_KEY or BRAVE_SEARCH_API_KEY"):
|
||||
await client.search({"query": "python"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user