8000 add TestAdapter and its tests, update BotAdapter ABC and reliant classes · rsliang/botbuilder-python@0c18886 · GitHub
[go: up one dir, main page]

Skip to content

Commit 0c18886

Browse files
committed
add TestAdapter and its tests, update BotAdapter ABC and reliant classes
1 parent 8123a27 commit 0c18886

File tree

6 files changed

+408
-13
lines changed

6 files changed

+408
-13
lines changed

libraries/botbuilder-core/botbuilder/core/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from .bot_framework_adapter import BotFrameworkAdapter, BotFrameworkAdapterSettings
1212
from .bot_context import BotContext
1313
from .middleware_set import AnonymousReceiveMiddleware, Middleware, MiddlewareSet
14+
from .test_adapter import TestAdapter
1415

1516
__all__ = ['AnonymousReceiveMiddleware',
1617
'BotAdapter',
@@ -19,4 +20,5 @@
1920
'BotFrameworkAdapterSettings',
2021
'Middleware',
2122
'MiddlewareSet',
23+
'TestAdapter',
2224
'__version__',]

libraries/botbuilder-core/botbuilder/core/bot_adapter.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ def __init__(self):
1414
self._middleware = MiddlewareSet()
1515

1616
@abstractmethod
17-
async def send_activity(self, activities: List[Activity]):
17+
async def send_activity(self, context: BotContext, activities: List[Activity]):
1818
"""
1919
Sends a set of activities to the user. An array of responses from the server will be returned.
2020
:param activities:
@@ -23,7 +23,7 @@ async def send_activity(self, activities: List[Activity]):
2323
raise NotImplementedError()
2424

2525
@abstractmethod
26-
async def update_activity(self, activity: Activity):
26+
async def update_activity(self, context: BotContext, activity: Activity):
2727
"""
2828
Replaces an existing activity.
2929
:param activity:
@@ -32,7 +32,7 @@ async def update_activity(self, activity: Activity):
3232
raise NotImplementedError()
3333

3434
@abstractmethod
35-
async def delete_activity(self, reference: ConversationReference):
35+
async def delete_activity(self, context: BotContext, reference: ConversationReference):
3636
"""
3737
Deletes an existing activity.
3838
:param reference:

libraries/botbuilder-core/botbuilder/core/bot_context.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,16 +64,17 @@ async def send_activity(self, *activity_or_text: Tuple[Activity, str]):
6464
activity.input_hint = 'acceptingInput'
6565

6666
async def callback(context: 'BotContext', output):
67-
responses = await context.adapter.send_activity(output)
67+
responses = await context.adapter.send_activity(context, output)
6868
context._responded = True
6969
return responses
7070

7171
await self._emit(self._on_send_activity, output, callback(self, output))
7272

7373
async def update_activity(self, activity: Activity):
74-
return asyncio.ensure_future(self._emit(self._on_update_activity,
75-
activity,
76-
self.adapter.update_activity(activity)))
74+
return await self._emit(self._on_update_activity, activity, self.adapter.update_activity(self, activity))
75+
76+
async def delete_activity(self, reference: ConversationReference):
77+
return await self._emit(self._on_delete_activity, reference, self.adapter.delete_activity(self, reference))
7778

7879
@staticmethod
7980
async def _emit(plugins, arg, logic):
@@ -119,9 +120,9 @@ def apply_conversation_reference(activity: Activity,
119120
:param is_incoming:
120121
:return:
121122
"""
122-
activity.channel_id=reference.channel_id
123-
activity.service_url=reference.service_url
124-
activity.conversation=reference.conversation
123+
activity.channel_id = reference.channel_id
124+
activity.service_url = reference.service_url
125+
activity.conversation = reference.conversation
125126
if is_incoming:
126127
activity.from_property = reference.user
127128
activity.recipient = reference.bot

libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ async def validate_activity(activity: Activity):
8383
if is_valid_activity:
8484
return req
8585

86-
async def update_activity(self, activity: Activity):
86+
async def update_activity(self, context: BotContext, activity: Activity):
8787
try:
8888
connector_client = ConnectorClient(self._credentials, activity.service_url)
8989
connector_client.config.add_user_agent(USER_AGENT)
@@ -94,7 +94,7 @@ async def update_activity(self, activity: Activity):
9494
except Exception as e:
9595
raise e
9696

97-
async def delete_activity(self, conversation_reference: ConversationReference):
97+
async def delete_activity(self, context: BotContext, conversation_reference: ConversationReference):
9898
try:
9999
connector_client = ConnectorClient(self._credentials, conversation_reference.service_url)
100100
connector_client.config.add_user_agent(USER_AGENT)
@@ -103,7 +103,7 @@ async def delete_activity(self, conversation_reference: ConversationReference):
103103
except Exception as e:
104104
raise e
105105

106-
async def send_activity(self, activities: List[Activity]):
106+
async def send_activity(self, context: BotContext, activities: List[Activity]):
107107
try:
108108
for activity in activities:
109109
if activity.type == 'delay':
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
import asyncio
5+
import inspect
6+
from datetime import datetime
7+
from typing import Coroutine, List
8+
from copy import copy
9+
from botbuilder.core import BotAdapter, BotContext
10+
from botbuilder.schema import (ActivityTypes, Activity, ConversationAccount,
11+
ConversationReference, ChannelAccount, ResourceResponse)
12+
13+
14+
class TestAdapter(BotAdapter):
15+
def __init__(self, logic: Coroutine, template: ConversationReference=None):
16+
"""
17+
Creates a new TestAdapter instance.
18+
:param logic:
19+
:param template:
20+
"""
21+
super(TestAdapter, self).__init__()
22+
self.logic = logic
23+
self._next_id: int = 0
24+
self.activity_buffer: List[Activity] = []
25+
self.updated_activities: List[Activity] = []
26+
self.deleted_activities: List[ConversationReference] = []
27+
28+
self.template: Activity = Activity(
29+
channel_id='test',
30+
service_url='https://test.com',
31+
from_property=ChannelAccount(id='User1', name='user'),
32+
recipient=ChannelAccount(id='bot', name='Bot'),
33+
conversation=ConversationAccount(id='Convo1')
34+
)
35+
if template is not None:
36+
self.template.service_url = template.service_url
37+
self.template.conversation = template.conversation
38+
self.template.channel_id = template.channel_id
39+
40+
async def send_activity(self, context, activities: List[Activity]):
41+
"""
42+
INTERNAL: called by the logic under test to send a set of activities. These will be buffered
43+
to the current `TestFlow` instance for comparison against the expected results.
44+
:param context:
45+
:param activities:
46+
:return:
47+
"""
48+
def id_mapper(activity):
49+
self.activity_buffer.append(activity)
50+
self._next_id += 1
51+
return ResourceResponse(id=str(self._next_id))
52+
"""This if-else code is temporary until the BotAdapter and Bot/TurnContext are revamped."""
53+
if type(activities) == list:
54+
responses = [id_mapper(activity) for activity in activities]
55+
else:
56+
responses = [id_mapper(activities)]
57+
return responses
58+
59+
async def delete_activity(self, context, reference: ConversationReference):
60+
"""
61+
INTERNAL: called by the logic under test to delete an existing activity. These are simply
62+
pushed onto a [deletedActivities](#deletedactivities) array for inspection after the turn
63+
completes.
64+
:param reference:
65+
:return:
66+
"""
67+
self.deleted_activities.append(reference)
68+
69+
async def update_activity(self, context, activity: Activity):
70+
"""
71+
INTERNAL: called by the logic under test to replace an existing activity. These are simply
72+
pushed onto an [updatedActivities](#updatedactivities) array for inspection after the turn
73+
completes.
74+
:param activity:
75+
:return:
76+
"""
77+
self.updated_activities.append(activity)
78+
79+
async def continue_conversation(self, reference, logic):
80+
"""
81+
The `TestAdapter` doesn't implement `continueConversation()` and will return an error if it's
82+
called.
83+
:param reference:
84+
:param logic:
85+
:return:
86+
"""
87+
raise NotImplementedError('TestAdapter.continue_conversation(): is not implemented.')
88+
89+
async def receive_activity(self, activity):
90+
"""
91+
INTERNAL: called by a `TestFlow` instance to simulate a user sending a message to the bot.
92+
This will cause the adapters middleware pipe to be run and it's logic to be called.
93+
:param activity:
94+
:return:
95+
"""
96+
if type(activity) == str:
97+
activity = Activity(type='message', text=activity)
98+
# Initialize request
99+
request = copy(self.template)
100+
101+
for key, value in vars(activity).items():
102+
if value is not None and key != 'additional_properties':
103+
setattr(request, key, value)
104+
105+
if not request.type:
106+
request.type = ActivityTypes.message
107+
if not request.id:
108+
self._next_id += 1
109+
request.id = str(self._next_id)
110+
111+
# Create context object and run middleware
112+
context = BotContext(self, request)
113+
return await self.run_middleware(context, self.logic)
114+
115+
async def send(self, user_says):
116+
"""
117+
Sends something to the bot. This returns a new `TestFlow` instance which can be used to add
118+
additional steps for inspecting the bots reply and then sending additional activities.
119+
:param user_says:
120+
:return:
121+
"""
122+
return TestFlow(await self.receive_activity(user_says), self)
123+
124+
async def test(self, user_says, expected, description=None, timeout=None) -> 'TestFlow':
125+
"""
126+
Send something to the bot and expects the bot to return with a given reply. This is simply a
127+
wrapper around calls to `send()` and `assertReply()`. This is such a common pattern that a
128+
helper is provided.
129+
:param user_says:
130+
:param expected:
131+
:param description:
132+
:param timeout:
133+
:return:
134+
"""
135+
test_flow = await self.send(user_says)
136+
test_flow = await test_flow.assert_reply(expected, description, timeout)
137+
return test_flow
138+
139+
async def tests(self, *args):
140+
"""
141+
Support multiple test cases without having to manually call `test()` repeatedly. This is a
142+
convenience layer around the `test()`. Valid args are either lists or tuples of parameters
143+
:param args:
144+
:return:
145+
"""
146+
for arg in args:
147+
description = None
148+
timeout = None
149+
if len(arg) >= 3:
150+
description = arg[2]
151+
if len(arg) == 4:
152+
timeout = arg[3]
153+
await self.test(arg[0], arg[1], description, timeout)
154+
155+
156+
class TestFlow(object):
157+
def __init__(self, previous, adapter: TestAdapter):
158+
"""
159+
INTERNAL: creates a new TestFlow instance.
160+
:param previous:
161+
:param adapter:
162+
"""
163+
self.previous = previous
164+
self.adapter = adapter
165+
166+
async def test(self, user_says, expected, description=None, timeout=None) -> 'TestFlow':
167+
"""
168+
Send something to the bot and expects the bot to return with a given reply. This is simply a
169+
wrapper around calls to `send()` and `assertReply()`. This is such a common pattern that a
170+
helper is provided.
171+
:param user_says:
172+
:param expected:
173+
:param description:
174+
:param timeout:
175+
:return:
176+
"""
177+
test_flow = await self.send(user_says)
178+
return await test_flow.assert_reply(expected, description or f'test("{user_says}", "{expected}")', timeout)
179+
180+
async def send(self, user_says) -> 'TestFlow':
181+
"""
182+
Sends something to the bot.
183+
:param user_says:
184+
:return:
185+
"""
186+
async def new_previous():
187+
nonlocal self, user_says
188+
if callable(self.previous):
189+
await self.previous()
190+
await self.adapter.receive_activity(user_says)
191+
192+
return TestFlow(await new_previous(), self.adapter)
193+
194+
async def assert_reply(self, expected, description=None, timeout=None) -> 'TestFlow':
195+
"""
196+
Generates an assertion if the bots response doesn't match the expected text/activity.
197+
:param expected:
198+
:param description:
199+
:param timeout:
200+
:return:
201+
"""
202+
def default_inspector(reply, description=None):
203+
if isinstance(expected, Activity):
204+
validate_activity(reply, expected)
205+
else:
206+
assert reply.type == 'message', description + f" type == {reply.type}"
207+
assert reply.text == expected, description + f" text == {reply.text}"
208+
209+
if description is None:
210+
description = ''
211+
212+
inspector = expected if type(expected) == 'function' else default_inspector
213+
214+
async def test_flow_previous():
215+
nonlocal timeout
216+
if not timeout:
217+
timeout = 3000
218+
start = datetime.now()
219+
adapter = self.adapter
220+
221+
async def wait_for_activity():
222+
nonlocal expected, timeout
223+
current = datetime.now()
224+
if (current - start).total_seconds() * 1000 > timeout:
225+
if type(expected) == Activity:
226+
expecting = expected.text
227+
elif callable(expected):
228+
expecting = inspect.getsourcefile(expected)
229+
else:
230+
expecting = str(expected)
231+
raise RuntimeError(f'TestAdapter.assert_reply({expecting}): {description} Timed out after '
232+
f'{current - start}ms.')
233+
elif len(adapter.activity_buffer) > 0:
234+
reply = adapter.activity_buffer.pop(0)
235+
inspector(reply, description)
236+
else:
237+
await asyncio.sleep(0.05)
238+
await wait_for_activity()
239+
240+
await wait_for_activity()
241+
242+
return TestFlow(await test_flow_previous(), self.adapter)
243+
244+
245+
def validate_activity(activity, expected) -> None:
246+
"""
247+
Helper method that compares activities
248+
:param activity:
249+
:param expected:
250+
:return:
251+
"""
252+
iterable_expected = vars(expected).items()
253+
for attr, value in iterable_expected:
254+
if value is not None and attr != 'additional_properties':
255+
assert value == getattr(activity, attr)

0 commit comments

Comments
 (0)
0