diff --git a/dev/microsoft-agents-testing/tests/manual_test/__init__.py b/dev/integration/data_driven_tests/basic_test.json similarity index 100% rename from dev/microsoft-agents-testing/tests/manual_test/__init__.py rename to dev/integration/data_driven_tests/basic_test.json diff --git a/dev/integration/data_driven_tests/basic_test.yaml b/dev/integration/data_driven_tests/basic_test.yaml new file mode 100644 index 00000000..a9199fbb --- /dev/null +++ b/dev/integration/data_driven_tests/basic_test.yaml @@ -0,0 +1,25 @@ +parent: ./defaults.yaml +description: A basic data driven test example +defaults: + input: + sleep: + assertion: +test: + - type: input + activity: + type: message + text: "Hello, World!" + + - type: sleep + duration: 1 + + - type: assertion + quantifier: ONE + selector: + index: -1 + quantifier: ONE + activity: + type: message + activity: + type: message + text: "Hello, World!" \ No newline at end of file diff --git a/dev/integration/data_driven_tests/defaults.yaml b/dev/integration/data_driven_tests/defaults.yaml new file mode 100644 index 00000000..6d65a3b3 --- /dev/null +++ b/dev/integration/data_driven_tests/defaults.yaml @@ -0,0 +1,10 @@ +defaults: + all: + channel_id: "test_channel" + service_url: "http://localhost:3978" + locale: "en-US" + conversation_id: "test_conversation" + input: + user_id: "test_user" + output: + user_id: "test_bot" \ No newline at end of file diff --git a/dev/integration/integration/README.md b/dev/integration/integration/README.md new file mode 100644 index 00000000..614462c9 --- /dev/null +++ b/dev/integration/integration/README.md @@ -0,0 +1,2 @@ +# Microsoft 365 Agents SDK for Python Integration Testing Framework + diff --git a/dev/integration/integration/integration/__init__.py b/dev/integration/integration/integration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/integration/integration/integration/foundational/__init__.py b/dev/integration/integration/integration/foundational/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/integration/integration/integration/foundational/_common.py b/dev/integration/integration/integration/foundational/_common.py new file mode 100644 index 00000000..f4d22304 --- /dev/null +++ b/dev/integration/integration/integration/foundational/_common.py @@ -0,0 +1,16 @@ +import json + +from microsoft_agents.activity import Activity + + +def load_activity(channel: str, name: str) -> Activity: + + with open( + "./dev/integration/src/tests/integration/foundational/activities/{}/{}.json".format( + channel, name + ), + "r", + ) as f: + activity = json.load(f) + + return Activity.model_validate(activity) diff --git a/dev/integration/integration/integration/foundational/test_suite.py b/dev/integration/integration/integration/foundational/test_suite.py new file mode 100644 index 00000000..a38edf0b --- /dev/null +++ b/dev/integration/integration/integration/foundational/test_suite.py @@ -0,0 +1,141 @@ +import json +import pytest +import asyncio + +from microsoft_agents.activity import ( + ActivityTypes, +) + +from src.core import integration, IntegrationFixtures, AiohttpEnvironment +from src.samples import QuickstartSample + +from ._common import load_activity + +DIRECTLINE = "directline" + +@integration() +class TestFoundation(IntegrationFixtures): + + def load_activity(self, activity_name) -> Activity: + return load_activity(DIRECTLINE, activity_name) + + @pytest.mark.asyncio + async def test__send_activity__sends_hello_world__returns_hello_world(self, agent_client): + activity = load_activity(DIRECTLINE, "hello_world.json") + result = await agent_client.send_activity(activity) + assert result is not None + last = result[-1] + assert last.type == ActivityTypes.message + assert last.text.lower() == "you said: {activity.text}".lower() + + @pytest.mark.asyncio + async def test__send_invoke__send_basic_invoke_activity__receive_invoke_response(self, agent_client): + activity = load_activity(DIRECTLINE, "basic_invoke.json") + result = await agent_client.send_activity(activity) + assert result + data = json.loads(result) + message = data.get("message", {}) + assert "Invoke received." in message + assert "data" in data + assert data["parameters"] and len(data["parameters"]) > 0 + assert "hi" in data["value"] + + @pytest.mark.asyncio + async def test__send_activity__sends_message_activity_to_ac_submit__return_valid_response(self, agent_client): + activity = load_activity(DIRECTLINE, "ac_submit.json") + result = await agent_client.send_activity(activity) + assert result is not None + last = result[-1] + assert last.type == ActivityTypes.message + assert "doStuff" in last.text + assert "Action.Submit" in last.text + assert "hello" in last.text + + @pytest.mark.asyncio + async def test__send_invoke_sends_invoke_activity_to_ac_execute__returns_valid_adaptive_card_invoke_response(self, agent_client): + activity = load_activity(DIRECTLINE, "ac_execute.json") + result = await agent_client.send_invoke(activity) + + result = json.loads(result) + + assert result.status == 200 + assert result.value + + assert "application/vnd.microsoft.card.adaptive" in result.type + + activity_data = json.loads(activity.value) + assert activity_data.get("action") + user_text = activity_data.get("usertext") + assert user_text in result.value + + @pytest.mark.asyncio + async def test__send_activity_sends_text__returns_poem(self, agent_client): + pass + + @pytest.mark.asyncio + async def test__send_expected_replies_activity__sends_text__returns_poem(self, agent_client): + activity = self.load_activity("expected_replies.json") + result = await agent_client.send_expected_replies(activity) + last = result[-1] + assert last.type == ActivityTypes.message + assert "Apollo" in last.text + assert "\n" in last.text + + @pytest.mark.asyncio + async def test__send_invoke__query_link__returns_text(self, agent_client): + activity = self.load_activity("query_link.json") + result = await agent_client.send_invoke(activity) + pass # TODO + + @pytest.mark.asyncio + async def test__send_invoke__select_item__receive_item(self, agent_client): + activity = self.load_activity("select_item.json") + result = await agent_client.send_invoke(activity) + pass # TODO + + @pytest.mark.asyncio + async def test__send_activity__conversation_update__returns_welcome_message(self, agent_client): + activity = self.load_activity("conversation_update.json") + result = await agent_client.send_activity(activity) + last = result[-1] + assert "Hello and Welcome!" in last.text + + @pytest.mark.asyncio + async def test__send_activity__send_heart_message_reaction__returns_message_reaction_heart(self, agent_client): + activity = self.load_activity("message_reaction_heart.json") + result = await agent_client.send_activity(activity) + last = result[-1] + assert last.type == ActivityTypes.message + assert "Message Reaction Added: heart" in last.text + + @pytest.mark.asyncio + async def test__send_activity__remove_heart_message_reaction__returns_message_reaction_heart(self, agent_client): + activity = self.load_activity + result = await agent_client.send_activity(activity) + last = result[-1] + assert last.type == ActivityTypes.message + assert "Message Reaction Removed: heart" in last.text + + @pytest.mark.asyncio + async def test__send_expected_replies_activity__send_seattle_today_weather__returns_weather(self, agent_client): + activity = self.load_activity("expected_replies_seattle_weather.json") + result = await agent_client.send_expected_replies(activity) + last = result[-1] + assert last.type == ActivityTypes.message + assert last.attachments and len(last.attachments) > 0 + + adaptive_card = last.attachments.first() + assert adaptive_card + assert "application/vnd.microsoft.card.adaptive" == adaptive_card.content_type + assert adaptive_card.content + + assert \ + "�" in adaptive_card.content or \ + "\\u00B0" in adaptive_card.content or \ + f"Missing temperature inside adaptive card: {adaptive_card.content}" in adaptive_card.content + + @pytest.mark.asyncio + async def test__send_activity__simulate_message_loop__expect_question_about_time_and_returns_weather(self, agent_client): + activities = self.load_activity("message_loop_1.json") + fresult = await agent_client.send_activity(activities[0]) + assert \ No newline at end of file diff --git a/dev/integration/integration/integration/test_quickstart.py b/dev/integration/integration/integration/test_quickstart.py new file mode 100644 index 00000000..967d4ebc --- /dev/null +++ b/dev/integration/integration/integration/test_quickstart.py @@ -0,0 +1,21 @@ +# import pytest +# import asyncio + +# from src.core import IntegrationFixtures, AiohttpEnvironment +# from src.samples import QuickstartSample + +# class TestQuickstart(Integration): +# _sample_cls = QuickstartSample +# _environment_cls = AiohttpEnvironment + +# @pytest.mark.asyncio +# async def test_welcome_message(self, agent_client, response_client): +# res = await agent_client.send_expect_replies("hi") +# await asyncio.sleep(1) # Wait for processing +# responses = await response_client.pop() + +# assert len(responses) == 0 + +# first_non_typing = next((r for r in res if r.type != "typing"), None) +# assert first_non_typing is not None +# assert first_non_typing.text == "you said: hi" diff --git a/dev/integration/integration/requirements.txt b/dev/integration/integration/requirements.txt new file mode 100644 index 00000000..d524e63a --- /dev/null +++ b/dev/integration/integration/requirements.txt @@ -0,0 +1 @@ +aioresponses \ No newline at end of file diff --git a/dev/integration/test_basics.py b/dev/integration/test_basics.py new file mode 100644 index 00000000..28c2244e --- /dev/null +++ b/dev/integration/test_basics.py @@ -0,0 +1,8 @@ +from microsoft_agents.testing import DataDrivenTester + + +class TestBasics(DataDrivenTester): + _input_dir = "./data_driven_tests" + + def test(self): + self._run_data_driven_test("input_file.json") diff --git a/dev/microsoft-agents-testing/_manual_test/__init__.py b/dev/microsoft-agents-testing/_manual_test/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/microsoft-agents-testing/tests/manual_test/env.TEMPLATE b/dev/microsoft-agents-testing/_manual_test/env.TEMPLATE similarity index 100% rename from dev/microsoft-agents-testing/tests/manual_test/env.TEMPLATE rename to dev/microsoft-agents-testing/_manual_test/env.TEMPLATE diff --git a/dev/microsoft-agents-testing/tests/manual_test/main.py b/dev/microsoft-agents-testing/_manual_test/main.py similarity index 100% rename from dev/microsoft-agents-testing/tests/manual_test/main.py rename to dev/microsoft-agents-testing/_manual_test/main.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/__init__.py index c8364fff..824f8e5e 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/__init__.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/__init__.py @@ -1,5 +1,20 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from .sdk_config import SDKConfig +from .assertions import ( + ActivityAssertion, + Selector, + AssertionQuantifier, + assert_activity, + assert_field, + check_activity, + check_activity_verbose, + check_field, + check_field_verbose, + FieldAssertionType, +) from .auth import generate_token, generate_token_from_config from .utils import populate_activity, get_host_and_port @@ -27,4 +42,14 @@ "Integration", "populate_activity", "get_host_and_port", + "ActivityAssertion", + "Selector", + "AssertionQuantifier", + "assert_activity", + "assert_field", + "check_activity", + "check_activity_verbose", + "check_field", + "check_field_verbose", + "FieldAssertionType", ] diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/assertions/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/assertions/__init__.py new file mode 100644 index 00000000..664b17c6 --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/assertions/__init__.py @@ -0,0 +1,26 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .activity_assertion import ActivityAssertion +from .assertions import ( + assert_activity, + assert_field, +) +from .check_activity import check_activity, check_activity_verbose +from .check_field import check_field, check_field_verbose +from .type_defs import FieldAssertionType, AssertionQuantifier, UNSET_FIELD +from .selector import Selector + +__all__ = [ + "ActivityAssertion", + "assert_activity", + "assert_field", + "check_activity", + "check_activity_verbose", + "check_field", + "check_field_verbose", + "FieldAssertionType", + "Selector", + "AssertionQuantifier", + "UNSET_FIELD", +] diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/assertions/activity_assertion.py b/dev/microsoft-agents-testing/microsoft_agents/testing/assertions/activity_assertion.py new file mode 100644 index 00000000..2fb161a5 --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/assertions/activity_assertion.py @@ -0,0 +1,106 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +from typing import Optional + +from microsoft_agents.activity import Activity + +from .check_activity import check_activity_verbose +from .selector import Selector +from .type_defs import AssertionQuantifier, AssertionErrorData + + +class ActivityAssertion: + """Class for asserting activities based on a selector and assertion criteria.""" + + _selector: Selector + _quantifier: AssertionQuantifier + _assertion: dict | Activity + + def __init__( + self, + assertion: dict | Activity | None = None, + selector: Selector | None = None, + quantifier: AssertionQuantifier = AssertionQuantifier.ALL, + ) -> None: + """Initializes the ActivityAssertion with the given configuration. + + :param config: The configuration dictionary containing quantifier, selector, and assertion. + """ + + self._assertion = assertion or {} + self._selector = selector or Selector() + self._quantifier = quantifier + + @staticmethod + def _combine_assertion_errors(errors: list[AssertionErrorData]) -> str: + """Combines multiple assertion errors into a single string representation. + + :param errors: The list of assertion errors to be combined. + :return: A string representation of the combined assertion errors. + """ + return "\n".join(str(error) for error in errors) + + def check(self, activities: list[Activity]) -> tuple[bool, Optional[str]]: + """Asserts that the given activities match the assertion criteria. + + :param activities: The list of activities to be tested. + :return: A tuple containing a boolean indicating if the assertion passed and an optional error message. + """ + + activities = self._selector(activities) + + count = 0 + for activity in activities: + res, assertion_error_data = check_activity_verbose( + activity, self._assertion + ) + if self._quantifier == AssertionQuantifier.ALL and not res: + return ( + False, + f"Activity did not match the assertion: {activity}\nError: {assertion_error_data}", + ) + if self._quantifier == AssertionQuantifier.NONE and res: + return ( + False, + f"Activity matched the assertion when none were expected: {activity}", + ) + if res: + count += 1 + + passes = True + if self._quantifier == AssertionQuantifier.ONE and count != 1: + return ( + False, + f"Expected exactly one activity to match the assertion, but found {count}.", + ) + + return passes, None + + def __call__(self, activities: list[Activity]) -> None: + """Allows the ActivityAssertion instance to be called directly. + + :param activities: The list of activities to be tested. + :return: A tuple containing a boolean indicating if the assertion passed and an optional error message. + """ + passes, error = self.check(activities) + assert passes, error + + @staticmethod + def from_config(config: dict) -> ActivityAssertion: + """Creates an ActivityAssertion instance from a configuration dictionary. + + :param config: The configuration dictionary containing quantifier, selector, and assertion. + :return: An ActivityAssertion instance. + """ + assertion = config.get("activity", {}) + selector = Selector.from_config(config.get("selector", {})) + quantifier = AssertionQuantifier.from_config(config.get("quantifier", "all")) + + return ActivityAssertion( + assertion=assertion, + selector=selector, + quantifier=quantifier, + ) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/assertions/assertions.py b/dev/microsoft-agents-testing/microsoft_agents/testing/assertions/assertions.py new file mode 100644 index 00000000..3ca15fde --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/assertions/assertions.py @@ -0,0 +1,35 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Any + +from microsoft_agents.activity import Activity + +from .type_defs import FieldAssertionType, AssertionQuantifier +from .check_activity import check_activity_verbose +from .check_field import check_field_verbose + + +def assert_field( + actual_value: Any, assertion: Any, assertion_type: FieldAssertionType +) -> None: + """Asserts that a specific field in the target matches the baseline. + + :param key_in_baseline: The key of the field to be tested. + :param target: The target dictionary containing the actual values. + :param assertion: The baseline dictionary containing the expected values. + """ + res, assertion_error_message = check_field_verbose( + actual_value, assertion, assertion_type + ) + assert res, assertion_error_message + + +def assert_activity(activity: Activity, assertion: Activity | dict) -> None: + """Asserts that the given activity matches the baseline activity. + + :param activity: The activity to be tested. + :param assertion: The baseline activity or a dictionary representing the expected activity data. + """ + res, assertion_error_data = check_activity_verbose(activity, assertion) + assert res, str(assertion_error_data) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/assertions/check_activity.py b/dev/microsoft-agents-testing/microsoft_agents/testing/assertions/check_activity.py new file mode 100644 index 00000000..d1de3893 --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/assertions/check_activity.py @@ -0,0 +1,90 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Any, Optional + +from microsoft_agents.activity import Activity +from microsoft_agents.testing.utils import normalize_activity_data + +from .check_field import check_field, _parse_assertion +from .type_defs import UNSET_FIELD, FieldAssertionType, AssertionErrorData + + +def _check( + actual: Any, baseline: Any, field_path: str = "" +) -> tuple[bool, Optional[AssertionErrorData]]: + """Recursively checks the actual data against the baseline data. + + :param actual: The actual data to be tested. + :param baseline: The baseline data to compare against. + :param field_path: The current field path being checked (for error reporting). + :return: A tuple containing a boolean indicating success and optional assertion error data. + """ + + assertion, assertion_type = _parse_assertion(baseline) + + if assertion_type is None: + if isinstance(baseline, dict): + for key in baseline: + new_field_path = f"{field_path}.{key}" if field_path else key + new_actual = actual.get(key, UNSET_FIELD) + new_baseline = baseline[key] + + res, assertion_error_data = _check( + new_actual, new_baseline, new_field_path + ) + if not res: + return False, assertion_error_data + return True, None + + elif isinstance(baseline, list): + for index, item in enumerate(baseline): + new_field_path = ( + f"{field_path}[{index}]" if field_path else f"[{index}]" + ) + new_actual = actual[index] if index < len(actual) else UNSET_FIELD + new_baseline = item + + res, assertion_error_data = _check( + new_actual, new_baseline, new_field_path + ) + if not res: + return False, assertion_error_data + return True, None + else: + raise ValueError("Unsupported baseline type for complex assertion.") + else: + assert isinstance(assertion_type, FieldAssertionType) + res = check_field(actual, assertion, assertion_type) + if res: + return True, None + else: + assertion_error_data = AssertionErrorData( + field_path=field_path, + actual_value=actual, + assertion=assertion, + assertion_type=assertion_type, + ) + return False, assertion_error_data + + +def check_activity(activity: Activity, baseline: Activity | dict) -> bool: + """Asserts that the given activity matches the baseline activity. + + :param activity: The activity to be tested. + :param baseline: The baseline activity or a dictionary representing the expected activity data. + """ + return check_activity_verbose(activity, baseline)[0] + + +def check_activity_verbose( + activity: Activity, baseline: Activity | dict +) -> tuple[bool, Optional[AssertionErrorData]]: + """Asserts that the given activity matches the baseline activity. + + :param activity: The activity to be tested. + :param baseline: The baseline activity or a dictionary representing the expected activity data. + """ + actual_activity = normalize_activity_data(activity) + baseline = normalize_activity_data(baseline) + return _check(actual_activity, baseline, "activity") diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/assertions/check_field.py b/dev/microsoft-agents-testing/microsoft_agents/testing/assertions/check_field.py new file mode 100644 index 00000000..6693f706 --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/assertions/check_field.py @@ -0,0 +1,101 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import re +from typing import Any, Optional + +from .type_defs import FieldAssertionType, UNSET_FIELD + + +_OPERATIONS = { + FieldAssertionType.EQUALS: lambda a, b: a == b or (a is UNSET_FIELD and b is None), + FieldAssertionType.NOT_EQUALS: lambda a, b: a != b + or (a is UNSET_FIELD and b is not None), + FieldAssertionType.GREATER_THAN: lambda a, b: a > b, + FieldAssertionType.LESS_THAN: lambda a, b: a < b, + FieldAssertionType.CONTAINS: lambda a, b: b in a, + FieldAssertionType.NOT_CONTAINS: lambda a, b: b not in a, + FieldAssertionType.RE_MATCH: lambda a, b: re.match(b, a) is not None, +} + + +def _parse_assertion(field: Any) -> tuple[Any, Optional[FieldAssertionType]]: + """Parses the assertion information and returns the assertion type and baseline value. + + :param assertion_info: The assertion information to be parsed. + :return: A tuple containing the assertion type and baseline value. + """ + + assertion_type = FieldAssertionType.EQUALS + assertion = None + + if ( + isinstance(field, dict) + and "assertion_type" in field + and "assertion" in field + and field["assertion_type"] in FieldAssertionType.__members__ + ): + # format: + # {"assertion_type": "__EQ__", "assertion": "value"} + assertion_type = FieldAssertionType[field["assertion_type"]] + assertion = field.get("assertion") + + elif ( + isinstance(field, list) + and len(field) >= 2 + and isinstance(field[0], str) + and field[0] in FieldAssertionType.__members__ + ): + # format: + # ["__EQ__", "assertion"] + assertion_type = FieldAssertionType[field[0]] + assertion = field[1] + elif isinstance(field, list) or isinstance(field, dict): + assertion_type = None + else: + # default format: direct value + assertion = field + + return assertion, assertion_type + + +def check_field( + actual_value: Any, assertion: Any, assertion_type: FieldAssertionType +) -> bool: + """Checks if the actual value satisfies the given assertion based on the assertion type. + + :param actual_value: The value to be checked. + :param assertion: The expected value or pattern to check against. + :param assertion_type: The type of assertion to perform. + :return: True if the assertion is satisfied, False otherwise. + """ + + operation = _OPERATIONS.get(assertion_type) + if not operation: + raise ValueError(f"Unsupported assertion type: {assertion_type}") + return operation(actual_value, assertion) + + +def check_field_verbose( + actual_value: Any, assertion: Any, assertion_type: FieldAssertionType +) -> tuple[bool, Optional[str]]: + """Checks if the actual value satisfies the given assertion based on the assertion type. + + :param actual_value: The value to be checked. + :param assertion: The expected value or pattern to check against. + :param assertion_type: The type of assertion to perform. + :return: A tuple containing a boolean indicating if the assertion is satisfied and an optional error message. + """ + + operation = _OPERATIONS.get(assertion_type) + if not operation: + raise ValueError(f"Unsupported assertion type: {assertion_type}") + + result = operation(actual_value, assertion) + if result: + return True, None + else: + return ( + False, + f"Assertion failed: actual value '{actual_value}' does not satisfy '{assertion_type.name}' with assertion '{assertion}'", + ) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/assertions/selector.py b/dev/microsoft-agents-testing/microsoft_agents/testing/assertions/selector.py new file mode 100644 index 00000000..f588216c --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/assertions/selector.py @@ -0,0 +1,99 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +from microsoft_agents.activity import Activity + +from .check_activity import check_activity + + +class Selector: + """Class for selecting activities based on a selector and quantifier.""" + + _selector: dict + _index: int | None + + def __init__( + self, + selector: dict | Activity | None = None, + index: int | None = None, + ) -> None: + """Initializes the Selector with the given configuration. + + :param selector: The selector to use for selecting activities. + The selector is an object holding the activity fields to match. + :param quantifier: The quantifier to use for selecting activities. + :param index: The index of the activity to select when quantifier is ONE. + + When quantifier is ALL, index should be None. + When quantifier is ONE, index defaults to 0 if not provided. + """ + + if selector is None: + selector = {} + elif isinstance(selector, Activity): + selector = selector.model_dump(exclude_unset=True) + + self._selector = selector + self._index = index + + def select_first(self, activities: list[Activity]) -> Activity | None: + """Selects the first activity from the list of activities. + + :param activities: The list of activities to select from. + :return: A list containing the first activity, or an empty list if none exist. + """ + res = self.select(activities) + if res: + return res[0] + return None + + def select(self, activities: list[Activity]) -> list[Activity]: + """Selects activities based on the selector configuration. + + :param activities: The list of activities to select from. + :return: A list of selected activities. + """ + if self._index is None: + return list( + filter( + lambda activity: check_activity(activity, self._selector), + activities, + ) + ) + else: + filtered_list = [] + for activity in activities: + if check_activity(activity, self._selector): + filtered_list.append(activity) + + if self._index < 0 and abs(self._index) <= len(filtered_list): + return [filtered_list[self._index]] + elif self._index >= 0 and self._index < len(filtered_list): + return [filtered_list[self._index]] + else: + return [] + + def __call__(self, activities: list[Activity]) -> list[Activity]: + """Allows the Selector instance to be called as a function. + + :param activities: The list of activities to select from. + :return: A list of selected activities. + """ + return self.select(activities) + + @staticmethod + def from_config(config: dict) -> Selector: + """Creates a Selector instance from a configuration dictionary. + + :param config: The configuration dictionary containing selector, quantifier, and index. + :return: A Selector instance. + """ + selector = config.get("activity", {}) + index = config.get("index", None) + + return Selector( + selector=selector, + index=index, + ) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/assertions/type_defs.py b/dev/microsoft-agents-testing/microsoft_agents/testing/assertions/type_defs.py new file mode 100644 index 00000000..97c4be49 --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/assertions/type_defs.py @@ -0,0 +1,70 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +from enum import Enum +from dataclasses import dataclass +from typing import Any + + +class UNSET_FIELD: + """Singleton to represent an unset field in activity comparisons.""" + + @staticmethod + def get(*args, **kwargs): + """Returns the singleton instance.""" + return UNSET_FIELD + + +class FieldAssertionType(str, Enum): + """Defines the types of assertions that can be made on fields.""" + + EQUALS = "EQUALS" + NOT_EQUALS = "NOT_EQUALS" + GREATER_THAN = "GREATER_THAN" + LESS_THAN = "LESS_THAN" + CONTAINS = "CONTAINS" + NOT_CONTAINS = "NOT_CONTAINS" + IN = "IN" + NOT_IN = "NOT_IN" + RE_MATCH = "RE_MATCH" + + +class AssertionQuantifier(str, Enum): + """Defines quantifiers for assertions on activities.""" + + ANY = "ANY" + ALL = "ALL" + ONE = "ONE" + NONE = "NONE" + + @staticmethod + def from_config(value: str) -> AssertionQuantifier: + """Creates an AssertionQuantifier from a configuration string. + + :param value: The configuration string. + :return: The corresponding AssertionQuantifier. + """ + value = value.upper() + if value not in AssertionQuantifier: + raise ValueError(f"Invalid AssertionQuantifier value: {value}") + return AssertionQuantifier(value) + + +@dataclass +class AssertionErrorData: + """Data class to hold information about assertion errors.""" + + field_path: str + actual_value: Any + assertion: Any + assertion_type: FieldAssertionType + + def __str__(self) -> str: + return ( + f"Assertion failed at '{self.field_path}': " + f"actual value '{self.actual_value}' " + f"does not satisfy assertion '{self.assertion}' " + f"of type '{self.assertion_type}'." + ) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/auth/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/auth/__init__.py index 3fe2a78f..80bb0402 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/auth/__init__.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/auth/__init__.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from .generate_token import generate_token, generate_token_from_config __all__ = ["generate_token", "generate_token_from_config"] diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/auth/generate_token.py b/dev/microsoft-agents-testing/microsoft_agents/testing/auth/generate_token.py index 83106639..f96f6d9e 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/auth/generate_token.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/auth/generate_token.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + import requests from microsoft_agents.hosting.core import AgentAuthConfiguration diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/__init__.py index 3ad1e376..cdc0f0df 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/__init__.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/__init__.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from .core import ( AgentClient, ApplicationRunner, diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/__init__.py index 9c69a2ae..a1161336 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/__init__.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/__init__.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from .application_runner import ApplicationRunner from .aiohttp import AiohttpEnvironment from .client import ( diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/aiohttp/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/aiohttp/__init__.py index 82d2d1d0..4625620e 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/aiohttp/__init__.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/aiohttp/__init__.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from .aiohttp_environment import AiohttpEnvironment from .aiohttp_runner import AiohttpRunner diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/aiohttp/aiohttp_environment.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/aiohttp/aiohttp_environment.py index 7ff83dc0..cd630697 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/aiohttp/aiohttp_environment.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/aiohttp/aiohttp_environment.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from aiohttp.web import Request, Response, Application from microsoft_agents.hosting.aiohttp import ( diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/aiohttp/aiohttp_runner.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/aiohttp/aiohttp_runner.py index c8fe23c2..518c9b5a 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/aiohttp/aiohttp_runner.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/aiohttp/aiohttp_runner.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from typing import Optional from threading import Thread, Event import asyncio diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/application_runner.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/application_runner.py index ebbc56f9..9c77d745 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/application_runner.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/application_runner.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + import asyncio from abc import ABC, abstractmethod from typing import Any, Optional diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/client/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/client/__init__.py index 1d59411e..7b778407 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/client/__init__.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/client/__init__.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from .agent_client import AgentClient from .response_client import ResponseClient diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/client/agent_client.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/client/agent_client.py index 73067207..0928dfff 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/client/agent_client.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/client/agent_client.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + import json import asyncio from typing import Optional, cast diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/client/response_client.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/client/response_client.py index b283efdf..e22a10df 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/client/response_client.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/client/response_client.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from __future__ import annotations import sys diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/environment.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/environment.py index 0aa99f24..a351e735 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/environment.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/environment.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from abc import ABC, abstractmethod from typing import Awaitable, Callable @@ -34,7 +37,7 @@ async def init_env(self, environ_config: dict) -> None: @abstractmethod def create_runner(self, *args, **kwargs) -> ApplicationRunner: """Create an application runner for the environment. - + Subclasses may accept additional arguments as needed. """ raise NotImplementedError() diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/integration.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/integration.py index 3b459617..1b0673f5 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/integration.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/integration.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + import pytest import os diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/sample.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/sample.py index 6dde3668..d97298cc 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/sample.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/core/sample.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from abc import ABC, abstractmethod from .environment import Environment diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/data_driven/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/data_driven/__init__.py new file mode 100644 index 00000000..0d6b013c --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/data_driven/__init__.py @@ -0,0 +1,3 @@ +from .data_driven_test import DataDrivenTest + +__all__ = ["DataDrivenTest"] diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/data_driven/data_driven_test.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/data_driven/data_driven_test.py new file mode 100644 index 00000000..631be08d --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/data_driven/data_driven_test.py @@ -0,0 +1,91 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License.s + +import asyncio + +import yaml + +from copy import deepcopy + +from microsoft_agents.activity import Activity + +from microsoft_agents.testing.assertions import ActivityAssertion +from microsoft_agents.testing.utils import ( + populate_activity, + update_with_defaults, +) + +from ..core import AgentClient, ResponseClient + + +class DataDrivenTest: + """Data driven test runner.""" + + def __init__(self, test_flow: dict) -> None: + self._description = test_flow.get("description", "") + + defaults = test_flow.get("defaults", {}) + self._input_defaults = defaults.get("input", {}) + self._assertion_defaults = defaults.get("assertion", {}) + self._sleep_defaults = defaults.get("sleep", {}) + + parent = test_flow.get("parent") + if parent: + with open(parent, "r", encoding="utf-8") as f: + parent_flow = yaml.safe_load(f) + input_defaults = parent_flow.get("defaults", {}).get("input", {}) + sleep_defaults = parent_flow.get("defaults", {}).get("sleep", {}) + assertion_defaults = parent_flow.get("defaults", {}).get( + "assertion", {} + ) + + self._input_defaults = {**input_defaults, **self._input_defaults} + self._sleep_defaults = {**sleep_defaults, **self._sleep_defaults} + self._assertion_defaults = { + **assertion_defaults, + **self._assertion_defaults, + } + + self._test = test_flow.get("test", []) + + def _load_input(self, input_data: dict) -> Activity: + defaults = deepcopy(self._input_defaults) + update_with_defaults(input_data, defaults) + return Activity.model_validate(input_data.get("activity", {})) + + def _load_assertion(self, assertion_data: dict) -> ActivityAssertion: + defaults = deepcopy(self._assertion_defaults) + update_with_defaults(assertion_data, defaults) + return ActivityAssertion.from_config(assertion_data) + + async def _sleep(self, sleep_data: dict) -> None: + duration = sleep_data.get("duration") + if duration is None: + duration = self._sleep_defaults.get("duration", 0) + await asyncio.sleep(duration) + + async def run( + self, agent_client: AgentClient, response_client: ResponseClient + ) -> None: + """Run the data driven test. + + :param agent_client: The agent client to send activities to. + """ + + responses = [] + for step in self._test: + step_type = step.get("type") + if not step_type: + raise ValueError("Each step must have a 'type' field.") + + if step_type == "input": + input_activity = self._load_input(step) + await agent_client.send_activity(input_activity) + + elif step_type == "assertion": + activity_assertion = self._load_assertion(step) + responses.extend(await response_client.pop()) + activity_assertion(responses) + + elif step_type == "sleep": + await self._sleep(step) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/data_driven/ddt.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/data_driven/ddt.py new file mode 100644 index 00000000..0a72166b --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/data_driven/ddt.py @@ -0,0 +1,41 @@ +# # Copyright (c) Microsoft Corporation. All rights reserved. +# # Licensed under the MIT License. + +# from typing import Callable + +# import pytest + +# from microsoft_agents.testing.integration.core import Integration + +# from .data_driven_test import DataDrivenTest + +# def _add_test_method(test_cls: Integration, test_path: str, base_dir: str) -> None: + +# test_case_name = f"test_data_driven__{test_path.replace('/', '_').replace('.', '_')}" + +# @pytest.mark.asyncio +# async def _func(self, agent_client, response_client) -> None: +# ddt = DataDrivenTest(f"{base_dir}/{test_path}") +# await ddt.run(agent_client, response_client) + +# setattr(test_cls, test_case_name, func) + +# def ddt(test_path: str) -> Callable[[Integration], Integration]: + +# def decorator(test_cls: Integration) -> Integration: + +# test_case_name = f"test_data_driven__{test_path.replace('/', '_').replace('.', '_')}" + +# async def func(self, agent_client, response_client) -> None: +# ddt = DataDrivenTest(test_path) + +# responses = [] + +# await for step in ddt: +# if isinstance(step, Activity): +# await agent_client.send_activity(step) +# elif isinstance(step, dict): +# # assertion +# responses.extend(await response_client.pop()) + +# return decorator diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/integration/ddt.py b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/ddt.py new file mode 100644 index 00000000..f09f9bf5 --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/integration/ddt.py @@ -0,0 +1,59 @@ +import asyncio + +from copy import deepcopy +from typing import Awaitable, Callable + +from microsoft_agents.activity import Activity + +from microsoft_agents.testing.assertions import assert_activity + + +class DataDrivenTest: + + def __init__(self, test_flow: dict) -> None: + self._description = test_flow.get("description", "") + + defaults = test_flow.get("defaults", {}) + self._input_defaults = defaults.get("input", {}) + self._assertion_defaults = defaults.get("assertion", {}) + self._sleep_defaults = defaults.get("sleep", {}) + + self._test = test_flow.get("test", []) + + def _load_input(self, input_data: dict) -> Activity: + data = deepcopy(self._input_defaults) + data.update(input_data) + return Activity.model_validate(data) + + def _load_assertion(self, assertion_data: dict) -> dict: + data = deepcopy(self._assertion_defaults) + data.update(assertion_data) + return data + + async def _sleep(self, sleep_data: dict) -> None: + duration = sleep_data.get("duration") + if duration is None: + duration = self._sleep_defaults.get("duration", 0) + await asyncio.sleep(duration) + + async def run(self, agent_client, response_client) -> None: + + for step in self._test: + step_type = step.get("type") + if not step_type: + raise ValueError("Each step must have a 'type' field.") + + if step_type == "input": + input_activity = self._load_input(step) + await agent_client.send_activity(input_activity) + elif step_type == "assertion": + assertion = self._load_assertion(step) + responses = await response_client.pop() + + selector = Selector(assertion.get("selector", {})) + selection = selector.select(responses) + + assert_activity(selection, assertion) + + elif step_type == "sleep": + await self._sleep(step) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/sdk_config.py b/dev/microsoft-agents-testing/microsoft_agents/testing/sdk_config.py index c1824ae5..61e1def8 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/sdk_config.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/sdk_config.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + import os from copy import deepcopy from dotenv import load_dotenv, dotenv_values diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/utils/__init__.py b/dev/microsoft-agents-testing/microsoft_agents/testing/utils/__init__.py index 0c902992..85ccc432 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/utils/__init__.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/utils/__init__.py @@ -1,7 +1,12 @@ -from .populate_activity import populate_activity -from .urls import get_host_and_port +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .populate import update_with_defaults, populate_activity +from .misc import get_host_and_port, normalize_activity_data __all__ = [ + "update_with_defaults", "populate_activity", "get_host_and_port", + "normalize_activity_data", ] diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/utils/misc.py b/dev/microsoft-agents-testing/microsoft_agents/testing/utils/misc.py new file mode 100644 index 00000000..ea6cda35 --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/utils/misc.py @@ -0,0 +1,23 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from urllib.parse import urlparse + +from microsoft_agents.activity import Activity + + +def get_host_and_port(url: str) -> tuple[str, int]: + """Extract host and port from a URL.""" + + parsed_url = urlparse(url) + host = parsed_url.hostname or "localhost" + port = parsed_url.port or (443 if parsed_url.scheme == "https" else 80) + return host, port + + +def normalize_activity_data(source: Activity | dict) -> dict: + """Normalize Activity data to a dictionary format.""" + + if isinstance(source, Activity): + return source.model_dump(exclude_unset=True, mode="json") + return source diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/utils/populate.py b/dev/microsoft-agents-testing/microsoft_agents/testing/utils/populate.py new file mode 100644 index 00000000..acec37a9 --- /dev/null +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/utils/populate.py @@ -0,0 +1,37 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from microsoft_agents.activity import Activity + + +def update_with_defaults(original: dict, defaults: dict) -> None: + """Populate a dictionary with default values. + + :param original: The original dictionary to populate. + :param defaults: The dictionary containing default values. + """ + + for key in defaults.keys(): + if key not in original: + original[key] = defaults[key] + elif isinstance(original[key], dict) and isinstance(defaults[key], dict): + update_with_defaults(original[key], defaults[key]) + + +def populate_activity(original: Activity, defaults: Activity | dict) -> Activity: + """Populate an Activity object with default values. + + :param original: The original Activity object to populate. + :param defaults: The Activity object or dictionary containing default values. + """ + + if isinstance(defaults, Activity): + defaults = defaults.model_dump(exclude_unset=True) + + new_activity_dict = original.model_dump(exclude_unset=True) + + for key in defaults.keys(): + if key not in new_activity_dict: + new_activity_dict[key] = defaults[key] + + return Activity.model_validate(new_activity_dict) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/utils/populate_activity.py b/dev/microsoft-agents-testing/microsoft_agents/testing/utils/populate_activity.py deleted file mode 100644 index a6b1c19f..00000000 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/utils/populate_activity.py +++ /dev/null @@ -1,16 +0,0 @@ -from microsoft_agents.activity import Activity - - -def populate_activity(original: Activity, defaults: Activity | dict) -> Activity: - """Populate an Activity object with default values.""" - - if isinstance(defaults, Activity): - defaults = defaults.model_dump(exclude_unset=True) - - new_activity = original.model_copy() - - for key in defaults.keys(): - if getattr(new_activity, key) is None: - setattr(new_activity, key, defaults[key]) - - return new_activity \ No newline at end of file diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/utils/urls.py b/dev/microsoft-agents-testing/microsoft_agents/testing/utils/urls.py deleted file mode 100644 index d964ebd2..00000000 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/utils/urls.py +++ /dev/null @@ -1,10 +0,0 @@ -from urllib.parse import urlparse - - -def get_host_and_port(url: str) -> tuple[str, int]: - """Extract host and port from a URL.""" - - parsed_url = urlparse(url) - host = parsed_url.hostname or "localhost" - port = parsed_url.port or (443 if parsed_url.scheme == "https" else 80) - return host, port diff --git a/dev/microsoft-agents-testing/pyproject.toml b/dev/microsoft-agents-testing/pyproject.toml index cf659e6f..5557ac38 100644 --- a/dev/microsoft-agents-testing/pyproject.toml +++ b/dev/microsoft-agents-testing/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools"] build-backend = "setuptools.build_meta" [project] -name = "microsoft-agents-hosting-core" +name = "microsoft-agents-testing" dynamic = ["version", "dependencies"] description = "Core library for Microsoft Agents" readme = {file = "README.md", content-type = "text/markdown"} diff --git a/dev/microsoft-agents-testing/pytest.ini b/dev/microsoft-agents-testing/pytest.ini new file mode 100644 index 00000000..fee2ab83 --- /dev/null +++ b/dev/microsoft-agents-testing/pytest.ini @@ -0,0 +1,40 @@ +[pytest] +# Pytest configuration for Microsoft Agents for Python + +# Treat all warnings as errors by default +# This ensures that any code generating warnings will fail tests, +# promoting cleaner code and early detection of issues +filterwarnings = + error + # Ignore specific warnings that are not actionable or are from dependencies + ignore::DeprecationWarning:pkg_resources.* + ignore::DeprecationWarning:setuptools.* + ignore::PendingDeprecationWarning + # pytest-asyncio warnings that are safe to ignore + ignore:.*deprecated.*asyncio.*:DeprecationWarning:pytest_asyncio.* + +# Test discovery configuration +testpaths = tests +python_files = test_*.py *_test.py +python_classes = Test* +python_functions = test_* +asyncio_mode=auto + +# Output configuration +addopts = + --strict-markers + --strict-config + --verbose + --tb=short + --durations=10 + +# Minimum version requirement +minversion = 6.0 + +# Markers for test categorization +markers = + unit: Unit tests + integration: Integration tests + slow: Slow tests that may take longer to run + requires_network: Tests that require network access + requires_auth: Tests that require authentication \ No newline at end of file diff --git a/dev/microsoft-agents-testing/tests/assertions/__init__.py b/dev/microsoft-agents-testing/tests/assertions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/microsoft-agents-testing/tests/assertions/_common.py b/dev/microsoft-agents-testing/tests/assertions/_common.py new file mode 100644 index 00000000..83e666e4 --- /dev/null +++ b/dev/microsoft-agents-testing/tests/assertions/_common.py @@ -0,0 +1,21 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest + +from microsoft_agents.activity import Activity + + +@pytest.fixture +def activity(): + return Activity(type="message", text="Hello, World!") + + +@pytest.fixture( + params=[ + Activity(type="message", text="Hello, World!"), + {"type": "message", "text": "Hello, World!"}, + ] +) +def baseline(request): + return request.param diff --git a/dev/microsoft-agents-testing/tests/assertions/test_activity_assertion.py b/dev/microsoft-agents-testing/tests/assertions/test_activity_assertion.py new file mode 100644 index 00000000..b459eab7 --- /dev/null +++ b/dev/microsoft-agents-testing/tests/assertions/test_activity_assertion.py @@ -0,0 +1,626 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest + +from microsoft_agents.activity import Activity +from microsoft_agents.testing import ( + ActivityAssertion, + Selector, + AssertionQuantifier, + FieldAssertionType, +) + + +class TestActivityAssertionCheckWithQuantifierAll: + """Tests for check() method with ALL quantifier.""" + + @pytest.fixture + def activities(self): + """Create a list of test activities.""" + return [ + Activity(type="message", text="Hello"), + Activity(type="message", text="World"), + Activity(type="event", name="test_event"), + Activity(type="message", text="Goodbye"), + ] + + def test_check_all_matching_activities(self, activities): + """Test that all matching activities pass the assertion.""" + assertion = ActivityAssertion( + assertion={"type": "message"}, + selector=Selector(selector={"type": "message"}), + quantifier=AssertionQuantifier.ALL, + ) + passes, error = assertion.check(activities) + assert passes is True + assert error is None + + def test_check_all_with_one_failing_activity(self, activities): + """Test that one failing activity causes ALL assertion to fail.""" + assertion = ActivityAssertion( + assertion={"text": "Hello"}, + selector=Selector(selector={"type": "message"}), + quantifier=AssertionQuantifier.ALL, + ) + passes, error = assertion.check(activities) + assert passes is False + assert error is not None + assert "Activity did not match the assertion" in error + + def test_check_all_with_empty_selector(self, activities): + """Test ALL quantifier with empty selector (matches all activities).""" + assertion = ActivityAssertion( + assertion={"type": "message"}, + selector=Selector(selector={}), + quantifier=AssertionQuantifier.ALL, + ) + passes, error = assertion.check(activities) + # Should fail because not all activities are messages + assert passes is False + + def test_check_all_with_empty_activities(self): + """Test ALL quantifier with empty activities list.""" + assertion = ActivityAssertion( + assertion={"type": "message"}, quantifier=AssertionQuantifier.ALL + ) + passes, error = assertion.check([]) + assert passes is True + assert error is None + + def test_check_all_with_complex_assertion(self, activities): + """Test ALL quantifier with complex nested assertion.""" + complex_activities = [ + Activity(type="message", text="Hello", channelData={"id": 1}), + Activity(type="message", text="World", channelData={"id": 2}), + ] + assertion = ActivityAssertion( + assertion={"type": "message"}, + selector=Selector(selector={"type": "message"}), + quantifier=AssertionQuantifier.ALL, + ) + passes, error = assertion.check(complex_activities) + assert passes is True + + +class TestActivityAssertionCheckWithQuantifierNone: + """Tests for check() method with NONE quantifier.""" + + @pytest.fixture + def activities(self): + """Create a list of test activities.""" + return [ + Activity(type="message", text="Hello"), + Activity(type="message", text="World"), + Activity(type="event", name="test_event"), + ] + + def test_check_none_with_no_matches(self, activities): + """Test NONE quantifier when no activities match.""" + assertion = ActivityAssertion( + assertion={"text": "Nonexistent"}, + selector=Selector(selector={"type": "message"}), + quantifier=AssertionQuantifier.NONE, + ) + passes, error = assertion.check(activities) + assert passes is True + assert error is None + + def test_check_none_with_one_match(self, activities): + """Test NONE quantifier fails when one activity matches.""" + assertion = ActivityAssertion( + assertion={"text": "Hello"}, + selector=Selector(selector={"type": "message"}), + quantifier=AssertionQuantifier.NONE, + ) + passes, error = assertion.check(activities) + assert passes is False + assert error is not None + assert "Activity matched the assertion when none were expected" in error + + def test_check_none_with_all_matching(self, activities): + """Test NONE quantifier fails when all activities match.""" + assertion = ActivityAssertion( + assertion={"type": "message"}, + selector=Selector(selector={"type": "message"}), + quantifier=AssertionQuantifier.NONE, + ) + passes, error = assertion.check(activities) + assert passes is False + + def test_check_none_with_empty_activities(self): + """Test NONE quantifier with empty activities list.""" + assertion = ActivityAssertion( + assertion={"type": "message"}, quantifier=AssertionQuantifier.NONE + ) + passes, error = assertion.check([]) + assert passes is True + assert error is None + + +class TestActivityAssertionCheckWithQuantifierOne: + """Tests for check() method with ONE quantifier.""" + + @pytest.fixture + def activities(self): + """Create a list of test activities.""" + return [ + Activity(type="message", text="First"), + Activity(type="message", text="Second"), + Activity(type="event", name="test_event"), + Activity(type="message", text="Third"), + ] + + def test_check_one_with_exactly_one_match(self, activities): + """Test ONE quantifier passes when exactly one activity matches.""" + assertion = ActivityAssertion( + assertion={"text": "First"}, + selector=Selector(selector={"type": "message"}), + quantifier=AssertionQuantifier.ONE, + ) + passes, error = assertion.check(activities) + assert passes is True + assert error is None + + def test_check_one_with_no_matches(self, activities): + """Test ONE quantifier fails when no activities match.""" + assertion = ActivityAssertion( + assertion={"text": "Nonexistent"}, + selector=Selector(selector={"type": "message"}), + quantifier=AssertionQuantifier.ONE, + ) + passes, error = assertion.check(activities) + assert passes is False + assert error is not None + assert "Expected exactly one activity" in error + assert "found 0" in error + + def test_check_one_with_multiple_matches(self, activities): + """Test ONE quantifier fails when multiple activities match.""" + assertion = ActivityAssertion( + assertion={"type": "message"}, + selector=Selector(selector={"type": "message"}), + quantifier=AssertionQuantifier.ONE, + ) + passes, error = assertion.check(activities) + assert passes is False + assert error is not None + assert "Expected exactly one activity" in error + assert "found 3" in error + + def test_check_one_with_empty_activities(self): + """Test ONE quantifier with empty activities list.""" + assertion = ActivityAssertion( + assertion={"type": "message"}, quantifier=AssertionQuantifier.ONE + ) + passes, error = assertion.check([]) + assert passes is False + assert "found 0" in error + + +class TestActivityAssertionCheckWithQuantifierAny: + """Tests for check() method with ANY quantifier.""" + + @pytest.fixture + def activities(self): + """Create a list of test activities.""" + return [ + Activity(type="message", text="Hello"), + Activity(type="message", text="World"), + Activity(type="event", name="test_event"), + ] + + def test_check_any_basic_functionality(self, activities): + """Test that ANY quantifier exists and can be used.""" + # ANY quantifier doesn't have special logic in the current implementation + # but should not cause errors + assertion = ActivityAssertion( + assertion={"type": "message"}, quantifier=AssertionQuantifier.ANY + ) + passes, error = assertion.check(activities) + # Based on the implementation, ANY behaves like checking if count > 0 + assert passes is True + assert error is None + + +class TestActivityAssertionFromConfig: + """Tests for from_config static method.""" + + def test_from_config_minimal(self): + """Test creating assertion from minimal config.""" + config = {} + assertion = ActivityAssertion.from_config(config) + assert assertion._assertion == {} + assert assertion._quantifier == AssertionQuantifier.ALL + + def test_from_config_with_assertion(self): + """Test creating assertion from config with assertion field.""" + config = {"activity": {"type": "message", "text": "Hello"}} + assertion = ActivityAssertion.from_config(config) + assert assertion._assertion == config["activity"] + + def test_from_config_with_selector(self): + """Test creating assertion from config with selector field.""" + config = {"selector": {"selector": {"type": "message"}, "quantifier": "ALL"}} + assertion = ActivityAssertion.from_config(config) + assert assertion._selector is not None + + def test_from_config_with_quantifier(self): + """Test creating assertion from config with quantifier field.""" + config = {"quantifier": "one"} + assertion = ActivityAssertion.from_config(config) + assert assertion._quantifier == AssertionQuantifier.ONE + + def test_from_config_with_all_fields(self): + """Test creating assertion from config with all fields.""" + config = { + "activity": {"type": "message"}, + "selector": { + "selector": {"text": "Hello"}, + "quantifier": "ONE", + "index": 0, + }, + "quantifier": "all", + } + assertion = ActivityAssertion.from_config(config) + assert assertion._assertion == {"type": "message"} + assert assertion._quantifier == AssertionQuantifier.ALL + + def test_from_config_with_case_insensitive_quantifier(self): + """Test from_config handles case-insensitive quantifier strings.""" + for quantifier_str in ["all", "ALL", "All", "ONE", "one", "NONE", "none"]: + config = {"quantifier": quantifier_str} + assertion = ActivityAssertion.from_config(config) + assert isinstance(assertion._quantifier, AssertionQuantifier) + + def test_from_config_with_complex_assertion(self): + """Test creating assertion from config with complex nested assertion.""" + config = { + "activity": {"type": "message", "channelData": {"nested": {"value": 123}}}, + "quantifier": "all", + } + assertion = ActivityAssertion.from_config(config) + assert assertion._assertion["type"] == "message" + assert assertion._assertion["channelData"]["nested"]["value"] == 123 + + +class TestActivityAssertionCombineErrors: + """Tests for _combine_assertion_errors static method.""" + + def test_combine_empty_errors(self): + """Test combining empty error list.""" + result = ActivityAssertion._combine_assertion_errors([]) + assert result == "" + + def test_combine_single_error(self): + """Test combining single error.""" + from microsoft_agents.testing.assertions.type_defs import ( + AssertionErrorData, + FieldAssertionType, + ) + + error = AssertionErrorData( + field_path="activity.text", + actual_value="Hello", + assertion="World", + assertion_type=FieldAssertionType.EQUALS, + ) + result = ActivityAssertion._combine_assertion_errors([error]) + assert "activity.text" in result + assert "Hello" in result + + def test_combine_multiple_errors(self): + """Test combining multiple errors.""" + from microsoft_agents.testing.assertions.type_defs import ( + AssertionErrorData, + FieldAssertionType, + ) + + errors = [ + AssertionErrorData( + field_path="activity.text", + actual_value="Hello", + assertion="World", + assertion_type=FieldAssertionType.EQUALS, + ), + AssertionErrorData( + field_path="activity.type", + actual_value="message", + assertion="event", + assertion_type=FieldAssertionType.EQUALS, + ), + ] + result = ActivityAssertion._combine_assertion_errors(errors) + assert "activity.text" in result + assert "activity.type" in result + assert "\n" in result + + +class TestActivityAssertionIntegration: + """Integration tests with realistic scenarios.""" + + @pytest.fixture + def conversation_activities(self): + """Create a realistic conversation flow.""" + return [ + Activity(type="conversationUpdate", name="add_member"), + Activity(type="message", text="Hello bot", from_property={"id": "user1"}), + Activity(type="message", text="Hi there!", from_property={"id": "bot"}), + Activity( + type="message", text="How are you?", from_property={"id": "user1"} + ), + Activity( + type="message", text="I'm doing well!", from_property={"id": "bot"} + ), + Activity(type="typing"), + Activity(type="message", text="Goodbye", from_property={"id": "user1"}), + ] + + def test_assert_all_user_messages_have_from_property(self, conversation_activities): + """Test that all user messages have a from_property.""" + assertion = ActivityAssertion( + assertion={"from_property": {"id": "user1"}}, + selector=Selector( + selector={"type": "message", "from_property": {"id": "user1"}}, + ), + quantifier=AssertionQuantifier.ALL, + ) + passes, error = assertion.check(conversation_activities) + assert passes is True + + def test_assert_no_error_messages(self, conversation_activities): + """Test that there are no error messages in the conversation.""" + assertion = ActivityAssertion( + assertion={"type": "error"}, + selector=Selector(selector={}), + quantifier=AssertionQuantifier.NONE, + ) + passes, error = assertion.check(conversation_activities) + assert passes is True + + def test_assert_exactly_one_conversation_update(self, conversation_activities): + """Test that there's exactly one conversation update.""" + assertion = ActivityAssertion( + assertion={"type": "conversationUpdate"}, + selector=Selector(selector={"type": "conversationUpdate"}), + quantifier=AssertionQuantifier.ONE, + ) + passes, error = assertion.check(conversation_activities) + assert passes is True + + def test_assert_first_message_is_greeting(self, conversation_activities): + """Test that the first message contains a greeting.""" + assertion = ActivityAssertion( + assertion={"text": {"assertion_type": "CONTAINS", "assertion": "Hello"}}, + selector=Selector(selector={"type": "message"}, index=0), + quantifier=AssertionQuantifier.ALL, + ) + passes, error = assertion.check(conversation_activities) + assert passes is True + + def test_complex_multi_field_assertion(self, conversation_activities): + """Test complex assertion with multiple fields.""" + assertion = ActivityAssertion( + assertion={"type": "message", "from_property": {"id": "bot"}}, + selector=Selector( + selector={"type": "message", "from_property": {"id": "bot"}}, + ), + quantifier=AssertionQuantifier.ALL, + ) + passes, error = assertion.check(conversation_activities) + assert passes is True + + +class TestActivityAssertionEdgeCases: + """Tests for edge cases and boundary conditions.""" + + def test_empty_assertion_matches_all(self): + """Test that empty assertion matches all activities.""" + activities = [ + Activity(type="message", text="Hello"), + Activity(type="event", name="test"), + ] + assertion = ActivityAssertion(assertion={}, quantifier=AssertionQuantifier.ALL) + passes, error = assertion.check(activities) + assert passes is True + + def test_assertion_with_none_values(self): + """Test assertion with None values.""" + activities = [Activity(type="message")] + assertion = ActivityAssertion( + assertion={"text": None}, quantifier=AssertionQuantifier.ALL + ) + passes, error = assertion.check(activities) + # This behavior depends on check_activity implementation + assert isinstance(passes, bool) + + def test_selector_filters_before_assertion(self): + """Test that selector filters activities before assertion check.""" + activities = [ + Activity(type="message", text="Hello"), + Activity(type="event", name="test"), + Activity(type="message", text="World"), + ] + # Selector gets only messages, assertion checks for specific text + assertion = ActivityAssertion( + assertion={"text": "Hello"}, + selector=Selector(selector={"type": "message"}, index=0), + quantifier=AssertionQuantifier.ALL, + ) + passes, error = assertion.check(activities) + assert passes is True + + def test_assertion_error_message_format(self): + """Test that error messages are properly formatted.""" + activities = [Activity(type="message", text="Wrong")] + assertion = ActivityAssertion( + assertion={"text": "Expected"}, quantifier=AssertionQuantifier.ALL + ) + passes, error = assertion.check(activities) + assert passes is False + assert error is not None + assert "Activity did not match the assertion" in error + assert "Error:" in error + + def test_multiple_activities_same_content(self): + """Test handling multiple activities with identical content.""" + activities = [ + Activity(type="message", text="Hello"), + Activity(type="message", text="Hello"), + Activity(type="message", text="Hello"), + ] + assertion = ActivityAssertion( + assertion={"text": "Hello"}, quantifier=AssertionQuantifier.ALL + ) + passes, error = assertion.check(activities) + assert passes is True + + def test_assertion_with_unset_fields(self): + """Test assertion against activities with unset fields.""" + activities = [ + Activity(type="message"), # No text field set + ] + assertion = ActivityAssertion( + assertion={"type": "message"}, quantifier=AssertionQuantifier.ALL + ) + passes, error = assertion.check(activities) + assert passes is True + + +class TestActivityAssertionErrorMessages: + """Tests specifically for error message content and formatting.""" + + def test_all_quantifier_error_includes_activity(self): + """Test that ALL quantifier error includes the failing activity.""" + activities = [Activity(type="message", text="Wrong")] + assertion = ActivityAssertion( + assertion={"text": "Expected"}, quantifier=AssertionQuantifier.ALL + ) + passes, error = assertion.check(activities) + assert passes is False + assert "Activity did not match the assertion" in error + + def test_none_quantifier_error_includes_activity(self): + """Test that NONE quantifier error includes the matching activity.""" + activities = [Activity(type="message", text="Unexpected")] + assertion = ActivityAssertion( + assertion={"text": "Unexpected"}, quantifier=AssertionQuantifier.NONE + ) + passes, error = assertion.check(activities) + assert passes is False + assert "Activity matched the assertion when none were expected" in error + + def test_one_quantifier_error_includes_count(self): + """Test that ONE quantifier error includes the actual count.""" + activities = [ + Activity(type="message"), + Activity(type="message"), + ] + assertion = ActivityAssertion( + assertion={"type": "message"}, quantifier=AssertionQuantifier.ONE + ) + passes, error = assertion.check(activities) + assert passes is False + assert "Expected exactly one activity" in error + assert "2" in error + + +class TestActivityAssertionRealWorldScenarios: + """Tests simulating real-world bot testing scenarios.""" + + def test_validate_welcome_message_sent(self): + """Test that a welcome message is sent when user joins.""" + activities = [ + Activity(type="conversationUpdate", name="add_member"), + Activity(type="message", text="Welcome to our bot!"), + ] + assertion = ActivityAssertion( + assertion={ + "type": "message", + "text": {"assertion_type": "CONTAINS", "assertion": "Welcome"}, + }, + selector=Selector(selector={"type": "message"}), + quantifier=AssertionQuantifier.ALL, + ) + passes, error = assertion.check(activities) + assert passes is True + + def test_validate_no_duplicate_responses(self): + """Test that bot doesn't send duplicate responses.""" + activities = [ + Activity(type="message", text="Response 1"), + Activity(type="message", text="Response 2"), + Activity(type="message", text="Response 3"), + ] + # Check that exactly one of each unique response exists + for response_text in ["Response 1", "Response 2", "Response 3"]: + assertion = ActivityAssertion( + assertion={"text": response_text}, + selector=Selector(selector={"type": "message"}), + quantifier=AssertionQuantifier.ONE, + ) + passes, error = assertion.check(activities) + assert passes is True + + def test_validate_error_handling_response(self): + """Test that bot responds appropriately to errors.""" + activities = [ + Activity(type="message", text="invalid command"), + Activity(type="message", text="I'm sorry, I didn't understand that."), + ] + assertion = ActivityAssertion( + assertion={ + "text": { + "assertion_type": "RE_MATCH", + "assertion": "sorry|understand|help", + } + }, + selector=Selector(selector={"type": "message"}, index=-1), # Last message + quantifier=AssertionQuantifier.ALL, + ) + passes, error = assertion.check(activities) + assert not passes + assert "sorry" in error and "understand" in error and "help" in error + assert FieldAssertionType.RE_MATCH.name in error + + def test_validate_typing_indicator_before_response(self): + """Test that typing indicator is sent before response.""" + activities = [ + Activity(type="message", text="User question"), + Activity(type="typing"), + Activity(type="message", text="Bot response"), + ] + # Verify typing indicator exists + typing_assertion = ActivityAssertion( + assertion={"type": "typing"}, + selector=Selector(selector={"type": "typing"}), + quantifier=AssertionQuantifier.ONE, + ) + passes, error = typing_assertion.check(activities) + assert passes is True + + def test_validate_conversation_flow_order(self): + """Test that conversation follows expected flow.""" + activities = [ + Activity(type="conversationUpdate"), + Activity(type="message", text="User: Hello"), + Activity(type="typing"), + Activity(type="message", text="Bot: Hi!"), + ] + + # Test each step individually + steps = [ + ({"type": "conversationUpdate"}, 0), + ({"type": "message"}, 1), + ({"type": "typing"}, 2), + ({"type": "message"}, 3), + ] + + for assertion_dict, expected_index in steps: + assertion = ActivityAssertion( + assertion=assertion_dict, + selector=Selector(selector={}, index=expected_index), + quantifier=AssertionQuantifier.ALL, + ) + passes, error = assertion.check(activities) + assert passes is True, f"Failed at index {expected_index}: {error}" diff --git a/dev/microsoft-agents-testing/tests/assertions/test_assert_activity.py b/dev/microsoft-agents-testing/tests/assertions/test_assert_activity.py new file mode 100644 index 00000000..20dae390 --- /dev/null +++ b/dev/microsoft-agents-testing/tests/assertions/test_assert_activity.py @@ -0,0 +1,261 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from microsoft_agents.activity import Activity, Attachment +from microsoft_agents.testing.assertions import assert_activity, check_activity + + +class TestAssertActivity: + """Tests for assert_activity function.""" + + def test_assert_activity_with_matching_simple_fields(self): + """Test that activity matches baseline with simple equal fields.""" + activity = Activity(type="message", text="Hello, World!") + baseline = {"type": "message", "text": "Hello, World!"} + assert_activity(activity, baseline) + + def test_assert_activity_with_non_matching_fields(self): + """Test that activity doesn't match baseline with different field values.""" + activity = Activity(type="message", text="Hello") + baseline = {"type": "message", "text": "Goodbye"} + assert not check_activity(activity, baseline) + + def test_assert_activity_with_activity_baseline(self): + """Test that baseline can be an Activity object.""" + activity = Activity(type="message", text="Hello") + baseline = Activity(type="message", text="Hello") + assert_activity(activity, baseline) + + def test_assert_activity_with_partial_baseline(self): + """Test that only fields in baseline are checked.""" + activity = Activity( + type="message", + text="Hello", + channel_id="test-channel", + conversation={"id": "conv123"}, + ) + baseline = {"type": "message", "text": "Hello"} + assert_activity(activity, baseline) + + def test_assert_activity_with_missing_field(self): + """Test that activity with missing field doesn't match baseline.""" + activity = Activity(type="message") + baseline = {"type": "message", "text": "Hello"} + assert not check_activity(activity, baseline) + + def test_assert_activity_with_none_values(self): + """Test that None values are handled correctly.""" + activity = Activity(type="message") + baseline = {"type": "message", "text": None} + assert_activity(activity, baseline) + + def test_assert_activity_with_empty_baseline(self): + """Test that empty baseline always matches.""" + activity = Activity(type="message", text="Hello") + baseline = {} + assert_activity(activity, baseline) + + def test_assert_activity_with_dict_assertion_format(self): + """Test using dict format for assertions.""" + activity = Activity(type="message", text="Hello, World!") + baseline = { + "type": "message", + "text": {"assertion_type": "CONTAINS", "assertion": "Hello"}, + } + assert_activity(activity, baseline) + + def test_assert_activity_with_list_assertion_format(self): + """Test using list format for assertions.""" + activity = Activity(type="message", text="Hello, World!") + baseline = {"type": "message", "text": ["CONTAINS", "World"]} + assert_activity(activity, baseline) + + def test_assert_activity_with_not_equals_assertion(self): + """Test NOT_EQUALS assertion type.""" + activity = Activity(type="message", text="Hello") + baseline = { + "type": "message", + "text": {"assertion_type": "NOT_EQUALS", "assertion": "Goodbye"}, + } + assert_activity(activity, baseline) + + def test_assert_activity_with_contains_assertion(self): + """Test CONTAINS assertion type.""" + activity = Activity(type="message", text="Hello, World!") + baseline = {"text": {"assertion_type": "CONTAINS", "assertion": "World"}} + assert_activity(activity, baseline) + + def test_assert_activity_with_not_contains_assertion(self): + """Test NOT_CONTAINS assertion type.""" + activity = Activity(type="message", text="Hello") + baseline = {"text": {"assertion_type": "NOT_CONTAINS", "assertion": "Goodbye"}} + assert_activity(activity, baseline) + + def test_assert_activity_with_regex_assertion(self): + """Test RE_MATCH assertion type.""" + activity = Activity(type="message", text="msg_20250112_001") + baseline = { + "text": {"assertion_type": "RE_MATCH", "assertion": r"^msg_\d{8}_\d{3}$"} + } + assert_activity(activity, baseline) + + def test_assert_activity_with_multiple_fields_and_mixed_assertions(self): + """Test multiple fields with different assertion types.""" + activity = Activity( + type="message", text="Hello, World!", channel_id="test-channel" + ) + baseline = { + "type": "message", + "text": ["CONTAINS", "Hello"], + "channel_id": {"assertion_type": "NOT_EQUALS", "assertion": "prod-channel"}, + } + assert_activity(activity, baseline) + + def test_assert_activity_fails_on_any_field_mismatch(self): + """Test that activity check fails if any field doesn't match.""" + activity = Activity(type="message", text="Hello", channel_id="test-channel") + baseline = {"type": "message", "text": "Hello", "channel_id": "prod-channel"} + assert not check_activity(activity, baseline) + + def test_assert_activity_with_numeric_fields(self): + """Test with numeric field values.""" + activity = Activity(type="message", locale="en-US") + activity.channel_data = {"timestamp": 1234567890} + baseline = {"type": "message", "channel_data": {"timestamp": 1234567890}} + assert_activity(activity, baseline) + + def test_assert_activity_with_greater_than_assertion(self): + """Test GREATER_THAN assertion on numeric fields.""" + activity = Activity(type="message") + activity.channel_data = {"count": 100} + baseline = { + "channel_data": { + "count": {"assertion_type": "GREATER_THAN", "assertion": 50} + } + } + + # This test depends on how nested dicts are handled + # If channel_data is compared as a whole dict, this might not work as expected + # Keeping this test to illustrate the concept + assert_activity(activity, baseline) + + def test_assert_activity_with_complex_nested_structures(self): + """Test with complex nested structures in baseline.""" + activity = Activity( + type="message", conversation={"id": "conv123", "name": "Test Conversation"} + ) + baseline = { + "type": "message", + "conversation": {"id": "conv123", "name": "Test Conversation"}, + } + assert_activity(activity, baseline) + + def test_assert_activity_with_boolean_fields(self): + """Test with boolean field values.""" + activity = Activity(type="message") + activity.channel_data = {"is_active": True} + baseline = {"channel_data": {"is_active": True}} + assert_activity(activity, baseline) + + def test_assert_activity_type_mismatch(self): + """Test that different activity types don't match.""" + activity = Activity(type="message", text="Hello") + baseline = {"type": "event", "text": "Hello"} + assert not check_activity(activity, baseline) + + def test_assert_activity_with_list_fields(self): + """Test with list field values.""" + activity = Activity(type="message") + activity.attachments = [Attachment(content_type="text/plain", content="test")] + baseline = { + "type": "message", + "attachments": [{"content_type": "text/plain", "content": "test"}], + } + assert_activity(activity, baseline) + + +class TestAssertActivityRealWorldScenarios: + """Tests simulating real-world usage scenarios.""" + + def test_validate_bot_response_message(self): + """Test validating a typical bot response.""" + activity = Activity( + type="message", + text="I found 3 results for your query.", + from_property={"id": "bot123", "name": "HelpBot"}, + ) + baseline = { + "type": "message", + "text": ["RE_MATCH", r"I found \d+ results"], + "from_property": {"id": "bot123"}, + } + assert_activity(activity, baseline) + + def test_validate_user_message(self): + """Test validating a user message with flexible text matching.""" + activity = Activity( + type="message", + text="help me with something", + from_property={"id": "user456"}, + ) + baseline = { + "type": "message", + "text": {"assertion_type": "CONTAINS", "assertion": "help"}, + } + assert_activity(activity, baseline) + + def test_validate_event_activity(self): + """Test validating an event activity.""" + activity = Activity( + type="event", name="conversationUpdate", value={"action": "add"} + ) + baseline = {"type": "event", "name": "conversationUpdate"} + + assert_activity(activity, baseline) + + def test_partial_match_allows_extra_fields(self): + """Test that extra fields in activity don't cause failure.""" + activity = Activity( + type="message", + text="Hello", + channel_id="teams", + conversation={"id": "conv123"}, + from_property={"id": "user123"}, + timestamp="2025-01-12T10:00:00Z", + ) + baseline = {"type": "message", "text": "Hello"} + assert_activity(activity, baseline) + + def test_strict_match_with_multiple_fields(self): + """Test strict matching with multiple fields specified.""" + activity = Activity(type="message", text="Hello", channel_id="teams") + baseline = {"type": "message", "text": "Hello", "channel_id": "teams"} + assert_activity(activity, baseline) + + def test_flexible_text_matching_with_regex(self): + """Test flexible text matching using regex patterns.""" + activity = Activity(type="message", text="Order #12345 has been confirmed") + baseline = {"type": "message", "text": ["RE_MATCH", r"Order #\d+ has been"]} + assert_activity(activity, baseline) + + def test_negative_assertions(self): + """Test using negative assertions to ensure fields don't match.""" + activity = Activity(type="message", text="Success", channel_id="teams") + baseline = { + "type": "message", + "text": {"assertion_type": "NOT_CONTAINS", "assertion": "Error"}, + "channel_id": {"assertion_type": "NOT_EQUALS", "assertion": "slack"}, + } + assert_activity(activity, baseline) + + def test_combined_positive_and_negative_assertions(self): + """Test combining positive and negative assertions.""" + activity = Activity( + type="message", text="Operation completed successfully", channel_id="teams" + ) + baseline = { + "type": "message", + "text": ["CONTAINS", "completed"], + "channel_id": ["NOT_EQUALS", "slack"], + } + assert_activity(activity, baseline) diff --git a/dev/microsoft-agents-testing/tests/assertions/test_check_field.py b/dev/microsoft-agents-testing/tests/assertions/test_check_field.py new file mode 100644 index 00000000..cafc556d --- /dev/null +++ b/dev/microsoft-agents-testing/tests/assertions/test_check_field.py @@ -0,0 +1,296 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest + +from microsoft_agents.testing.assertions.check_field import ( + check_field, + _parse_assertion, +) +from microsoft_agents.testing.assertions.type_defs import FieldAssertionType + + +class TestParseAssertion: + + @pytest.fixture( + params=[ + FieldAssertionType.EQUALS, + FieldAssertionType.NOT_EQUALS, + FieldAssertionType.GREATER_THAN, + ] + ) + def assertion_type_str(self, request): + return request.param + + @pytest.fixture(params=["simple_value", {"key": "value"}, 42]) + def assertion_value(self, request): + return request.param + + def test_parse_assertion_dict(self, assertion_value, assertion_type_str): + + assertion, assertion_type = _parse_assertion( + {"assertion_type": assertion_type_str, "assertion": assertion_value} + ) + assert assertion == assertion_value + assert assertion_type == FieldAssertionType(assertion_type_str) + + def test_parse_assertion_list(self, assertion_value, assertion_type_str): + assertion, assertion_type = _parse_assertion( + [assertion_type_str, assertion_value] + ) + assert assertion == assertion_value + assert assertion_type.value == assertion_type_str + + @pytest.mark.parametrize( + "field", + ["value", 123, 12.34], + ) + def test_parse_assertion_default(self, field): + assertion, assertion_type = _parse_assertion(field) + assert assertion == field + assert assertion_type == FieldAssertionType.EQUALS + + @pytest.mark.parametrize( + "field", + [ + {"assertion_type": FieldAssertionType.IN}, + {"assertion_type": FieldAssertionType.IN, "key": "value"}, + [FieldAssertionType.RE_MATCH], + [], + {"assertion_type": "invalid", "assertion": "test"}, + ], + ) + def test_parse_assertion_none(self, field): + assertion, assertion_type = _parse_assertion(field) + assert assertion is None + assert assertion_type is None + + +class TestCheckFieldEquals: + """Tests for EQUALS assertion type.""" + + def test_equals_with_matching_strings(self): + assert check_field("hello", "hello", FieldAssertionType.EQUALS) is True + + def test_equals_with_non_matching_strings(self): + assert check_field("hello", "world", FieldAssertionType.EQUALS) is False + + def test_equals_with_matching_integers(self): + assert check_field(42, 42, FieldAssertionType.EQUALS) is True + + def test_equals_with_non_matching_integers(self): + assert check_field(42, 43, FieldAssertionType.EQUALS) is False + + def test_equals_with_none_values(self): + assert check_field(None, None, FieldAssertionType.EQUALS) is True + + def test_equals_with_boolean_values(self): + assert check_field(True, True, FieldAssertionType.EQUALS) is True + assert check_field(False, False, FieldAssertionType.EQUALS) is True + assert check_field(True, False, FieldAssertionType.EQUALS) is False + + +class TestCheckFieldNotEquals: + """Tests for NOT_EQUALS assertion type.""" + + def test_not_equals_with_different_strings(self): + assert check_field("hello", "world", FieldAssertionType.NOT_EQUALS) is True + + def test_not_equals_with_matching_strings(self): + assert check_field("hello", "hello", FieldAssertionType.NOT_EQUALS) is False + + def test_not_equals_with_different_integers(self): + assert check_field(42, 43, FieldAssertionType.NOT_EQUALS) is True + + def test_not_equals_with_matching_integers(self): + assert check_field(42, 42, FieldAssertionType.NOT_EQUALS) is False + + +class TestCheckFieldGreaterThan: + """Tests for GREATER_THAN assertion type.""" + + def test_greater_than_with_larger_value(self): + assert check_field(10, 5, FieldAssertionType.GREATER_THAN) is True + + def test_greater_than_with_smaller_value(self): + assert check_field(5, 10, FieldAssertionType.GREATER_THAN) is False + + def test_greater_than_with_equal_value(self): + assert check_field(10, 10, FieldAssertionType.GREATER_THAN) is False + + def test_greater_than_with_floats(self): + assert check_field(10.5, 10.2, FieldAssertionType.GREATER_THAN) is True + assert check_field(10.2, 10.5, FieldAssertionType.GREATER_THAN) is False + + def test_greater_than_with_negative_numbers(self): + assert check_field(-5, -10, FieldAssertionType.GREATER_THAN) is True + assert check_field(-10, -5, FieldAssertionType.GREATER_THAN) is False + + +class TestCheckFieldLessThan: + """Tests for LESS_THAN assertion type.""" + + def test_less_than_with_smaller_value(self): + assert check_field(5, 10, FieldAssertionType.LESS_THAN) is True + + def test_less_than_with_larger_value(self): + assert check_field(10, 5, FieldAssertionType.LESS_THAN) is False + + def test_less_than_with_equal_value(self): + assert check_field(10, 10, FieldAssertionType.LESS_THAN) is False + + def test_less_than_with_floats(self): + assert check_field(10.2, 10.5, FieldAssertionType.LESS_THAN) is True + assert check_field(10.5, 10.2, FieldAssertionType.LESS_THAN) is False + + +class TestCheckFieldContains: + """Tests for CONTAINS assertion type.""" + + def test_contains_substring_in_string(self): + assert check_field("hello world", "world", FieldAssertionType.CONTAINS) is True + + def test_contains_substring_not_in_string(self): + assert check_field("hello world", "foo", FieldAssertionType.CONTAINS) is False + + def test_contains_element_in_list(self): + assert check_field([1, 2, 3, 4], 3, FieldAssertionType.CONTAINS) is True + + def test_contains_element_not_in_list(self): + assert check_field([1, 2, 3, 4], 5, FieldAssertionType.CONTAINS) is False + + def test_contains_key_in_dict(self): + assert check_field({"a": 1, "b": 2}, "a", FieldAssertionType.CONTAINS) is True + + def test_contains_key_not_in_dict(self): + assert check_field({"a": 1, "b": 2}, "c", FieldAssertionType.CONTAINS) is False + + def test_contains_empty_string(self): + assert check_field("hello", "", FieldAssertionType.CONTAINS) is True + + +class TestCheckFieldNotContains: + """Tests for NOT_CONTAINS assertion type.""" + + def test_not_contains_substring_not_in_string(self): + assert ( + check_field("hello world", "foo", FieldAssertionType.NOT_CONTAINS) is True + ) + + def test_not_contains_substring_in_string(self): + assert ( + check_field("hello world", "world", FieldAssertionType.NOT_CONTAINS) + is False + ) + + def test_not_contains_element_not_in_list(self): + assert check_field([1, 2, 3, 4], 5, FieldAssertionType.NOT_CONTAINS) is True + + def test_not_contains_element_in_list(self): + assert check_field([1, 2, 3, 4], 3, FieldAssertionType.NOT_CONTAINS) is False + + +class TestCheckFieldReMatch: + """Tests for RE_MATCH assertion type.""" + + def test_re_match_simple_pattern(self): + assert check_field("hello123", r"hello\d+", FieldAssertionType.RE_MATCH) is True + + def test_re_match_no_match(self): + assert check_field("hello", r"\d+", FieldAssertionType.RE_MATCH) is False + + def test_re_match_email_pattern(self): + pattern = r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}" + assert ( + check_field("test@example.com", pattern, FieldAssertionType.RE_MATCH) + is True + ) + assert ( + check_field("invalid-email", pattern, FieldAssertionType.RE_MATCH) is False + ) + + def test_re_match_anchored_pattern(self): + assert ( + check_field("hello world", r"^hello", FieldAssertionType.RE_MATCH) is True + ) + assert ( + check_field("hello world", r"^world", FieldAssertionType.RE_MATCH) is False + ) + + def test_re_match_full_string(self): + assert check_field("abc", r"^abc$", FieldAssertionType.RE_MATCH) is True + assert check_field("abcd", r"^abc$", FieldAssertionType.RE_MATCH) is False + + def test_re_match_case_sensitive(self): + assert check_field("Hello", r"hello", FieldAssertionType.RE_MATCH) is False + assert check_field("Hello", r"Hello", FieldAssertionType.RE_MATCH) is True + + +class TestCheckFieldEdgeCases: + """Tests for edge cases and error handling.""" + + def test_invalid_assertion_type(self): + # Passing an unsupported assertion type should return False + with pytest.raises(ValueError): + assert check_field("test", "test", "INVALID_TYPE") + + def test_none_actual_value_with_equals(self): + assert check_field(None, "test", FieldAssertionType.EQUALS) is False + assert check_field(None, None, FieldAssertionType.EQUALS) is True + + def test_empty_string_comparisons(self): + assert check_field("", "", FieldAssertionType.EQUALS) is True + assert check_field("", "test", FieldAssertionType.EQUALS) is False + + def test_empty_list_contains(self): + assert check_field([], "item", FieldAssertionType.CONTAINS) is False + + def test_zero_comparisons(self): + assert check_field(0, 0, FieldAssertionType.EQUALS) is True + assert check_field(0, 1, FieldAssertionType.LESS_THAN) is True + assert check_field(0, -1, FieldAssertionType.GREATER_THAN) is True + + def test_type_mismatch_comparisons(self): + # Different types should work with equality checks + assert check_field("42", 42, FieldAssertionType.EQUALS) is False + assert check_field("42", 42, FieldAssertionType.NOT_EQUALS) is True + + def test_complex_data_structures(self): + actual = {"nested": {"value": 123}} + expected = {"nested": {"value": 123}} + assert check_field(actual, expected, FieldAssertionType.EQUALS) is True + + def test_list_equality(self): + assert check_field([1, 2, 3], [1, 2, 3], FieldAssertionType.EQUALS) is True + assert check_field([1, 2, 3], [3, 2, 1], FieldAssertionType.EQUALS) is False + + +class TestCheckFieldWithRealWorldScenarios: + """Tests simulating real-world usage scenarios.""" + + def test_validate_response_status_code(self): + assert check_field(200, 200, FieldAssertionType.EQUALS) is True + assert check_field(404, 200, FieldAssertionType.NOT_EQUALS) is True + + def test_validate_response_contains_keyword(self): + response = "Success: Operation completed successfully" + assert check_field(response, "Success", FieldAssertionType.CONTAINS) is True + assert check_field(response, "Error", FieldAssertionType.NOT_CONTAINS) is True + + def test_validate_numeric_threshold(self): + temperature = 72.5 + assert check_field(temperature, 100, FieldAssertionType.LESS_THAN) is True + assert check_field(temperature, 0, FieldAssertionType.GREATER_THAN) is True + + def test_validate_message_format(self): + message_id = "msg_20250112_001" + pattern = r"^msg_\d{8}_\d{3}$" + assert check_field(message_id, pattern, FieldAssertionType.RE_MATCH) is True + + def test_validate_list_membership(self): + allowed_roles = ["admin", "user", "guest"] + assert check_field(allowed_roles, "admin", FieldAssertionType.CONTAINS) is True + assert ( + check_field(allowed_roles, "superuser", FieldAssertionType.NOT_CONTAINS) + is True + ) diff --git a/dev/microsoft-agents-testing/tests/assertions/test_integration_assertion.py b/dev/microsoft-agents-testing/tests/assertions/test_integration_assertion.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/microsoft-agents-testing/tests/assertions/test_selector.py b/dev/microsoft-agents-testing/tests/assertions/test_selector.py new file mode 100644 index 00000000..5360cb6b --- /dev/null +++ b/dev/microsoft-agents-testing/tests/assertions/test_selector.py @@ -0,0 +1,309 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest + +from microsoft_agents.activity import Activity +from microsoft_agents.testing.assertions.selector import Selector + + +class TestSelectorSelectWithQuantifierAll: + """Tests for select() method with ALL quantifier.""" + + @pytest.fixture + def activities(self): + """Create a list of test activities.""" + return [ + Activity(type="message", text="Hello"), + Activity(type="message", text="World"), + Activity(type="event", name="test_event"), + Activity(type="message", text="Goodbye"), + ] + + def test_select_all_matching_type(self, activities): + """Test selecting all activities with matching type.""" + selector = Selector(selector={"type": "message"}) + result = selector.select(activities) + assert len(result) == 3 + assert all(a.type == "message" for a in result) + + def test_select_all_matching_multiple_fields(self, activities): + """Test selecting all activities matching multiple fields.""" + selector = Selector( + selector={"type": "message", "text": "Hello"}, + ) + result = selector.select(activities) + assert len(result) == 1 + assert result[0].text == "Hello" + + def test_select_all_no_matches(self, activities): + """Test selecting all with no matches returns empty list.""" + selector = Selector( + selector={"type": "nonexistent"}, + ) + result = selector.select(activities) + assert len(result) == 0 + + def test_select_all_empty_selector(self, activities): + """Test selecting all with empty selector returns all activities.""" + selector = Selector(selector={}) + result = selector.select(activities) + assert len(result) == len(activities) + + def test_select_all_from_empty_list(self): + """Test selecting from empty activity list.""" + selector = Selector(selector={"type": "message"}) + result = selector.select([]) + assert len(result) == 0 + + +class TestSelectorSelectWithQuantifierOne: + """Tests for select() method with ONE quantifier.""" + + @pytest.fixture + def activities(self): + """Create a list of test activities.""" + return [ + Activity(type="message", text="First"), + Activity(type="message", text="Second"), + Activity(type="event", name="test_event"), + Activity(type="message", text="Third"), + ] + + def test_select_one_default_index(self, activities): + """Test selecting one activity with default index (0).""" + selector = Selector(selector={"type": "message"}, index=0) + result = selector.select(activities) + assert len(result) == 1 + assert result[0].text == "First" + + def test_select_one_explicit_index(self, activities): + """Test selecting one activity with explicit index.""" + selector = Selector(selector={"type": "message"}, index=1) + result = selector.select(activities) + assert len(result) == 1 + assert result[0].text == "Second" + + def test_select_one_last_index(self, activities): + """Test selecting one activity with last valid index.""" + selector = Selector(selector={"type": "message"}, index=2) + result = selector.select(activities) + assert len(result) == 1 + assert result[0].text == "Third" + + def test_select_one_negative_index(self, activities): + """Test selecting one activity with negative index.""" + selector = Selector(selector={"type": "message"}, index=-1) + result = selector.select(activities) + assert len(result) == 1 + assert result[0].text == "Third" + + def test_select_one_negative_index_from_start(self, activities): + """Test selecting one activity with negative index from start.""" + selector = Selector(selector={"type": "message"}, index=-2) + result = selector.select(activities) + assert len(result) == 1 + assert result[0].text == "Second" + + def test_select_one_index_out_of_range(self, activities): + """Test selecting with index out of range returns empty list.""" + selector = Selector(selector={"type": "message"}, index=10) + result = selector.select(activities) + assert len(result) == 0 + + def test_select_one_negative_index_out_of_range(self, activities): + """Test selecting with negative index out of range returns empty list.""" + selector = Selector(selector={"type": "message"}, index=-10) + result = selector.select(activities) + assert len(result) == 0 + + def test_select_one_no_matches(self, activities): + """Test selecting one with no matches returns empty list.""" + selector = Selector(selector={"type": "nonexistent"}, index=0) + result = selector.select(activities) + assert len(result) == 0 + + def test_select_one_from_empty_list(self): + """Test selecting one from empty list returns empty list.""" + selector = Selector(selector={"type": "message"}, index=0) + result = selector.select([]) + assert len(result) == 0 + + +class TestSelectorSelectFirst: + """Tests for select_first() method.""" + + @pytest.fixture + def activities(self): + """Create a list of test activities.""" + return [ + Activity(type="message", text="First"), + Activity(type="message", text="Second"), + Activity(type="event", name="test_event"), + ] + + def test_select_first_with_matches(self, activities): + """Test select_first returns first matching activity.""" + selector = Selector(selector={"type": "message"}) + result = selector.select_first(activities) + assert result is not None + assert result.text == "First" + + def test_select_first_no_matches(self, activities): + """Test select_first with no matches returns None.""" + selector = Selector( + selector={"type": "nonexistent"}, + ) + result = selector.select_first(activities) + assert result is None + + def test_select_first_empty_list(self): + """Test select_first on empty list returns None.""" + selector = Selector(selector={"type": "message"}) + result = selector.select_first([]) + assert result is None + + def test_select_first_with_one_quantifier(self, activities): + """Test select_first with ONE quantifier and specific index.""" + selector = Selector(selector={"type": "message"}, index=1) + result = selector.select_first(activities) + assert result is not None + assert result.text == "Second" + + +class TestSelectorCallable: + """Tests for __call__ method.""" + + @pytest.fixture + def activities(self): + """Create a list of test activities.""" + return [ + Activity(type="message", text="Hello"), + Activity(type="event", name="test_event"), + ] + + def test_call_invokes_select(self, activities): + """Test that calling selector instance invokes select().""" + selector = Selector(selector={"type": "message"}) + result = selector(activities) + assert len(result) == 1 + assert result[0].text == "Hello" + + def test_call_returns_same_as_select(self, activities): + """Test that __call__ returns same result as select().""" + selector = Selector(selector={"type": "event"}, index=0) + call_result = selector(activities) + select_result = selector.select(activities) + assert call_result == select_result + + +class TestSelectorIntegration: + """Integration tests with realistic scenarios.""" + + @pytest.fixture + def conversation_activities(self): + """Create a realistic conversation flow.""" + return [ + Activity(type="conversationUpdate", name="add_member"), + Activity(type="message", text="Hello bot", from_property={"id": "user1"}), + Activity(type="message", text="Hi there!", from_property={"id": "bot"}), + Activity( + type="message", text="How are you?", from_property={"id": "user1"} + ), + Activity( + type="message", text="I'm doing well!", from_property={"id": "bot"} + ), + Activity(type="typing"), + Activity(type="message", text="Goodbye", from_property={"id": "user1"}), + ] + + def test_select_all_user_messages(self, conversation_activities): + """Test selecting all messages from a specific user.""" + selector = Selector( + selector={"type": "message", "from_property": {"id": "user1"}}, + ) + result = selector.select(conversation_activities) + assert len(result) == 3 + + def test_select_first_bot_response(self, conversation_activities): + """Test selecting first bot response.""" + selector = Selector( + selector={"type": "message", "from_property": {"id": "bot"}}, index=0 + ) + result = selector.select(conversation_activities) + assert len(result) == 1 + assert result[0].text == "Hi there!" + + def test_select_last_message_negative_index(self, conversation_activities): + """Test selecting last message using negative index.""" + selector = Selector(selector={"type": "message"}, index=-1) + result = selector.select(conversation_activities) + assert len(result) == 1 + assert result[0].text == "Goodbye" + + def test_select_typing_indicator(self, conversation_activities): + """Test selecting typing indicator.""" + selector = Selector( + selector={"type": "typing"}, + ) + result = selector.select(conversation_activities) + assert len(result) == 1 + + def test_select_conversation_update(self, conversation_activities): + """Test selecting conversation update events.""" + selector = Selector( + selector={"type": "conversationUpdate"}, + ) + result = selector.select(conversation_activities) + assert len(result) == 1 + assert result[0].name == "add_member" + + +class TestSelectorEdgeCases: + """Tests for edge cases and boundary conditions.""" + + def test_select_with_partial_match(self): + """Test that partial matches work correctly.""" + activities = [ + Activity(type="message", text="Hello", channelData={"id": 1}), + Activity(type="message", text="World"), + ] + # Only matching on type, not text + selector = Selector(selector={"type": "message"}) + result = selector.select(activities) + assert len(result) == 2 + + def test_select_with_none_values(self): + """Test selecting activities with None values.""" + activities = [ + Activity(type="message"), + Activity(type="message", text="Hello"), + ] + selector = Selector( + selector={"type": "message", "text": None}, + ) + result = selector.select(activities) + # This depends on how check_activity handles None + assert isinstance(result, list) + + def test_select_single_activity_list(self): + """Test selecting from list with single activity.""" + activities = [Activity(type="message", text="Only one")] + selector = Selector(selector={"type": "message"}, index=0) + result = selector.select(activities) + assert len(result) == 1 + assert result[0].text == "Only one" + + def test_select_with_boundary_index_zero(self): + """Test selecting with index 0 on single item.""" + activities = [Activity(type="message", text="Single")] + selector = Selector(selector={"type": "message"}, index=0) + result = selector.select(activities) + assert len(result) == 1 + + def test_select_with_boundary_negative_one(self): + """Test selecting with index -1 on single item.""" + activities = [Activity(type="message", text="Single")] + selector = Selector(selector={"type": "message"}, index=-1) + result = selector.select(activities) + assert len(result) == 1 diff --git a/dev/microsoft-agents-testing/tests/integration/data_driven/__init__.py b/dev/microsoft-agents-testing/tests/integration/data_driven/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/microsoft-agents-testing/tests/integration/data_driven/test_data_driven_test.py b/dev/microsoft-agents-testing/tests/integration/data_driven/test_data_driven_test.py new file mode 100644 index 00000000..32186e1e --- /dev/null +++ b/dev/microsoft-agents-testing/tests/integration/data_driven/test_data_driven_test.py @@ -0,0 +1,465 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest +import tempfile +import os +from unittest.mock import AsyncMock + +from microsoft_agents.activity import Activity, ActivityTypes +from microsoft_agents.testing.integration.data_driven import DataDrivenTest +from microsoft_agents.testing.integration.core import AgentClient, ResponseClient + + +class TestDataDrivenTestSleep: + """Tests for _sleep method.""" + + @pytest.mark.asyncio + async def test_sleep_with_explicit_duration(self): + """Test sleep with explicit duration.""" + test_flow = {"test": []} + ddt = DataDrivenTest(test_flow) + + import time + + start = time.time() + await ddt._sleep({"duration": 0.1}) + elapsed = time.time() - start + + assert elapsed >= 0.1 + assert elapsed < 0.2 # Allow some margin + + @pytest.mark.asyncio + async def test_sleep_with_default_duration(self): + """Test sleep uses default duration when not specified.""" + test_flow = {"defaults": {"sleep": {"duration": 0.1}}, "test": []} + ddt = DataDrivenTest(test_flow) + + import time + + start = time.time() + await ddt._sleep({}) + elapsed = time.time() - start + + assert elapsed >= 0.1 + assert elapsed < 0.2 + + @pytest.mark.asyncio + async def test_sleep_without_duration_defaults_to_zero(self): + """Test sleep defaults to 0 when no duration specified.""" + test_flow = {"test": []} + ddt = DataDrivenTest(test_flow) + + import time + + start = time.time() + await ddt._sleep({}) + elapsed = time.time() - start + + assert elapsed < 0.1 # Should be nearly instant + + @pytest.mark.asyncio + async def test_sleep_overrides_default_duration(self): + """Test that explicit duration overrides default.""" + test_flow = {"defaults": {"sleep": {"duration": 5.0}}, "test": []} + ddt = DataDrivenTest(test_flow) + + import time + + start = time.time() + await ddt._sleep({"duration": 0.1}) + elapsed = time.time() - start + + assert elapsed >= 0.1 + assert elapsed < 0.2 # Should use explicit duration, not default + + +class TestDataDrivenTestRun: + """Tests for run method.""" + + @pytest.mark.asyncio + async def test_run_empty_test(self): + """Test running an empty test.""" + test_flow = {"test": []} + ddt = DataDrivenTest(test_flow) + + agent_client = AsyncMock(spec=AgentClient) + response_client = AsyncMock(spec=ResponseClient) + response_client.pop = AsyncMock(return_value=[]) + + await ddt.run(agent_client, response_client) + + agent_client.send_activity.assert_not_called() + response_client.pop.assert_not_called() + + @pytest.mark.asyncio + async def test_run_single_input_step(self): + """Test running a test with single input step.""" + test_flow = { + "defaults": {"input": {"activity": {"type": "message"}}}, + "test": [{"type": "input", "activity": {"text": "Hello"}}], + } + ddt = DataDrivenTest(test_flow) + + agent_client = AsyncMock(spec=AgentClient) + response_client = AsyncMock(spec=ResponseClient) + + await ddt.run(agent_client, response_client) + + agent_client.send_activity.assert_called_once() + call_args = agent_client.send_activity.call_args[0][0] + assert isinstance(call_args, Activity) + assert call_args.text == "Hello" + assert call_args.type == "message" + + @pytest.mark.asyncio + async def test_run_input_and_assertion_passing(self): + """Test running a test with input and passing assertion.""" + test_flow = { + "test": [ + {"type": "input", "activity": {"type": "message", "text": "Hello"}}, + {"type": "assertion", "activity": {"type": "message"}}, + ] + } + ddt = DataDrivenTest(test_flow) + + agent_client = AsyncMock(spec=AgentClient) + response_client = AsyncMock(spec=ResponseClient) + response_activity = Activity(type="message", text="Response") + response_client.pop = AsyncMock(return_value=[response_activity]) + + # Should not raise any assertion error + await ddt.run(agent_client, response_client) + + agent_client.send_activity.assert_called_once() + response_client.pop.assert_called_once() + + @pytest.mark.asyncio + async def test_run_input_and_assertion_failing(self): + """Test running a test with input and failing assertion.""" + test_flow = { + "test": [ + {"type": "input", "activity": {"type": "message", "text": "Hello"}}, + {"type": "assertion", "activity": {"type": "event"}}, # Will fail + ] + } + ddt = DataDrivenTest(test_flow) + + agent_client = AsyncMock(spec=AgentClient) + response_client = AsyncMock(spec=ResponseClient) + response_activity = Activity(type="message", text="Response") + response_client.pop = AsyncMock(return_value=[response_activity]) + + # Should raise assertion error + with pytest.raises(AssertionError): + await ddt.run(agent_client, response_client) + + @pytest.mark.asyncio + async def test_run_assertion_accumulates_responses(self): + """Test that assertions accumulate responses from response_client.""" + test_flow = { + "test": [ + {"type": "input", "activity": {"type": "message", "text": "Hello"}}, + {"type": "assertion", "activity": {"type": "message"}}, + ] + } + ddt = DataDrivenTest(test_flow) + + agent_client = AsyncMock(spec=AgentClient) + response_client = AsyncMock(spec=ResponseClient) + + # Response client returns activities + response_activity1 = Activity(type="message", text="Response 1") + response_activity2 = Activity(type="message", text="Response 2") + response_client.pop = AsyncMock( + return_value=[response_activity1, response_activity2] + ) + + # Should not raise - both are message type + await ddt.run(agent_client, response_client) + + response_client.pop.assert_called_once() + + @pytest.mark.asyncio + async def test_run_multiple_assertions_accumulate(self): + """Test that multiple assertions accumulate responses.""" + test_flow = { + "test": [ + {"type": "input", "activity": {"type": "message", "text": "First"}}, + { + "type": "assertion", + "activity": {"type": "message"}, + "quantifier": "one", + }, + {"type": "input", "activity": {"type": "message", "text": "Second"}}, + { + "type": "assertion", + "activity": {"type": "message"}, + "quantifier": "all", + }, + ] + } + ddt = DataDrivenTest(test_flow) + + agent_client = AsyncMock(spec=AgentClient) + response_client = AsyncMock(spec=ResponseClient) + + # First pop returns one activity, second returns two + response_client.pop = AsyncMock( + side_effect=[ + [Activity(type="message", text="Response 1")], + [Activity(type="message", text="Response 2")], + ] + ) + + await ddt.run(agent_client, response_client) + + assert agent_client.send_activity.call_count == 2 + assert response_client.pop.call_count == 2 + + @pytest.mark.asyncio + async def test_run_with_sleep_step(self): + """Test running a test with sleep step.""" + test_flow = {"test": [{"type": "sleep", "duration": 0.1}]} + ddt = DataDrivenTest(test_flow) + + agent_client = AsyncMock(spec=AgentClient) + response_client = AsyncMock(spec=ResponseClient) + + import time + + start = time.time() + await ddt.run(agent_client, response_client) + elapsed = time.time() - start + + assert elapsed >= 0.1 + assert elapsed < 0.2 + + @pytest.mark.asyncio + async def test_run_multiple_steps(self): + """Test running a test with multiple steps.""" + test_flow = { + "test": [ + {"type": "input", "activity": {"type": "message", "text": "Hello"}}, + {"type": "sleep", "duration": 0.05}, + {"type": "input", "activity": {"type": "message", "text": "World"}}, + {"type": "assertion", "activity": {"type": "message"}}, + ] + } + ddt = DataDrivenTest(test_flow) + + agent_client = AsyncMock(spec=AgentClient) + response_client = AsyncMock(spec=ResponseClient) + response_client.pop = AsyncMock( + return_value=[Activity(type="message", text="Response")] + ) + + import time + + start = time.time() + await ddt.run(agent_client, response_client) + elapsed = time.time() - start + + assert agent_client.send_activity.call_count == 2 + assert elapsed >= 0.05 + response_client.pop.assert_called_once() + + @pytest.mark.asyncio + async def test_run_step_without_type_raises_error(self): + """Test that a step without type raises ValueError.""" + test_flow = {"test": [{"text": "Hello"}]} # Missing 'type' field + ddt = DataDrivenTest(test_flow) + + agent_client = AsyncMock(spec=AgentClient) + response_client = AsyncMock(spec=ResponseClient) + + with pytest.raises(ValueError, match="Each step must have a 'type' field"): + await ddt.run(agent_client, response_client) + + @pytest.mark.asyncio + async def test_run_with_assertion_quantifier_all(self): + """Test assertion with quantifier 'all'.""" + test_flow = { + "test": [ + {"type": "input", "activity": {"type": "message", "text": "Hello"}}, + { + "type": "assertion", + "quantifier": "all", + "activity": {"type": "message"}, + }, + ] + } + ddt = DataDrivenTest(test_flow) + + agent_client = AsyncMock(spec=AgentClient) + response_client = AsyncMock(spec=ResponseClient) + response_client.pop = AsyncMock( + return_value=[ + Activity(type="message", text="Response 1"), + Activity(type="message", text="Response 2"), + ] + ) + + # Should pass - all are message type + await ddt.run(agent_client, response_client) + + @pytest.mark.asyncio + async def test_run_with_assertion_quantifier_one(self): + """Test assertion with quantifier 'one'.""" + test_flow = { + "test": [ + {"type": "input", "activity": {"type": "message", "text": "Hello"}}, + { + "type": "assertion", + "quantifier": "one", + "activity": {"type": "event"}, + }, + ] + } + ddt = DataDrivenTest(test_flow) + + agent_client = AsyncMock(spec=AgentClient) + response_client = AsyncMock(spec=ResponseClient) + response_client.pop = AsyncMock( + return_value=[ + Activity(type="message", text="Response 1"), + Activity(type="event", name="test_event"), + Activity(type="message", text="Response 2"), + ] + ) + + # Should pass - exactly one event type + await ddt.run(agent_client, response_client) + + @pytest.mark.asyncio + async def test_run_with_assertion_selector(self): + """Test assertion with selector.""" + test_flow = { + "test": [ + {"type": "input", "activity": {"type": "message", "text": "Hello"}}, + { + "type": "assertion", + "selector": {"activity": {"type": "message"}}, + "activity": {"text": "Response"}, + }, + ] + } + ddt = DataDrivenTest(test_flow) + + agent_client = AsyncMock(spec=AgentClient) + response_client = AsyncMock(spec=ResponseClient) + response_client.pop = AsyncMock( + return_value=[ + Activity(type="event", name="test"), + Activity(type="message", text="Response"), + Activity(type="typing"), + ] + ) + + # Should pass - the message activity matches + await ddt.run(agent_client, response_client) + + @pytest.mark.asyncio + async def test_run_populate_activity_with_defaults(self): + """Test that input activities are populated with defaults.""" + test_flow = { + "defaults": { + "input": { + "activity": { + "type": "message", + "locale": "en-US", + "channelId": "test-channel", + } + } + }, + "test": [{"type": "input", "activity": {"text": "Hello"}}], + } + ddt = DataDrivenTest(test_flow) + + agent_client = AsyncMock(spec=AgentClient) + response_client = AsyncMock(spec=ResponseClient) + + await ddt.run(agent_client, response_client) + + call_args = agent_client.send_activity.call_args[0][0] + assert call_args.text == "Hello" + assert call_args.type == "message" + assert call_args.locale == "en-US" + assert call_args.channel_id == "test-channel" + + +class TestDataDrivenTestIntegration: + """Integration tests for DataDrivenTest with real scenarios.""" + + @pytest.mark.asyncio + async def test_full_conversation_flow(self): + """Test a complete conversation flow.""" + test_flow = { + "description": "Complete conversation test", + "defaults": { + "input": {"activity": {"type": "message", "locale": "en-US"}}, + "assertion": {"quantifier": "all"}, + }, + "test": [ + {"type": "input", "activity": {"text": "Hello"}}, + {"type": "assertion", "activity": {"type": "message"}}, + {"type": "sleep", "duration": 0.05}, + {"type": "input", "activity": {"text": "How are you?"}}, + {"type": "assertion", "activity": {"type": "message"}}, + ], + } + ddt = DataDrivenTest(test_flow) + + agent_client = AsyncMock(spec=AgentClient) + response_client = AsyncMock(spec=ResponseClient) + response_client.pop = AsyncMock( + side_effect=[ + [Activity(type="message", text="Hi there!")], + [Activity(type="message", text="I'm doing well!")], + ] + ) + + await ddt.run(agent_client, response_client) + + assert agent_client.send_activity.call_count == 2 + assert response_client.pop.call_count == 2 + + @pytest.mark.asyncio + @pytest.mark.skip(reason="TODO") + async def test_with_parent_file_integration(self): + """Test with parent file providing defaults.""" + parent_content = """defaults: + input: + type: message + locale: en-US + assertion: + quantifier: all +""" + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write(parent_content) + parent_file = f.name + + try: + test_flow = { + "parent": parent_file, + "test": [ + {"type": "input", "activity": {"text": "Hello"}}, + {"type": "assertion", "activity": {"type": "message"}}, + ], + } + ddt = DataDrivenTest(test_flow) + + agent_client = AsyncMock(spec=AgentClient) + response_client = AsyncMock(spec=ResponseClient) + response_client.pop = AsyncMock( + return_value=[Activity(type="message", text="Response")] + ) + + await ddt.run(agent_client, response_client) + + call_args = agent_client.send_activity.call_args[0][0] + assert call_args.locale == "en-US" + assert call_args.type == "message" + finally: + os.unlink(parent_file) diff --git a/dev/microsoft-agents-testing/tests/utils/__init__.py b/dev/microsoft-agents-testing/tests/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/microsoft-agents-testing/tests/utils/test_populate.py b/dev/microsoft-agents-testing/tests/utils/test_populate.py new file mode 100644 index 00000000..07b99eab --- /dev/null +++ b/dev/microsoft-agents-testing/tests/utils/test_populate.py @@ -0,0 +1,358 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest +from microsoft_agents.activity import Activity, ChannelAccount, ConversationAccount + +from microsoft_agents.testing.utils.populate import ( + update_with_defaults, + populate_activity, +) + + +class TestUpdateWithDefaults: + """Tests for the update_with_defaults function.""" + + def test_update_with_defaults_with_empty_original(self): + """Test that defaults are added to an empty dictionary.""" + original = {} + defaults = {"key1": "value1", "key2": "value2"} + update_with_defaults(original, defaults) + assert original == {"key1": "value1", "key2": "value2"} + + def test_update_with_defaults_with_empty_defaults(self): + """Test that original dictionary is unchanged when defaults is empty.""" + original = {"key1": "value1"} + defaults = {} + update_with_defaults(original, defaults) + assert original == {"key1": "value1"} + + def test_update_with_defaults_with_non_overlapping_keys(self): + """Test that defaults are added when keys don't overlap.""" + original = {"key1": "value1"} + defaults = {"key2": "value2", "key3": "value3"} + update_with_defaults(original, defaults) + assert original == {"key1": "value1", "key2": "value2", "key3": "value3"} + + def test_update_with_defaults_preserves_existing_values(self): + """Test that existing values in original are not overwritten.""" + original = {"key1": "original_value", "key2": "value2"} + defaults = {"key1": "default_value", "key3": "value3"} + update_with_defaults(original, defaults) + assert original == { + "key1": "original_value", + "key2": "value2", + "key3": "value3", + } + + def test_update_with_defaults_with_nested_dicts(self): + """Test that nested dictionaries are recursively updated.""" + original = {"nested": {"key1": "original"}} + defaults = {"nested": {"key1": "default", "key2": "value2"}} + update_with_defaults(original, defaults) + assert original == {"nested": {"key1": "original", "key2": "value2"}} + + def test_update_with_defaults_with_deeply_nested_dicts(self): + """Test recursive update with deeply nested structures.""" + original = {"level1": {"level2": {"key1": "original"}}} + defaults = { + "level1": { + "level2": {"key1": "default", "key2": "value2"}, + "level2b": {"key3": "value3"}, + } + } + update_with_defaults(original, defaults) + assert original == { + "level1": { + "level2": {"key1": "original", "key2": "value2"}, + "level2b": {"key3": "value3"}, + } + } + + def test_update_with_defaults_adds_nested_dict_when_missing(self): + """Test that nested dicts are added when they don't exist in original.""" + original = {"key1": "value1"} + defaults = {"nested": {"key2": "value2"}} + update_with_defaults(original, defaults) + assert original == {"key1": "value1", "nested": {"key2": "value2"}} + + def test_update_with_defaults_with_mixed_types(self): + """Test with various value types: strings, numbers, booleans, lists.""" + original = {"str": "text", "num": 42} + defaults = { + "str": "default_text", + "bool": True, + "list": [1, 2, 3], + "none": None, + } + update_with_defaults(original, defaults) + assert original == { + "str": "text", + "num": 42, + "bool": True, + "list": [1, 2, 3], + "none": None, + } + + def test_update_with_defaults_with_none_values(self): + """Test that None values in defaults are added.""" + original = {"key1": "value1"} + defaults = {"key2": None} + update_with_defaults(original, defaults) + assert original == {"key1": "value1", "key2": None} + + def test_update_with_defaults_preserves_none_in_original(self): + """Test that None values in original are preserved.""" + original = {"key1": None} + defaults = {"key1": "default_value"} + update_with_defaults(original, defaults) + assert original == {"key1": None} + + def test_update_with_defaults_with_list_values(self): + """Test that list values are not merged, only added if missing.""" + original = {"list1": [1, 2]} + defaults = {"list1": [3, 4], "list2": [5, 6]} + update_with_defaults(original, defaults) + assert original == {"list1": [1, 2], "list2": [5, 6]} + + def test_update_with_defaults_type_mismatch_original_wins(self): + """Test that when types differ, original value is preserved.""" + original = {"key1": "string_value"} + defaults = {"key1": {"nested": "dict"}} + update_with_defaults(original, defaults) + assert original == {"key1": "string_value"} + + def test_update_with_defaults_type_mismatch_defaults_dict(self): + """Test that when original is dict and default is not, original is preserved.""" + original = {"key1": {"nested": "dict"}} + defaults = {"key1": "string_value"} + update_with_defaults(original, defaults) + assert original == {"key1": {"nested": "dict"}} + + def test_update_with_defaults_modifies_in_place(self): + """Test that the function modifies the original dict in place.""" + original = {"key1": "value1"} + original_id = id(original) + defaults = {"key2": "value2"} + update_with_defaults(original, defaults) + assert id(original) == original_id + assert original == {"key1": "value1", "key2": "value2"} + + def test_update_with_defaults_with_complex_nested_structure(self): + """Test with complex real-world-like nested structure.""" + original = { + "user": {"name": "Alice", "settings": {"theme": "dark"}}, + "timestamp": "2025-01-01", + } + defaults = { + "user": { + "name": "DefaultName", + "settings": {"theme": "light", "language": "en"}, + "role": "user", + }, + "channel": "default-channel", + } + update_with_defaults(original, defaults) + assert original == { + "user": { + "name": "Alice", + "settings": {"theme": "dark", "language": "en"}, + "role": "user", + }, + "timestamp": "2025-01-01", + "channel": "default-channel", + } + + +class TestPopulateActivity: + """Tests for the populate_activity function.""" + + def test_populate_activity_with_none_values_filled(self): + """Test that None values in original are replaced with defaults.""" + original = Activity(type="message") + defaults = Activity(type="message", text="Default text") + result = populate_activity(original, defaults) + assert result.text == "Default text" + assert result.type == "message" + + def test_populate_activity_preserves_existing_values(self): + """Test that existing non-None values are preserved.""" + original = Activity(type="message", text="Original text") + defaults = Activity(type="event", text="Default text") + result = populate_activity(original, defaults) + assert result.text == "Original text" + assert result.type == "message" + + def test_populate_activity_returns_new_instance(self): + """Test that a new Activity instance is returned.""" + original = Activity(type="message", text="Original") + defaults = {"text": "Default text"} + result = populate_activity(original, defaults) + assert result is not original + assert id(result) != id(original) + + def test_populate_activity_original_unchanged(self): + """Test that the original Activity is not modified.""" + original = Activity(type="message") + defaults = Activity(type="message", text="Default text") + original_text = original.text + result = populate_activity(original, defaults) + assert original.text == original_text + assert result.text == "Default text" + + def test_populate_activity_with_dict_defaults(self): + """Test that defaults can be provided as a dictionary.""" + original = Activity(type="message") + original.channel_id = "channel" + defaults = {"text": "Default text", "channel_id": "default-channel"} + result = populate_activity(original, defaults) + assert result.text == "Default text" + assert result.channel_id == "channel" + + def test_populate_activity_with_activity_defaults(self): + """Test that defaults can be provided as an Activity object.""" + original = Activity(type="message") + defaults = Activity(type="event", text="Default text", channel_id="channel") + result = populate_activity(original, defaults) + assert result.text == "Default text" + + def test_populate_activity_with_empty_defaults(self): + """Test that original is unchanged when defaults is empty.""" + original = Activity(type="message", text="Original text") + defaults = {} + result = populate_activity(original, defaults) + assert result.text == "Original text" + assert result.type == "message" + + def test_populate_activity_with_multiple_fields(self): + """Test populating multiple None fields.""" + original = Activity( + type="message", + ) + defaults = { + "text": "Default text", + "channel_id": "default-channel", + "locale": "en-US", + } + result = populate_activity(original, defaults) + assert result.text == "Default text" + assert result.channel_id == "default-channel" + assert result.locale == "en-US" + + def test_populate_activity_with_complex_objects(self): + """Test populating with complex nested objects.""" + original = Activity(type="message") + defaults = Activity( + type="invoke", + from_property=ChannelAccount(id="bot123", name="Bot"), + conversation=ConversationAccount(id="conv123", name="Conversation"), + ) + result = populate_activity(original, defaults) + assert result.from_property is not None + assert result.from_property.id == "bot123" + assert result.conversation is not None + assert result.conversation.id == "conv123" + + def test_populate_activity_preserves_complex_objects(self): + """Test that existing complex objects are preserved.""" + original = Activity( + type="message", + from_property=ChannelAccount(id="user456", name="User"), + ) + defaults = Activity( + type="invoke", from_property=ChannelAccount(id="bot123", name="Bot") + ) + result = populate_activity(original, defaults) + assert result.from_property.id == "user456" + + def test_populate_activity_partial_defaults(self): + """Test that only specified defaults are applied.""" + original = Activity(type="message") + defaults = {"text": "Default text"} + result = populate_activity(original, defaults) + assert result.text == "Default text" + assert result.channel_id is None + + def test_populate_activity_with_zero_and_empty_string(self): + """Test that zero and empty string are considered as set values.""" + original = Activity(type="message", text="") + defaults = {"text": "Default text", "locale": "en-US"} + result = populate_activity(original, defaults) + # Empty strings should be preserved as they are not None + assert result.text == "" + assert result.locale == "en-US" + + def test_populate_activity_with_false_boolean(self): + """Test that False boolean values are preserved.""" + original = Activity(type="message") + original.history_disclosed = False + defaults = {"history_disclosed": True} + result = populate_activity(original, defaults) + # False should be preserved as it's not None + assert result.history_disclosed is False + + def test_populate_activity_with_zero_numeric(self): + """Test that numeric zero values are preserved.""" + original = Activity(type="message") + # Assuming there's a numeric field we can test + original.channel_data = {"count": 0} + defaults = {"channel_data": {"count": 10}} + result = populate_activity(original, defaults) + # Zero should be preserved + assert result.channel_data == {"count": 0} + + def test_populate_activity_defaults_from_activity_excludes_unset(self): + """Test that only explicitly set fields from Activity defaults are used.""" + original = Activity(type="message") + # Create defaults with only type set explicitly + defaults = Activity(type="event") + result = populate_activity(original, defaults) + # Since defaults Activity didn't explicitly set text, it should remain None + assert result.text is None + + def test_populate_activity_with_empty_activity_defaults(self): + """Test with an Activity that has no fields set.""" + original = Activity(type="message") + defaults = {} + result = populate_activity(original, defaults) + assert result.type == "message" + assert result.text is None + + def test_populate_activity_real_world_scenario(self): + """Test a real-world scenario of populating a bot response.""" + original = Activity( + type="message", + text="User's query result", + from_property=ChannelAccount(id="bot123"), + ) + defaults = { + "conversation": ConversationAccount(id="default-conv"), + "channel_id": "teams", + "locale": "en-US", + } + result = populate_activity(original, defaults) + assert result.text == "User's query result" + assert result.from_property.id == "bot123" + assert result.conversation.id == "default-conv" + assert result.channel_id == "teams" + assert result.locale == "en-US" + + def test_populate_activity_with_list_fields(self): + """Test populating list fields like attachments or entities.""" + original = Activity(type="message") + defaults = {"attachments": [], "entities": []} + result = populate_activity(original, defaults) + assert result.attachments == [] + assert result.entities == [] + + def test_populate_activity_preserves_empty_lists(self): + """Test that empty lists in original are preserved.""" + original = Activity(type="message", attachments=[], entities=[]) + defaults = { + "attachments": [{"type": "card"}], + "entities": [{"type": "mention"}], + } + result = populate_activity(original, defaults) + # Empty lists are not None, so they should be preserved + assert result.attachments == [] + assert result.entities == [] diff --git a/tests/activity/test_sub_channels.py b/tests/activity/test_sub_channels.py deleted file mode 100644 index 67c6d92c..00000000 --- a/tests/activity/test_sub_channels.py +++ /dev/null @@ -1,5 +0,0 @@ -from microsoft_agents.activity import Activity, ChannelId, Entity - - -class TestSubChannels: - pass