Skip to content

Commit 75338d9

Browse files
committed
test(client): cover run_forever()/stop() lifecycle for all three client classes
7 tests: run_forever() blocks until stop() is called (Publication/Connection/ Exposure), stop() sets _stop_event (Connection/Exposure), stop() cancels an attached background task (Connection/Exposure). Mutation-verified: removing the stop_event.wait() from run_forever() breaks the three "returns_when_stop" tests; removing the task.cancel() breaks the two task-cancel tests.
1 parent 2e5d6bb commit 75338d9

1 file changed

Lines changed: 147 additions & 0 deletions

File tree

tests/test_client_lifecycle.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"""ZhubPublication / ZhubConnection / ZhubExposure lifecycle API.
2+
3+
Pre-fix: run_forever() was missing on all three classes. stop() was missing
4+
on ZhubConnection and ZhubExposure. The module quickstart and publish()
5+
docstring both referenced pub.run_forever() / conn.run_forever() — following
6+
either caused AttributeError before any connection attempt.
7+
8+
Post-fix: all three classes expose stop() + run_forever(). run_forever()
9+
awaits the stop event so it returns as soon as stop() is called from a
10+
concurrent task, making it safe to use as the final await in asyncio.run(main()).
11+
"""
12+
13+
import asyncio
14+
15+
import pytest
16+
17+
from zhub import ZhubConnection, ZhubExposure, ZhubPublication
18+
from zhub.client import ZhubConnection, ZhubExposure
19+
from zhub.manifest import Manifest
20+
21+
22+
# ---------------------------------------------------------------------------
23+
# Helpers
24+
# ---------------------------------------------------------------------------
25+
26+
def _make_pub() -> ZhubPublication:
27+
from zhub.client import publish
28+
from zhub.manifest import chat_only_manifest
29+
from dataclasses import fields
30+
# Build a bare ZhubPublication without starting the background task.
31+
from zhub.client import ZhubPublication
32+
import dataclasses
33+
pub = object.__new__(ZhubPublication)
34+
pub.name = "testpub"
35+
pub.base_url = ""
36+
pub.api_key = ""
37+
pub.manifest = Manifest(name="testpub")
38+
pub.hub_url = "ws://localhost"
39+
pub.chat_handler = lambda msgs, opts: "hi"
40+
pub.on_connection_event = None
41+
pub._task = None
42+
pub._stop_event = asyncio.Event()
43+
pub._connections = {}
44+
pub._pending = {}
45+
pub._ws = None
46+
return pub
47+
48+
49+
def _make_conn() -> ZhubConnection:
50+
return ZhubConnection(
51+
ai_name="ai",
52+
api_key="zk_test",
53+
hub_url="ws://localhost",
54+
client_manifest=Manifest(name="ai-client"),
55+
capabilities={},
56+
)
57+
58+
59+
def _make_exp() -> ZhubExposure:
60+
return ZhubExposure(
61+
name="dev",
62+
hub_url="ws://localhost",
63+
client_manifest=Manifest(name="dev"),
64+
capabilities={},
65+
)
66+
67+
68+
# ---------------------------------------------------------------------------
69+
# run_forever() tests
70+
# ---------------------------------------------------------------------------
71+
72+
@pytest.mark.asyncio
73+
async def test_pub_run_forever_returns_when_stop_called():
74+
"""run_forever() must return (not hang) after stop() is called."""
75+
pub = _make_pub()
76+
task = asyncio.create_task(pub.run_forever())
77+
await asyncio.sleep(0) # yield so run_forever() enters wait
78+
assert not task.done(), "run_forever() should still be blocking"
79+
await pub.stop()
80+
await asyncio.wait_for(task, timeout=1.0)
81+
assert task.done()
82+
83+
84+
@pytest.mark.asyncio
85+
async def test_conn_run_forever_returns_when_stop_called():
86+
"""ZhubConnection.run_forever() must return after stop()."""
87+
conn = _make_conn()
88+
task = asyncio.create_task(conn.run_forever())
89+
await asyncio.sleep(0)
90+
assert not task.done()
91+
await conn.stop()
92+
await asyncio.wait_for(task, timeout=1.0)
93+
assert task.done()
94+
95+
96+
@pytest.mark.asyncio
97+
async def test_exp_run_forever_returns_when_stop_called():
98+
"""ZhubExposure.run_forever() must return after stop()."""
99+
exp = _make_exp()
100+
task = asyncio.create_task(exp.run_forever())
101+
await asyncio.sleep(0)
102+
assert not task.done()
103+
await exp.stop()
104+
await asyncio.wait_for(task, timeout=1.0)
105+
assert task.done()
106+
107+
108+
# ---------------------------------------------------------------------------
109+
# stop() sets the stop event and cancels any background task
110+
# ---------------------------------------------------------------------------
111+
112+
@pytest.mark.asyncio
113+
async def test_conn_stop_sets_event():
114+
conn = _make_conn()
115+
assert not conn._stop_event.is_set()
116+
await conn.stop()
117+
assert conn._stop_event.is_set()
118+
119+
120+
@pytest.mark.asyncio
121+
async def test_exp_stop_sets_event():
122+
exp = _make_exp()
123+
assert not exp._stop_event.is_set()
124+
await exp.stop()
125+
assert exp._stop_event.is_set()
126+
127+
128+
@pytest.mark.asyncio
129+
async def test_conn_stop_cancels_task():
130+
conn = _make_conn()
131+
# Attach a real long-running dummy task
132+
dummy = asyncio.create_task(asyncio.sleep(3600))
133+
conn._task = dummy
134+
await conn.stop()
135+
# Give the event loop a tick to process the cancellation
136+
await asyncio.sleep(0)
137+
assert dummy.cancelled()
138+
139+
140+
@pytest.mark.asyncio
141+
async def test_exp_stop_cancels_task():
142+
exp = _make_exp()
143+
dummy = asyncio.create_task(asyncio.sleep(3600))
144+
exp._task = dummy
145+
await exp.stop()
146+
await asyncio.sleep(0)
147+
assert dummy.cancelled()

0 commit comments

Comments
 (0)