Skip to content

Commit f1d5cf4

Browse files
authored
test: add unit tests for helpers (#815) (#847)
* test: add unit tests for helpers (#815) Add 63 tests across openai_compatible_helpers, async_helpers, and server_type covering critical gaps in pure functions used by every OpenAI-compatible backend. - extract_model_tool_requests: 9 tests (single/multi/unknown/null args/malformed JSON/mixed known+unknown) - chat_completion_delta_merge: 7 tests (text/reasoning/tool assembly with index merging/force-separate/stop_reason) - message_to_openai_message: 5 tests (text/images/empty images list/roles) - messages_to_docs: 4 tests (empty/single/optional fields/across messages) - send_to_queue: 5 tests (coroutine/async-iter/direct-iter/exception propagation) - wait_for_all_mots: 2 tests (resolve all/empty list) - get_current_event_loop: 2 tests (running/no-loop) - ClientCache: 6 tests (put/get/evict-LRU/refresh/overwrite/size) - _server_type: 7 parametrized tests (localhost variants/openai/ unknown/malformed URL) Fixes #815 * refactor: trim 5 trivial/vacuous tests from helpers suite Remove tests that either test language builtins (OrderedDict.get, len()), duplicate existing coverage (assistant_role vs text_only), or can't meaningfully exercise the function under test without a real pending thunk (wait_for_all_mots).
1 parent fdddf8c commit f1d5cf4

3 files changed

Lines changed: 518 additions & 0 deletions

File tree

test/helpers/test_async_helpers.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""Unit tests for mellea.helpers.async_helpers."""
2+
3+
import asyncio
4+
5+
import pytest
6+
7+
from mellea.helpers.async_helpers import (
8+
ClientCache,
9+
get_current_event_loop,
10+
send_to_queue,
11+
)
12+
13+
# --- send_to_queue ---
14+
15+
16+
class TestSendToQueue:
17+
async def test_coroutine_single_value(self):
18+
"""Coroutine returning a non-iterator value is put into queue followed by sentinel."""
19+
20+
async def produce():
21+
return "result"
22+
23+
q: asyncio.Queue = asyncio.Queue()
24+
await send_to_queue(produce(), q)
25+
assert await q.get() == "result"
26+
assert await q.get() is None # sentinel
27+
28+
async def test_coroutine_returning_async_iterator(self):
29+
"""Coroutine returning an async iterator streams items then sentinel."""
30+
31+
async def produce():
32+
async def _gen():
33+
yield "a"
34+
yield "b"
35+
36+
return _gen()
37+
38+
q: asyncio.Queue = asyncio.Queue()
39+
await send_to_queue(produce(), q)
40+
assert await q.get() == "a"
41+
assert await q.get() == "b"
42+
assert await q.get() is None
43+
44+
async def test_async_iterator_directly(self):
45+
"""Passing an async iterator (not wrapped in coroutine) streams items."""
46+
47+
async def _gen():
48+
yield 1
49+
yield 2
50+
51+
q: asyncio.Queue = asyncio.Queue()
52+
await send_to_queue(_gen(), q)
53+
assert await q.get() == 1
54+
assert await q.get() == 2
55+
assert await q.get() is None
56+
57+
async def test_exception_propagated_to_queue(self):
58+
"""Exceptions during generation are put into queue instead of raising."""
59+
60+
async def explode():
61+
raise ValueError("boom")
62+
63+
q: asyncio.Queue = asyncio.Queue()
64+
await send_to_queue(explode(), q)
65+
item = await q.get()
66+
assert isinstance(item, ValueError)
67+
assert str(item) == "boom"
68+
69+
async def test_iterator_exception_propagated(self):
70+
"""Exception mid-iteration is captured and put into queue."""
71+
72+
async def _gen():
73+
yield "ok"
74+
raise RuntimeError("mid-stream")
75+
76+
q: asyncio.Queue = asyncio.Queue()
77+
await send_to_queue(_gen(), q)
78+
assert await q.get() == "ok"
79+
item = await q.get()
80+
assert isinstance(item, RuntimeError)
81+
82+
83+
# --- get_current_event_loop ---
84+
85+
86+
class TestGetCurrentEventLoop:
87+
async def test_returns_loop_when_running(self):
88+
loop = get_current_event_loop()
89+
assert loop is not None
90+
assert loop is asyncio.get_running_loop()
91+
92+
def test_returns_none_when_no_loop(self):
93+
assert get_current_event_loop() is None
94+
95+
96+
# --- ClientCache ---
97+
98+
99+
class TestClientCache:
100+
def test_put_and_get(self):
101+
cache = ClientCache(capacity=3)
102+
cache.put(1, "a")
103+
assert cache.get(1) == "a"
104+
105+
def test_evicts_lru(self):
106+
cache = ClientCache(capacity=2)
107+
cache.put(1, "a")
108+
cache.put(2, "b")
109+
cache.put(3, "c") # evicts key 1
110+
assert cache.get(1) is None
111+
assert cache.get(2) == "b"
112+
assert cache.get(3) == "c"
113+
114+
def test_access_refreshes_lru_order(self):
115+
cache = ClientCache(capacity=2)
116+
cache.put(1, "a")
117+
cache.put(2, "b")
118+
cache.get(1) # refresh key 1 — now key 2 is LRU
119+
cache.put(3, "c") # evicts key 2
120+
assert cache.get(1) == "a"
121+
assert cache.get(2) is None
122+
assert cache.get(3) == "c"
123+
124+
def test_overwrite_existing_key(self):
125+
cache = ClientCache(capacity=2)
126+
cache.put(1, "old")
127+
cache.put(1, "new")
128+
assert cache.get(1) == "new"
129+
assert cache.current_size() == 1
130+
131+
132+
if __name__ == "__main__":
133+
pytest.main([__file__])

0 commit comments

Comments
 (0)