diff --git a/tests_v4/test_capability_proxy.py b/tests_v4/test_capability_proxy.py new file mode 100644 index 000000000..67109ae4a --- /dev/null +++ b/tests_v4/test_capability_proxy.py @@ -0,0 +1,304 @@ +""" +Tests for clients/_proxy.py - CapabilityProxy implementation. +""" +from __future__ import annotations + +from dataclasses import dataclass +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from astrbot_sdk.clients._proxy import CapabilityProxy +from astrbot_sdk.errors import AstrBotError + + +@dataclass +class MockCapabilityDescriptor: + """Mock capability descriptor for testing.""" + name: str + supports_stream: bool | None = None + + +class MockPeer: + """Mock peer for testing CapabilityProxy.""" + + def __init__(self): + self.remote_capability_map: dict[str, MockCapabilityDescriptor] = {} + self.invoke = AsyncMock(return_value={"result": "ok"}) + self.invoke_stream = AsyncMock() + + +class TestCapabilityProxyInit: + """Tests for CapabilityProxy initialization.""" + + def test_init_with_peer(self): + """CapabilityProxy should store peer reference.""" + peer = MagicMock() + proxy = CapabilityProxy(peer) + assert proxy._peer is peer + + +class TestCapabilityProxyGetDescriptor: + """Tests for CapabilityProxy._get_descriptor() method.""" + + def test_get_descriptor_returns_descriptor(self): + """_get_descriptor should return descriptor if found.""" + peer = MagicMock() + peer.remote_capability_map = { + "db.get": MockCapabilityDescriptor(name="db.get") + } + proxy = CapabilityProxy(peer) + + result = proxy._get_descriptor("db.get") + assert result is not None + assert result.name == "db.get" + + def test_get_descriptor_returns_none_for_missing(self): + """_get_descriptor should return None if not found.""" + peer = MagicMock() + peer.remote_capability_map = {} + proxy = CapabilityProxy(peer) + + result = proxy._get_descriptor("nonexistent") + assert result is None + + def test_get_descriptor_with_empty_map(self): + """_get_descriptor should work with empty capability map.""" + peer = MagicMock() + peer.remote_capability_map = {} + proxy = CapabilityProxy(peer) + + result = proxy._get_descriptor("anything") + assert result is None + + +class TestCapabilityProxyEnsureAvailable: + """Tests for CapabilityProxy._ensure_available() method.""" + + def test_ensure_available_passes_when_descriptor_exists(self): + """_ensure_available should pass when descriptor exists.""" + peer = MagicMock() + peer.remote_capability_map = { + "test.cap": MockCapabilityDescriptor(name="test.cap") + } + proxy = CapabilityProxy(peer) + + # Should not raise + proxy._ensure_available("test.cap", stream=False) + + def test_ensure_available_raises_capability_not_found(self): + """_ensure_available should raise capability_not_found when missing.""" + peer = MagicMock() + peer.remote_capability_map = {"other.cap": MockCapabilityDescriptor(name="other.cap")} + proxy = CapabilityProxy(peer) + + with pytest.raises(AstrBotError) as exc_info: + proxy._ensure_available("missing.cap", stream=False) + + assert exc_info.value.code == "capability_not_found" + assert "missing.cap" in exc_info.value.message + + def test_ensure_available_passes_when_map_empty(self): + """_ensure_available should pass (return None) when capability map is empty.""" + peer = MagicMock() + peer.remote_capability_map = {} + proxy = CapabilityProxy(peer) + + # Should not raise when map is empty + proxy._ensure_available("any.cap", stream=False) + + def test_ensure_available_raises_for_stream_not_supported(self): + """_ensure_available should raise when stream requested but not supported.""" + peer = MagicMock() + peer.remote_capability_map = { + "test.cap": MockCapabilityDescriptor(name="test.cap", supports_stream=False) + } + proxy = CapabilityProxy(peer) + + with pytest.raises(AstrBotError) as exc_info: + proxy._ensure_available("test.cap", stream=True) + + assert exc_info.value.code == "invalid_input" + assert "不支持 stream=true" in exc_info.value.message + + def test_ensure_available_passes_for_stream_supported(self): + """_ensure_available should pass when stream is supported.""" + peer = MagicMock() + peer.remote_capability_map = { + "test.cap": MockCapabilityDescriptor(name="test.cap", supports_stream=True) + } + proxy = CapabilityProxy(peer) + + # Should not raise + proxy._ensure_available("test.cap", stream=True) + + def test_ensure_available_handles_none_supports_stream(self): + """_ensure_available should treat None supports_stream as not supporting stream.""" + peer = MagicMock() + peer.remote_capability_map = { + "test.cap": MockCapabilityDescriptor(name="test.cap", supports_stream=None) + } + proxy = CapabilityProxy(peer) + + # Should not raise for non-stream + proxy._ensure_available("test.cap", stream=False) + + # Should raise for stream=True when supports_stream is None + with pytest.raises(AstrBotError) as exc_info: + proxy._ensure_available("test.cap", stream=True) + assert exc_info.value.code == "invalid_input" + + +class TestCapabilityProxyCall: + """Tests for CapabilityProxy.call() method.""" + + @pytest.mark.asyncio + async def test_call_invokes_peer(self): + """call() should invoke peer with correct parameters.""" + peer = MockPeer() + peer.remote_capability_map = { + "db.get": MockCapabilityDescriptor(name="db.get") + } + proxy = CapabilityProxy(peer) + + result = await proxy.call("db.get", {"key": "test"}) + + peer.invoke.assert_called_once_with("db.get", {"key": "test"}, stream=False) + assert result == {"result": "ok"} + + @pytest.mark.asyncio + async def test_call_without_capability_map(self): + """call() should work when capability map is empty.""" + peer = MockPeer() + peer.remote_capability_map = {} + proxy = CapabilityProxy(peer) + + result = await proxy.call("any.cap", {}) + + peer.invoke.assert_called_once_with("any.cap", {}, stream=False) + assert result == {"result": "ok"} + + @pytest.mark.asyncio + async def test_call_raises_for_missing_capability(self): + """call() should raise for missing capability when map is not empty.""" + peer = MockPeer() + peer.remote_capability_map = { + "other.cap": MockCapabilityDescriptor(name="other.cap") + } + proxy = CapabilityProxy(peer) + + with pytest.raises(AstrBotError) as exc_info: + await proxy.call("missing.cap", {}) + + assert exc_info.value.code == "capability_not_found" + + +class MockAsyncIterator: + """Mock async iterator for testing stream responses.""" + + def __init__(self, items): + self._items = list(items) + self._index = 0 + + def __aiter__(self): + return self + + async def __anext__(self): + if self._index >= len(self._items): + raise StopAsyncIteration + item = self._items[self._index] + self._index += 1 + return item + + +@dataclass +class MockEvent: + """Mock stream event for testing.""" + phase: str + data: dict + + +class TestCapabilityProxyStream: + """Tests for CapabilityProxy.stream() method.""" + + @pytest.mark.asyncio + async def test_stream_yields_delta_data(self): + """stream() should yield data from delta events.""" + peer = MockPeer() + + # invoke_stream is an async method that returns AsyncIterator + events = [ + MockEvent(phase="delta", data={"text": "chunk1"}), + MockEvent(phase="delta", data={"text": "chunk2"}), + MockEvent(phase="complete", data={"done": True}), + ] + peer.invoke_stream = AsyncMock(return_value=MockAsyncIterator(events)) + peer.remote_capability_map = { + "llm.stream": MockCapabilityDescriptor(name="llm.stream", supports_stream=True) + } + proxy = CapabilityProxy(peer) + + chunks = [] + async for data in proxy.stream("llm.stream", {"prompt": "hi"}): + chunks.append(data) + + assert len(chunks) == 2 + assert chunks[0] == {"text": "chunk1"} + assert chunks[1] == {"text": "chunk2"} + + @pytest.mark.asyncio + async def test_stream_filters_non_delta_events(self): + """stream() should only yield delta events.""" + peer = MockPeer() + + events = [ + MockEvent(phase="start", data={"session": "abc"}), + MockEvent(phase="delta", data={"text": "hello"}), + MockEvent(phase="complete", data={}), + MockEvent(phase="delta", data={"text": "world"}), + ] + peer.invoke_stream = AsyncMock(return_value=MockAsyncIterator(events)) + peer.remote_capability_map = { + "test.stream": MockCapabilityDescriptor(name="test.stream", supports_stream=True) + } + proxy = CapabilityProxy(peer) + + chunks = [] + async for data in proxy.stream("test.stream", {}): + chunks.append(data) + + # Only delta events should be yielded + assert len(chunks) == 2 + assert chunks[0] == {"text": "hello"} + assert chunks[1] == {"text": "world"} + + @pytest.mark.asyncio + async def test_stream_raises_for_non_streaming_capability(self): + """stream() should raise when capability doesn't support streaming.""" + peer = MockPeer() + peer.remote_capability_map = { + "db.get": MockCapabilityDescriptor(name="db.get", supports_stream=False) + } + proxy = CapabilityProxy(peer) + + with pytest.raises(AstrBotError) as exc_info: + async for _ in proxy.stream("db.get", {}): + pass + + assert exc_info.value.code == "invalid_input" + + @pytest.mark.asyncio + async def test_stream_works_without_capability_map(self): + """stream() should work when capability map is empty.""" + peer = MockPeer() + + events = [MockEvent(phase="delta", data={"text": "ok"})] + peer.invoke_stream = AsyncMock(return_value=MockAsyncIterator(events)) + peer.remote_capability_map = {} + proxy = CapabilityProxy(peer) + + chunks = [] + async for data in proxy.stream("any.stream", {}): + chunks.append(data) + + assert chunks == [{"text": "ok"}] diff --git a/tests_v4/test_clients_module.py b/tests_v4/test_clients_module.py new file mode 100644 index 000000000..539dfbb70 --- /dev/null +++ b/tests_v4/test_clients_module.py @@ -0,0 +1,69 @@ +""" +Tests for clients/__init__.py - Module exports. +""" +from __future__ import annotations + +import pytest + + +class TestClientsModuleExports: + """Tests for clients module exports.""" + + def test_exports_db_client(self): + """clients module should export DBClient.""" + from astrbot_sdk.clients import DBClient + + assert DBClient is not None + + def test_exports_llm_client(self): + """clients module should export LLMClient.""" + from astrbot_sdk.clients import LLMClient + + assert LLMClient is not None + + def test_exports_llm_response(self): + """clients module should export LLMResponse.""" + from astrbot_sdk.clients import LLMResponse + + assert LLMResponse is not None + + def test_exports_chat_message(self): + """clients module should export ChatMessage.""" + from astrbot_sdk.clients import ChatMessage + + assert ChatMessage is not None + + def test_exports_memory_client(self): + """clients module should export MemoryClient.""" + from astrbot_sdk.clients import MemoryClient + + assert MemoryClient is not None + + def test_exports_platform_client(self): + """clients module should export PlatformClient.""" + from astrbot_sdk.clients import PlatformClient + + assert PlatformClient is not None + + def test_all_exports_defined(self): + """__all__ should contain all expected exports.""" + from astrbot_sdk.clients import __all__ + + assert "DBClient" in __all__ + assert "LLMClient" in __all__ + assert "LLMResponse" in __all__ + assert "ChatMessage" in __all__ + assert "MemoryClient" in __all__ + assert "PlatformClient" in __all__ + + def test_does_not_export_capability_proxy(self): + """CapabilityProxy should not be in public exports.""" + from astrbot_sdk.clients import __all__ + + assert "CapabilityProxy" not in __all__ + + def test_capability_proxy_importable_from_private(self): + """CapabilityProxy should be importable from _proxy.""" + from astrbot_sdk.clients._proxy import CapabilityProxy + + assert CapabilityProxy is not None diff --git a/tests_v4/test_db_client.py b/tests_v4/test_db_client.py new file mode 100644 index 000000000..1dd32f4a1 --- /dev/null +++ b/tests_v4/test_db_client.py @@ -0,0 +1,213 @@ +""" +Tests for clients/db.py - DBClient implementation. +""" +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from astrbot_sdk.clients.db import DBClient +from astrbot_sdk.clients._proxy import CapabilityProxy + + +class TestDBClientInit: + """Tests for DBClient initialization.""" + + def test_init_with_proxy(self): + """DBClient should store proxy reference.""" + proxy = MagicMock(spec=CapabilityProxy) + client = DBClient(proxy) + assert client._proxy is proxy + + +class TestDBClientGet: + """Tests for DBClient.get() method.""" + + @pytest.mark.asyncio + async def test_get_returns_dict_value(self): + """get() should return dict value from proxy response.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={"value": {"data": "test"}}) + + client = DBClient(proxy) + result = await client.get("my_key") + + proxy.call.assert_called_once_with("db.get", {"key": "my_key"}) + assert result == {"data": "test"} + + @pytest.mark.asyncio + async def test_get_returns_none_for_missing_key(self): + """get() should return None when value is not found.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={"value": None}) + + client = DBClient(proxy) + result = await client.get("missing_key") + + assert result is None + + @pytest.mark.asyncio + async def test_get_returns_none_for_non_dict_value(self): + """get() should return None when value is not a dict.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={"value": "not a dict"}) + + client = DBClient(proxy) + result = await client.get("my_key") + + assert result is None + + @pytest.mark.asyncio + async def test_get_returns_none_when_value_key_missing(self): + """get() should return None when 'value' key is missing in response.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={}) + + client = DBClient(proxy) + result = await client.get("my_key") + + assert result is None + + @pytest.mark.asyncio + async def test_get_with_empty_key(self): + """get() should work with empty string key.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={"value": {"empty": True}}) + + client = DBClient(proxy) + result = await client.get("") + + proxy.call.assert_called_once_with("db.get", {"key": ""}) + assert result == {"empty": True} + + +class TestDBClientSet: + """Tests for DBClient.set() method.""" + + @pytest.mark.asyncio + async def test_set_calls_proxy_with_key_and_value(self): + """set() should call proxy with correct parameters.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={}) + + client = DBClient(proxy) + await client.set("test_key", {"name": "value"}) + + proxy.call.assert_called_once_with( + "db.set", + {"key": "test_key", "value": {"name": "value"}}, + ) + + @pytest.mark.asyncio + async def test_set_with_empty_dict(self): + """set() should work with empty dict value.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={}) + + client = DBClient(proxy) + await client.set("empty", {}) + + proxy.call.assert_called_once_with("db.set", {"key": "empty", "value": {}}) + + @pytest.mark.asyncio + async def test_set_with_nested_dict(self): + """set() should work with nested dict.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={}) + + client = DBClient(proxy) + await client.set("nested", {"level1": {"level2": {"level3": "deep"}}}) + + proxy.call.assert_called_once_with( + "db.set", + {"key": "nested", "value": {"level1": {"level2": {"level3": "deep"}}}}, + ) + + +class TestDBClientDelete: + """Tests for DBClient.delete() method.""" + + @pytest.mark.asyncio + async def test_delete_calls_proxy_with_key(self): + """delete() should call proxy with correct key.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={}) + + client = DBClient(proxy) + await client.delete("to_delete") + + proxy.call.assert_called_once_with("db.delete", {"key": "to_delete"}) + + @pytest.mark.asyncio + async def test_delete_with_empty_key(self): + """delete() should work with empty string key.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={}) + + client = DBClient(proxy) + await client.delete("") + + proxy.call.assert_called_once_with("db.delete", {"key": ""}) + + +class TestDBClientList: + """Tests for DBClient.list() method.""" + + @pytest.mark.asyncio + async def test_list_returns_keys(self): + """list() should return list of keys.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={"keys": ["key1", "key2", "key3"]}) + + client = DBClient(proxy) + result = await client.list() + + proxy.call.assert_called_once_with("db.list", {"prefix": None}) + assert result == ["key1", "key2", "key3"] + + @pytest.mark.asyncio + async def test_list_with_prefix(self): + """list() should pass prefix parameter.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={"keys": ["user:1", "user:2"]}) + + client = DBClient(proxy) + result = await client.list(prefix="user:") + + proxy.call.assert_called_once_with("db.list", {"prefix": "user:"}) + assert result == ["user:1", "user:2"] + + @pytest.mark.asyncio + async def test_list_returns_empty_list_when_no_keys(self): + """list() should return empty list when no keys found.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={}) + + client = DBClient(proxy) + result = await client.list() + + assert result == [] + + @pytest.mark.asyncio + async def test_list_converts_non_string_items(self): + """list() should convert non-string items to string.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={"keys": [123, 456]}) + + client = DBClient(proxy) + result = await client.list() + + assert result == ["123", "456"] + + @pytest.mark.asyncio + async def test_list_with_none_prefix(self): + """list() should handle None prefix.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={"keys": []}) + + client = DBClient(proxy) + result = await client.list(prefix=None) + + proxy.call.assert_called_once_with("db.list", {"prefix": None}) + assert result == [] diff --git a/tests_v4/test_llm_client.py b/tests_v4/test_llm_client.py new file mode 100644 index 000000000..060048909 --- /dev/null +++ b/tests_v4/test_llm_client.py @@ -0,0 +1,249 @@ +""" +Tests for clients/llm.py - LLMClient and related models. +""" +from __future__ import annotations + +from collections.abc import AsyncIterator +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from astrbot_sdk.clients.llm import ChatMessage, LLMClient, LLMResponse +from astrbot_sdk.clients._proxy import CapabilityProxy + + +class TestChatMessage: + """Tests for ChatMessage model.""" + + def test_create_with_role_and_content(self): + """ChatMessage should have role and content.""" + msg = ChatMessage(role="user", content="Hello") + assert msg.role == "user" + assert msg.content == "Hello" + + def test_model_dump(self): + """ChatMessage should serialize correctly.""" + msg = ChatMessage(role="assistant", content="Hi there") + data = msg.model_dump() + assert data == {"role": "assistant", "content": "Hi there"} + + +class TestLLMResponse: + """Tests for LLMResponse model.""" + + def test_create_with_text_only(self): + """LLMResponse should work with just text.""" + response = LLMResponse(text="Hello") + assert response.text == "Hello" + assert response.usage is None + assert response.finish_reason is None + assert response.tool_calls == [] + + def test_create_with_all_fields(self): + """LLMResponse should accept all fields.""" + response = LLMResponse( + text="Response", + usage={"prompt_tokens": 10, "completion_tokens": 5}, + finish_reason="stop", + tool_calls=[{"name": "search", "args": {"query": "test"}}], + ) + assert response.text == "Response" + assert response.usage["prompt_tokens"] == 10 + assert response.finish_reason == "stop" + assert len(response.tool_calls) == 1 + + def test_model_validate(self): + """LLMResponse should validate from dict.""" + data = { + "text": "Validated", + "usage": {"total_tokens": 100}, + "finish_reason": "length", + "tool_calls": [], + } + response = LLMResponse.model_validate(data) + assert response.text == "Validated" + assert response.usage["total_tokens"] == 100 + + +class TestLLMClientInit: + """Tests for LLMClient initialization.""" + + def test_init_with_proxy(self): + """LLMClient should store proxy reference.""" + proxy = MagicMock(spec=CapabilityProxy) + client = LLMClient(proxy) + assert client._proxy is proxy + + +class TestLLMClientChat: + """Tests for LLMClient.chat() method.""" + + @pytest.mark.asyncio + async def test_chat_with_prompt_only(self): + """chat() should work with just prompt.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={"text": "Hello back"}) + + client = LLMClient(proxy) + result = await client.chat("Hello") + + proxy.call.assert_called_once() + call_args = proxy.call.call_args + assert call_args[0][0] == "llm.chat" + assert call_args[0][1]["prompt"] == "Hello" + assert result == "Hello back" + + @pytest.mark.asyncio + async def test_chat_with_system_prompt(self): + """chat() should pass system prompt.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={"text": "Response"}) + + client = LLMClient(proxy) + result = await client.chat("Hi", system="Be helpful") + + call_args = proxy.call.call_args[0][1] + assert call_args["system"] == "Be helpful" + assert result == "Response" + + @pytest.mark.asyncio + async def test_chat_with_history(self): + """chat() should pass conversation history.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={"text": "OK"}) + + client = LLMClient(proxy) + history = [ + ChatMessage(role="user", content="Hello"), + ChatMessage(role="assistant", content="Hi"), + ] + await client.chat("How are you?", history=history) + + call_args = proxy.call.call_args[0][1] + assert len(call_args["history"]) == 2 + assert call_args["history"][0] == {"role": "user", "content": "Hello"} + + @pytest.mark.asyncio + async def test_chat_with_model_and_temperature(self): + """chat() should pass model and temperature.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={"text": "Done"}) + + client = LLMClient(proxy) + await client.chat("Test", model="gpt-4", temperature=0.5) + + call_args = proxy.call.call_args[0][1] + assert call_args["model"] == "gpt-4" + assert call_args["temperature"] == 0.5 + + @pytest.mark.asyncio + async def test_chat_returns_empty_string_for_missing_text(self): + """chat() should return empty string if text is missing.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={}) + + client = LLMClient(proxy) + result = await client.chat("Hello") + + assert result == "" + + +class TestLLMClientChatRaw: + """Tests for LLMClient.chat_raw() method.""" + + @pytest.mark.asyncio + async def test_chat_raw_returns_llm_response(self): + """chat_raw() should return LLMResponse object.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock( + return_value={ + "text": "Raw response", + "usage": {"tokens": 50}, + "finish_reason": "stop", + "tool_calls": [], + } + ) + + client = LLMClient(proxy) + result = await client.chat_raw("Test") + + assert isinstance(result, LLMResponse) + assert result.text == "Raw response" + assert result.usage["tokens"] == 50 + + @pytest.mark.asyncio + async def test_chat_raw_passes_kwargs(self): + """chat_raw() should pass additional kwargs to proxy.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={"text": "OK"}) + + client = LLMClient(proxy) + await client.chat_raw("Test", custom_param="value", another=123) + + call_args = proxy.call.call_args[0][1] + assert call_args["custom_param"] == "value" + assert call_args["another"] == 123 + + +class TestLLMClientStreamChat: + """Tests for LLMClient.stream_chat() method.""" + + @pytest.mark.asyncio + async def test_stream_chat_yields_text_chunks(self): + """stream_chat() should yield text chunks.""" + proxy = MagicMock(spec=CapabilityProxy) + + async def mock_stream(name, payload): + yield {"text": "Hello"} + yield {"text": " "} + yield {"text": "World"} + + proxy.stream = mock_stream + + client = LLMClient(proxy) + chunks = [] + async for chunk in client.stream_chat("Test"): + chunks.append(chunk) + + assert chunks == ["Hello", " ", "World"] + + @pytest.mark.asyncio + async def test_stream_chat_with_system_and_history(self): + """stream_chat() should pass system and history.""" + proxy = MagicMock(spec=CapabilityProxy) + + captured_payload = None + + async def mock_stream(name, payload): + nonlocal captured_payload + captured_payload = payload + yield {"text": "Done"} + + proxy.stream = mock_stream + + client = LLMClient(proxy) + history = [ChatMessage(role="user", content="Hi")] + chunks = [] + async for chunk in client.stream_chat("Test", system="Be nice", history=history): + chunks.append(chunk) + + assert captured_payload["system"] == "Be nice" + assert len(captured_payload["history"]) == 1 + + @pytest.mark.asyncio + async def test_stream_chat_yields_empty_string_for_missing_text(self): + """stream_chat() should yield empty string if text is missing.""" + proxy = MagicMock(spec=CapabilityProxy) + + async def mock_stream(name, payload): + yield {} + yield {"other": "data"} + + proxy.stream = mock_stream + + client = LLMClient(proxy) + chunks = [] + async for chunk in client.stream_chat("Test"): + chunks.append(chunk) + + assert chunks == ["", ""] diff --git a/tests_v4/test_memory_client.py b/tests_v4/test_memory_client.py new file mode 100644 index 000000000..d388b4dc1 --- /dev/null +++ b/tests_v4/test_memory_client.py @@ -0,0 +1,185 @@ +""" +Tests for clients/memory.py - MemoryClient implementation. +""" +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from astrbot_sdk.clients.memory import MemoryClient +from astrbot_sdk.clients._proxy import CapabilityProxy + + +class TestMemoryClientInit: + """Tests for MemoryClient initialization.""" + + def test_init_with_proxy(self): + """MemoryClient should store proxy reference.""" + proxy = MagicMock(spec=CapabilityProxy) + client = MemoryClient(proxy) + assert client._proxy is proxy + + +class TestMemoryClientSearch: + """Tests for MemoryClient.search() method.""" + + @pytest.mark.asyncio + async def test_search_returns_items(self): + """search() should return list of items.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock( + return_value={ + "items": [ + {"id": "1", "content": "first"}, + {"id": "2", "content": "second"}, + ] + } + ) + + client = MemoryClient(proxy) + result = await client.search("test query") + + proxy.call.assert_called_once_with( + "memory.search", + {"query": "test query"}, + ) + assert len(result) == 2 + assert result[0]["content"] == "first" + + @pytest.mark.asyncio + async def test_search_returns_empty_list_for_no_results(self): + """search() should return empty list when no items found.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={}) + + client = MemoryClient(proxy) + result = await client.search("nonexistent") + + assert result == [] + + @pytest.mark.asyncio + async def test_search_with_empty_query(self): + """search() should work with empty query.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={"items": []}) + + client = MemoryClient(proxy) + result = await client.search("") + + proxy.call.assert_called_once_with("memory.search", {"query": ""}) + assert result == [] + + +class TestMemoryClientSave: + """Tests for MemoryClient.save() method.""" + + @pytest.mark.asyncio + async def test_save_with_key_and_value(self): + """save() should call proxy with key and value.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={}) + + client = MemoryClient(proxy) + await client.save("my_key", {"data": "value"}) + + proxy.call.assert_called_once_with( + "memory.save", + {"key": "my_key", "value": {"data": "value"}}, + ) + + @pytest.mark.asyncio + async def test_save_with_extra_kwargs(self): + """save() should merge extra kwargs into value.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={}) + + client = MemoryClient(proxy) + await client.save("key", {"base": 1}, extra="added", another=2) + + call_args = proxy.call.call_args[0][1] + assert call_args["value"] == {"base": 1, "extra": "added", "another": 2} + + @pytest.mark.asyncio + async def test_save_with_only_kwargs(self): + """save() should work with only kwargs (no value).""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={}) + + client = MemoryClient(proxy) + await client.save("key", name="test", count=5) + + call_args = proxy.call.call_args[0][1] + assert call_args["value"] == {"name": "test", "count": 5} + + @pytest.mark.asyncio + async def test_save_with_none_value(self): + """save() should handle None value with kwargs.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={}) + + client = MemoryClient(proxy) + await client.save("key", None, field="value") + + call_args = proxy.call.call_args[0][1] + assert call_args["value"] == {"field": "value"} + + @pytest.mark.asyncio + async def test_save_with_empty_value_and_no_kwargs(self): + """save() should work with empty dict.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={}) + + client = MemoryClient(proxy) + await client.save("key", {}) + + proxy.call.assert_called_once_with( + "memory.save", + {"key": "key", "value": {}}, + ) + + @pytest.mark.asyncio + async def test_save_raises_type_error_for_non_dict_value(self): + """save() should raise TypeError for non-dict value.""" + proxy = AsyncMock(spec=CapabilityProxy) + + client = MemoryClient(proxy) + + with pytest.raises(TypeError, match="memory.save 的 value 必须是 dict"): + await client.save("key", "not a dict") + + @pytest.mark.asyncio + async def test_save_raises_type_error_for_list_value(self): + """save() should raise TypeError for list value.""" + proxy = AsyncMock(spec=CapabilityProxy) + + client = MemoryClient(proxy) + + with pytest.raises(TypeError, match="memory.save 的 value 必须是 dict"): + await client.save("key", [1, 2, 3]) + + +class TestMemoryClientDelete: + """Tests for MemoryClient.delete() method.""" + + @pytest.mark.asyncio + async def test_delete_calls_proxy_with_key(self): + """delete() should call proxy with correct key.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={}) + + client = MemoryClient(proxy) + await client.delete("to_delete") + + proxy.call.assert_called_once_with("memory.delete", {"key": "to_delete"}) + + @pytest.mark.asyncio + async def test_delete_with_empty_key(self): + """delete() should work with empty string key.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={}) + + client = MemoryClient(proxy) + await client.delete("") + + proxy.call.assert_called_once_with("memory.delete", {"key": ""}) diff --git a/tests_v4/test_platform_client.py b/tests_v4/test_platform_client.py new file mode 100644 index 000000000..dda6043fe --- /dev/null +++ b/tests_v4/test_platform_client.py @@ -0,0 +1,157 @@ +""" +Tests for clients/platform.py - PlatformClient implementation. +""" +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from astrbot_sdk.clients.platform import PlatformClient +from astrbot_sdk.clients._proxy import CapabilityProxy + + +class TestPlatformClientInit: + """Tests for PlatformClient initialization.""" + + def test_init_with_proxy(self): + """PlatformClient should store proxy reference.""" + proxy = MagicMock(spec=CapabilityProxy) + client = PlatformClient(proxy) + assert client._proxy is proxy + + +class TestPlatformClientSend: + """Tests for PlatformClient.send() method.""" + + @pytest.mark.asyncio + async def test_send_returns_response(self): + """send() should return response dict.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={"message_id": "msg_123", "sent": True}) + + client = PlatformClient(proxy) + result = await client.send("session-1", "Hello") + + proxy.call.assert_called_once_with( + "platform.send", + {"session": "session-1", "text": "Hello"}, + ) + assert result["message_id"] == "msg_123" + + @pytest.mark.asyncio + async def test_send_with_empty_text(self): + """send() should work with empty text.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={}) + + client = PlatformClient(proxy) + result = await client.send("session-1", "") + + call_args = proxy.call.call_args[0][1] + assert call_args["text"] == "" + + @pytest.mark.asyncio + async def test_send_with_special_characters(self): + """send() should handle special characters in text.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={}) + + client = PlatformClient(proxy) + await client.send("session-1", "Hello\nWorld\t! @#$%") + + call_args = proxy.call.call_args[0][1] + assert call_args["text"] == "Hello\nWorld\t! @#$%" + + +class TestPlatformClientSendImage: + """Tests for PlatformClient.send_image() method.""" + + @pytest.mark.asyncio + async def test_send_image_returns_response(self): + """send_image() should return response dict.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={"image_id": "img_456"}) + + client = PlatformClient(proxy) + result = await client.send_image("session-1", "https://example.com/image.png") + + proxy.call.assert_called_once_with( + "platform.send_image", + {"session": "session-1", "image_url": "https://example.com/image.png"}, + ) + assert result["image_id"] == "img_456" + + @pytest.mark.asyncio + async def test_send_image_with_file_url(self): + """send_image() should work with file:// URL.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={}) + + client = PlatformClient(proxy) + await client.send_image("session-1", "file:///path/to/image.jpg") + + call_args = proxy.call.call_args[0][1] + assert call_args["image_url"] == "file:///path/to/image.jpg" + + @pytest.mark.asyncio + async def test_send_image_with_base64_url(self): + """send_image() should work with data URL.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={}) + + client = PlatformClient(proxy) + await client.send_image("session-1", "data:image/png;base64,abc123") + + call_args = proxy.call.call_args[0][1] + assert call_args["image_url"] == "data:image/png;base64,abc123" + + +class TestPlatformClientGetMembers: + """Tests for PlatformClient.get_members() method.""" + + @pytest.mark.asyncio + async def test_get_members_returns_list(self): + """get_members() should return list of members.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock( + return_value={ + "members": [ + {"id": "user1", "name": "Alice"}, + {"id": "user2", "name": "Bob"}, + ] + } + ) + + client = PlatformClient(proxy) + result = await client.get_members("group-1") + + proxy.call.assert_called_once_with( + "platform.get_members", + {"session": "group-1"}, + ) + assert len(result) == 2 + assert result[0]["name"] == "Alice" + + @pytest.mark.asyncio + async def test_get_members_returns_empty_list(self): + """get_members() should return empty list when no members.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={}) + + client = PlatformClient(proxy) + result = await client.get_members("empty-group") + + assert result == [] + + @pytest.mark.asyncio + async def test_get_members_with_private_session(self): + """get_members() should work with private session.""" + proxy = AsyncMock(spec=CapabilityProxy) + proxy.call = AsyncMock(return_value={"members": [{"id": "single_user"}]}) + + client = PlatformClient(proxy) + result = await client.get_members("private-123") + + call_args = proxy.call.call_args[0][1] + assert call_args["session"] == "private-123"