├── doc ├── full_example │ ├── apps │ │ ├── __init__.py │ │ ├── apps.yaml │ │ ├── entity_ids.py │ │ ├── kitchen.py │ │ └── bathroom.py │ ├── tests │ │ ├── __init__.py │ │ ├── test_assertions_dsl.py │ │ ├── test_vanilla_file.py │ │ ├── test_kitchen.py │ │ └── test_bathroom.py │ ├── pytest.ini │ ├── start_tdd.sh │ ├── pyproject.toml │ └── conftest.py ├── pytest_example.py └── unittest_example.py ├── _config.yml ├── test ├── integration_tests │ ├── apps │ │ ├── __init__.py │ │ ├── apps.yaml │ │ ├── entity_ids.py │ │ ├── kitchen.py │ │ └── bathroom.py │ └── tests │ │ ├── __init__.py │ │ ├── test_assertions_dsl.py │ │ ├── test_vanilla_file.py │ │ └── test_kitchen.py ├── test_events.py ├── test_miscellaneous_helper_functions.py ├── test_extra_hass_functions.py ├── test_with_arguments.py ├── test_time_travel.py ├── test_assert_that.py ├── test_state.py ├── test_logging.py ├── appdaemon_mock │ └── test_scheduler.py ├── test_assert_callback_registration.py └── test_automation_fixture.py ├── MANIFEST.in ├── appdaemontestframework ├── common.py ├── __init__.py ├── appdaemon_mock │ ├── appdaemon.py │ ├── __init__.py │ └── scheduler.py ├── pytest_conftest.py ├── time_travel.py ├── given_that.py ├── automation_fixture.py ├── assert_that.py └── hass_mocks.py ├── pytest.ini ├── .idea ├── codeStyles │ └── codeStyleConfig.xml ├── vcs.xml ├── inspectionProfiles │ └── profiles_settings.xml ├── modules.xml ├── misc.xml ├── appdaemontestframework.iml └── runConfigurations │ ├── All_tests.xml │ ├── __mark_only__tests.xml │ └── All_tests_except_w__pytester.xml ├── conftest.py ├── LICENSE ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── pyproject.toml └── CHANGELOG.md /doc/full_example/apps/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-cayman -------------------------------------------------------------------------------- /doc/full_example/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /doc/full_example/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | -------------------------------------------------------------------------------- /test/integration_tests/apps/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/integration_tests/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | 4 | 5 | -------------------------------------------------------------------------------- /appdaemontestframework/common.py: -------------------------------------------------------------------------------- 1 | class AppdaemonTestFrameworkError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --ignore doc 3 | markers = 4 | only 5 | using_pytester 6 | -------------------------------------------------------------------------------- /doc/full_example/start_tdd.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | pytest-watch \ 4 | --ext=.py,.yaml \ 5 | -- \ 6 | -s \ 7 | --tb=line 8 | -------------------------------------------------------------------------------- /doc/full_example/apps/apps.yaml: -------------------------------------------------------------------------------- 1 | Kitchen: 2 | module: kitchen 3 | class: Kitchen 4 | 5 | Bathroom: 6 | module: bathroom 7 | class: Bathroom 8 | -------------------------------------------------------------------------------- /test/integration_tests/apps/apps.yaml: -------------------------------------------------------------------------------- 1 | Kitchen: 2 | module: kitchen 3 | class: Kitchen 4 | 5 | Bathroom: 6 | module: bathroom 7 | class: Bathroom 8 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | from appdaemontestframework.pytest_conftest import * 2 | 3 | @fixture 4 | def configure_appdaemontestframework_for_pytester(testdir): 5 | """ 6 | Extra test fixtue use for testing pytest runners. 7 | """ 8 | testdir.makeconftest( 9 | """ 10 | from appdaemontestframework.pytest_conftest import * 11 | """) 12 | -------------------------------------------------------------------------------- /appdaemontestframework/__init__.py: -------------------------------------------------------------------------------- 1 | from appdaemontestframework.assert_that import AssertThatWrapper 2 | from appdaemontestframework.given_that import GivenThatWrapper 3 | from appdaemontestframework.time_travel import TimeTravelWrapper 4 | from appdaemontestframework.hass_mocks import HassMocks 5 | from appdaemontestframework.automation_fixture import automation_fixture -------------------------------------------------------------------------------- /.idea/appdaemontestframework.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 12 | -------------------------------------------------------------------------------- /doc/full_example/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "example-appdaemon-project" 3 | version = "0.1.0" 4 | description = "Example project using Appdaemon Test Framework" 5 | requires-python = ">=3.10" 6 | dependencies = [ 7 | "appdaemon>=4.0.0", 8 | "appdaemontestframework>=4.1.0", 9 | ] 10 | 11 | [project.optional-dependencies] 12 | test = [ 13 | "pytest>=6.0.0", 14 | "pytest-asyncio>=0.20.0", 15 | "coverage[toml]>=6.0", 16 | ] 17 | 18 | [build-system] 19 | requires = ["hatchling"] 20 | build-backend = "hatchling.build" 21 | 22 | [tool.pytest.ini_options] 23 | addopts = "--verbose" -------------------------------------------------------------------------------- /doc/full_example/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from appdaemontestframework import patch_hass, AssertThatWrapper, GivenThatWrapper, TimeTravelWrapper 3 | 4 | 5 | @pytest.fixture 6 | def hass_functions(): 7 | patched_hass_functions, unpatch_callback = patch_hass() 8 | yield patched_hass_functions 9 | unpatch_callback() 10 | 11 | 12 | @pytest.fixture 13 | def given_that(hass_functions): 14 | return GivenThatWrapper(hass_functions) 15 | 16 | 17 | @pytest.fixture 18 | def assert_that(hass_functions): 19 | return AssertThatWrapper(hass_functions) 20 | 21 | 22 | @pytest.fixture 23 | def time_travel(hass_functions): 24 | return TimeTravelWrapper(hass_functions) 25 | -------------------------------------------------------------------------------- /appdaemontestframework/appdaemon_mock/appdaemon.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import pytz 3 | import threading 4 | 5 | 6 | class MockAppDaemon: 7 | """Implementation of appdaemon's internal AppDaemon class suitable for testing""" 8 | 9 | def __init__(self, **kwargs): 10 | 11 | # 12 | # Import various AppDaemon bits and pieces now to avoid circular import 13 | # 14 | 15 | from appdaemontestframework.appdaemon_mock.scheduler import MockScheduler 16 | 17 | # Use UTC timezone just for testing. 18 | self.tz = pytz.timezone("UTC") 19 | 20 | self.sched = MockScheduler(self) 21 | 22 | # Add main_thread_id for compatibility with newer Appdaemon versions 23 | self.main_thread_id = threading.current_thread().ident 24 | -------------------------------------------------------------------------------- /test/test_events.py: -------------------------------------------------------------------------------- 1 | from appdaemon.plugins.hass.hassapi import Hass 2 | 3 | from appdaemontestframework import automation_fixture 4 | 5 | LIGHT = 'light.some_light' 6 | COVER = 'cover.some_cover' 7 | 8 | 9 | class MockAutomation(Hass): 10 | def initialize(self): 11 | pass 12 | 13 | def send_event(self): 14 | self.fire_event("SOME_EVENT", my_keyword="hello") 15 | 16 | 17 | @automation_fixture(MockAutomation) 18 | def automation(): 19 | pass 20 | 21 | 22 | def test_it_does_not_crash_when_testing_automation_that_sends_events(given_that, 23 | automation: MockAutomation): 24 | # For now, there is not assertion feature on events, so we're just ensuring 25 | # appdaemontestframework is not crashing when testing an automation that 26 | # sends events. 27 | automation.send_event() 28 | -------------------------------------------------------------------------------- /doc/full_example/tests/test_assertions_dsl.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from types import SimpleNamespace 3 | 4 | ## Custom Assertions DSL ################# 5 | def assert_that(expected): 6 | class Wrapper: 7 | def equals(self, actual): 8 | assert expected == actual 9 | return Wrapper() 10 | 11 | 12 | def assert_that_partial(expected): 13 | def equals(expected, actual): 14 | assert expected == actual 15 | return partial(equals, expected) 16 | 17 | 18 | def assert_that_sn(expected): 19 | assert_that = SimpleNamespace() 20 | 21 | def equals(actual): 22 | assert expected == actual 23 | assert_that.equals = equals 24 | return assert_that 25 | 26 | ########################################## 27 | 28 | 29 | def test_assertions_dsl(): 30 | assert_that(44).equals(44) 31 | assert_that_partial(44)(44) 32 | assert_that_sn(44).equals(44) 33 | # the_function 34 | -------------------------------------------------------------------------------- /test/integration_tests/tests/test_assertions_dsl.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from types import SimpleNamespace 3 | 4 | ## Custom Assertions DSL ################# 5 | def assert_that(expected): 6 | class Wrapper: 7 | def equals(self, actual): 8 | assert expected == actual 9 | return Wrapper() 10 | 11 | 12 | def assert_that_partial(expected): 13 | def equals(expected, actual): 14 | assert expected == actual 15 | return partial(equals, expected) 16 | 17 | 18 | def assert_that_sn(expected): 19 | assert_that = SimpleNamespace() 20 | 21 | def equals(actual): 22 | assert expected == actual 23 | assert_that.equals = equals 24 | return assert_that 25 | 26 | ########################################## 27 | 28 | 29 | def test_assertions_dsl(): 30 | assert_that(44).equals(44) 31 | assert_that_partial(44)(44) 32 | assert_that_sn(44).equals(44) 33 | # the_function 34 | -------------------------------------------------------------------------------- /test/test_miscellaneous_helper_functions.py: -------------------------------------------------------------------------------- 1 | import appdaemon.plugins.hass.hassapi as hass 2 | 3 | from appdaemontestframework import automation_fixture 4 | 5 | COVER = 'cover.some_cover' 6 | 7 | class WithMiscellaneousHelperFunctions(hass.Hass): 8 | def initialize(self): 9 | pass 10 | 11 | def get_entity_exists(self, entity): 12 | return self.entity_exists(entity) 13 | 14 | @automation_fixture(WithMiscellaneousHelperFunctions) 15 | def with_miscellaneous_helper_functions(): 16 | pass 17 | 18 | 19 | def test_entity_exists_true(given_that, with_miscellaneous_helper_functions, hass_functions): 20 | given_that.state_of(COVER).is_set_to("closed", {'friendly_name': f"{COVER}", 'current_position': 0}) 21 | assert with_miscellaneous_helper_functions.get_entity_exists(COVER)==True 22 | 23 | def test_entity_exists_false(given_that, with_miscellaneous_helper_functions, hass_functions): 24 | assert with_miscellaneous_helper_functions.get_entity_exists("not_existent_entity")==False 25 | -------------------------------------------------------------------------------- /doc/pytest_example.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from myrooms import LivingRoom 3 | 4 | # Important: 5 | # For this example to work, do not forget to copy the `conftest.py` file. 6 | # See README.md for more info 7 | 8 | @pytest.fixture 9 | def living_room(given_that): 10 | living_room = LivingRoom(None, None, None, None, None, None, None, None) 11 | living_room.initialize() 12 | given_that.mock_functions_are_cleared() 13 | return living_room 14 | 15 | 16 | def test_during_night_light_turn_on(given_that, living_room, assert_that): 17 | given_that.state_of('sensor.living_room_illumination').is_set_to(200) # 200lm == night 18 | living_room._new_motion(None, None, None) 19 | assert_that('light.living_room').was.turned_on() 20 | 21 | def test_during_day_light_DOES_NOT_turn_on(given_that, living_room, assert_that): 22 | given_that.state_of('sensor.living_room_illumination').is_set_to(1000) # 1000lm == sunlight 23 | living_room._new_motion(None, None, None) 24 | assert_that('light.living_room').was_not.turned_on() 25 | -------------------------------------------------------------------------------- /test/test_extra_hass_functions.py: -------------------------------------------------------------------------------- 1 | import appdaemon.plugins.hass.hassapi as hass 2 | 3 | from appdaemontestframework import automation_fixture 4 | 5 | 6 | class WithExtraHassFunctions(hass.Hass): 7 | def initialize(self): 8 | pass 9 | 10 | def call_notify(self): 11 | self.notify(message="test", name="html5") 12 | 13 | def call_now_is_between(self): 14 | self.now_is_between("sunset - 00:45:00", "sunrise + 00:45:00") 15 | 16 | 17 | @automation_fixture(WithExtraHassFunctions) 18 | def with_extra_hass_functions(): 19 | pass 20 | 21 | 22 | def test_now_is_between(given_that, with_extra_hass_functions, hass_mocks): 23 | with_extra_hass_functions.call_now_is_between() 24 | hass_mocks.hass_functions['now_is_between'].assert_called_with("sunset - 00:45:00", "sunrise + 00:45:00") 25 | 26 | 27 | def test_notify(given_that, with_extra_hass_functions, hass_mocks): 28 | with_extra_hass_functions.call_notify() 29 | hass_mocks.hass_functions['notify'].assert_called_with(message="test", name="html5") 30 | -------------------------------------------------------------------------------- /.idea/runConfigurations/All_tests.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 18 | -------------------------------------------------------------------------------- /.idea/runConfigurations/__mark_only__tests.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 18 | -------------------------------------------------------------------------------- /.idea/runConfigurations/All_tests_except_w__pytester.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Florian Kempenich 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /doc/full_example/apps/entity_ids.py: -------------------------------------------------------------------------------- 1 | ID = { 2 | 'kitchen': { 3 | 'motion_sensor': 'binary_sensor.kitchen_motion', 4 | 'light': 'light.kitchen_light', 5 | 'speaker': 'media_player.kitchen_speaker', 6 | 'button': 'binary_sensor.kitchen_button', 7 | 'temperature': 'sensor.kitchen_temperature' 8 | }, 9 | 'bathroom': { 10 | 'motion_sensor': 'binary_sensor.bathroom_motion', 11 | 'button': 'binary_sensor.bathroom_button', 12 | 'led_light': 'light.bathroom_gateway_light', 13 | 'speaker': 'media_player.bathroom_speaker', 14 | 'water_heater': 'switch.water_heater', 15 | 'gateway_mac_address': '78:11:DC:B3:56:C9' 16 | }, 17 | 'living_room': { 18 | 'motion_sensor': 'binary_sensor.living_room_motion', 19 | 'soundbar': 'media_player.soundbar_speaker', 20 | 'controller': 'media_player.controller_speaker', 21 | 'temperature': 'sensor.living_room_temperature' 22 | }, 23 | 'outside': { 24 | 'temperature': 'sensor.outside_temperature' 25 | }, 26 | 'cast_groups': { 27 | 'entire_flat': 'media_player.the_entire_flat_speaker_group' 28 | }, 29 | 'debug': { 30 | 'flic_black': 'flic_80e4da71a8b3' 31 | } 32 | } 33 | 34 | -------------------------------------------------------------------------------- /test/integration_tests/apps/entity_ids.py: -------------------------------------------------------------------------------- 1 | ID = { 2 | 'kitchen': { 3 | 'motion_sensor': 'binary_sensor.kitchen_motion', 4 | 'light': 'light.kitchen_light', 5 | 'speaker': 'media_player.kitchen_speaker', 6 | 'button': 'binary_sensor.kitchen_button', 7 | 'temperature': 'sensor.kitchen_temperature' 8 | }, 9 | 'bathroom': { 10 | 'motion_sensor': 'binary_sensor.bathroom_motion', 11 | 'button': 'binary_sensor.bathroom_button', 12 | 'led_light': 'light.bathroom_gateway_light', 13 | 'speaker': 'media_player.bathroom_speaker', 14 | 'water_heater': 'switch.water_heater', 15 | 'gateway_mac_address': '78:11:DC:B3:56:C9' 16 | }, 17 | 'living_room': { 18 | 'motion_sensor': 'binary_sensor.living_room_motion', 19 | 'soundbar': 'media_player.soundbar_speaker', 20 | 'controller': 'media_player.controller_speaker', 21 | 'temperature': 'sensor.living_room_temperature' 22 | }, 23 | 'outside': { 24 | 'temperature': 'sensor.outside_temperature' 25 | }, 26 | 'cast_groups': { 27 | 'entire_flat': 'media_player.the_entire_flat_speaker_group' 28 | }, 29 | 'debug': { 30 | 'flic_black': 'flic_80e4da71a8b3' 31 | } 32 | } 33 | 34 | -------------------------------------------------------------------------------- /appdaemontestframework/appdaemon_mock/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import appdaemon.utils 3 | 4 | # Appdaemon 4 uses Python asyncio programming. Since our tests are not async 5 | # we replace the sync_wrapper decorator with one that will always result in 6 | # synchronizing appdatemon calls. 7 | def sync_wrapper(coro): 8 | def inner_sync_wrapper(self, *args, **kwargs): 9 | start_new_loop = None 10 | # try to get the running loop 11 | # `get_running_loop()` is new in Python 3.7, fall back on privateinternal for 3.6 12 | try: 13 | get_running_loop = asyncio.get_running_loop 14 | except AttributeError: 15 | get_running_loop = asyncio._get_running_loop 16 | 17 | # If there is no running loop we will need to start a new one and run it to completion 18 | try: 19 | if get_running_loop(): 20 | start_new_loop = False 21 | else: 22 | start_new_loop = True 23 | except RuntimeError: 24 | start_new_loop = True 25 | 26 | if start_new_loop is True: 27 | f = asyncio.ensure_future(coro(self, *args, **kwargs)) 28 | asyncio.get_event_loop().run_until_complete(f) 29 | f = f.result() 30 | else: 31 | # don't use create_task. It's python3.7 only 32 | f = asyncio.ensure_future(coro(self, *args, **kwargs)) 33 | 34 | return f 35 | 36 | return inner_sync_wrapper 37 | 38 | # Monkey patch in our sync_wrapper 39 | appdaemon.utils.sync_wrapper = sync_wrapper 40 | -------------------------------------------------------------------------------- /appdaemontestframework/pytest_conftest.py: -------------------------------------------------------------------------------- 1 | from pytest import fixture 2 | from appdaemontestframework import HassMocks, AssertThatWrapper, GivenThatWrapper, TimeTravelWrapper 3 | import warnings 4 | import textwrap 5 | 6 | # Only expose the test fixtures and pytest needed things so `import *` doesn't pollute things 7 | __all__ = [ 8 | 'pytest_plugins', 9 | 'fixture', 10 | 'hass_mocks', 11 | 'hass_functions', 12 | 'given_that', 13 | 'assert_that', 14 | 'time_travel', 15 | ] 16 | 17 | pytest_plugins = 'pytester' 18 | 19 | 20 | class DeprecatedDict(dict): 21 | """Helper class that will give a deprectaion warning when accessing any of it's members""" 22 | def __getitem__(self, key): 23 | message = textwrap.dedent( 24 | """ 25 | Usage of the `hass_functions` test fixture is deprecated. 26 | Replace `hass_functions` with the `hass_mocks` test fixture and access the `hass_functions` property. 27 | hass_functions['{0}'] ==becomes==> hass_mocks.hass_functions['{0}'] 28 | """.format(key)) 29 | warnings.warn(message, DeprecationWarning, stacklevel=2) 30 | return super().__getitem__(key) 31 | 32 | 33 | @fixture 34 | def hass_mocks(): 35 | hass_mocks = HassMocks() 36 | yield hass_mocks 37 | hass_mocks.unpatch_mocks() 38 | 39 | 40 | @fixture 41 | def hass_functions(hass_mocks): 42 | return DeprecatedDict(hass_mocks.hass_functions) 43 | 44 | 45 | @fixture 46 | def given_that(hass_mocks): 47 | return GivenThatWrapper(hass_mocks) 48 | 49 | 50 | @fixture 51 | def assert_that(hass_mocks): 52 | return AssertThatWrapper(hass_mocks) 53 | 54 | 55 | @fixture 56 | def time_travel(hass_mocks): 57 | return TimeTravelWrapper(hass_mocks) 58 | -------------------------------------------------------------------------------- /doc/unittest_example.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from appdaemontestframework import patch_hass, GivenThatWrapper, AssertThatWrapper, TimeTravelWrapper 3 | from myrooms import LivingRoom 4 | 5 | # Important: 6 | # This class is equivalent to the setup done in the `conftest.py` on the pytest version. 7 | # Do not forget to inherint from it in all your XXXTestCase classes. 8 | # See below in `LivingRoomTestCase` 9 | class AppdaemonTestCase(unittest.TestCase): 10 | @classmethod 11 | def setUpClass(cls): 12 | patched_hass_functions, unpatch_callback = patch_hass() 13 | 14 | cls.given_that = GivenThatWrapper(patched_hass_functions) 15 | cls.assert_that = AssertThatWrapper(patched_hass_functions) 16 | cls.time_travel = TimeTravelWrapper(patched_hass_functions) 17 | 18 | cls.unpatch_callback = unpatch_callback 19 | 20 | @classmethod 21 | def tearDownClass(cls): 22 | cls.unpatch_callback() 23 | 24 | 25 | class LivingRoomTestCase(AppdaemonTestCase): 26 | def setUp(self): 27 | self.living_room = LivingRoom(None, None, None, None, None, None, None, None) 28 | self.living_room.initialize() 29 | self.given_that.mock_functions_are_cleared() 30 | 31 | def test_during_night_light_turn_on(self): 32 | self.given_that.state_of('sensor.living_room_illumination').is_set_to(200) # 200lm == night 33 | self.living_room._new_motion(None, None, None) 34 | self.assert_that('light.living_room').was.turned_on() 35 | 36 | def test_during_day_light_DOES_NOT_turn_on(self): 37 | self.given_that.state_of('sensor.living_room_illumination').is_set_to(1000) # 1000lm == sun light 38 | self.living_room._new_motion(None, None, None) 39 | self.assert_that('light.living_room').was_not.turned_on() 40 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master, main] 6 | pull_request: 7 | branches: [master, main] 8 | release: 9 | types: [published] 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | python-version: ["3.10", "3.11", "3.12"] 17 | fail-fast: false 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v4 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install -e ".[test]" 31 | 32 | - name: Run tests with pytest 33 | run: | 34 | pytest 35 | coverage run -m pytest 36 | coverage report 37 | 38 | - name: Upload coverage reports 39 | if: matrix.python-version == '3.11' 40 | uses: codecov/codecov-action@v3 41 | with: 42 | fail_ci_if_error: false 43 | 44 | deploy: 45 | needs: test 46 | runs-on: ubuntu-latest 47 | if: github.event_name == 'release' && github.event.action == 'published' 48 | 49 | steps: 50 | - uses: actions/checkout@v4 51 | 52 | - name: Set up Python 53 | uses: actions/setup-python@v4 54 | with: 55 | python-version: "3.11" 56 | 57 | - name: Install dependencies 58 | run: | 59 | python -m pip install --upgrade pip 60 | pip install build twine 61 | 62 | - name: Build package 63 | run: python -m build 64 | 65 | - name: Publish to PyPI 66 | env: 67 | TWINE_USERNAME: __token__ 68 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 69 | run: twine upload dist/* 70 | -------------------------------------------------------------------------------- /test/test_with_arguments.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import appdaemon.plugins.hass.hassapi as hass 3 | 4 | from appdaemontestframework import automation_fixture 5 | 6 | 7 | class WithArguments(hass.Hass): 8 | """ 9 | Simulate a Class initialized with arguments in `apps.yml` 10 | 11 | WithArguments: 12 | module: somemodule 13 | class: WithArguments 14 | # Below are the arguments 15 | name: "Frank" 16 | color: "blue" 17 | 18 | See: http://appdaemon.readthedocs.io/en/latest/APPGUIDE.html#passing-arguments-to-apps 19 | """ 20 | 21 | def initialize(self): 22 | pass 23 | 24 | def get_arg_passed_via_config(self, key): 25 | return self.args[key] 26 | 27 | def get_all_args(self): 28 | return self.args 29 | 30 | 31 | @automation_fixture(WithArguments) 32 | def with_arguments(given_that): 33 | pass 34 | 35 | 36 | def test_argument_not_mocked(given_that, with_arguments): 37 | with pytest.raises(KeyError): 38 | with_arguments.get_arg_passed_via_config('name') 39 | 40 | 41 | def test_argument_mocked(given_that, with_arguments): 42 | given_that.passed_arg('name').is_set_to('Frank') 43 | assert with_arguments.get_arg_passed_via_config('name') == 'Frank' 44 | 45 | 46 | def test_multiple_arguments_mocked(given_that, with_arguments): 47 | given_that.passed_arg('name').is_set_to('Frank') 48 | given_that.passed_arg('color').is_set_to('blue') 49 | assert with_arguments.get_arg_passed_via_config('name') == 'Frank' 50 | assert with_arguments.get_arg_passed_via_config('color') == 'blue' 51 | assert with_arguments.get_all_args() == {'name': 'Frank', 'color': 'blue'} 52 | 53 | 54 | def test_clear_mocked_arguments(given_that, with_arguments): 55 | given_that.passed_arg('name').is_set_to('Frank') 56 | assert with_arguments.get_arg_passed_via_config('name') == 'Frank' 57 | 58 | given_that.mock_functions_are_cleared(clear_mock_passed_args=False) 59 | assert with_arguments.get_arg_passed_via_config('name') == 'Frank' 60 | 61 | given_that.mock_functions_are_cleared(clear_mock_passed_args=True) 62 | with pytest.raises(KeyError): 63 | with_arguments.get_arg_passed_via_config('name') 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .pytest_cache 2 | *.pyc 3 | dist 4 | build 5 | *.egg-info 6 | .tox 7 | 8 | # vscode dirs 9 | .vscode/ 10 | 11 | # Python virtual envrionments 12 | /venv*/ 13 | 14 | # Created by https://www.gitignore.io/api/pycharm 15 | # Edit at https://www.gitignore.io/?templates=pycharm 16 | 17 | ### PyCharm ### 18 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 19 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 20 | 21 | # User-specific stuff 22 | .idea/**/workspace.xml 23 | .idea/**/tasks.xml 24 | .idea/**/usage.statistics.xml 25 | .idea/**/dictionaries 26 | .idea/**/shelf 27 | 28 | # Generated files 29 | .idea/**/contentModel.xml 30 | 31 | # Sensitive or high-churn files 32 | .idea/**/dataSources/ 33 | .idea/**/dataSources.ids 34 | .idea/**/dataSources.local.xml 35 | .idea/**/sqlDataSources.xml 36 | .idea/**/dynamic.xml 37 | .idea/**/uiDesigner.xml 38 | .idea/**/dbnavigator.xml 39 | 40 | # Gradle 41 | .idea/**/gradle.xml 42 | .idea/**/libraries 43 | 44 | # Gradle and Maven with auto-import 45 | # When using Gradle or Maven with auto-import, you should exclude module files, 46 | # since they will be recreated, and may cause churn. Uncomment if using 47 | # auto-import. 48 | # .idea/modules.xml 49 | # .idea/*.iml 50 | # .idea/modules 51 | 52 | # CMake 53 | cmake-build-*/ 54 | 55 | # Mongo Explorer plugin 56 | .idea/**/mongoSettings.xml 57 | 58 | # File-based project format 59 | *.iws 60 | 61 | # IntelliJ 62 | out/ 63 | 64 | # mpeltonen/sbt-idea plugin 65 | .idea_modules/ 66 | 67 | # JIRA plugin 68 | atlassian-ide-plugin.xml 69 | 70 | # Cursive Clojure plugin 71 | .idea/replstate.xml 72 | 73 | # Crashlytics plugin (for Android Studio and IntelliJ) 74 | com_crashlytics_export_strings.xml 75 | crashlytics.properties 76 | crashlytics-build.properties 77 | fabric.properties 78 | 79 | # Editor-based Rest Client 80 | .idea/httpRequests 81 | 82 | # Android studio 3.1+ serialized cache file 83 | .idea/caches/build_file_checksums.ser 84 | 85 | ### PyCharm Patch ### 86 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 87 | 88 | # *.iml 89 | # modules.xml 90 | # .idea/misc.xml 91 | # *.ipr 92 | 93 | # Sonarlint plugin 94 | .idea/sonarlint 95 | 96 | # End of https://www.gitignore.io/api/pycharm 97 | -------------------------------------------------------------------------------- /appdaemontestframework/time_travel.py: -------------------------------------------------------------------------------- 1 | from appdaemontestframework.hass_mocks import HassMocks 2 | import datetime 3 | 4 | class TimeTravelWrapper: 5 | """ 6 | AppDaemon Test Framework Utility to simulate going forward in time 7 | """ 8 | 9 | def __init__(self, hass_mocks: HassMocks): 10 | self._hass_mocks = hass_mocks 11 | 12 | def fast_forward(self, duration): 13 | """ 14 | Simulate going forward in time. 15 | 16 | It calls all the functions that have been registered with AppDaemon 17 | for a later schedule run. A function is only called if it's scheduled 18 | time is before or at the simulated time. 19 | 20 | You can chain the calls and call `fast_forward` multiple times in a single test 21 | 22 | Format: 23 | > time_travel.fast_forward(10).minutes() 24 | > # Or 25 | > time_travel.fast_forward(30).seconds() 26 | """ 27 | return UnitsWrapper(duration, self._fast_forward_seconds) 28 | 29 | def assert_current_time(self, expected_current_time): 30 | """ 31 | Assert the current time is as expected 32 | 33 | Expected current time is expressed as a duration from T = 0 34 | 35 | Format: 36 | > time_travel.assert_current_time(10).minutes() 37 | > # Or 38 | > time_travel.assert_current_time(30).seconds() 39 | """ 40 | return UnitsWrapper(expected_current_time, self._assert_current_time_seconds) 41 | 42 | 43 | def _fast_forward_seconds(self, seconds_to_fast_forward): 44 | self._hass_mocks.AD.sched.sim_fast_forward(datetime.timedelta(seconds=seconds_to_fast_forward)) 45 | 46 | def _assert_current_time_seconds(self, expected_seconds_from_start): 47 | sched = self._hass_mocks.AD.sched 48 | elapsed_seconds = (sched.get_now_sync() - sched.sim_get_start_time()).total_seconds() 49 | assert elapsed_seconds == expected_seconds_from_start 50 | 51 | 52 | class UnitsWrapper: 53 | def __init__(self, duration, function_with_arg_in_seconds): 54 | self.duration = duration 55 | self.function_with_arg_in_seconds = function_with_arg_in_seconds 56 | 57 | def minutes(self): 58 | self.function_with_arg_in_seconds(self.duration * 60) 59 | 60 | def seconds(self): 61 | self.function_with_arg_in_seconds(self.duration) 62 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "appdaemontestframework" 7 | version = "5.0.0" 8 | description = "Clean, human-readable tests for Appdaemon" 9 | readme = "README.md" 10 | license = "MIT" 11 | requires-python = ">=3.10" 12 | authors = [ 13 | { name = "Florian Kempenich", email = "Flori@nKempenich.com" }, 14 | ] 15 | keywords = [ 16 | "appdaemon", 17 | "homeassistant", 18 | "test", 19 | "tdd", 20 | "clean-code", 21 | "home-automation" 22 | ] 23 | classifiers = [ 24 | "Development Status :: 4 - Beta", 25 | "Environment :: Console", 26 | "Framework :: Pytest", 27 | "Intended Audience :: Developers", 28 | "License :: OSI Approved :: MIT License", 29 | "Programming Language :: Python :: 3", 30 | "Programming Language :: Python :: 3.10", 31 | "Programming Language :: Python :: 3.11", 32 | "Programming Language :: Python :: 3.12", 33 | "Topic :: Utilities", 34 | "Topic :: Home Automation", 35 | "Topic :: Software Development :: Testing", 36 | ] 37 | dependencies = [ 38 | "appdaemon>=4.0.0", 39 | "packaging>=20.0", 40 | "mock>=3.0.5", 41 | "setuptools>=75.3.2", 42 | ] 43 | 44 | [project.optional-dependencies] 45 | test = [ 46 | "pytest>=6.0.0", 47 | "pytest-asyncio>=0.20.0", 48 | "coverage[toml]>=6.0", 49 | ] 50 | dev = [ 51 | "appdaemontestframework[test]", 52 | "pylint>=2.0.0", 53 | "autopep8>=1.5.0", 54 | ] 55 | 56 | [project.urls] 57 | Homepage = "https://floriankempenich.github.io/Appdaemon-Test-Framework" 58 | Documentation = "https://floriankempenich.github.io/Appdaemon-Test-Framework" 59 | Repository = "https://github.com/FlorianKempenich/Appdaemon-Test-Framework" 60 | Issues = "https://github.com/FlorianKempenich/Appdaemon-Test-Framework/issues" 61 | 62 | [tool.hatch.build.targets.sdist] 63 | include = [ 64 | "/appdaemontestframework", 65 | "/README.md", 66 | "/LICENSE", 67 | ] 68 | 69 | [tool.hatch.build.targets.wheel] 70 | packages = ["appdaemontestframework"] 71 | 72 | [tool.pytest.ini_options] 73 | addopts = "--ignore=doc" 74 | markers = [ 75 | "only: marks tests as the only ones to run", 76 | "using_pytester: marks tests that use pytester", 77 | ] 78 | 79 | [tool.coverage.run] 80 | source = ["appdaemontestframework"] 81 | omit = ["*/tests/*", "*/test_*"] 82 | 83 | [tool.coverage.report] 84 | exclude_lines = [ 85 | "pragma: no cover", 86 | "def __repr__", 87 | "raise AssertionError", 88 | "raise NotImplementedError", 89 | ] 90 | 91 | [dependency-groups] 92 | dev = [ 93 | "appdaemon>=4.4.2", 94 | "coverage>=7.6.1", 95 | "pytest>=8.3.5", 96 | "pytest-asyncio>=0.24.0", 97 | ] 98 | -------------------------------------------------------------------------------- /doc/full_example/tests/test_vanilla_file.py: -------------------------------------------------------------------------------- 1 | from mock import patch, MagicMock 2 | import pytest 3 | 4 | """ 5 | Class hierarchy to patch 6 | """ 7 | 8 | 9 | class MotherClass(): 10 | def __init__(self): 11 | print('I am the mother class') 12 | 13 | def exploding_method(self, text=''): 14 | raise Exception(''.join(["Exploding method!!", text])) 15 | 16 | 17 | class ChildClass(MotherClass): 18 | # def __init__(self): 19 | # print('I am child class') 20 | def call_the_exploding_of_mother(self): 21 | return self.exploding_method() 22 | 23 | def call_the_exploding_of_mother_with_args(self): 24 | return self.exploding_method(text='hellooooo') 25 | 26 | 27 | class UnrelatedClass(): 28 | def __init__(self): 29 | print('I am unrelated') 30 | 31 | def call_the_exploding_of_mother_via_child(self): 32 | child = ChildClass() 33 | child.call_the_exploding_of_mother() 34 | 35 | ################################################### 36 | 37 | 38 | @pytest.mark.skip 39 | class TestVanillaClasses: 40 | def test_without_mocking(self): 41 | child = ChildClass() 42 | with pytest.raises(Exception, message='Exploding method!!'): 43 | child.call_the_exploding_of_mother() 44 | 45 | 46 | def test_without_mocking_2(self): 47 | child = ChildClass() 48 | with pytest.raises(Exception, message='Exploding method!!hellooooo'): 49 | child.call_the_exploding_of_mother_with_args() 50 | 51 | 52 | @patch('tests.test_vanilla_file.MotherClass.exploding_method') 53 | def test_mocking_exploding(self, exploding_method): 54 | exploding_method.return_value = 'moooooooocked' 55 | child = ChildClass() 56 | assert child.call_the_exploding_of_mother() == 'moooooooocked' 57 | 58 | 59 | @patch.object(MotherClass, 'exploding_method') 60 | def test_mocking_exploding_via_object(self, exploding_method): 61 | exploding_method.return_value = 'moooooooocked' 62 | child = ChildClass() 63 | assert child.call_the_exploding_of_mother() == 'moooooooocked' 64 | 65 | 66 | @patch.object(MotherClass, 'exploding_method') 67 | def test_mocking_exploding_via_object_2(self, exploding_method): 68 | exploding_method.return_value = 'moooooooocked' 69 | child = ChildClass() 70 | a = MagicMock() 71 | a.call_args 72 | a.call_args_list 73 | child.call_the_exploding_of_mother_with_args() 74 | exploding_method.assert_called_once() 75 | print(exploding_method.call_args) 76 | print(exploding_method.call_args_list) 77 | # assert 1 == 3 78 | # assert child.call_the_exploding_of_mother() == 'moooooooocked' 79 | -------------------------------------------------------------------------------- /test/integration_tests/tests/test_vanilla_file.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from mock import patch 3 | 4 | """ 5 | Class hierarchy to patch 6 | """ 7 | 8 | DEBUG = False 9 | 10 | 11 | def print(msg): 12 | if DEBUG: 13 | import builtins 14 | builtins.print(msg) 15 | 16 | 17 | class MotherClass(): 18 | def __init__(self): 19 | print('I am the mother class') 20 | 21 | def exploding_method(self, text=''): 22 | raise Exception(''.join(["Exploding method!!", text])) 23 | 24 | 25 | class ChildClass(MotherClass): 26 | # def __init__(self): 27 | # print('I am child class') 28 | def call_the_exploding_of_mother(self): 29 | return self.exploding_method() 30 | 31 | def call_the_exploding_of_mother_with_args(self): 32 | return self.exploding_method(text='hellooooo') 33 | 34 | 35 | class UnrelatedClass(): 36 | def __init__(self): 37 | print('I am unrelated') 38 | 39 | def call_the_exploding_of_mother_via_child(self): 40 | child = ChildClass() 41 | child.call_the_exploding_of_mother() 42 | 43 | 44 | ################################################### 45 | 46 | 47 | class TestVanillaClasses: 48 | def test_without_mocking(self): 49 | child = ChildClass() 50 | with pytest.raises(Exception, match='Exploding method!!'): 51 | child.call_the_exploding_of_mother() 52 | 53 | def test_without_mocking_2(self): 54 | child = ChildClass() 55 | with pytest.raises(Exception, match='Exploding method!!hellooooo'): 56 | child.call_the_exploding_of_mother_with_args() 57 | 58 | @patch('tests.test_vanilla_file.MotherClass.exploding_method') 59 | def test_mocking_exploding(self, exploding_method): 60 | exploding_method.return_value = 'moooooooocked' 61 | child = ChildClass() 62 | assert child.call_the_exploding_of_mother() == 'moooooooocked' 63 | 64 | @patch.object(MotherClass, 'exploding_method') 65 | def test_mocking_exploding_via_object(self, exploding_method): 66 | exploding_method.return_value = 'moooooooocked' 67 | child = ChildClass() 68 | assert child.call_the_exploding_of_mother() == 'moooooooocked' 69 | 70 | @patch.object(MotherClass, 'exploding_method') 71 | def test_mocking_exploding_via_object_2(self, exploding_method): 72 | exploding_method.return_value = 'moooooooocked' 73 | child = ChildClass() 74 | child.call_the_exploding_of_mother_with_args() 75 | exploding_method.assert_called_once() 76 | print(exploding_method.call_args) 77 | print(exploding_method.call_args_list) 78 | # assert 1 == 3 79 | # assert child.call_the_exploding_of_mother() == 'moooooooocked' 80 | -------------------------------------------------------------------------------- /doc/full_example/apps/kitchen.py: -------------------------------------------------------------------------------- 1 | import appdaemon.plugins.hass.hassapi as hass 2 | from uuid import uuid4 3 | try: 4 | # Module namespaces when Automation Modules are loaded in AppDaemon 5 | # is different from the 'real' python one. 6 | # Appdaemon doesn't seem to take into account packages 7 | from apps.entity_ids import ID 8 | except ModuleNotFoundError: 9 | from entity_ids import ID 10 | 11 | # TODO: Put this in config (through apps.yml, check doc) 12 | PHONE_PUSHBULLET_ID = "device/OnePlus 5T" 13 | SHORT_DELAY = 10 14 | LONG_DELAY = 30 15 | 16 | MSG_TITLE = "Water Heater" 17 | MSG_SHORT_OFF = f"was turned off for {SHORT_DELAY} minutes" 18 | MSG_LONG_OFF = f"was turned off for {LONG_DELAY} minutes" 19 | MSG_ON = "was turned back on" 20 | 21 | 22 | class Kitchen(hass.Hass): 23 | def initialize(self): 24 | self.listen_event(self._new_motion, 'motion', 25 | entity_id=ID['kitchen']['motion_sensor']) 26 | self.listen_state(self._no_more_motion, 27 | ID['kitchen']['motion_sensor'], new='off') 28 | self.listen_event(self._new_button_click, 'click', 29 | entity_id=ID['kitchen']['button'], click_type='single') 30 | self.listen_event(self._new_button_double_click, 'click', 31 | entity_id=ID['kitchen']['button'], click_type='double') 32 | 33 | self.scheduled_callbacks_uuids = [] 34 | 35 | def _new_motion(self, _event, _data, _kwargs): 36 | self.turn_on(ID['kitchen']['light']) 37 | 38 | def _no_more_motion(self, _entity, _attribute, _old, _new, _kwargs): 39 | self.turn_off(ID['kitchen']['light']) 40 | 41 | def _new_button_click(self, _e, _d, _k): 42 | self._turn_off_water_heater_for_X_minutes(SHORT_DELAY) 43 | self._send_water_heater_notification(MSG_SHORT_OFF) 44 | 45 | def _new_button_double_click(self, _e, _d, _k): 46 | self._turn_off_water_heater_for_X_minutes(LONG_DELAY) 47 | self._send_water_heater_notification(MSG_LONG_OFF) 48 | 49 | def _new_button_long_press(self, _e, _d, _k): 50 | pass 51 | 52 | def _turn_off_water_heater_for_X_minutes(self, minutes): 53 | self.turn_off(ID['bathroom']['water_heater']) 54 | callback_uuid = uuid4() 55 | self.run_in(self._after_delay, minutes * 60, unique_id=callback_uuid) 56 | self.scheduled_callbacks_uuids.append(callback_uuid) 57 | 58 | def _send_water_heater_notification(self, message): 59 | self.call_service('notify/pushbullet', 60 | target=PHONE_PUSHBULLET_ID, 61 | title=MSG_TITLE, 62 | message=message) 63 | 64 | def _after_delay(self, kwargs): 65 | last_callback_uuid = self.scheduled_callbacks_uuids[-1] 66 | this_callback_uuid = kwargs['unique_id'] 67 | if this_callback_uuid == last_callback_uuid: 68 | self.turn_on(ID['bathroom']['water_heater']) 69 | self._send_water_heater_notification(MSG_ON) 70 | else: 71 | self.scheduled_callbacks_uuids.remove(this_callback_uuid) 72 | -------------------------------------------------------------------------------- /test/integration_tests/apps/kitchen.py: -------------------------------------------------------------------------------- 1 | import appdaemon.plugins.hass.hassapi as hass 2 | from uuid import uuid4 3 | try: 4 | # Module namespaces when Automation Modules are loaded in AppDaemon 5 | # is different from the 'real' python one. 6 | # Appdaemon doesn't seem to take into account packages 7 | from apps.entity_ids import ID 8 | except ModuleNotFoundError: 9 | from entity_ids import ID 10 | 11 | # TODO: Put this in config (through apps.yml, check doc) 12 | PHONE_PUSHBULLET_ID = "device/OnePlus 5T" 13 | SHORT_DELAY = 10 14 | LONG_DELAY = 30 15 | 16 | MSG_TITLE = "Water Heater" 17 | MSG_SHORT_OFF = f"was turned off for {SHORT_DELAY} minutes" 18 | MSG_LONG_OFF = f"was turned off for {LONG_DELAY} minutes" 19 | MSG_ON = "was turned back on" 20 | 21 | 22 | class Kitchen(hass.Hass): 23 | def initialize(self): 24 | self.listen_event(self._new_motion, 'motion', 25 | entity_id=ID['kitchen']['motion_sensor']) 26 | self.listen_state(self._no_more_motion, 27 | ID['kitchen']['motion_sensor'], new='off') 28 | self.listen_event(self._new_button_click, 'click', 29 | entity_id=ID['kitchen']['button'], click_type='single') 30 | self.listen_event(self._new_button_double_click, 'click', 31 | entity_id=ID['kitchen']['button'], click_type='double') 32 | 33 | self.scheduled_callbacks_uuids = [] 34 | 35 | def _new_motion(self, _event, _data, _kwargs): 36 | self.turn_on(ID['kitchen']['light']) 37 | 38 | def _no_more_motion(self, _entity, _attribute, _old, _new, _kwargs): 39 | self.turn_off(ID['kitchen']['light']) 40 | 41 | def _new_button_click(self, _e, _d, _k): 42 | self._turn_off_water_heater_for_X_minutes(SHORT_DELAY) 43 | self._send_water_heater_notification(MSG_SHORT_OFF) 44 | 45 | def _new_button_double_click(self, _e, _d, _k): 46 | self._turn_off_water_heater_for_X_minutes(LONG_DELAY) 47 | self._send_water_heater_notification(MSG_LONG_OFF) 48 | 49 | def _new_button_long_press(self, _e, _d, _k): 50 | pass 51 | 52 | def _turn_off_water_heater_for_X_minutes(self, minutes): 53 | self.turn_off(ID['bathroom']['water_heater']) 54 | callback_uuid = uuid4() 55 | self.run_in(self._after_delay, minutes * 60, unique_id=callback_uuid) 56 | self.scheduled_callbacks_uuids.append(callback_uuid) 57 | 58 | def _send_water_heater_notification(self, message): 59 | self.call_service('notify/pushbullet', 60 | target=PHONE_PUSHBULLET_ID, 61 | title=MSG_TITLE, 62 | message=message) 63 | 64 | def _after_delay(self, kwargs): 65 | last_callback_uuid = self.scheduled_callbacks_uuids[-1] 66 | this_callback_uuid = kwargs['unique_id'] 67 | if this_callback_uuid == last_callback_uuid: 68 | self.turn_on(ID['bathroom']['water_heater']) 69 | self._send_water_heater_notification(MSG_ON) 70 | else: 71 | self.scheduled_callbacks_uuids.remove(this_callback_uuid) 72 | -------------------------------------------------------------------------------- /appdaemontestframework/given_that.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from appdaemontestframework.common import AppdaemonTestFrameworkError 4 | from appdaemontestframework.hass_mocks import HassMocks 5 | 6 | 7 | class StateNotSetError(AppdaemonTestFrameworkError): 8 | def __init__(self, entity_id): 9 | super().__init__(f""" 10 | State for entity: '{entity_id}' was never set! 11 | Please make sure to set the state with `given_that.state_of({entity_id}).is_set_to(STATE)` 12 | before trying to access the mocked state 13 | """) 14 | 15 | 16 | class AttributeNotSetError(AppdaemonTestFrameworkError): 17 | pass 18 | 19 | 20 | class GivenThatWrapper: 21 | def __init__(self, hass_mocks: HassMocks): 22 | self._hass_mocks = hass_mocks 23 | self._init_mocked_states() 24 | self._init_mocked_passed_args() 25 | 26 | def _init_mocked_states(self): 27 | self.mocked_states = {} 28 | 29 | def get_state_mock(entity_id=None, *, attribute=None): 30 | if entity_id is None: 31 | resdict = dict() 32 | for entityid in self.mocked_states: 33 | state = self.mocked_states[entityid] 34 | resdict.update({ 35 | entityid: { 36 | "state": state['main'], 37 | "attributes": state['attributes'] 38 | } 39 | }) 40 | return resdict 41 | else: 42 | if entity_id not in self.mocked_states: 43 | raise StateNotSetError(entity_id) 44 | 45 | state = self.mocked_states[entity_id] 46 | 47 | if attribute is None: 48 | return state['main'] 49 | elif attribute == 'all': 50 | def format_time(timestamp: datetime): 51 | if not timestamp: return None 52 | return timestamp.isoformat() 53 | 54 | return { 55 | "last_updated": format_time(state['last_updated']), 56 | "last_changed": format_time(state['last_changed']), 57 | "state": state["main"], 58 | "attributes": state['attributes'], 59 | "entity_id": entity_id, 60 | } 61 | else: 62 | return state['attributes'].get(attribute) 63 | 64 | self._hass_mocks.hass_functions['get_state'].side_effect = get_state_mock 65 | 66 | def entity_exists_mock(entity_id): 67 | return entity_id in self.mocked_states 68 | 69 | self._hass_mocks.hass_functions['entity_exists'].side_effect = entity_exists_mock 70 | 71 | def _init_mocked_passed_args(self): 72 | self.mocked_passed_args = self._hass_mocks.hass_functions['args'] 73 | self.mocked_passed_args.clear() 74 | 75 | def state_of(self, entity_id): 76 | given_that_wrapper = self 77 | 78 | class IsWrapper: 79 | def is_set_to(self, 80 | state, 81 | attributes=None, 82 | last_updated: datetime = None, 83 | last_changed: datetime = None): 84 | if not attributes: 85 | attributes = {} 86 | given_that_wrapper.mocked_states[entity_id] = { 87 | 'main': state, 88 | 'attributes': attributes, 89 | 'last_updated': last_updated, 90 | 'last_changed': last_changed 91 | } 92 | 93 | return IsWrapper() 94 | 95 | def passed_arg(self, argument_key): 96 | given_that_wrapper = self 97 | 98 | class IsWrapper: 99 | @staticmethod 100 | def is_set_to(argument_value): 101 | given_that_wrapper.mocked_passed_args[argument_key] = \ 102 | argument_value 103 | 104 | return IsWrapper() 105 | 106 | def time_is(self, time_as_datetime): 107 | self._hass_mocks.AD.sched.sim_set_start_time(time_as_datetime) 108 | 109 | def mock_functions_are_cleared(self, clear_mock_states=False, 110 | clear_mock_passed_args=False): 111 | for mocked_function in self._hass_mocks.hass_functions.values(): 112 | mocked_function.reset_mock() 113 | if clear_mock_states: 114 | self._init_mocked_states() 115 | if clear_mock_passed_args: 116 | self._init_mocked_passed_args() 117 | -------------------------------------------------------------------------------- /test/test_time_travel.py: -------------------------------------------------------------------------------- 1 | from appdaemon.plugins.hass.hassapi import Hass 2 | from appdaemontestframework import automation_fixture 3 | import mock 4 | import pytest 5 | import datetime 6 | 7 | 8 | class MockAutomation(Hass): 9 | def initialize(self): 10 | pass 11 | 12 | 13 | @automation_fixture(MockAutomation) 14 | def automation(): 15 | pass 16 | 17 | 18 | @automation_fixture(MockAutomation) 19 | def automation_at_noon(given_that): 20 | given_that.time_is(datetime.datetime(2020, 1, 1, 12, 0)) 21 | 22 | 23 | def test_callback_not_called_before_timeout(time_travel, automation): 24 | foo = mock.Mock() 25 | automation.run_in(foo, 10) 26 | 27 | time_travel.fast_forward(5).seconds() 28 | foo.assert_not_called() 29 | 30 | 31 | def test_callback_called_after_timeout(time_travel, automation): 32 | foo = mock.Mock() 33 | automation.run_in(foo, 10) 34 | time_travel.fast_forward(20).seconds() 35 | foo.assert_called() 36 | 37 | 38 | def test_canceled_timer_does_not_run_callback(time_travel, automation): 39 | foo = mock.Mock() 40 | handle = automation.run_in(foo, 10) 41 | time_travel.fast_forward(5).seconds() 42 | automation.cancel_timer(handle) 43 | time_travel.fast_forward(10).seconds() 44 | foo.assert_not_called() 45 | 46 | 47 | class Test_fast_forward: 48 | def test_seconds(self, time_travel, automation_at_noon): 49 | time_travel.fast_forward(600).seconds() 50 | assert automation_at_noon.datetime() == datetime.datetime(2020, 1, 1, 12, 10) 51 | 52 | def test_minutes(self, time_travel, automation_at_noon): 53 | time_travel.fast_forward(90).minutes() 54 | assert automation_at_noon.datetime() == datetime.datetime(2020, 1, 1, 13, 30) 55 | 56 | 57 | class Test_callback_execution: 58 | def test_callbacks_are_run_in_time_order(self, time_travel, automation): 59 | first_mock = mock.Mock() 60 | second_mock = mock.Mock() 61 | third_mock = mock.Mock() 62 | manager = mock.Mock() 63 | manager.attach_mock(first_mock, "first_mock") 64 | manager.attach_mock(second_mock, "second_mock") 65 | manager.attach_mock(third_mock, "third_mock") 66 | 67 | automation.run_in(second_mock, 20) 68 | automation.run_in(third_mock, 30) 69 | automation.run_in(first_mock, 10) 70 | 71 | time_travel.fast_forward(30).seconds() 72 | 73 | expected_call_order = [ 74 | mock.call.first_mock({}), 75 | mock.call.second_mock({}), 76 | mock.call.third_mock({}), 77 | ] 78 | assert manager.mock_calls == expected_call_order 79 | 80 | def test_callback_not_called_before_timeout(self, time_travel, automation): 81 | callback_mock = mock.Mock() 82 | automation.run_in(callback_mock, 10) 83 | time_travel.fast_forward(5).seconds() 84 | callback_mock.assert_not_called() 85 | 86 | def test_callback_called_after_timeout(self, time_travel, automation): 87 | scheduled_callback = mock.Mock(name="Scheduled Callback") 88 | automation.run_in(scheduled_callback, 10) 89 | time_travel.fast_forward(20).seconds() 90 | scheduled_callback.assert_called() 91 | 92 | def test_canceled_timer_does_not_run_callback(self, time_travel, automation): 93 | callback_mock = mock.Mock() 94 | handle = automation.run_in(callback_mock, 10) 95 | time_travel.fast_forward(5).seconds() 96 | automation.cancel_timer(handle) 97 | time_travel.fast_forward(10).seconds() 98 | callback_mock.assert_not_called() 99 | 100 | def test_time_is_correct_when_callback_it_run( 101 | self, time_travel, given_that, automation 102 | ): 103 | given_that.time_is(datetime.datetime(2020, 1, 1, 12, 0)) 104 | 105 | time_when_called = [] 106 | 107 | def callback(kwargs): 108 | nonlocal time_when_called 109 | time_when_called.append(automation.datetime()) 110 | 111 | automation.run_in(callback, 1) 112 | automation.run_in(callback, 15) 113 | automation.run_in(callback, 65) 114 | time_travel.fast_forward(90).seconds() 115 | 116 | expected_call_times = [ 117 | datetime.datetime(2020, 1, 1, 12, 0, 1), 118 | datetime.datetime(2020, 1, 1, 12, 0, 15), 119 | datetime.datetime(2020, 1, 1, 12, 1, 5), 120 | ] 121 | assert expected_call_times == time_when_called 122 | 123 | def test_callback_called_with_correct_args(self, time_travel, automation): 124 | callback_mock = mock.Mock() 125 | automation.run_in(callback_mock, 1, arg1="asdf", arg2="qwerty") 126 | time_travel.fast_forward(10).seconds() 127 | callback_mock.assert_called_once_with({"arg1": "asdf", "arg2": "qwerty"}) 128 | -------------------------------------------------------------------------------- /test/test_assert_that.py: -------------------------------------------------------------------------------- 1 | from datetime import time, datetime 2 | 3 | import appdaemon.plugins.hass.hassapi as hass 4 | import pytest 5 | from pytest import mark 6 | 7 | from appdaemontestframework import automation_fixture 8 | 9 | 10 | """ 11 | Note: 12 | The Appdaemon test framework was the fruit of a refactor of the tests 13 | suite of one of the Appdaemon projects I was working on at the time. 14 | Because it didn't start as a standalone project itself but was part of a test 15 | suite, it didn't have tests itself. 16 | (lessons learned, now my 'heavy' tests helpers are themselves tested :) 17 | 18 | Anyways, what that means is: Most of the base functionality is tested only 19 | through `integration_tests`. 20 | New feature should come with the proper unit tests. 21 | """ 22 | 23 | LIGHT = 'light.some_light' 24 | SWITCH = 'switch.some_switch' 25 | TRANSITION_DURATION = 2 26 | 27 | 28 | class MockAutomation(hass.Hass): 29 | def initialize(self): 30 | pass 31 | 32 | def turn_on_light(self, via_helper=False): 33 | if via_helper: 34 | self.turn_on(LIGHT) 35 | else: 36 | self.call_service('light/turn_on', entity_id=LIGHT) 37 | 38 | def turn_off_light(self, via_helper=False): 39 | if via_helper: 40 | self.turn_off(LIGHT) 41 | else: 42 | self.call_service('light/turn_off', entity_id=LIGHT) 43 | 44 | def turn_on_switch(self, via_helper=False): 45 | if via_helper: 46 | self.turn_on(SWITCH) 47 | else: 48 | self.call_service('switch/turn_on', entity_id=SWITCH) 49 | 50 | def turn_off_switch(self, via_helper=False): 51 | if via_helper: 52 | self.turn_off(SWITCH) 53 | else: 54 | self.call_service('switch/turn_off', entity_id=SWITCH) 55 | 56 | def turn_on_light_with_transition(self, via_helper=False): 57 | if via_helper: 58 | self.turn_on(LIGHT, transition=TRANSITION_DURATION) 59 | else: 60 | self.call_service( 61 | 'light/turn_on', 62 | entity_id=LIGHT, 63 | transition=TRANSITION_DURATION 64 | ) 65 | 66 | def turn_off_light_with_transition(self, via_helper=False): 67 | if via_helper: 68 | self.turn_off(LIGHT, transition=TRANSITION_DURATION) 69 | else: 70 | self.call_service( 71 | 'light/turn_off', 72 | entity_id=LIGHT, 73 | transition=TRANSITION_DURATION 74 | ) 75 | 76 | 77 | @automation_fixture(MockAutomation) 78 | def automation(): 79 | pass 80 | 81 | 82 | class TestTurnedOn: 83 | class TestViaService: 84 | def test_was_turned_on(self, assert_that, automation): 85 | assert_that(LIGHT).was_not.turned_on() 86 | automation.turn_on_light() 87 | assert_that(LIGHT).was.turned_on() 88 | 89 | assert_that(SWITCH).was_not.turned_on() 90 | automation.turn_on_switch() 91 | assert_that(SWITCH).was.turned_on() 92 | 93 | def test_with_kwargs(self, assert_that, automation): 94 | assert_that(LIGHT).was_not.turned_on() 95 | automation.turn_on_light_with_transition() 96 | assert_that(LIGHT).was.turned_on(transition=TRANSITION_DURATION) 97 | 98 | class TestViaHelper: 99 | def test_was_turned_on(self, assert_that, automation): 100 | assert_that(LIGHT).was_not.turned_on() 101 | automation.turn_on_light(via_helper=True) 102 | assert_that(LIGHT).was.turned_on() 103 | 104 | assert_that(SWITCH).was_not.turned_on() 105 | automation.turn_on_switch(via_helper=True) 106 | assert_that(SWITCH).was.turned_on() 107 | 108 | def test_with_kwargs(self, assert_that, automation): 109 | assert_that(LIGHT).was_not.turned_on() 110 | automation.turn_on_light_with_transition(via_helper=True) 111 | assert_that(LIGHT).was.turned_on(transition=TRANSITION_DURATION) 112 | 113 | 114 | class TestTurnedOff: 115 | class TestViaService: 116 | def test_was_turned_off(self, assert_that, automation): 117 | assert_that(LIGHT).was_not.turned_off() 118 | automation.turn_off_light() 119 | assert_that(LIGHT).was.turned_off() 120 | 121 | assert_that(SWITCH).was_not.turned_off() 122 | automation.turn_off_switch() 123 | assert_that(SWITCH).was.turned_off() 124 | 125 | def test_with_kwargs(self, assert_that, automation): 126 | assert_that(LIGHT).was_not.turned_off() 127 | automation.turn_off_light_with_transition() 128 | assert_that(LIGHT).was.turned_off(transition=TRANSITION_DURATION) 129 | 130 | class TestViaHelper: 131 | def test_was_turned_off(self, assert_that, automation): 132 | assert_that(LIGHT).was_not.turned_off() 133 | automation.turn_off_light(via_helper=True) 134 | assert_that(LIGHT).was.turned_off() 135 | 136 | assert_that(SWITCH).was_not.turned_off() 137 | automation.turn_off_switch(via_helper=True) 138 | assert_that(SWITCH).was.turned_off() 139 | 140 | def test_with_kwargs(self, assert_that, automation): 141 | assert_that(LIGHT).was_not.turned_off() 142 | automation.turn_off_light_with_transition(via_helper=True) 143 | assert_that(LIGHT).was.turned_off(transition=TRANSITION_DURATION) 144 | -------------------------------------------------------------------------------- /test/test_state.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone, timedelta 2 | 3 | import pytest 4 | from appdaemon.plugins.hass.hassapi import Hass 5 | from pytest import raises 6 | 7 | from appdaemontestframework import automation_fixture 8 | from appdaemontestframework.given_that import StateNotSetError 9 | 10 | LIGHT = 'light.some_light' 11 | COVER = 'cover.some_cover' 12 | 13 | 14 | class MockAutomation(Hass): 15 | def initialize(self): 16 | pass 17 | 18 | def is_light_turned_on(self) -> bool: 19 | return self.get_state(LIGHT) == 'on' 20 | 21 | def get_light_brightness(self) -> int: 22 | return self.get_state(LIGHT, attribute='brightness') 23 | 24 | def get_all_attributes_from_light(self): 25 | return self.get_state(LIGHT, attribute='all') 26 | 27 | def get_without_using_keyword(self): 28 | return self.get_state(LIGHT, 'brightness') 29 | 30 | def get_complete_state_dictionary(self): 31 | return self.get_state() 32 | 33 | 34 | @automation_fixture(MockAutomation) 35 | def automation(): 36 | pass 37 | 38 | 39 | def test_state_was_never_set__raise_error(given_that, 40 | automation: MockAutomation): 41 | with raises(StateNotSetError, match=r'.*State.*was never set.*'): 42 | automation.get_light_brightness() 43 | 44 | 45 | def test_set_and_get_state(given_that, automation: MockAutomation): 46 | given_that.state_of(LIGHT).is_set_to('off') 47 | assert not automation.is_light_turned_on() 48 | 49 | given_that.state_of(LIGHT).is_set_to('on') 50 | assert automation.is_light_turned_on() 51 | 52 | 53 | def test_attribute_was_never_set__raise_error(given_that, 54 | automation: MockAutomation): 55 | given_that.state_of(LIGHT).is_set_to('on') 56 | assert automation.get_light_brightness() is None 57 | 58 | 59 | def test_set_and_get_attribute(given_that, automation: MockAutomation): 60 | given_that.state_of(LIGHT).is_set_to('on', attributes={'brightness': 11}) 61 | assert automation.get_light_brightness() == 11 62 | 63 | given_that.state_of(LIGHT).is_set_to('on', {'brightness': 22}) 64 | assert automation.get_light_brightness() == 22 65 | 66 | 67 | def test_set_and_get_all_attribute(given_that, automation: MockAutomation): 68 | given_that.state_of(LIGHT).is_set_to('on', attributes={'brightness': 11, 69 | 'color': 'blue'}) 70 | assert automation.get_all_attributes_from_light() == { 71 | 'state': 'on', 'last_updated': None, 'last_changed': None, 72 | 'entity_id': LIGHT, 73 | 'attributes': {'brightness': 11, 'color': 'blue'} 74 | } 75 | 76 | 77 | def test_last_updated_changed__get_all__return_iso_formatted_date( 78 | given_that, 79 | automation: MockAutomation): 80 | utc_plus_3 = timezone(timedelta(hours=3)) 81 | updated = datetime( 82 | year=2020, 83 | month=3, 84 | day=3, 85 | hour=11, 86 | minute=27, 87 | second=37, 88 | microsecond=3, 89 | tzinfo=utc_plus_3) 90 | changed = datetime( 91 | year=2020, 92 | month=3, 93 | day=14, 94 | hour=20, 95 | microsecond=123456, 96 | tzinfo=timezone.utc) 97 | 98 | given_that.state_of(LIGHT).is_set_to('on', 99 | attributes={'brightness': 11, 100 | 'color': 'blue'}, 101 | last_updated=updated, 102 | last_changed=changed) 103 | 104 | expected_updated = '2020-03-03T11:27:37.000003+03:00' 105 | expected_changed = '2020-03-14T20:00:00.123456+00:00' 106 | all_attributes = automation.get_all_attributes_from_light() 107 | assert all_attributes['last_updated'] == expected_updated 108 | assert all_attributes['last_changed'] == expected_changed 109 | assert all_attributes == { 110 | 'state': 'on', 111 | 'last_updated': expected_updated, 112 | 'last_changed': expected_changed, 113 | 'entity_id': LIGHT, 114 | 'attributes': {'brightness': 11, 'color': 'blue'} 115 | } 116 | 117 | 118 | def test_get_complete_state_dictionary(given_that, automation: MockAutomation): 119 | given_that.state_of(LIGHT).is_set_to('on', attributes={'brightness': 11, 120 | 'color': 'blue'}) 121 | given_that.state_of(COVER).is_set_to("closed", {'friendly_name': f"{COVER}", 122 | 'current_position': 0}) 123 | assert automation.get_complete_state_dictionary() == { 124 | COVER: {'attributes': {'current_position': 0, 125 | 'friendly_name': COVER}, 126 | 'state': 'closed'}, 127 | LIGHT: {'attributes': {'brightness': 11, 'color': 'blue'}, 128 | 'state': 'on'}} 129 | 130 | 131 | @pytest.mark.only 132 | def test_throw_typeerror_when_attributes_arg_not_passed_via_keyword(given_that, 133 | automation: MockAutomation): 134 | given_that.state_of(LIGHT).is_set_to('on', attributes={'brightness': 11, 135 | 'color': 'blue'}) 136 | with pytest.raises(TypeError): 137 | automation.get_without_using_keyword() 138 | -------------------------------------------------------------------------------- /test/test_logging.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | 3 | import pytest 4 | from pytest import mark 5 | import logging 6 | 7 | 8 | @mark.using_pytester 9 | class TestLearningTest: 10 | def test_logging_failure(self, testdir): 11 | testdir.makepyfile( 12 | """ 13 | import logging 14 | 15 | def test_log_failure(caplog): 16 | caplog.set_level(logging.INFO) 17 | logging.info("logging failure") 18 | assert 1 == 2 19 | """ 20 | ) 21 | result = testdir.runpytest() 22 | result.stdout.re_match_lines_random(r".*logging failure.*") 23 | 24 | def test_not_logging_success(self, testdir): 25 | testdir.makepyfile( 26 | """ 27 | import logging 28 | 29 | def test_log_success(caplog): 30 | caplog.set_level(logging.INFO) 31 | logging.info("logging success") 32 | assert 1 == 1 33 | """ 34 | ) 35 | result = testdir.runpytest() 36 | # Check that the test passed (should see a dot or PASSED) 37 | assert result.ret == 0 # Test should pass 38 | # In modern pytest, successful tests don't show log output in main stdout by default 39 | # The log output only appears for failed tests or when using -s flag 40 | assert "logging success" not in result.stdout.str() 41 | 42 | 43 | def inject_mock_automation_and_run_test(testdir, test_src): 44 | testdir.makepyfile( 45 | dedent( 46 | """ 47 | from appdaemon.plugins.hass.hassapi import Hass 48 | from appdaemontestframework import automation_fixture 49 | 50 | class MockAutomation(Hass): 51 | def initialize(self): 52 | pass 53 | 54 | def log_error(self, msg, level=None): 55 | if level: 56 | self.error(msg, level) 57 | else: 58 | self.error(msg) 59 | 60 | def log_log(self, msg, level=None): 61 | if level: 62 | self.log(msg, level) 63 | else: 64 | self.log(msg) 65 | 66 | @automation_fixture(MockAutomation) 67 | def mock_automation(): 68 | pass 69 | 70 | %s 71 | 72 | """ 73 | ) 74 | % dedent(test_src) 75 | ) 76 | 77 | return testdir.runpytest() 78 | 79 | 80 | @mark.using_pytester 81 | @mark.usefixtures("configure_appdaemontestframework_for_pytester") 82 | class TestLogging: 83 | def test_error(self, testdir): 84 | result = inject_mock_automation_and_run_test( 85 | testdir, 86 | """ 87 | def test_failing_test_with_log_error(mock_automation): 88 | mock_automation.log_error("logging some error") 89 | assert 1 == 2 90 | """, 91 | ) 92 | result.stdout.fnmatch_lines_random("*ERROR*logging some error*") 93 | 94 | def test_error_with_level(self, testdir): 95 | result = inject_mock_automation_and_run_test( 96 | testdir, 97 | """ 98 | def test_log_levels_work(mock_automation): 99 | # Test that log_error with different levels works without exceptions 100 | mock_automation.log_error("info message", 'INFO') 101 | mock_automation.log_error("warning message", 'WARNING') 102 | mock_automation.log_error("error message", 'ERROR') 103 | assert 1 == 2 # Force failure to see logs 104 | """, 105 | ) 106 | # Just verify the test ran and logs were generated 107 | assert result.ret == 1 # Test should fail 108 | assert "info message" in result.stdout.str() 109 | assert "warning message" in result.stdout.str() 110 | assert "error message" in result.stdout.str() 111 | 112 | def test_log(self, testdir): 113 | result = inject_mock_automation_and_run_test( 114 | testdir, 115 | """ 116 | def test_log_method_works(mock_automation): 117 | # Test that log_log method works without exceptions 118 | mock_automation.log_log("test log message") 119 | assert 1 == 2 # Force failure to see logs 120 | """, 121 | ) 122 | # Just verify the test ran and logs were generated 123 | assert result.ret == 1 # Test should fail 124 | assert "test log message" in result.stdout.str() 125 | 126 | def test_log_with_level(self, testdir): 127 | result = inject_mock_automation_and_run_test( 128 | testdir, 129 | """ 130 | def test_log_with_explicit_levels(mock_automation): 131 | # Test that log_log with explicit levels works without exceptions 132 | mock_automation.log_log("info level", 'INFO') 133 | mock_automation.log_log("warning level", 'WARNING') 134 | mock_automation.log_log("error level", 'ERROR') 135 | assert 1 == 2 # Force failure to see logs 136 | """, 137 | ) 138 | # Just verify the test ran and logs were generated 139 | assert result.ret == 1 # Test should fail 140 | assert "info level" in result.stdout.str() 141 | assert "warning level" in result.stdout.str() 142 | assert "error level" in result.stdout.str() 143 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | 8 | # [Unreleased] 9 | ## Features 10 | * 11 | 12 | ## Fixes 13 | * 14 | 15 | ## Breaking Changes 16 | * None 17 | 18 | 19 | # [4.0.0b1&2] 20 | ## Features 21 | * 22 | 23 | ## Fixes 24 | * Missing sub-packages are added to the distribution 25 | 26 | ## Breaking Changes 27 | * None 28 | 29 | 30 | # [4.0.0b0] 31 | ## Features 32 | * User-facing: None 33 | * Internal architecture: Rework of the entire scheduler mocking mechanism preparing for some seriously cool features 😃 34 | 35 | ## Fixes 36 | * None 37 | 38 | ## Breaking Changes 39 | * None 40 | 41 | 42 | # [3.0.5] 2020-03-18 43 | ## Features 44 | * None 45 | 46 | ## Fixes 47 | * Automations can fire events without crashing the test framework 48 | 49 | ## Breaking Changes 50 | * None 51 | 52 | 53 | # [3.0.1-4] 2020-03-03 54 | Minor releases to test CD pipeline 55 | 56 | 57 | # [3.0.0] 2020-03-03 58 | ## Features 59 | * None 60 | 61 | ## Fixes 62 | * Return value when using 'all' in `get_state(ENTITY, attribute='all')` 63 | 64 | ## Breaking Changes 65 | * Api to set state in `given_that.state_of(ENTITY).is_set_to(...)` 66 | 67 | 68 | # [2.8.0] 2020-02-19 69 | ## Features 70 | * Add `CHANGELOG.md` which follow Keep a Changelog format 71 | * Officially adopt to Semantic Versioning 72 | * Appdaemon 3 is now deprecated, using it will throw a warning 73 | 74 | ## Fixes 75 | * Support for Python 3.8 76 | 77 | ## Breaking Changes 78 | * None 79 | 80 | 81 | # [2.7.0] 2020-01-25 82 | ## Features 83 | * Update pipfile.lock to use appdaemon 4.0.1 84 | * Use 'automation_fixture' in integration tests 85 | 86 | ## Fixed 87 | * Fix bug with appdaemon >= 4.0.0 88 | 89 | ## Breaking Changes 90 | * Deprecate direct use of `hass_functions` in favor of new `hass_mocks` 91 | 92 | 93 | # [2.6.1] 2019-12-23 94 | ## Features 95 | * Add support for kwargs in 'turned_off' - Contributed by @snjoetw 96 | 97 | ## Fixes 98 | * Fix bug when turning on/off via service - Contributed by @snjoetw 99 | 100 | ## Breaking Changes 101 | * None 102 | 103 | 104 | # [2.6.0] 2019-11-21 105 | ## Features 106 | * 'get_state' support call w/o args (get all state) + more mocked functions - Contributed by @foxcris 107 | * Support for new functions run_at, entity_exists, extended support for function get_state 108 | * Add name attribute to mock hass object 109 | 110 | # [2.5.1] 2019-09-20 111 | ## Features 112 | * Add various `run_*` functions to `hass_mocks` 113 | * Add sunrise/sunset scheduler 114 | 115 | ## Fixues 116 | * None 117 | 118 | ## Breaking Changes 119 | * None 120 | 121 | 122 | # [2.5.0] 2019-09-11 123 | ## Features 124 | * Add cancel timer to time_travel 125 | 126 | ## Fixes 127 | * None 128 | 129 | ## Breaking Changes 130 | * None 131 | 132 | 133 | 134 | # [2.4.0] 2019-08-13 135 | ## Features 136 | * Add 'run_minutely' to callback - Contributed by @jshridha 137 | 138 | ## Fixes 139 | * None 140 | 141 | ## Breaking Changes 142 | * None 143 | 144 | 145 | # [2.3.3] 2019-08-05 146 | ## Features 147 | * None 148 | 149 | ## Fixes 150 | * Register pytest custom marks to remove warnings 151 | * Update deps to fix security vulnerability 152 | * Fix get_state to match appdaemon's api - Contributed by @jshridha 153 | 154 | ## Breaking Changes 155 | * None 156 | 157 | 158 | # [2.3.2] 2019-08-03 159 | ## Features 160 | * Patch `notify` and add test for extra patched functions 161 | * Update PyCharm configs 162 | * Use @automation_fixture 163 | 164 | ## Fixes 165 | * None 166 | 167 | ## Breaking Changes 168 | * None 169 | 170 | 171 | # [2.3.1] 2019-04-11 172 | ## Features 173 | * Add complex code example in `README.md` 174 | * Update documentation with 'attribute' in `get_state` 175 | 176 | ## Fixes 177 | * Remove useless dependencies 178 | 179 | ## Breaking Changes 180 | * None 181 | 182 | 183 | # [2.3.0] 2019-04-11 184 | ## Features 185 | * Support for passing 'attribute' argument to get_state 186 | 187 | ## Fixes 188 | * None 189 | 190 | ## Breaking Changes 191 | * None 192 | 193 | 194 | # [2.2.0] 2019-04-11 195 | ## Features 196 | * Mock Hass 'self.log()/error()' with native python logging 197 | * Move pytester config fixture to conftest 198 | 199 | ## Fixes 200 | * None 201 | 202 | ## Breaking Changes 203 | * None 204 | 205 | 206 | # [2.1.1] 2019-04-10 207 | ## Features 208 | * Refactor framework initialization 209 | 210 | ## Fixes 211 | * None 212 | 213 | ## Breaking Changes 214 | * Patched 'hass_functions' now return 'None' by default 215 | 216 | 217 | # [2.1.0] 2019-04-10 218 | ## Features 219 | * Assert callbacks were registered during 'initialize()' 220 | 221 | ## Fixes 222 | * None 223 | 224 | ## Breaking Changes 225 | * None 226 | 227 | 228 | # [2.0.2] 2019-04-09 229 | ## Features 230 | * Update description on PyPi 231 | 232 | ## Fixes 233 | * None 234 | 235 | ## Breaking Changes 236 | * None 237 | 238 | 239 | # [2.0.1] 2019-04-09 240 | ## Features 241 | * None 242 | 243 | ## Fixes 244 | * Update deps to prevent security vulnerabilities 245 | 246 | ## Breaking Changes 247 | * None 248 | 249 | 250 | # [2.0.0] 2019-04-09 251 | ## Features 252 | * Added `@automation_fixture` 253 | 254 | ## Fixes 255 | * None 256 | 257 | ## Breaking Changes 258 | * None 259 | 260 | 261 | # [1.2.5] 2018-12-24 262 | ## Features 263 | * Undocumented 264 | 265 | ## Fixes 266 | * Undocumented 267 | 268 | ## Breaking Changes 269 | * Undocumented 270 | 271 | 272 | # [1.2.4] 2018-12-06 273 | ## Features 274 | * Undocumented 275 | 276 | ## Fixes 277 | * Undocumented 278 | 279 | ## Breaking Changes 280 | * Undocumented 281 | 282 | 283 | # [1.2.3] 2018-12-06 284 | ## Features 285 | * Undocumented 286 | 287 | ## Fixes 288 | * Undocumented 289 | 290 | ## Breaking Changes 291 | * Undocumented 292 | 293 | 294 | # [1.2.2] 2018-08-12 295 | ## Features 296 | * Undocumented 297 | 298 | ## Fixes 299 | * Undocumented 300 | 301 | ## Breaking Changes 302 | * Undocumented 303 | 304 | 305 | # [1.2.1] 2018-08-04 306 | ## Features 307 | * Undocumented 308 | 309 | ## Fixes 310 | * Undocumented 311 | 312 | ## Breaking Changes 313 | * Undocumented 314 | 315 | 316 | # [1.2.0] 2018-08-04 317 | ## Features 318 | * Undocumented 319 | 320 | ## Fixes 321 | * Undocumented 322 | 323 | ## Breaking Changes 324 | * Undocumented 325 | 326 | 327 | # [1.1.1] 2018-07-23 328 | ## Features 329 | * Undocumented 330 | 331 | ## Fixes 332 | * Undocumented 333 | 334 | ## Breaking Changes 335 | * Undocumented 336 | 337 | 338 | # [1.1.0] 2018-07-23 339 | ## Features 340 | * Undocumented 341 | 342 | ## Fixes 343 | * Undocumented 344 | 345 | ## Breaking Changes 346 | * Undocumented 347 | 348 | 349 | # [1.0.0] 2018-07-16 350 | ## Features 351 | * Initial release 352 | 353 | ## Fixes 354 | * None 355 | 356 | ## Breaking Changes 357 | * None 358 | -------------------------------------------------------------------------------- /appdaemontestframework/automation_fixture.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from inspect import isfunction, signature 3 | 4 | import pytest 5 | from appdaemon.plugins.hass.hassapi import Hass 6 | 7 | from appdaemontestframework.common import AppdaemonTestFrameworkError 8 | 9 | 10 | class AutomationFixtureError(AppdaemonTestFrameworkError): 11 | pass 12 | 13 | 14 | def _instantiate_and_initialize_automation( 15 | function, automation_class, given_that, hass_functions, hass_mocks 16 | ): 17 | from appdaemontestframework.appdaemon_mock.appdaemon import MockAppDaemon 18 | from appdaemon.models.config.app import AppConfig 19 | 20 | _inject_helpers_and_call_function(function, given_that, hass_functions, hass_mocks) 21 | 22 | # Create mock AppDaemon and AppConfig for new Appdaemon version 23 | mock_ad = MockAppDaemon() 24 | mock_config = AppConfig( 25 | name=automation_class.__name__, 26 | module=automation_class.__module__, 27 | **{ 28 | "class": automation_class.__name__ 29 | }, # 'class' is a Python keyword, so we use dict unpacking 30 | ) 31 | 32 | automation = automation_class(mock_ad, mock_config) 33 | automation.initialize() 34 | given_that.mock_functions_are_cleared() 35 | return automation 36 | 37 | 38 | def _inject_helpers_and_call_function(function, given_that, hass_functions, hass_mocks): 39 | injectable_fixtures = { 40 | "given_that": given_that, 41 | "hass_functions": hass_functions, 42 | "hass_mocks": hass_mocks, 43 | } 44 | 45 | def _check_valid(param): 46 | if param not in injectable_fixtures: 47 | raise AutomationFixtureError( 48 | f"'{param}' is not a valid fixture! | The only fixtures injectable in '@automation_fixture' are: {list(injectable_fixtures.keys())}" 49 | ) 50 | 51 | if param == "hass_functions": 52 | warnings.warn( 53 | """ 54 | Injecting `hass_functions` into automation fixtures is deprecated. 55 | Replace `hass_functions` with `hass_mocks` injections and access hass_functions with `hass_mocks.hass_functions` 56 | """, 57 | DeprecationWarning, 58 | ) 59 | 60 | args = [] 61 | for param in signature(function).parameters: 62 | _check_valid(param) 63 | args.append(injectable_fixtures.get(param)) 64 | 65 | function(*tuple(args)) 66 | 67 | 68 | def ensure_automation_is_valid(automation_class): 69 | def function_exist_in_automation_class(func_name): 70 | return func_name in dir(automation_class) 71 | 72 | def function_has_arguments_other_than_self(func_name): 73 | func_parameters = signature(getattr(automation_class, func_name)).parameters 74 | return list(func_parameters.keys()) != ["self"] 75 | 76 | def __init___was_overridden(): 77 | return "__init__" in automation_class.__dict__ 78 | 79 | # noinspection PyPep8Naming,SpellCheckingInspection 80 | def not_subclass_of_Hass(): 81 | return not issubclass(automation_class, Hass) 82 | 83 | if not function_exist_in_automation_class("initialize"): 84 | raise AutomationFixtureError( 85 | f"'{automation_class.__name__}' has no 'initialize' function! Make sure you implemented it!" 86 | ) 87 | if function_has_arguments_other_than_self("initialize"): 88 | raise AutomationFixtureError( 89 | f"'{automation_class.__name__}' 'initialize' should have no arguments other than 'self'!" 90 | ) 91 | if __init___was_overridden(): 92 | raise AutomationFixtureError( 93 | f"'{automation_class.__name__}' should not override '__init__'" 94 | ) 95 | if not_subclass_of_Hass(): 96 | raise AutomationFixtureError( 97 | f"'{automation_class.__name__}' should be a subclass of 'Hass'" 98 | ) 99 | 100 | 101 | class _AutomationFixtureDecoratorWithoutArgs: 102 | def __init__(self, automation_classes): 103 | self.automation_classes = automation_classes 104 | for automation in self.automation_classes: 105 | ensure_automation_is_valid(automation) 106 | 107 | def __call__(self, function): 108 | @pytest.fixture(params=self.automation_classes, ids=self._generate_id) 109 | def automation_fixture_with_initialisation( 110 | request, given_that, hass_functions, hass_mocks 111 | ): 112 | automation_class = request.param 113 | return _instantiate_and_initialize_automation( 114 | function, automation_class, given_that, hass_functions, hass_mocks 115 | ) 116 | 117 | return automation_fixture_with_initialisation 118 | 119 | def _generate_id(self, automation_classes): 120 | return automation_classes.__name__ 121 | 122 | 123 | class _AutomationFixtureDecoratorWithArgs: 124 | def __init__(self, automation_classes_with_args): 125 | self.automation_classes_with_args = automation_classes_with_args 126 | for automation, _args in self.automation_classes_with_args: 127 | ensure_automation_is_valid(automation) 128 | 129 | def __call__(self, function): 130 | @pytest.fixture(params=self.automation_classes_with_args, ids=self._generate_id) 131 | def automation_fixture_with_initialisation( 132 | request, given_that, hass_functions, hass_mocks 133 | ): 134 | automation_class = request.param[0] 135 | automation_args = request.param[1] 136 | automation = _instantiate_and_initialize_automation( 137 | function, automation_class, given_that, hass_functions, hass_mocks 138 | ) 139 | return (automation, automation_args) 140 | 141 | return automation_fixture_with_initialisation 142 | 143 | def _generate_id(self, automation_classes_with_args): 144 | return automation_classes_with_args[0].__name__ 145 | 146 | 147 | def automation_fixture(*args): 148 | """ 149 | Decorator to seamlessly initialize and inject an automation fixture 150 | 151 | 4 Versions: 152 | - Single Class: @automation_fixture(MyAutomation) 153 | - Multiple Classes: @automation_fixture(MyAutomation, MyOtherAutomation) 154 | - Single Class w/ params: @automation_fixture((upstairs.Bedroom, {'motion': 'binary_sensor.bedroom_motion'})) 155 | - Multiple Classes w/ params: @automation_fixture( 156 | (upstairs.Bedroom, {'motion': 'binary_sensor.bedroom_motion'}), 157 | (upstairs.Bathroom, {'motion': 'binary_sensor.bathroom_motion'}), 158 | ) 159 | 160 | When multiple classes are passed, tests will be generated for each automation. 161 | When using parameters, the injected object will be a tuple: `(Initialized_Automation, params)` 162 | 163 | # Pre-initialization setup 164 | All code in the `@automation_fixture` function will be executed before initializing the `automation_class` 165 | 166 | 3 fixtures are injectable in `@automation_fixture`: 'given_that', 'hass_mocks' and 'hass_functions' 167 | 'hass_functions' is deprecated in favor of 'hass_mocks' 168 | 169 | Examples: 170 | ```python 171 | @automation_fixture(Bathroom) 172 | def bathroom(): 173 | pass 174 | # -> `Bathroom` automation will be initialized and available in tests as `bathroom` 175 | 176 | --- 177 | 178 | @automation_fixture(Bathroom) 179 | def bathroom(given_that): 180 | given_that.time_is(time(hour=13)) 181 | 182 | # -> 1. `given_that.time_is(time(hour=13))` will be called 183 | # -> 2. `Bathroom` automation will be initialized and available in tests as `bathroom` 184 | 185 | ``` 186 | 187 | Do not return anything, any returned object will be ignored 188 | 189 | """ 190 | if not args or isfunction(args[0]): 191 | raise AutomationFixtureError( 192 | "Do not forget to pass the automation class(es) as argument" 193 | ) 194 | 195 | if type(args[0]) is not tuple: 196 | automation_classes = args 197 | return _AutomationFixtureDecoratorWithoutArgs(automation_classes) 198 | else: 199 | automation_classes_with_args = args 200 | return _AutomationFixtureDecoratorWithArgs(automation_classes_with_args) 201 | -------------------------------------------------------------------------------- /appdaemontestframework/appdaemon_mock/scheduler.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import uuid 3 | import pytz 4 | import asyncio 5 | import threading 6 | from typing import Any, Callable, Optional 7 | from appdaemontestframework.appdaemon_mock.appdaemon import MockAppDaemon 8 | 9 | 10 | class MockScheduler: 11 | """Implement the AppDaemon Scheduler appropriate for testing and provide extra interfaces for adjusting the simulation""" 12 | 13 | def __init__(self, AD: MockAppDaemon): 14 | self.AD = AD 15 | self._registered_callbacks = [] 16 | self._loop = None 17 | self._loop_thread = None 18 | 19 | # Default to Jan 1st, 2000 12:00AM 20 | # internal time is stored as a naive datetime in UTC 21 | self.sim_set_start_time(datetime.datetime(2000, 1, 1, 0, 0)) 22 | 23 | # Initialize event loop for async operations 24 | self._ensure_event_loop() 25 | 26 | def _ensure_event_loop(self): 27 | """Ensure we have an event loop available for async operations""" 28 | try: 29 | self._loop = asyncio.get_running_loop() 30 | except RuntimeError: 31 | # No running loop, create our own 32 | self._loop = asyncio.new_event_loop() 33 | # Don't set as the running loop here to avoid conflicts 34 | 35 | def _run_async(self, coro): 36 | """Run an async coroutine in our event loop""" 37 | if self._loop is None: 38 | self._ensure_event_loop() 39 | 40 | try: 41 | # If we're already in an event loop context 42 | running_loop = asyncio.get_running_loop() 43 | if running_loop == self._loop: 44 | # We're already in our loop 45 | return asyncio.create_task(coro) 46 | else: 47 | # Different loop is running, use run_coroutine_threadsafe 48 | future = asyncio.run_coroutine_threadsafe(coro, self._loop) 49 | return future.result() 50 | except RuntimeError: 51 | # No running loop, we can use run_until_complete 52 | return self._loop.run_until_complete(coro) 53 | 54 | ### Implement the AppDaemon APIs for Scheduler 55 | async def get_now(self): 56 | """Return current localized naive datetime""" 57 | return self.get_now_sync() 58 | 59 | def get_now_sync(self): 60 | """Same as `get_now` but synchronous""" 61 | return pytz.utc.localize(self._now) 62 | 63 | async def get_now_ts(self): 64 | """Return the current localized timestamp""" 65 | return (await self.get_now()).timestamp() 66 | 67 | def get_now_ts_sync(self): 68 | """Synchronous version of get_now_ts""" 69 | return self.get_now_sync().timestamp() 70 | 71 | async def get_now_naive(self): 72 | return self.make_naive(await self.get_now()) 73 | 74 | def get_now_naive_sync(self): 75 | """Synchronous version of get_now_naive""" 76 | return self.make_naive(self.get_now_sync()) 77 | 78 | async def insert_schedule(self, name, aware_dt, callback, repeat, type_, **kwargs): 79 | naive_dt = self.make_naive(aware_dt) 80 | return self._queue_callback(callback, kwargs, naive_dt) 81 | 82 | def insert_schedule_sync(self, name, aware_dt, callback, repeat, type_, **kwargs): 83 | """Synchronous version of insert_schedule""" 84 | naive_dt = self.make_naive(aware_dt) 85 | return self._queue_callback(callback, kwargs, naive_dt) 86 | 87 | async def cancel_timer(self, name, handle): 88 | return self.cancel_timer_sync(name, handle) 89 | 90 | def cancel_timer_sync(self, name, handle): 91 | """Synchronous version of cancel_timer""" 92 | for callback in self._registered_callbacks: 93 | if callback.handle == handle: 94 | self._registered_callbacks.remove(callback) 95 | return True 96 | return False 97 | 98 | def convert_naive(self, dt): 99 | # Is it naive? 100 | result = None 101 | if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None: 102 | # Localize with the configured timezone 103 | result = self.AD.tz.localize(dt) 104 | else: 105 | result = dt 106 | 107 | return result 108 | 109 | def make_naive(self, dt): 110 | local = dt.astimezone(self.AD.tz) 111 | return datetime.datetime( 112 | local.year, 113 | local.month, 114 | local.day, 115 | local.hour, 116 | local.minute, 117 | local.second, 118 | local.microsecond, 119 | ) 120 | 121 | ### Test framework simulation functions 122 | def sim_set_start_time(self, time): 123 | """Set the absolute start time and set current time to that as well. 124 | if time is a datetime, it goes right to that. 125 | if time is time, it will set to that time with the current date. 126 | All dates/datetimes should be localized naive 127 | 128 | To guarantee consistency, you can not set the start time while any callbacks are scheduled. 129 | """ 130 | if len(self._registered_callbacks) > 0: 131 | raise RuntimeError( 132 | "You can not set start time while callbacks are scheduled" 133 | ) 134 | 135 | if type(time) == datetime.time: 136 | time = datetime.datetime.combine(self._now.date(), time) 137 | self._start_time = self._now = time 138 | 139 | def sim_get_start_time(self): 140 | """returns localized naive datetime of the start of the simulation""" 141 | return pytz.utc.localize(self._start_time) 142 | 143 | def sim_elapsed_seconds(self): 144 | """Returns number of seconds elapsed since the start of the simulation""" 145 | return (self._now - self._start_time).total_seconds() 146 | 147 | def sim_fast_forward(self, time): 148 | """Fastforward time and invoke callbacks. time can be a timedelta, time, or datetime (all should be localized naive)""" 149 | if type(time) == datetime.timedelta: 150 | target_datetime = self._now + time 151 | elif type(time) == datetime.time: 152 | if time > self._now.time(): 153 | target_datetime = datetime.datetime.combine(self._now.date(), time) 154 | else: 155 | # handle wrap around to next day if time is in the past already 156 | target_date = self._now.date() + datetime.timedelta(days=1) 157 | target_datetime = datetime.datetime.combine(target_date, time) 158 | elif type(time) == datetime.datetime: 159 | target_datetime = time 160 | else: 161 | raise ValueError(f"Unknown time type '{type(time)}' for fast_forward") 162 | 163 | self._run_callbacks_and_advance_time(target_datetime) 164 | 165 | ### Internal functions 166 | def _queue_callback(self, callback_function, kwargs, run_date_time): 167 | """queue a new callback and return its handle""" 168 | interval = kwargs.get("interval", 0) 169 | new_callback = CallbackInfo(callback_function, kwargs, run_date_time, interval) 170 | 171 | if new_callback.run_date_time < self._now: 172 | raise ValueError("Can not schedule events in the past") 173 | 174 | self._registered_callbacks.append(new_callback) 175 | return new_callback.handle 176 | 177 | def _run_callbacks_and_advance_time(self, target_datetime, run_callbacks=True): 178 | """run all callbacks scheduled between now and target_datetime""" 179 | if target_datetime < self._now: 180 | raise ValueError("You can not fast forward to a time in the past.") 181 | 182 | while True: 183 | callbacks_to_run = [ 184 | x 185 | for x in self._registered_callbacks 186 | if x.run_date_time <= target_datetime 187 | ] 188 | if not callbacks_to_run: 189 | break 190 | # sort so we call them in the order from oldest to newest 191 | callbacks_to_run.sort(key=lambda cb: cb.run_date_time) 192 | # dispatch the oldest callback 193 | callback = callbacks_to_run[0] 194 | self._now = callback.run_date_time 195 | if run_callbacks: 196 | callback() 197 | if callback.interval > 0: 198 | callback.run_date_time += datetime.timedelta(seconds=callback.interval) 199 | else: 200 | self._registered_callbacks.remove(callback) 201 | 202 | self._now = target_datetime 203 | 204 | def __getattr__(self, name: str): 205 | raise RuntimeError(f"'{name}' has not been mocked in {self.__class__.__name__}") 206 | 207 | 208 | class CallbackInfo: 209 | """Class to hold info about a scheduled callback""" 210 | 211 | def __init__(self, callback_function, kwargs, run_date_time, interval): 212 | self.handle = str(uuid.uuid4()) 213 | self.run_date_time = run_date_time 214 | self.callback_function = callback_function 215 | self.kwargs = kwargs 216 | self.interval = interval 217 | 218 | def __call__(self): 219 | self.callback_function(self.kwargs) 220 | -------------------------------------------------------------------------------- /test/appdaemon_mock/test_scheduler.py: -------------------------------------------------------------------------------- 1 | from appdaemontestframework.appdaemon_mock.scheduler import MockScheduler 2 | from appdaemontestframework.appdaemon_mock.appdaemon import MockAppDaemon 3 | import asyncio 4 | import pytest 5 | import mock 6 | import datetime 7 | import pytz 8 | 9 | 10 | @pytest.fixture 11 | def scheduler() -> MockScheduler: 12 | return MockScheduler(MockAppDaemon()) 13 | 14 | 15 | def test_calling_a_scheduler_method_not_mocked_raises_a_helpful_error_message( 16 | scheduler): 17 | # For instance `parse_time` is a method that's not mocked because it is 18 | # not needed with the current time travel logic. 19 | with pytest.raises(RuntimeError) as error: 20 | scheduler.parse_time("2020/01/01T23:11") 21 | 22 | assert "'parse_time' has not been mocked" in str(error.value) 23 | 24 | 25 | class Test_get_now: 26 | """tests for SchedulerMocks.get_now_*() calls""" 27 | @pytest.mark.asyncio 28 | async def test_get_now_returns_proper_time(self, scheduler): 29 | # the time should default to Jan 1st, 2000 12:00AM UTC 30 | assert await scheduler.get_now() == pytz.utc.localize(datetime.datetime(2000, 1, 1, 0, 0)) 31 | 32 | @pytest.mark.asyncio 33 | async def test_get_now_ts_returns_proper_time_stamp(self, scheduler): 34 | # the time should default to Jan 1st, 2000 12:00AM 35 | assert await scheduler.get_now_ts() == pytz.utc.localize(datetime.datetime(2000, 1, 1, 0, 0)).timestamp() 36 | 37 | 38 | class Test_time_movement: 39 | def test_set_start_time_to_known_time(self, scheduler): 40 | new_time = datetime.datetime(2010, 6, 1, 0, 0) 41 | scheduler.sim_set_start_time(new_time) 42 | assert scheduler.get_now_sync() == pytz.utc.localize(new_time) 43 | 44 | @pytest.mark.asyncio 45 | async def test_cant_set_start_time_with_pending_callbacks(self, scheduler): 46 | scheduled_time = scheduler.get_now_sync() + datetime.timedelta(seconds=10) 47 | await scheduler.insert_schedule('', scheduled_time, lambda: None, False, None) 48 | with pytest.raises(RuntimeError) as cm: 49 | scheduler.sim_set_start_time(datetime.datetime(2010, 6, 1, 0, 0)) 50 | assert str(cm.value) == 'You can not set start time while callbacks are scheduled' 51 | 52 | def test_fast_forward_to_past_raises_exception(self, scheduler): 53 | with pytest.raises(ValueError) as cm: 54 | scheduler.sim_fast_forward(datetime.timedelta(-1)) 55 | assert str(cm.value) == "You can not fast forward to a time in the past." 56 | 57 | @pytest.mark.asyncio 58 | async def test_fast_forward_to_time_in_future_goes_to_correct_time(self, scheduler): 59 | scheduler.sim_set_start_time(datetime.datetime(2015, 1, 1, 12, 0)) 60 | scheduler.sim_fast_forward(datetime.time(14, 0)) 61 | assert await scheduler.get_now() == pytz.utc.localize(datetime.datetime(2015, 1, 1, 14, 0)) 62 | 63 | @pytest.mark.asyncio 64 | async def test_fast_forward_to_time_in_past_wraps_to_correct_time(self, scheduler): 65 | scheduler.sim_set_start_time(datetime.datetime(2015, 1, 1, 12, 0)) 66 | scheduler.sim_fast_forward(datetime.time(7, 0)) 67 | assert await scheduler.get_now() == pytz.utc.localize(datetime.datetime(2015, 1, 2, 7, 0)) 68 | 69 | @pytest.mark.asyncio 70 | async def test_fast_forward_to_datetime_goes_to_correct_time(self, scheduler): 71 | to_datetime = datetime.datetime(2020, 5, 4, 10, 10) 72 | scheduler.sim_fast_forward(to_datetime) 73 | assert await scheduler.get_now() == pytz.utc.localize(to_datetime) 74 | 75 | @pytest.mark.asyncio 76 | async def test_fast_forward_by_timedelta_goes_to_correct_time(self, scheduler): 77 | scheduler.sim_set_start_time(datetime.datetime(2015, 1, 1, 12, 0)) 78 | scheduler.sim_fast_forward(datetime.timedelta(days=1)) 79 | assert await scheduler.get_now() == pytz.utc.localize(datetime.datetime(2015, 1, 2, 12, 0)) 80 | 81 | 82 | class Test_scheduling_and_dispatch: 83 | @pytest.mark.asyncio 84 | async def test_schedule_in_the_future_succeeds(self, scheduler: MockScheduler): 85 | scheduled_time = await scheduler.get_now() + datetime.timedelta(seconds=10) 86 | await scheduler.insert_schedule('', scheduled_time, lambda: None, False, None) 87 | 88 | @pytest.mark.asyncio 89 | async def test_schedule_in_the_past_raises_exception(self, scheduler: MockScheduler): 90 | scheduled_time = await scheduler.get_now() - datetime.timedelta(seconds=10) 91 | with pytest.raises(ValueError): 92 | await scheduler.insert_schedule('', scheduled_time, lambda: None, False, None) 93 | 94 | @pytest.mark.asyncio 95 | async def test_callbacks_are_run_in_time_order(self, scheduler:MockScheduler): 96 | first_mock = mock.Mock() 97 | second_mock = mock.Mock() 98 | third_mock = mock.Mock() 99 | manager = mock.Mock() 100 | manager.attach_mock(first_mock, 'first_mock') 101 | manager.attach_mock(second_mock, 'second_mock') 102 | manager.attach_mock(third_mock, 'third_mock') 103 | 104 | now = await scheduler.get_now() 105 | # Note: insert them out of order to try and expose possible bugs 106 | await asyncio.gather( 107 | scheduler.insert_schedule('', now + datetime.timedelta(seconds=10), first_mock, False, None), 108 | scheduler.insert_schedule('', now + datetime.timedelta(seconds=30), third_mock, False, None), 109 | scheduler.insert_schedule('', now + datetime.timedelta(seconds=20), second_mock, False, None)) 110 | 111 | 112 | scheduler.sim_fast_forward(datetime.timedelta(seconds=30)) 113 | 114 | expected_call_order = [mock.call.first_mock({}), mock.call.second_mock({}), mock.call.third_mock({})] 115 | assert manager.mock_calls == expected_call_order 116 | 117 | @pytest.mark.asyncio 118 | async def test_callback_not_called_before_timeout(self, scheduler: MockScheduler): 119 | callback_mock = mock.Mock() 120 | now = await scheduler.get_now() 121 | await scheduler.insert_schedule('', now + datetime.timedelta(seconds=10), callback_mock, False, None), 122 | scheduler.sim_fast_forward(datetime.timedelta(seconds=5)) 123 | 124 | callback_mock.assert_not_called() 125 | 126 | @pytest.mark.asyncio 127 | async def test_callback_called_after_timeout(self, scheduler: MockScheduler): 128 | callback_mock = mock.Mock() 129 | now = await scheduler.get_now() 130 | await scheduler.insert_schedule('', now + datetime.timedelta(seconds=10), callback_mock, False, None), 131 | scheduler.sim_fast_forward(datetime.timedelta(seconds=20)) 132 | 133 | callback_mock.assert_called() 134 | 135 | @pytest.mark.asyncio 136 | async def test_canceled_timer_does_not_run_callback(self, scheduler: MockScheduler): 137 | callback_mock = mock.Mock() 138 | now = await scheduler.get_now() 139 | handle = await scheduler.insert_schedule('', now + datetime.timedelta(seconds=10), callback_mock, False, None) 140 | scheduler.sim_fast_forward(datetime.timedelta(seconds=5)) 141 | await scheduler.cancel_timer('', handle) 142 | scheduler.sim_fast_forward(datetime.timedelta(seconds=10)) 143 | 144 | callback_mock.assert_not_called() 145 | 146 | @pytest.mark.asyncio 147 | async def test_time_is_correct_when_callback_it_run(self, scheduler: MockScheduler): 148 | scheduler.sim_set_start_time(datetime.datetime(2020, 1, 1, 12, 0)) 149 | 150 | time_when_called = [] 151 | def callback(kwargs): 152 | nonlocal time_when_called 153 | time_when_called.append(scheduler.get_now_sync()) 154 | 155 | now = await scheduler.get_now() 156 | await asyncio.gather( 157 | scheduler.insert_schedule('', now + datetime.timedelta(seconds=1), callback, False, None), 158 | scheduler.insert_schedule('', now + datetime.timedelta(seconds=15), callback, False, None), 159 | scheduler.insert_schedule('', now + datetime.timedelta(seconds=65), callback, False, None)) 160 | scheduler.sim_fast_forward(datetime.timedelta(seconds=90)) 161 | 162 | expected_call_times = [ 163 | pytz.utc.localize(datetime.datetime(2020, 1, 1, 12, 0, 1)), 164 | pytz.utc.localize(datetime.datetime(2020, 1, 1, 12, 0, 15)), 165 | pytz.utc.localize(datetime.datetime(2020, 1, 1, 12, 1, 5)), 166 | ] 167 | assert expected_call_times == time_when_called 168 | 169 | @pytest.mark.asyncio 170 | async def test_callback_called_with_correct_args(self, scheduler: MockScheduler): 171 | callback_mock = mock.Mock() 172 | now = await scheduler.get_now() 173 | handle = await scheduler.insert_schedule('', now + datetime.timedelta(seconds=1), callback_mock, False, None, arg1='asdf', arg2='qwerty') 174 | scheduler.sim_fast_forward(datetime.timedelta(seconds=10)) 175 | 176 | callback_mock.assert_called_once_with({'arg1': 'asdf', 'arg2': 'qwerty'}) 177 | 178 | @pytest.mark.asyncio 179 | async def test_callback_with_interval_is_rescheduled_after_being_run(self, scheduler: MockScheduler): 180 | callback_mock = mock.Mock() 181 | now = await scheduler.get_now() 182 | handle = await scheduler.insert_schedule('', now + datetime.timedelta(seconds=10), callback_mock, False, None, interval=10) 183 | 184 | # Advance 3 time and make sure it's called each time 185 | scheduler.sim_fast_forward(datetime.timedelta(seconds=10)) 186 | assert callback_mock.call_count == 1 187 | scheduler.sim_fast_forward(datetime.timedelta(seconds=10)) 188 | assert callback_mock.call_count == 2 189 | scheduler.sim_fast_forward(datetime.timedelta(seconds=10)) 190 | assert callback_mock.call_count == 3 191 | -------------------------------------------------------------------------------- /doc/full_example/tests/test_kitchen.py: -------------------------------------------------------------------------------- 1 | from apps.kitchen import Kitchen 2 | import pytest 3 | from mock import patch, MagicMock 4 | from apps.entity_ids import ID 5 | 6 | # TODO: Put this in config (through apps.yml, check doc) 7 | PHONE_PUSHBULLET_ID = "device/OnePlus 5T" 8 | 9 | 10 | @pytest.fixture 11 | def kitchen(given_that): 12 | kitchen = Kitchen( 13 | None, None, None, None, None, None, None, None) 14 | kitchen.initialize() 15 | 16 | given_that.mock_functions_are_cleared() 17 | return kitchen 18 | 19 | 20 | @pytest.fixture 21 | def when_new(kitchen): 22 | class WhenNewWrapper: 23 | def motion(self): 24 | kitchen._new_motion(None, None, None) 25 | 26 | def no_more_motion(self): 27 | kitchen._no_more_motion( 28 | None, None, None, None, None) 29 | 30 | def click_button(self, type='single'): 31 | { 32 | 'single': kitchen._new_button_click, 33 | 'double': kitchen._new_button_double_click, 34 | 'long': kitchen._new_button_long_press 35 | }[type](None, None, None) 36 | 37 | return WhenNewWrapper() 38 | 39 | 40 | class TestInitialization: 41 | def test_callbacks_are_registered(self, kitchen, hass_functions): 42 | # Given: The mocked callback Appdaemon registration functions 43 | listen_event = hass_functions['listen_event'] 44 | listen_state = hass_functions['listen_state'] 45 | 46 | # When: Calling `initialize` 47 | kitchen.initialize() 48 | 49 | # Then: callbacks are registered 50 | listen_event.assert_any_call( 51 | kitchen._new_button_click, 52 | 'click', 53 | entity_id=ID['kitchen']['button'], 54 | click_type='single') 55 | 56 | listen_event.assert_any_call( 57 | kitchen._new_button_double_click, 58 | 'click', 59 | entity_id=ID['kitchen']['button'], 60 | click_type='double') 61 | 62 | listen_event.assert_any_call( 63 | kitchen._new_motion, 64 | 'motion', 65 | entity_id=ID['kitchen']['motion_sensor']) 66 | 67 | listen_state.assert_any_call( 68 | kitchen._no_more_motion, 69 | ID['kitchen']['motion_sensor'], 70 | new='off') 71 | 72 | 73 | class TestAutomaticLights: 74 | def test_turn_on(self, when_new, assert_that): 75 | when_new.motion() 76 | assert_that(ID['kitchen']['light']).was.turned_on() 77 | 78 | def test_turn_off(self, when_new, assert_that): 79 | when_new.no_more_motion() 80 | assert_that(ID['kitchen']['light']).was.turned_off() 81 | 82 | 83 | SHORT_DELAY = 10 84 | LONG_DELAY = 30 85 | 86 | 87 | @pytest.fixture 88 | def assert_water_heater_notif_sent(assert_that): 89 | def assert_water_heater_sent_wrapper(message): 90 | assert_that('notify/pushbullet').was.called_with( 91 | title="Water Heater", 92 | message=message, 93 | target=PHONE_PUSHBULLET_ID) 94 | 95 | return assert_water_heater_sent_wrapper 96 | 97 | 98 | @pytest.fixture 99 | def assert_water_heater_notif_NOT_sent(assert_that): 100 | def assert_water_heater_NOT_sent_wrapper(message): 101 | assert_that('notify/pushbullet').was_not.called_with( 102 | title="Water Heater", 103 | message=message, 104 | target=PHONE_PUSHBULLET_ID) 105 | 106 | return assert_water_heater_NOT_sent_wrapper 107 | 108 | 109 | class TestSingleClickOnButton: 110 | def test_turn_off_water_heater(self, when_new, assert_that): 111 | when_new.click_button() 112 | assert_that(ID['bathroom']['water_heater']).was.turned_off() 113 | 114 | def test_send_notification(self, when_new, assert_water_heater_notif_sent): 115 | when_new.click_button() 116 | assert_water_heater_notif_sent( 117 | f"was turned off for {SHORT_DELAY} minutes") 118 | 119 | class TestAfterDelay: 120 | def test_turn_water_heater_back_on(self, when_new, time_travel, assert_that): 121 | when_new.click_button() 122 | time_travel.fast_forward(SHORT_DELAY).minutes() 123 | assert_that(ID['bathroom']['water_heater']).was.turned_on() 124 | 125 | def test_send_notification(self, when_new, time_travel, assert_water_heater_notif_sent): 126 | when_new.click_button() 127 | time_travel.fast_forward(SHORT_DELAY).minutes() 128 | assert_water_heater_notif_sent("was turned back on") 129 | 130 | 131 | class TestDoubleClickOnButton: 132 | def test_turn_off_water_heater(self, when_new, assert_that): 133 | when_new.click_button(type='double') 134 | assert_that(ID['bathroom']['water_heater']).was.turned_off() 135 | 136 | def test_send_notification(self, when_new, assert_water_heater_notif_sent): 137 | when_new.click_button(type='double') 138 | assert_water_heater_notif_sent( 139 | f"was turned off for {LONG_DELAY} minutes") 140 | 141 | class TestAfterShortDelay: 142 | def test_DOES_NOT_turn_water_heater_back_on(self, when_new, time_travel, assert_that): 143 | when_new.click_button(type='double') 144 | time_travel.fast_forward(SHORT_DELAY).minutes() 145 | assert_that(ID['bathroom']['water_heater']).was_not.turned_on() 146 | 147 | def test_DOES_NOT_send_notification(self, when_new, time_travel, assert_water_heater_notif_NOT_sent): 148 | when_new.click_button(type='double') 149 | time_travel.fast_forward(SHORT_DELAY).minutes() 150 | assert_water_heater_notif_NOT_sent("was turned back on") 151 | 152 | class TestAfterLongDelay: 153 | def test_turn_water_heater_back_on(self, when_new, time_travel, assert_that): 154 | when_new.click_button(type='double') 155 | time_travel.fast_forward(LONG_DELAY).minutes() 156 | assert_that(ID['bathroom']['water_heater']).was.turned_on() 157 | 158 | def test_send_notification(self, when_new, time_travel, assert_water_heater_notif_sent): 159 | when_new.click_button(type='double') 160 | time_travel.fast_forward(LONG_DELAY).minutes() 161 | assert_water_heater_notif_sent("was turned back on") 162 | 163 | 164 | class TestClickCancellation: 165 | class TestSingleClick: 166 | def test_new_click_cancels_previous_one(self, when_new, time_travel, assert_that): 167 | # T = 0min 168 | # FF = 0min 169 | time_travel.assert_current_time(0).minutes() 170 | when_new.click_button() 171 | 172 | # T = 2min 173 | # FF = 2min 174 | time_travel.fast_forward(2).minutes() 175 | time_travel.assert_current_time(2).minutes() 176 | when_new.click_button() 177 | 178 | # T = SHORT_DELAY 179 | # FF = SHORT_DELAY - 2min 180 | # Do NOT turn water heater back on yet! 181 | time_travel.fast_forward(SHORT_DELAY - 2).minutes() 182 | time_travel.assert_current_time(SHORT_DELAY).minutes() 183 | assert_that(ID['bathroom']['water_heater']).was_not.turned_on() 184 | 185 | # T = SHORT_DELAY + 2min 186 | # FF = SHORT_DELAY + 2min - (2min + 8min) 187 | time_travel.fast_forward(SHORT_DELAY - 8).minutes() 188 | time_travel.assert_current_time(SHORT_DELAY + 2).minutes() 189 | assert_that(ID['bathroom']['water_heater']).was.turned_on() 190 | 191 | def test_multiple_clicks(self, when_new, time_travel, assert_that): 192 | # Given: 3 clicks, every 2 seconds 193 | when_new.click_button() 194 | time_travel.fast_forward(2).minutes() 195 | when_new.click_button() 196 | time_travel.fast_forward(2).minutes() 197 | when_new.click_button() 198 | 199 | time_travel.assert_current_time(4).minutes() 200 | 201 | # When 1/2: 202 | # Fast forwarding up until 1 min before reactivation 203 | # scheduled by last click 204 | time_travel.fast_forward(SHORT_DELAY - 1).minutes() 205 | # Then 1/2: 206 | # Water heater still not turned back on (first clicks ignored) 207 | assert_that(ID['bathroom']['water_heater']).was_not.turned_on() 208 | 209 | # When 2/2: 210 | # Fast forwarding after reactivation 211 | # scheduled by last click 212 | time_travel.fast_forward(SHORT_DELAY - 1).minutes() 213 | # Then 2/2: 214 | # Water heater still now turned back on 215 | assert_that(ID['bathroom']['water_heater']).was.turned_on() 216 | 217 | class TestDoubleClick: 218 | def test_multiple_clicks(self, when_new, time_travel, assert_that): 219 | # Given: 3 clicks, every 2 seconds 220 | when_new.click_button(type='double') 221 | time_travel.fast_forward(2).minutes() 222 | when_new.click_button(type='double') 223 | time_travel.fast_forward(2).minutes() 224 | when_new.click_button(type='double') 225 | 226 | time_travel.assert_current_time(4).minutes() 227 | 228 | # When 1/2: 229 | # Fast forwarding up until 1 min before reactivation 230 | # scheduled by last click 231 | time_travel.fast_forward(LONG_DELAY - 1).minutes() 232 | # Then 1/2: 233 | # Water heater still not turned back on (first clicks ignored) 234 | assert_that(ID['bathroom']['water_heater']).was_not.turned_on() 235 | 236 | # When 2/2: 237 | # Fast forwarding after reactivation 238 | # scheduled by last click 239 | time_travel.fast_forward(LONG_DELAY - 1).minutes() 240 | # Then 2/2: 241 | # Water heater still now turned back on 242 | assert_that(ID['bathroom']['water_heater']).was.turned_on() 243 | 244 | class TestMixedClicks: 245 | def test_short_then_long_keep_latest(self, when_new, time_travel, assert_that): 246 | when_new.click_button() 247 | time_travel.fast_forward(2).minutes() 248 | when_new.click_button(type='double') 249 | 250 | time_travel.fast_forward(LONG_DELAY - 1).minutes() 251 | assert_that(ID['bathroom']['water_heater']).was_not.turned_on() 252 | time_travel.fast_forward(1).minutes() 253 | assert_that(ID['bathroom']['water_heater']).was.turned_on() 254 | 255 | def test_long_then_short_keep_latest(self, when_new, time_travel, assert_that): 256 | when_new.click_button(type='double') 257 | time_travel.fast_forward(2).minutes() 258 | when_new.click_button() 259 | 260 | time_travel.fast_forward(SHORT_DELAY - 1).minutes() 261 | assert_that(ID['bathroom']['water_heater']).was_not.turned_on() 262 | time_travel.fast_forward(1).minutes() 263 | assert_that(ID['bathroom']['water_heater']).was.turned_on() 264 | -------------------------------------------------------------------------------- /appdaemontestframework/assert_that.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | from abc import ABC, abstractmethod 3 | 4 | 5 | ### Custom Matchers ################################################## 6 | 7 | 8 | class ServiceOnAnyDomain: 9 | def __init__(self, service): 10 | self.service = ''.join(['/', service]) 11 | 12 | # Turn on service look like: 'DOMAIN/SERVICE' 13 | # We just check that the SERVICE part is equal 14 | 15 | def __eq__(self, other): 16 | """ 17 | Turn on service look like: 'DOMAIN/SERVICE' 18 | We just check that the SERVICE part is equal 19 | """ 20 | return self.service in other 21 | 22 | def __repr__(self): 23 | return "'ANY_DOMAIN" + self.service + "'" 24 | 25 | 26 | class AnyString: 27 | def __eq__(self, other): 28 | return isinstance(other, str) 29 | 30 | 31 | assert 'somedomain/my_service' == ServiceOnAnyDomain('my_service') 32 | assert 'asdfasdf' == AnyString() 33 | 34 | 35 | ###################################################################### 36 | 37 | 38 | ### Custom Exception ################################################# 39 | class EitherOrAssertionError(AssertionError): 40 | def __init__(self, first_assertion_error, second_assertion_error): 41 | message = '\n'.join([ 42 | '', 43 | '', 44 | 'At least ONE of the following exceptions should not have been raised', 45 | '', 46 | 'The problem is EITHER:', 47 | str(first_assertion_error), 48 | '', 49 | 'OR', 50 | str(second_assertion_error)]) 51 | 52 | super(EitherOrAssertionError, self).__init__(message) 53 | 54 | 55 | ###################################################################### 56 | 57 | 58 | class Was(ABC): 59 | @abstractmethod 60 | def turned_on(self, **service_specific_parameters): 61 | pass 62 | 63 | @abstractmethod 64 | def turned_off(self): 65 | pass 66 | 67 | @abstractmethod 68 | def called_with(self, **kwargs): 69 | pass 70 | 71 | def called(self): 72 | self.called_with() 73 | 74 | 75 | class WasWrapper(Was): 76 | def __init__(self, thing_to_check, hass_functions): 77 | self.thing_to_check = thing_to_check 78 | self.hass_functions = hass_functions 79 | 80 | def turned_on(self, **service_specific_parameters): 81 | """ Assert that a given entity_id has been turned on """ 82 | entity_id = self.thing_to_check 83 | 84 | service_not_called = _capture_assert_failure_exception( 85 | lambda: self.hass_functions['call_service'].assert_any_call( 86 | ServiceOnAnyDomain('turn_on'), 87 | **{'entity_id': entity_id, **service_specific_parameters})) 88 | 89 | turn_on_helper_not_called = _capture_assert_failure_exception( 90 | lambda: self.hass_functions['turn_on'].assert_any_call( 91 | entity_id, 92 | **service_specific_parameters)) 93 | 94 | if service_not_called and turn_on_helper_not_called: 95 | raise EitherOrAssertionError( 96 | service_not_called, turn_on_helper_not_called) 97 | 98 | def turned_off(self, **service_specific_parameters): 99 | """ Assert that a given entity_id has been turned off """ 100 | entity_id = self.thing_to_check 101 | 102 | service_not_called = _capture_assert_failure_exception( 103 | lambda: self.hass_functions['call_service'].assert_any_call( 104 | ServiceOnAnyDomain('turn_off'), 105 | **{'entity_id': entity_id, **service_specific_parameters})) 106 | 107 | turn_off_helper_not_called = _capture_assert_failure_exception( 108 | lambda: self.hass_functions['turn_off'].assert_any_call( 109 | entity_id, 110 | **service_specific_parameters)) 111 | 112 | if service_not_called and turn_off_helper_not_called: 113 | raise EitherOrAssertionError( 114 | service_not_called, turn_off_helper_not_called) 115 | 116 | def called_with(self, **kwargs): 117 | """ Assert that a given service has been called with the given arguments""" 118 | service_full_name = self.thing_to_check 119 | 120 | self.hass_functions['call_service'].assert_any_call( 121 | service_full_name, **kwargs) 122 | 123 | 124 | class WasNotWrapper(Was): 125 | def __init__(self, was_wrapper): 126 | self.was_wrapper = was_wrapper 127 | 128 | def turned_on(self, **service_specific_parameters): 129 | """ Assert that a given entity_id has NOT been turned ON w/ the given parameters""" 130 | thing_not_turned_on_with_given_params = _capture_assert_failure_exception( 131 | lambda: self.was_wrapper.turned_on(**service_specific_parameters)) 132 | 133 | if not thing_not_turned_on_with_given_params: 134 | raise AssertionError( 135 | "Should NOT have been turned ON w/ the given params: " 136 | + str(self.was_wrapper.thing_to_check)) 137 | 138 | def turned_off(self, **service_specific_parameters): 139 | """ Assert that a given entity_id has NOT been turned OFF """ 140 | thing_not_turned_off = _capture_assert_failure_exception( 141 | lambda: self.was_wrapper.turned_off(**service_specific_parameters)) 142 | 143 | if not thing_not_turned_off: 144 | raise AssertionError( 145 | "Should NOT have been turned OFF: " 146 | + str(self.was_wrapper.thing_to_check)) 147 | 148 | def called_with(self, **kwargs): 149 | """ Assert that a given service has NOT been called with the given arguments""" 150 | service_not_called = _capture_assert_failure_exception( 151 | lambda: self.was_wrapper.called_with(**kwargs)) 152 | 153 | if not service_not_called: 154 | raise AssertionError( 155 | "Service shoud NOT have been called with the given args: " + str(kwargs)) 156 | 157 | 158 | class ListensToWrapper: 159 | def __init__(self, automation_thing_to_check, hass_functions): 160 | self.automation_thing_to_check = automation_thing_to_check 161 | self.listen_event = hass_functions['listen_event'] 162 | self.listen_state = hass_functions['listen_state'] 163 | 164 | def event(self, event, **event_data): 165 | listens_to_wrapper = self 166 | 167 | class WithCallbackWrapper: 168 | def with_callback(self, callback): 169 | listens_to_wrapper.automation_thing_to_check.initialize() 170 | listens_to_wrapper.listen_event.assert_any_call( 171 | callback, 172 | event, 173 | **event_data) 174 | 175 | return WithCallbackWrapper() 176 | 177 | def state(self, entity_id, **listen_state_opts): 178 | listens_to_wrapper = self 179 | 180 | class WithCallbackWrapper: 181 | def with_callback(self, callback): 182 | listens_to_wrapper.automation_thing_to_check.initialize() 183 | listens_to_wrapper.listen_state.assert_any_call( 184 | callback, 185 | entity_id, 186 | **listen_state_opts) 187 | 188 | return WithCallbackWrapper() 189 | 190 | 191 | class RegisteredWrapper: 192 | def __init__(self, automation_thing_to_check, hass_functions): 193 | self.automation_thing_to_check = automation_thing_to_check 194 | self._run_daily = hass_functions['run_daily'] 195 | self._run_mintely = hass_functions['run_minutely'] 196 | self._run_at = hass_functions['run_at'] 197 | 198 | 199 | def run_daily(self, time_, **kwargs): 200 | registered_wrapper = self 201 | 202 | class WithCallbackWrapper: 203 | def with_callback(self, callback): 204 | registered_wrapper.automation_thing_to_check.initialize() 205 | registered_wrapper._run_daily.assert_any_call( 206 | callback, 207 | time_, 208 | **kwargs) 209 | 210 | return WithCallbackWrapper() 211 | 212 | def run_minutely(self, time_, **kwargs): 213 | registered_wrapper = self 214 | 215 | class WithCallbackWrapper: 216 | def with_callback(self, callback): 217 | registered_wrapper.automation_thing_to_check.initialize() 218 | registered_wrapper._run_mintely.assert_any_call( 219 | callback, 220 | time_, 221 | **kwargs) 222 | 223 | return WithCallbackWrapper() 224 | 225 | def run_at(self, time_, **kwargs): 226 | registered_wrapper = self 227 | 228 | class WithCallbackWrapper: 229 | def with_callback(self, callback): 230 | registered_wrapper.automation_thing_to_check.initialize() 231 | registered_wrapper._run_at.assert_any_call( 232 | callback, 233 | time_, 234 | **kwargs) 235 | 236 | return WithCallbackWrapper() 237 | 238 | 239 | NOT_INIT_ERROR = textwrap.dedent("""\ 240 | AssertThat has not been initialized! 241 | 242 | Call `assert_that(THING_TO_CHECK).was.ASSERTION` 243 | And NOT `assert_that.was.ASSERTION` 244 | """) 245 | 246 | 247 | def _ensure_init(property): 248 | if property is None: 249 | raise Exception(NOT_INIT_ERROR) 250 | return property 251 | 252 | 253 | class AssertThatWrapper: 254 | def __init__(self, hass_mocks): 255 | # Access the `_hass_functions` through private member for now to avoid genearting deprecation 256 | # warnings while keeping compatibility. 257 | self.hass_functions = hass_mocks._hass_functions 258 | self._was = None 259 | self._was_not = None 260 | self._listens_to = None 261 | self._registered = None 262 | 263 | def __call__(self, thing_to_check): 264 | self._was = WasWrapper(thing_to_check, self.hass_functions) 265 | self._was_not = WasNotWrapper(self.was) 266 | self._listens_to = ListensToWrapper(thing_to_check, self.hass_functions) 267 | self._registered = RegisteredWrapper(thing_to_check, self.hass_functions) 268 | return self 269 | 270 | @property 271 | def was(self): 272 | return _ensure_init(self._was) 273 | 274 | @property 275 | def was_not(self): 276 | return _ensure_init(self._was_not) 277 | 278 | @property 279 | def listens_to(self): 280 | return _ensure_init(self._listens_to) 281 | 282 | @property 283 | def registered(self): 284 | return _ensure_init(self._registered) 285 | 286 | 287 | def _capture_assert_failure_exception(function_with_assertion): 288 | """ Returns wether the assertion was successful or not. But does not throw """ 289 | try: 290 | function_with_assertion() 291 | return None 292 | except AssertionError as failed_assert: 293 | return failed_assert 294 | -------------------------------------------------------------------------------- /test/integration_tests/tests/test_kitchen.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from apps.kitchen import Kitchen 4 | from appdaemon.plugins.hass.hassapi import Hass 5 | from mock import MagicMock 6 | from apps.entity_ids import ID 7 | 8 | # TODO: Put this in config (through apps.yml, check doc) 9 | from appdaemontestframework import automation_fixture 10 | 11 | PHONE_PUSHBULLET_ID = "device/OnePlus 5T" 12 | 13 | 14 | @automation_fixture(Kitchen) 15 | def kitchen(given_that): 16 | pass 17 | 18 | 19 | @pytest.fixture 20 | def when_new(kitchen): 21 | class WhenNewWrapper: 22 | def motion(self): 23 | kitchen._new_motion(None, None, None) 24 | 25 | def no_more_motion(self): 26 | kitchen._no_more_motion(None, None, None, None, None) 27 | 28 | def click_button(self, type="single"): 29 | { 30 | "single": kitchen._new_button_click, 31 | "double": kitchen._new_button_double_click, 32 | "long": kitchen._new_button_long_press, 33 | }[type](None, None, None) 34 | 35 | return WhenNewWrapper() 36 | 37 | 38 | class TestInitialization: 39 | def test_callbacks_are_registered(self, kitchen, hass_mocks): 40 | # Given: The mocked callback Appdaemon registration functions 41 | listen_event = hass_mocks.hass_functions["listen_event"] 42 | listen_state = hass_mocks.hass_functions["listen_state"] 43 | 44 | # When: Calling `initialize` 45 | kitchen.initialize() 46 | 47 | # Then: callbacks are registered 48 | listen_event.assert_any_call( 49 | kitchen._new_button_click, 50 | "click", 51 | entity_id=ID["kitchen"]["button"], 52 | click_type="single", 53 | ) 54 | 55 | listen_event.assert_any_call( 56 | kitchen._new_button_double_click, 57 | "click", 58 | entity_id=ID["kitchen"]["button"], 59 | click_type="double", 60 | ) 61 | 62 | listen_event.assert_any_call( 63 | kitchen._new_motion, "motion", entity_id=ID["kitchen"]["motion_sensor"] 64 | ) 65 | 66 | listen_state.assert_any_call( 67 | kitchen._no_more_motion, ID["kitchen"]["motion_sensor"], new="off" 68 | ) 69 | 70 | 71 | class TestAutomaticLights: 72 | def test_turn_on(self, when_new, assert_that): 73 | when_new.motion() 74 | assert_that(ID["kitchen"]["light"]).was.turned_on() 75 | 76 | def test_turn_off(self, when_new, assert_that): 77 | when_new.no_more_motion() 78 | assert_that(ID["kitchen"]["light"]).was.turned_off() 79 | 80 | 81 | SHORT_DELAY = 10 82 | LONG_DELAY = 30 83 | 84 | 85 | @pytest.fixture 86 | def assert_water_heater_notif_sent(assert_that): 87 | def assert_water_heater_sent_wrapper(message): 88 | assert_that("notify/pushbullet").was.called_with( 89 | title="Water Heater", message=message, target=PHONE_PUSHBULLET_ID 90 | ) 91 | 92 | return assert_water_heater_sent_wrapper 93 | 94 | 95 | @pytest.fixture 96 | def assert_water_heater_notif_NOT_sent(assert_that): 97 | def assert_water_heater_NOT_sent_wrapper(message): 98 | assert_that("notify/pushbullet").was_not.called_with( 99 | title="Water Heater", message=message, target=PHONE_PUSHBULLET_ID 100 | ) 101 | 102 | return assert_water_heater_NOT_sent_wrapper 103 | 104 | 105 | class TestSingleClickOnButton: 106 | def test_turn_off_water_heater(self, when_new, assert_that): 107 | when_new.click_button() 108 | assert_that(ID["bathroom"]["water_heater"]).was.turned_off() 109 | 110 | def test_send_notification(self, when_new, assert_water_heater_notif_sent): 111 | when_new.click_button() 112 | assert_water_heater_notif_sent(f"was turned off for {SHORT_DELAY} minutes") 113 | 114 | class TestAfterDelay: 115 | def test_turn_water_heater_back_on(self, when_new, time_travel, assert_that): 116 | when_new.click_button() 117 | time_travel.fast_forward(SHORT_DELAY).minutes() 118 | assert_that(ID["bathroom"]["water_heater"]).was.turned_on() 119 | 120 | def test_send_notification( 121 | self, when_new, time_travel, assert_water_heater_notif_sent 122 | ): 123 | when_new.click_button() 124 | time_travel.fast_forward(SHORT_DELAY).minutes() 125 | assert_water_heater_notif_sent("was turned back on") 126 | 127 | 128 | class TestDoubleClickOnButton: 129 | def test_turn_off_water_heater(self, when_new, assert_that): 130 | when_new.click_button(type="double") 131 | assert_that(ID["bathroom"]["water_heater"]).was.turned_off() 132 | 133 | def test_send_notification(self, when_new, assert_water_heater_notif_sent): 134 | when_new.click_button(type="double") 135 | assert_water_heater_notif_sent(f"was turned off for {LONG_DELAY} minutes") 136 | 137 | class TestAfterShortDelay: 138 | def test_DOES_NOT_turn_water_heater_back_on( 139 | self, when_new, time_travel, assert_that 140 | ): 141 | when_new.click_button(type="double") 142 | time_travel.fast_forward(SHORT_DELAY).minutes() 143 | assert_that(ID["bathroom"]["water_heater"]).was_not.turned_on() 144 | 145 | def test_DOES_NOT_send_notification( 146 | self, when_new, time_travel, assert_water_heater_notif_NOT_sent 147 | ): 148 | when_new.click_button(type="double") 149 | time_travel.fast_forward(SHORT_DELAY).minutes() 150 | assert_water_heater_notif_NOT_sent("was turned back on") 151 | 152 | class TestAfterLongDelay: 153 | def test_turn_water_heater_back_on(self, when_new, time_travel, assert_that): 154 | when_new.click_button(type="double") 155 | time_travel.fast_forward(LONG_DELAY).minutes() 156 | assert_that(ID["bathroom"]["water_heater"]).was.turned_on() 157 | 158 | def test_send_notification( 159 | self, when_new, time_travel, assert_water_heater_notif_sent 160 | ): 161 | when_new.click_button(type="double") 162 | time_travel.fast_forward(LONG_DELAY).minutes() 163 | assert_water_heater_notif_sent("was turned back on") 164 | 165 | 166 | class TestClickCancellation: 167 | class TestSingleClick: 168 | def test_new_click_cancels_previous_one( 169 | self, when_new, time_travel, assert_that 170 | ): 171 | # T = 0min 172 | # FF = 0min 173 | time_travel.assert_current_time(0).minutes() 174 | when_new.click_button() 175 | 176 | # T = 2min 177 | # FF = 2min 178 | time_travel.fast_forward(2).minutes() 179 | time_travel.assert_current_time(2).minutes() 180 | when_new.click_button() 181 | 182 | # T = SHORT_DELAY 183 | # FF = SHORT_DELAY - 2min 184 | # Do NOT turn water heater back on yet! 185 | time_travel.fast_forward(SHORT_DELAY - 2).minutes() 186 | time_travel.assert_current_time(SHORT_DELAY).minutes() 187 | assert_that(ID["bathroom"]["water_heater"]).was_not.turned_on() 188 | 189 | # T = SHORT_DELAY + 2min 190 | # FF = SHORT_DELAY + 2min - (2min + 8min) 191 | time_travel.fast_forward(SHORT_DELAY - 8).minutes() 192 | time_travel.assert_current_time(SHORT_DELAY + 2).minutes() 193 | assert_that(ID["bathroom"]["water_heater"]).was.turned_on() 194 | 195 | def test_multiple_clicks(self, when_new, time_travel, assert_that): 196 | # Given: 3 clicks, every 2 seconds 197 | when_new.click_button() 198 | time_travel.fast_forward(2).minutes() 199 | when_new.click_button() 200 | time_travel.fast_forward(2).minutes() 201 | when_new.click_button() 202 | 203 | time_travel.assert_current_time(4).minutes() 204 | 205 | # When 1/2: 206 | # Fast forwarding up until 1 min before reactivation 207 | # scheduled by last click 208 | time_travel.fast_forward(SHORT_DELAY - 1).minutes() 209 | # Then 1/2: 210 | # Water heater still not turned back on (first clicks ignored) 211 | assert_that(ID["bathroom"]["water_heater"]).was_not.turned_on() 212 | 213 | # When 2/2: 214 | # Fast forwarding after reactivation 215 | # scheduled by last click 216 | time_travel.fast_forward(SHORT_DELAY - 1).minutes() 217 | # Then 2/2: 218 | # Water heater still now turned back on 219 | assert_that(ID["bathroom"]["water_heater"]).was.turned_on() 220 | 221 | class TestDoubleClick: 222 | def test_multiple_clicks(self, when_new, time_travel, assert_that): 223 | # Given: 3 clicks, every 2 seconds 224 | when_new.click_button(type="double") 225 | time_travel.fast_forward(2).minutes() 226 | when_new.click_button(type="double") 227 | time_travel.fast_forward(2).minutes() 228 | when_new.click_button(type="double") 229 | 230 | time_travel.assert_current_time(4).minutes() 231 | 232 | # When 1/2: 233 | # Fast forwarding up until 1 min before reactivation 234 | # scheduled by last click 235 | time_travel.fast_forward(LONG_DELAY - 1).minutes() 236 | # Then 1/2: 237 | # Water heater still not turned back on (first clicks ignored) 238 | assert_that(ID["bathroom"]["water_heater"]).was_not.turned_on() 239 | 240 | # When 2/2: 241 | # Fast forwarding after reactivation 242 | # scheduled by last click 243 | time_travel.fast_forward(LONG_DELAY - 1).minutes() 244 | # Then 2/2: 245 | # Water heater still now turned back on 246 | assert_that(ID["bathroom"]["water_heater"]).was.turned_on() 247 | 248 | class TestMixedClicks: 249 | def test_short_then_long_keep_latest(self, when_new, time_travel, assert_that): 250 | when_new.click_button() 251 | time_travel.fast_forward(2).minutes() 252 | when_new.click_button(type="double") 253 | 254 | time_travel.fast_forward(LONG_DELAY - 1).minutes() 255 | assert_that(ID["bathroom"]["water_heater"]).was_not.turned_on() 256 | time_travel.fast_forward(1).minutes() 257 | assert_that(ID["bathroom"]["water_heater"]).was.turned_on() 258 | 259 | def test_long_then_short_keep_latest(self, when_new, time_travel, assert_that): 260 | when_new.click_button(type="double") 261 | time_travel.fast_forward(2).minutes() 262 | when_new.click_button() 263 | 264 | time_travel.fast_forward(SHORT_DELAY - 1).minutes() 265 | assert_that(ID["bathroom"]["water_heater"]).was_not.turned_on() 266 | time_travel.fast_forward(1).minutes() 267 | assert_that(ID["bathroom"]["water_heater"]).was.turned_on() 268 | -------------------------------------------------------------------------------- /doc/full_example/apps/bathroom.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | import appdaemon.plugins.hass.hassapi as hass 3 | from datetime import time 4 | try: 5 | # Module namespaces when Automation Modules are loaded in AppDaemon 6 | # is different from the 'real' python one. 7 | # Appdaemon doesn't seem to take into account packages 8 | from apps.entity_ids import ID 9 | except ModuleNotFoundError: 10 | from entity_ids import ID 11 | 12 | FAKE_MUTE_VOLUME = 0.1 13 | BATHROOM_VOLUMES = { 14 | 'regular': 0.4, 15 | 'shower': 0.7 16 | } 17 | DEFAULT_VOLUMES = { 18 | 'kitchen': 0.40, 19 | 'living_room_soundbar': 0.25, 20 | 'living_room_controller': 0.57, 21 | 'bathroom': FAKE_MUTE_VOLUME 22 | } 23 | """ 24 | 4 Behaviors as States of a State Machine: 25 | 26 | * Day: The normal mode 27 | * Evening: Same as Day. Using red light instead. 28 | * Shower: Volume up, it's shower time! 29 | * AfterShower: Turn off all devices while using the HairDryer 30 | 31 | 32 | Detail of each State: 33 | 34 | * Day: Normal mode - Activated at 4AM by EveningState 35 | - Light on-off WHITE 36 | - Bathroom mute / un-mute when entering & leaving bathroom. If media playing 37 | - Water heater back on 38 | ACTIVATE NEXT STEP: 39 | - 8PM => EveningState 40 | - TODO: Will use luminosity instead of time in the future 41 | - Click => ShowerState 42 | 43 | * Evening: Evening Mode 44 | - Light on-off RED 45 | - Bathroom mute / un-mute when entering & leaving bathroom. If media playing 46 | ACTIVATE NEXT STEP: 47 | - 4AM => DayState 48 | - Click => ShowerState 49 | 50 | * Shower: GREEN - Shower time 51 | - Sound notif at start 52 | - Light always on GREEN 53 | - Volume 70% Bathroom 54 | - Volume 0% everywhere else 55 | - Do not react to motion 56 | ACTIVATE NEXT STEP: 57 | - Click => AfterShower 58 | 59 | * AfterShower: YELLOW - Using Hair dryer :) 60 | - Sound notif at start 61 | - Light always on YELLOW 62 | - Volume 'fake mute' 63 | - Water heater off 64 | - Music / podcast paused 65 | ACTIVATE NEXT STEP: 66 | - MABB & Time in [8PM, 4AM[ => EveningState 67 | - MABB & Time out [8PM, 4AM[ => DayState 68 | """ 69 | 70 | 71 | class BathroomBehavior: 72 | def start(self): 73 | pass 74 | 75 | def new_motion_bathroom(self): 76 | pass 77 | 78 | def new_motion_kitchen_living_room(self): 79 | pass 80 | 81 | def no_more_motion_bathroom(self): 82 | pass 83 | 84 | def click_on_bathroom_button(self): 85 | pass 86 | 87 | def time_triggered(self, hour_of_day): 88 | pass 89 | 90 | 91 | class Bathroom(hass.Hass): 92 | def initialize(self): 93 | self.behaviors = None 94 | self._initialize_behaviors() 95 | 96 | self.current_behavior = None 97 | self.start_initial_behavior() 98 | 99 | self._register_time_callbacks() 100 | self._register_motion_callbacks() 101 | self._register_button_click_callback() 102 | 103 | self._register_debug_callback() 104 | 105 | def _initialize_behaviors(self): 106 | self.behaviors = { 107 | 'day': DayBehavior(self), 108 | 'evening': EveningBehavior(self), 109 | 'shower': ShowerBehavior(self), 110 | 'after_shower': AfterShowerBehavior(self) 111 | } 112 | 113 | def start_initial_behavior(self): 114 | current_hour = self.time().hour 115 | if current_hour < 4 or current_hour >= 20: 116 | self.start_behavior('evening') 117 | else: 118 | self.start_behavior('day') 119 | 120 | def _register_time_callbacks(self): 121 | self.run_daily(self._time_triggered, time(hour=4), hour=4) 122 | self.run_daily(self._time_triggered, time(hour=20), hour=20) 123 | 124 | def _register_motion_callbacks(self): 125 | self.listen_event(self._new_motion_bathroom, 'motion', 126 | entity_id=ID['bathroom']['motion_sensor']) 127 | self.listen_event(self._new_motion_kitchen, 'motion', 128 | entity_id=ID['kitchen']['motion_sensor']) 129 | self.listen_event(self._new_motion_living_room, 'motion', 130 | entity_id=ID['living_room']['motion_sensor']) 131 | self.listen_state(self._no_more_motion_bathroom, 132 | ID['bathroom']['motion_sensor'], new='off') 133 | 134 | def _register_button_click_callback(self): 135 | self.listen_event(self._new_click_bathroom_button, 'click', 136 | entity_id=ID['bathroom']['button'], 137 | click_type='single') 138 | 139 | def _register_debug_callback(self): 140 | def _debug(self, _e, data, _k): 141 | if data['click_type'] == 'single': 142 | self.call_service('xiaomi_aqara/play_ringtone', 143 | ringtone_id=10001, ringtone_vol=20) 144 | # self.pause_media_entire_flat() 145 | # self.turn_on_bathroom_light('blue') 146 | elif data['click_type'] == 'double': 147 | pass 148 | 149 | self.listen_event(_debug, 'flic_click', 150 | entity_id=ID['debug']['flic_black']) 151 | 152 | """ 153 | Callbacks 154 | """ 155 | 156 | def _time_triggered(self, kwargs): 157 | self.current_behavior.time_triggered(kwargs['hour']) 158 | 159 | def _new_motion_bathroom(self, _e, _d, _k): 160 | self.current_behavior.new_motion_bathroom() 161 | 162 | def _new_motion_kitchen(self, _e, _d, _k): 163 | self.current_behavior.new_motion_kitchen_living_room() 164 | 165 | def _new_motion_living_room(self, _e, _d, _k): 166 | self.current_behavior.new_motion_kitchen_living_room() 167 | 168 | def _no_more_motion_bathroom(self, _e, _a, _o, _n, _k): 169 | self.current_behavior.no_more_motion_bathroom() 170 | 171 | def _new_click_bathroom_button(self, _e, _d, _k): 172 | self.current_behavior.click_on_bathroom_button() 173 | 174 | """ 175 | Bathroom Services 176 | """ 177 | 178 | def start_behavior(self, behavior): 179 | self.log("Starting Behavior: " + behavior) 180 | self.current_behavior = self.behaviors[behavior] 181 | self.current_behavior.start() 182 | 183 | def reset_all_volumes(self): 184 | self._set_volume(ID['kitchen']['speaker'], DEFAULT_VOLUMES['kitchen']) 185 | self._set_volume(ID['bathroom']['speaker'], 186 | DEFAULT_VOLUMES['bathroom']) 187 | self._set_volume(ID['living_room']['soundbar'], 188 | DEFAULT_VOLUMES['living_room_soundbar']) 189 | self._set_volume(ID['living_room']['controller'], 190 | DEFAULT_VOLUMES['living_room_controller']) 191 | 192 | def mute_all_except_bathroom(self): 193 | # Bug with sound bar firmware: Can only increase the volume by 10% at a time 194 | # to prevent this being a problem, we're not muting it 195 | # self._set_volume(ID['living_room']['soundbar'], FAKE_MUTE_VOLUME) 196 | self._set_volume(ID['living_room']['controller'], FAKE_MUTE_VOLUME) 197 | self._set_volume(ID['kitchen']['speaker'], FAKE_MUTE_VOLUME) 198 | 199 | def mute_bathroom(self): 200 | self._set_volume(ID['bathroom']['speaker'], FAKE_MUTE_VOLUME) 201 | 202 | def unmute_bathroom(self): 203 | self._set_volume(ID['bathroom']['speaker'], 204 | BATHROOM_VOLUMES['regular']) 205 | 206 | def set_shower_volume_bathroom(self): 207 | self._set_volume(ID['bathroom']['speaker'], BATHROOM_VOLUMES['shower']) 208 | 209 | def is_media_casting_bathroom(self): 210 | return (self._is_media_casting(ID['bathroom']['speaker']) 211 | or self._is_media_casting(ID['cast_groups']['entire_flat'])) 212 | 213 | def pause_media_playback_entire_flat(self): 214 | self._pause_media(ID['bathroom']['speaker']) 215 | self._pause_media(ID['cast_groups']['entire_flat']) 216 | 217 | def resume_media_playback_entire_flat(self): 218 | self._play_media(ID['bathroom']['speaker']) 219 | self._play_media(ID['cast_groups']['entire_flat']) 220 | 221 | def turn_on_bathroom_light(self, color_name): 222 | self.turn_on(ID['bathroom']['led_light'], color_name=color_name) 223 | 224 | def turn_off_bathroom_light(self): 225 | self.turn_off(ID['bathroom']['led_light']) 226 | 227 | def turn_off_water_heater(self): 228 | self.turn_off(ID['bathroom']['water_heater']) 229 | 230 | def turn_on_water_heater(self): 231 | self.turn_on(ID['bathroom']['water_heater']) 232 | 233 | def play_notification_sound(self): 234 | self.call_service('xiaomi_aqara/play_ringtone', 235 | ringtone_id=10001, ringtone_vol=20, gw_mac=ID['bathroom']['gateway_mac_address']) 236 | 237 | """ 238 | Private functions 239 | """ 240 | 241 | def _set_volume(self, entity_id, volume): 242 | self.call_service('media_player/volume_set', 243 | entity_id=entity_id, 244 | volume_level=volume) 245 | 246 | def _is_media_casting(self, media_player_id): 247 | return self.get_state(media_player_id) != 'off' 248 | 249 | def _pause_media(self, entity_id): 250 | self.call_service('media_player/media_pause', 251 | entity_id=entity_id) 252 | 253 | def _play_media(self, entity_id): 254 | self.call_service('media_player/media_play', 255 | entity_id=entity_id) 256 | 257 | 258 | """ 259 | Behavior Implementations 260 | """ 261 | 262 | 263 | class ShowerBehavior(BathroomBehavior): 264 | def __init__(self, bathroom): 265 | self.bathroom = bathroom 266 | 267 | def start(self): 268 | self.bathroom.play_notification_sound() 269 | self.bathroom.turn_on_bathroom_light('GREEN') 270 | self.bathroom.set_shower_volume_bathroom() 271 | self.bathroom.mute_all_except_bathroom() 272 | 273 | def click_on_bathroom_button(self): 274 | self.bathroom.start_behavior('after_shower') 275 | 276 | 277 | class AfterShowerBehavior(BathroomBehavior): 278 | def __init__(self, bathroom): 279 | self.bathroom = bathroom 280 | 281 | def start(self): 282 | self.bathroom.play_notification_sound() 283 | self.bathroom.turn_on_bathroom_light('YELLOW') 284 | self.bathroom.pause_media_playback_entire_flat() 285 | self.bathroom.mute_bathroom() 286 | self.bathroom.turn_off_water_heater() 287 | 288 | def new_motion_kitchen_living_room(self): 289 | self._activate_next_state() 290 | 291 | def no_more_motion_bathroom(self): 292 | self._activate_next_state() 293 | 294 | def _activate_next_state(self): 295 | self.bathroom.resume_media_playback_entire_flat() 296 | self.bathroom.start_initial_behavior() 297 | 298 | 299 | 300 | class DayBehavior(BathroomBehavior): 301 | def __init__(self, bathroom): 302 | self.bathroom = bathroom 303 | 304 | def start(self): 305 | self.bathroom.turn_on_water_heater() 306 | self.bathroom.turn_off_bathroom_light() 307 | self.bathroom.reset_all_volumes() 308 | 309 | def new_motion_bathroom(self): 310 | self.bathroom.turn_on_bathroom_light('WHITE') 311 | if self.bathroom.is_media_casting_bathroom(): 312 | self.bathroom.unmute_bathroom() 313 | 314 | def no_more_motion_bathroom(self): 315 | self.bathroom.turn_off_bathroom_light() 316 | self.bathroom.mute_bathroom() 317 | 318 | def time_triggered(self, hour_of_day): 319 | if hour_of_day == 20: 320 | self.bathroom.start_behavior('evening') 321 | 322 | def click_on_bathroom_button(self): 323 | self.bathroom.start_behavior('shower') 324 | 325 | 326 | class EveningBehavior(BathroomBehavior): 327 | def __init__(self, bathroom): 328 | self.bathroom = bathroom 329 | 330 | def new_motion_bathroom(self): 331 | self.bathroom.turn_on_bathroom_light('RED') 332 | if self.bathroom.is_media_casting_bathroom(): 333 | self.bathroom.unmute_bathroom() 334 | 335 | def new_motion_kitchen_living_room(self): 336 | self.bathroom.turn_off_bathroom_light() 337 | self.bathroom.mute_bathroom() 338 | 339 | def no_more_motion_bathroom(self): 340 | self.bathroom.turn_off_bathroom_light() 341 | self.bathroom.mute_bathroom() 342 | 343 | def time_triggered(self, hour_of_day): 344 | if hour_of_day == 4: 345 | self.bathroom.start_behavior('day') 346 | -------------------------------------------------------------------------------- /test/integration_tests/apps/bathroom.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | import appdaemon.plugins.hass.hassapi as hass 3 | from datetime import time 4 | try: 5 | # Module namespaces when Automation Modules are loaded in AppDaemon 6 | # is different from the 'real' python one. 7 | # Appdaemon doesn't seem to take into account packages 8 | from apps.entity_ids import ID 9 | except ModuleNotFoundError: 10 | from entity_ids import ID 11 | 12 | FAKE_MUTE_VOLUME = 0.1 13 | BATHROOM_VOLUMES = { 14 | 'regular': 0.4, 15 | 'shower': 0.7 16 | } 17 | DEFAULT_VOLUMES = { 18 | 'kitchen': 0.40, 19 | 'living_room_soundbar': 0.25, 20 | 'living_room_controller': 0.57, 21 | 'bathroom': FAKE_MUTE_VOLUME 22 | } 23 | """ 24 | 4 Behaviors as States of a State Machine: 25 | 26 | * Day: The normal mode 27 | * Evening: Same as Day. Using red light instead. 28 | * Shower: Volume up, it's shower time! 29 | * AfterShower: Turn off all devices while using the HairDryer 30 | 31 | 32 | Detail of each State: 33 | 34 | * Day: Normal mode - Activated at 4AM by EveningState 35 | - Light on-off WHITE 36 | - Bathroom mute / un-mute when entering & leaving bathroom. If media playing 37 | - Water heater back on 38 | ACTIVATE NEXT STEP: 39 | - 8PM => EveningState 40 | - TODO: Will use luminosity instead of time in the future 41 | - Click => ShowerState 42 | 43 | * Evening: Evening Mode 44 | - Light on-off RED 45 | - Bathroom mute / un-mute when entering & leaving bathroom. If media playing 46 | ACTIVATE NEXT STEP: 47 | - 4AM => DayState 48 | - Click => ShowerState 49 | 50 | * Shower: GREEN - Shower time 51 | - Sound notif at start 52 | - Light always on GREEN 53 | - Volume 70% Bathroom 54 | - Volume 0% everywhere else 55 | - Do not react to motion 56 | ACTIVATE NEXT STEP: 57 | - Click => AfterShower 58 | 59 | * AfterShower: YELLOW - Using Hair dryer :) 60 | - Sound notif at start 61 | - Light always on YELLOW 62 | - Volume 'fake mute' 63 | - Water heater off 64 | - Music / podcast paused 65 | ACTIVATE NEXT STEP: 66 | - MABB & Time in [8PM, 4AM[ => EveningState 67 | - MABB & Time out [8PM, 4AM[ => DayState 68 | """ 69 | 70 | 71 | class BathroomBehavior: 72 | def start(self): 73 | pass 74 | 75 | def new_motion_bathroom(self): 76 | pass 77 | 78 | def new_motion_kitchen_living_room(self): 79 | pass 80 | 81 | def no_more_motion_bathroom(self): 82 | pass 83 | 84 | def click_on_bathroom_button(self): 85 | pass 86 | 87 | def time_triggered(self, hour_of_day): 88 | pass 89 | 90 | 91 | class Bathroom(hass.Hass): 92 | def initialize(self): 93 | self.behaviors = None 94 | self._initialize_behaviors() 95 | 96 | self.current_behavior = None 97 | self.start_initial_behavior() 98 | 99 | self._register_time_callbacks() 100 | self._register_motion_callbacks() 101 | self._register_button_click_callback() 102 | 103 | self._register_debug_callback() 104 | 105 | def _initialize_behaviors(self): 106 | self.behaviors = { 107 | 'day': DayBehavior(self), 108 | 'evening': EveningBehavior(self), 109 | 'shower': ShowerBehavior(self), 110 | 'after_shower': AfterShowerBehavior(self) 111 | } 112 | 113 | def start_initial_behavior(self): 114 | current_hour = self.time().hour 115 | if current_hour < 4 or current_hour >= 20: 116 | self.start_behavior('evening') 117 | else: 118 | self.start_behavior('day') 119 | 120 | def _register_time_callbacks(self): 121 | self.run_daily(self._time_triggered, time(hour=4), hour=4) 122 | self.run_daily(self._time_triggered, time(hour=20), hour=20) 123 | 124 | def _register_motion_callbacks(self): 125 | self.listen_event(self._new_motion_bathroom, 'motion', 126 | entity_id=ID['bathroom']['motion_sensor']) 127 | self.listen_event(self._new_motion_kitchen, 'motion', 128 | entity_id=ID['kitchen']['motion_sensor']) 129 | self.listen_event(self._new_motion_living_room, 'motion', 130 | entity_id=ID['living_room']['motion_sensor']) 131 | self.listen_state(self._no_more_motion_bathroom, 132 | ID['bathroom']['motion_sensor'], new='off') 133 | 134 | def _register_button_click_callback(self): 135 | self.listen_event(self._new_click_bathroom_button, 'click', 136 | entity_id=ID['bathroom']['button'], 137 | click_type='single') 138 | 139 | def _register_debug_callback(self): 140 | def _debug(self, _e, data, _k): 141 | if data['click_type'] == 'single': 142 | self.call_service('xiaomi_aqara/play_ringtone', 143 | ringtone_id=10001, ringtone_vol=20) 144 | # self.pause_media_entire_flat() 145 | # self.turn_on_bathroom_light('blue') 146 | elif data['click_type'] == 'double': 147 | pass 148 | 149 | self.listen_event(_debug, 'flic_click', 150 | entity_id=ID['debug']['flic_black']) 151 | 152 | """ 153 | Callbacks 154 | """ 155 | 156 | def _time_triggered(self, kwargs): 157 | self.current_behavior.time_triggered(kwargs['hour']) 158 | 159 | def _new_motion_bathroom(self, _e, _d, _k): 160 | self.current_behavior.new_motion_bathroom() 161 | 162 | def _new_motion_kitchen(self, _e, _d, _k): 163 | self.current_behavior.new_motion_kitchen_living_room() 164 | 165 | def _new_motion_living_room(self, _e, _d, _k): 166 | self.current_behavior.new_motion_kitchen_living_room() 167 | 168 | def _no_more_motion_bathroom(self, _e, _a, _o, _n, _k): 169 | self.current_behavior.no_more_motion_bathroom() 170 | 171 | def _new_click_bathroom_button(self, _e, _d, _k): 172 | self.current_behavior.click_on_bathroom_button() 173 | 174 | """ 175 | Bathroom Services 176 | """ 177 | 178 | def start_behavior(self, behavior): 179 | self.log("Starting Behavior: " + behavior) 180 | self.current_behavior = self.behaviors[behavior] 181 | self.current_behavior.start() 182 | 183 | def reset_all_volumes(self): 184 | self._set_volume(ID['kitchen']['speaker'], DEFAULT_VOLUMES['kitchen']) 185 | self._set_volume(ID['bathroom']['speaker'], 186 | DEFAULT_VOLUMES['bathroom']) 187 | self._set_volume(ID['living_room']['soundbar'], 188 | DEFAULT_VOLUMES['living_room_soundbar']) 189 | self._set_volume(ID['living_room']['controller'], 190 | DEFAULT_VOLUMES['living_room_controller']) 191 | 192 | def mute_all_except_bathroom(self): 193 | # Bug with sound bar firmware: Can only increase the volume by 10% at a time 194 | # to prevent this being a problem, we're not muting it 195 | # self._set_volume(ID['living_room']['soundbar'], FAKE_MUTE_VOLUME) 196 | self._set_volume(ID['living_room']['controller'], FAKE_MUTE_VOLUME) 197 | self._set_volume(ID['kitchen']['speaker'], FAKE_MUTE_VOLUME) 198 | 199 | def mute_bathroom(self): 200 | self._set_volume(ID['bathroom']['speaker'], FAKE_MUTE_VOLUME) 201 | 202 | def unmute_bathroom(self): 203 | self._set_volume(ID['bathroom']['speaker'], 204 | BATHROOM_VOLUMES['regular']) 205 | 206 | def set_shower_volume_bathroom(self): 207 | self._set_volume(ID['bathroom']['speaker'], BATHROOM_VOLUMES['shower']) 208 | 209 | def is_media_casting_bathroom(self): 210 | return (self._is_media_casting(ID['bathroom']['speaker']) 211 | or self._is_media_casting(ID['cast_groups']['entire_flat'])) 212 | 213 | def pause_media_playback_entire_flat(self): 214 | self._pause_media(ID['bathroom']['speaker']) 215 | self._pause_media(ID['cast_groups']['entire_flat']) 216 | 217 | def resume_media_playback_entire_flat(self): 218 | self._play_media(ID['bathroom']['speaker']) 219 | self._play_media(ID['cast_groups']['entire_flat']) 220 | 221 | def turn_on_bathroom_light(self, color_name): 222 | self.turn_on(ID['bathroom']['led_light'], color_name=color_name) 223 | 224 | def turn_off_bathroom_light(self): 225 | self.turn_off(ID['bathroom']['led_light']) 226 | 227 | def turn_off_water_heater(self): 228 | self.turn_off(ID['bathroom']['water_heater']) 229 | 230 | def turn_on_water_heater(self): 231 | self.turn_on(ID['bathroom']['water_heater']) 232 | 233 | def play_notification_sound(self): 234 | self.call_service('xiaomi_aqara/play_ringtone', 235 | ringtone_id=10001, ringtone_vol=20, gw_mac=ID['bathroom']['gateway_mac_address']) 236 | 237 | """ 238 | Private functions 239 | """ 240 | 241 | def _set_volume(self, entity_id, volume): 242 | self.call_service('media_player/volume_set', 243 | entity_id=entity_id, 244 | volume_level=volume) 245 | 246 | def _is_media_casting(self, media_player_id): 247 | return self.get_state(media_player_id) != 'off' 248 | 249 | def _pause_media(self, entity_id): 250 | self.call_service('media_player/media_pause', 251 | entity_id=entity_id) 252 | 253 | def _play_media(self, entity_id): 254 | self.call_service('media_player/media_play', 255 | entity_id=entity_id) 256 | 257 | 258 | """ 259 | Behavior Implementations 260 | """ 261 | 262 | 263 | class ShowerBehavior(BathroomBehavior): 264 | def __init__(self, bathroom): 265 | self.bathroom = bathroom 266 | 267 | def start(self): 268 | self.bathroom.play_notification_sound() 269 | self.bathroom.turn_on_bathroom_light('GREEN') 270 | self.bathroom.set_shower_volume_bathroom() 271 | self.bathroom.mute_all_except_bathroom() 272 | 273 | def click_on_bathroom_button(self): 274 | self.bathroom.start_behavior('after_shower') 275 | 276 | 277 | class AfterShowerBehavior(BathroomBehavior): 278 | def __init__(self, bathroom): 279 | self.bathroom = bathroom 280 | 281 | def start(self): 282 | self.bathroom.play_notification_sound() 283 | self.bathroom.turn_on_bathroom_light('YELLOW') 284 | self.bathroom.pause_media_playback_entire_flat() 285 | self.bathroom.mute_bathroom() 286 | self.bathroom.turn_off_water_heater() 287 | 288 | def new_motion_kitchen_living_room(self): 289 | self._activate_next_state() 290 | 291 | def no_more_motion_bathroom(self): 292 | self._activate_next_state() 293 | 294 | def _activate_next_state(self): 295 | self.bathroom.resume_media_playback_entire_flat() 296 | self.bathroom.start_initial_behavior() 297 | 298 | 299 | 300 | class DayBehavior(BathroomBehavior): 301 | def __init__(self, bathroom): 302 | self.bathroom = bathroom 303 | 304 | def start(self): 305 | self.bathroom.turn_on_water_heater() 306 | self.bathroom.turn_off_bathroom_light() 307 | self.bathroom.reset_all_volumes() 308 | 309 | def new_motion_bathroom(self): 310 | self.bathroom.turn_on_bathroom_light('WHITE') 311 | if self.bathroom.is_media_casting_bathroom(): 312 | self.bathroom.unmute_bathroom() 313 | 314 | def no_more_motion_bathroom(self): 315 | self.bathroom.turn_off_bathroom_light() 316 | self.bathroom.mute_bathroom() 317 | 318 | def time_triggered(self, hour_of_day): 319 | if hour_of_day == 20: 320 | self.bathroom.start_behavior('evening') 321 | 322 | def click_on_bathroom_button(self): 323 | self.bathroom.start_behavior('shower') 324 | 325 | 326 | class EveningBehavior(BathroomBehavior): 327 | def __init__(self, bathroom): 328 | self.bathroom = bathroom 329 | 330 | def new_motion_bathroom(self): 331 | self.bathroom.turn_on_bathroom_light('RED') 332 | if self.bathroom.is_media_casting_bathroom(): 333 | self.bathroom.unmute_bathroom() 334 | 335 | def new_motion_kitchen_living_room(self): 336 | self.bathroom.turn_off_bathroom_light() 337 | self.bathroom.mute_bathroom() 338 | 339 | def no_more_motion_bathroom(self): 340 | self.bathroom.turn_off_bathroom_light() 341 | self.bathroom.mute_bathroom() 342 | 343 | def time_triggered(self, hour_of_day): 344 | if hour_of_day == 4: 345 | self.bathroom.start_behavior('day') 346 | -------------------------------------------------------------------------------- /test/test_assert_callback_registration.py: -------------------------------------------------------------------------------- 1 | from datetime import time, datetime 2 | 3 | import appdaemon.plugins.hass.hassapi as hass 4 | import pytest 5 | from pytest import mark 6 | 7 | from appdaemontestframework import automation_fixture 8 | 9 | 10 | class MockAutomation(hass.Hass): 11 | should_listen_state = False 12 | should_listen_event = False 13 | should_register_run_daily = False 14 | should_register_run_minutely = False 15 | should_register_run_at = False 16 | 17 | def initialize(self): 18 | if self.should_listen_state: 19 | self.listen_state(self._my_listen_state_callback, 'some_entity', new='off') 20 | if self.should_listen_event: 21 | self.listen_event(self._my_listen_event_callback, 'zwave.scene_activated', scene_id=3) 22 | if self.should_register_run_daily: 23 | self.run_daily(self._my_run_daily_callback, time(hour=3, minute=7), extra_param='ok') 24 | if self.should_register_run_minutely: 25 | self.run_minutely(self._my_run_minutely_callback, time(hour=3, minute=7), extra_param='ok') 26 | if self.should_register_run_at: 27 | self.run_at(self._my_run_at_callback, datetime(2019,11,5,22,43,0,0), extra_param='ok') 28 | 29 | def _my_listen_state_callback(self, entity, attribute, old, new, kwargs): 30 | pass 31 | 32 | def _my_listen_event_callback(self, event_name, data, kwargs): 33 | pass 34 | 35 | def _my_run_daily_callback(self, kwargs): 36 | pass 37 | 38 | def _my_run_minutely_callback(self, kwargs): 39 | pass 40 | 41 | def _my_run_at_callback(self, kwargs): 42 | pass 43 | 44 | def _some_other_function(self, entity, attribute, old, new, kwargs): 45 | pass 46 | 47 | def enable_listen_state_during_initialize(self): 48 | self.should_listen_state = True 49 | 50 | def enable_listen_event_during_initialize(self): 51 | self.should_listen_event = True 52 | 53 | def enable_register_run_daily_during_initialize(self): 54 | self.should_register_run_daily = True 55 | 56 | def enable_register_run_minutely_during_initialize(self): 57 | self.should_register_run_minutely = True 58 | 59 | def enable_register_run_at_during_initialize(self): 60 | self.should_register_run_at = True 61 | 62 | @automation_fixture(MockAutomation) 63 | def automation(): 64 | pass 65 | 66 | 67 | class TestAssertListenState: 68 | def test_success(self, automation: MockAutomation, assert_that): 69 | automation.enable_listen_state_during_initialize() 70 | 71 | assert_that(automation) \ 72 | .listens_to.state('some_entity', new='off') \ 73 | .with_callback(automation._my_listen_state_callback) 74 | 75 | def test_failure__not_listening(self, automation: MockAutomation, assert_that): 76 | with pytest.raises(AssertionError): 77 | assert_that(automation) \ 78 | .listens_to.state('some_entity', new='off') \ 79 | .with_callback(automation._my_listen_state_callback) 80 | 81 | def test_failure__wrong_entity(self, automation: MockAutomation, assert_that): 82 | automation.enable_listen_state_during_initialize() 83 | 84 | with pytest.raises(AssertionError): 85 | assert_that(automation) \ 86 | .listens_to.state('WRONG', new='on') \ 87 | .with_callback(automation._my_listen_state_callback) 88 | 89 | def test_failure__wrong_kwargs(self, automation: MockAutomation, assert_that): 90 | automation.enable_listen_state_during_initialize() 91 | 92 | with pytest.raises(AssertionError): 93 | assert_that(automation) \ 94 | .listens_to.state('some_entity', new='WRONG') \ 95 | .with_callback(automation._my_listen_state_callback) 96 | 97 | with pytest.raises(AssertionError): 98 | assert_that(automation) \ 99 | .listens_to.state('some_entity', wrong='off') \ 100 | .with_callback(automation._my_listen_state_callback) 101 | 102 | def test_failure__wrong_callback(self, automation: MockAutomation, assert_that): 103 | automation.enable_listen_state_during_initialize() 104 | 105 | with pytest.raises(AssertionError): 106 | assert_that(automation) \ 107 | .listens_to.state('some_entity', new='on') \ 108 | .with_callback(automation._some_other_function) 109 | 110 | 111 | class TestAssertListenEvent: 112 | def test_success(self, automation: MockAutomation, assert_that): 113 | automation.enable_listen_event_during_initialize() 114 | 115 | assert_that(automation) \ 116 | .listens_to.event('zwave.scene_activated', scene_id=3) \ 117 | .with_callback(automation._my_listen_event_callback) 118 | 119 | def test_failure__not_listening(self, automation: MockAutomation, assert_that): 120 | with pytest.raises(AssertionError): 121 | assert_that(automation) \ 122 | .listens_to.event('zwave.scene_activated', scene_id=3) \ 123 | .with_callback(automation._my_listen_event_callback) 124 | 125 | def test_failure__wrong_event(self, automation: MockAutomation, assert_that): 126 | automation.enable_listen_state_during_initialize() 127 | 128 | with pytest.raises(AssertionError): 129 | assert_that(automation) \ 130 | .listens_to.event('WRONG', scene_id=3) \ 131 | .with_callback(automation._my_listen_event_callback) 132 | 133 | def test_failure__wrong_kwargs(self, automation: MockAutomation, assert_that): 134 | automation.enable_listen_state_during_initialize() 135 | 136 | with pytest.raises(AssertionError): 137 | assert_that(automation) \ 138 | .listens_to.event('zwave.scene_activated', scene_id='WRONG') \ 139 | .with_callback(automation._my_listen_event_callback) 140 | 141 | with pytest.raises(AssertionError): 142 | assert_that(automation) \ 143 | .listens_to.event('zwave.scene_activated', wrong=3) \ 144 | .with_callback(automation._my_listen_event_callback) 145 | 146 | def test_failure__wrong_callback(self, automation: MockAutomation, assert_that): 147 | automation.enable_listen_state_during_initialize() 148 | 149 | with pytest.raises(AssertionError): 150 | assert_that(automation) \ 151 | .listens_to.event('zwave.scene_activated', scene_id=3) \ 152 | .with_callback(automation._some_other_function) 153 | 154 | 155 | class TestRegisteredRunDaily: 156 | def test_success(self, automation: MockAutomation, assert_that): 157 | automation.enable_register_run_daily_during_initialize() 158 | 159 | assert_that(automation) \ 160 | .registered.run_daily(time(hour=3, minute=7), extra_param='ok') \ 161 | .with_callback(automation._my_run_daily_callback) 162 | 163 | def test_failure__not_listening(self, automation: MockAutomation, assert_that): 164 | with pytest.raises(AssertionError): 165 | assert_that(automation) \ 166 | .registered.run_daily(time(hour=3, minute=7), extra_param='ok') \ 167 | .with_callback(automation._my_run_daily_callback) 168 | 169 | def test_failure__wrong_time(self, automation: MockAutomation, assert_that): 170 | automation.enable_register_run_daily_during_initialize() 171 | 172 | with pytest.raises(AssertionError): 173 | assert_that(automation) \ 174 | .registered.run_daily(time(hour=4), extra_param='ok') \ 175 | .with_callback(automation._my_run_daily_callback) 176 | 177 | def test_failure__wrong_kwargs(self, automation: MockAutomation, assert_that): 178 | automation.enable_register_run_daily_during_initialize() 179 | 180 | with pytest.raises(AssertionError): 181 | assert_that(automation) \ 182 | .registered.run_daily(time(hour=3, minute=7), extra_param='WRONG') \ 183 | .with_callback(automation._my_run_daily_callback) 184 | 185 | with pytest.raises(AssertionError): 186 | assert_that(automation) \ 187 | .registered.run_daily(time(hour=3, minute=7), wrong='ok') \ 188 | .with_callback(automation._my_run_daily_callback) 189 | 190 | def test_failure__wrong_callback(self, automation: MockAutomation, assert_that): 191 | automation.enable_register_run_daily_during_initialize() 192 | 193 | with pytest.raises(AssertionError): 194 | assert_that(automation) \ 195 | .registered.run_daily(time(hour=3, minute=7), extra_param='ok') \ 196 | .with_callback(automation._some_other_function) 197 | 198 | 199 | class TestRegisteredRunMinutely: 200 | def test_success(self, automation: MockAutomation, assert_that): 201 | automation.enable_register_run_minutely_during_initialize() 202 | 203 | assert_that(automation) \ 204 | .registered.run_minutely(time(hour=3, minute=7), extra_param='ok') \ 205 | .with_callback(automation._my_run_minutely_callback) 206 | 207 | def test_failure__not_listening(self, automation: MockAutomation, assert_that): 208 | with pytest.raises(AssertionError): 209 | assert_that(automation) \ 210 | .registered.run_minutely(time(hour=3, minute=7), extra_param='ok') \ 211 | .with_callback(automation._my_run_minutely_callback) 212 | 213 | def test_failure__wrong_time(self, automation: MockAutomation, assert_that): 214 | automation.enable_register_run_minutely_during_initialize() 215 | 216 | with pytest.raises(AssertionError): 217 | assert_that(automation) \ 218 | .registered.run_minutely(time(hour=4), extra_param='ok') \ 219 | .with_callback(automation._my_run_minutely_callback) 220 | 221 | def test_failure__wrong_kwargs(self, automation: MockAutomation, assert_that): 222 | automation.enable_register_run_minutely_during_initialize() 223 | 224 | with pytest.raises(AssertionError): 225 | assert_that(automation) \ 226 | .registered.run_minutely(time(hour=3, minute=7), extra_param='WRONG') \ 227 | .with_callback(automation._my_run_minutely_callback) 228 | 229 | with pytest.raises(AssertionError): 230 | assert_that(automation) \ 231 | .registered.run_minutely(time(hour=3, minute=7), wrong='ok') \ 232 | .with_callback(automation._my_run_minutely_callback) 233 | 234 | def test_failure__wrong_callback(self, automation: MockAutomation, assert_that): 235 | automation.enable_register_run_minutely_during_initialize() 236 | 237 | with pytest.raises(AssertionError): 238 | assert_that(automation) \ 239 | .registered.run_minutely(time(hour=3, minute=7), extra_param='ok') \ 240 | .with_callback(automation._some_other_function) 241 | 242 | 243 | class TestRegisteredRunAt: 244 | def test_success(self, automation: MockAutomation, assert_that): 245 | automation.enable_register_run_at_during_initialize() 246 | 247 | assert_that(automation) \ 248 | .registered.run_at(datetime(2019,11,5,22,43,0,0), extra_param='ok') \ 249 | .with_callback(automation._my_run_at_callback) 250 | 251 | def test_failure__not_listening(self, automation: MockAutomation, assert_that): 252 | with pytest.raises(AssertionError): 253 | assert_that(automation) \ 254 | .registered.run_at(datetime(2019,11,5,22,43,0,0), extra_param='ok') \ 255 | .with_callback(automation._my_run_at_callback) 256 | 257 | def test_failure__wrong_time(self, automation: MockAutomation, assert_that): 258 | automation.enable_register_run_at_during_initialize() 259 | 260 | with pytest.raises(AssertionError): 261 | assert_that(automation) \ 262 | .registered.run_at(datetime(2019,11,5,20,43,0,0), extra_param='ok') \ 263 | .with_callback(automation._my_run_at_callback) 264 | 265 | def test_failure__wrong_kwargs(self, automation: MockAutomation, assert_that): 266 | automation.enable_register_run_at_during_initialize() 267 | 268 | with pytest.raises(AssertionError): 269 | assert_that(automation) \ 270 | .registered.run_at(datetime(2019,11,5,22,43,0,0), extra_param='WRONG') \ 271 | .with_callback(automation._my_run_at_callback) 272 | 273 | with pytest.raises(AssertionError): 274 | assert_that(automation) \ 275 | .registered.run_at(datetime(2019,11,5,22,43,0,0), wrong='ok') \ 276 | .with_callback(automation._my_run_minutely_callback) 277 | 278 | def test_failure__wrong_callback(self, automation: MockAutomation, assert_that): 279 | automation.enable_register_run_at_during_initialize() 280 | 281 | with pytest.raises(AssertionError): 282 | assert_that(automation) \ 283 | .registered.run_at(datetime(2019,11,5,22,43,0,0), extra_param='ok') \ 284 | .with_callback(automation._some_other_function) 285 | -------------------------------------------------------------------------------- /test/test_automation_fixture.py: -------------------------------------------------------------------------------- 1 | import re 2 | from textwrap import dedent 3 | 4 | import pytest 5 | from appdaemon.plugins.hass.hassapi import Hass 6 | from pytest import mark, fixture 7 | 8 | 9 | class MockAutomation(Hass): 10 | def __init__(self, ad, app_config): 11 | super().__init__(ad, app_config) 12 | self.was_created = True 13 | self.was_initialized = False 14 | 15 | def initialize(self): 16 | self.was_initialized = True 17 | 18 | 19 | def expected_error_regex_was_found_in_stdout_lines(result, expected_error_regex): 20 | for line in result.outlines: 21 | if re.search(expected_error_regex, line): 22 | return True 23 | return False 24 | 25 | 26 | @mark.using_pytester 27 | @mark.usefixtures("configure_appdaemontestframework_for_pytester") 28 | class TestAutomationFixture: 29 | def test_fixture_is_available_for_injection(self, testdir): 30 | testdir.makepyfile( 31 | """ 32 | from appdaemon.plugins.hass.hassapi import Hass 33 | from appdaemontestframework import automation_fixture 34 | 35 | class MockAutomation(Hass): 36 | def initialize(self): 37 | pass 38 | 39 | @automation_fixture(MockAutomation) 40 | def mock_automation(): 41 | pass 42 | 43 | def test_is_injected_as_fixture(mock_automation): 44 | assert mock_automation is not None 45 | """ 46 | ) 47 | 48 | result = testdir.runpytest() 49 | result.assert_outcomes(passed=1) 50 | 51 | def test_automation_was_initialized(self, testdir): 52 | testdir.makepyfile( 53 | """ 54 | from appdaemon.plugins.hass.hassapi import Hass 55 | from appdaemontestframework import automation_fixture 56 | 57 | class MockAutomation(Hass): 58 | was_initialized: False 59 | 60 | def initialize(self): 61 | self.was_initialized = True 62 | 63 | 64 | @automation_fixture(MockAutomation) 65 | def mock_automation(): 66 | pass 67 | 68 | def test_was_initialized(mock_automation): 69 | assert mock_automation.was_initialized 70 | """ 71 | ) 72 | 73 | result = testdir.runpytest() 74 | result.assert_outcomes(passed=1) 75 | 76 | def test_calls_to_appdaemon_during_initialize_are_cleared_before_entering_test( 77 | self, testdir 78 | ): 79 | testdir.makepyfile( 80 | """ 81 | from appdaemon.plugins.hass.hassapi import Hass 82 | from appdaemontestframework import automation_fixture 83 | 84 | class MockAutomation(Hass): 85 | def initialize(self): 86 | self.turn_on('light.living_room') 87 | 88 | 89 | @automation_fixture(MockAutomation) 90 | def mock_automation(): 91 | pass 92 | 93 | def test_some_test(mock_automation): 94 | assert mock_automation is not None 95 | 96 | """ 97 | ) 98 | 99 | result = testdir.runpytest() 100 | result.assert_outcomes(passed=1) 101 | 102 | def test_multiple_automations(self, testdir): 103 | testdir.makepyfile( 104 | """ 105 | from appdaemon.plugins.hass.hassapi import Hass 106 | from appdaemontestframework import automation_fixture 107 | 108 | class MockAutomation(Hass): 109 | def initialize(self): 110 | pass 111 | 112 | class OtherMockAutomation(Hass): 113 | def initialize(self): 114 | pass 115 | 116 | 117 | @automation_fixture(MockAutomation, OtherMockAutomation) 118 | def mock_automation(): 119 | pass 120 | 121 | def test_some_test(mock_automation): 122 | assert mock_automation is not None 123 | """ 124 | ) 125 | 126 | result = testdir.runpytest() 127 | result.assert_outcomes(passed=2) 128 | 129 | def test_given_that_fixture_is_injectable_in_automation_fixture(self, testdir): 130 | testdir.makepyfile( 131 | """ 132 | from appdaemon.plugins.hass.hassapi import Hass 133 | from appdaemontestframework import automation_fixture 134 | 135 | class MockAutomation(Hass): 136 | def initialize(self): 137 | pass 138 | 139 | def assert_light_on(self): 140 | assert self.get_state('light.bed') == 'on' 141 | 142 | 143 | @automation_fixture(MockAutomation) 144 | def mock_automation(given_that): 145 | given_that.state_of('light.bed').is_set_to('on') 146 | 147 | def test_some_test(mock_automation): 148 | mock_automation.assert_light_on() 149 | """ 150 | ) 151 | 152 | result = testdir.runpytest() 153 | result.assert_outcomes(passed=1) 154 | 155 | def test_decorator_called_without_automation__raise_error(self, testdir): 156 | testdir.makepyfile( 157 | """ 158 | from appdaemon.plugins.hass.hassapi import Hass 159 | from appdaemontestframework import automation_fixture 160 | 161 | class MockAutomation(Hass): 162 | def initialize(self): 163 | pass 164 | 165 | @automation_fixture 166 | def mock_automation(): 167 | pass 168 | 169 | def test_some_test(mock_automation): 170 | assert mock_automation is not None 171 | """ 172 | ) 173 | 174 | result = testdir.runpytest() 175 | result.assert_outcomes(errors=1) 176 | assert expected_error_regex_was_found_in_stdout_lines( 177 | result, r"AutomationFixtureError.*argument" 178 | ) 179 | 180 | def test_name_attribute_of_hass_object_set_to_automation_class_name(self, testdir): 181 | testdir.makepyfile( 182 | """ 183 | from appdaemon.plugins.hass.hassapi import Hass 184 | from appdaemontestframework import automation_fixture 185 | 186 | class MockAutomation(Hass): 187 | def initialize(self): 188 | pass 189 | 190 | @automation_fixture(MockAutomation) 191 | def mock_automation(): 192 | pass 193 | 194 | def test_name_attribute_of_hass_object_set_to_automation_class_name(mock_automation): 195 | assert mock_automation.name == 'MockAutomation' 196 | """ 197 | ) 198 | 199 | result = testdir.runpytest() 200 | result.assert_outcomes(passed=1) 201 | 202 | class TestInvalidAutomation: 203 | @fixture 204 | def assert_automation_class_fails(self, testdir): 205 | def wrapper(automation_class_src, expected_error_regex): 206 | # Given: Test file with given automation class 207 | testdir.makepyfile( 208 | dedent( 209 | """ 210 | from appdaemon.plugins.hass.hassapi import Hass 211 | from appdaemontestframework import automation_fixture 212 | 213 | %s 214 | 215 | @automation_fixture(MockAutomation) 216 | def mock_automation(): 217 | pass 218 | 219 | def test_some_test(mock_automation): 220 | assert mock_automation is not None 221 | """ 222 | ) 223 | % dedent(automation_class_src) 224 | ) 225 | 226 | # When: Running 'pytest' 227 | result = testdir.runpytest() 228 | 229 | # Then: Found 1 error & stdout has a line with expected error 230 | result.assert_outcomes(errors=1) 231 | 232 | if not expected_error_regex_was_found_in_stdout_lines( 233 | result, expected_error_regex 234 | ): 235 | pytest.fail( 236 | f"Couldn't fine line matching error: '{expected_error_regex}'" 237 | ) 238 | 239 | return wrapper 240 | 241 | def test_automation_has_no_initialize_function( 242 | self, assert_automation_class_fails 243 | ): 244 | assert_automation_class_fails( 245 | automation_class_src=""" 246 | class MockAutomation(Hass): 247 | def some_other_function(self): 248 | self.turn_on('light.living_room') 249 | """, 250 | expected_error_regex=r"AutomationFixtureError: 'MockAutomation' .* no 'initialize' function", 251 | ) 252 | 253 | def test_initialize_function_has_arguments_other_than_self( 254 | self, assert_automation_class_fails 255 | ): 256 | assert_automation_class_fails( 257 | automation_class_src=""" 258 | class MockAutomation(Hass): 259 | def initialize(self, some_arg): 260 | self.turn_on('light.living_room') 261 | """, 262 | expected_error_regex=r"AutomationFixtureError: 'MockAutomation'.*" 263 | r"'initialize' should have no arguments other than 'self'", 264 | ) 265 | 266 | def test___init___was_overridden(self, assert_automation_class_fails): 267 | assert_automation_class_fails( 268 | automation_class_src=""" 269 | class MockAutomation(Hass): 270 | def __init__(self, ad, app_config): 271 | super().__init__(ad, app_config) 272 | self.log("do some things in '__init__'") 273 | 274 | def initialize(self): 275 | self.turn_on('light.living_room') 276 | """, 277 | expected_error_regex=r"AutomationFixtureError: 'MockAutomation'.*should not override '__init__'", 278 | ) 279 | 280 | # noinspection PyPep8Naming,SpellCheckingInspection 281 | def test_not_a_subclass_of_Hass(self, assert_automation_class_fails): 282 | assert_automation_class_fails( 283 | automation_class_src=""" 284 | class MockAutomation: 285 | def initialize(self): 286 | pass 287 | """, 288 | expected_error_regex=r"AutomationFixtureError: 'MockAutomation'.*should be a subclass of 'Hass'", 289 | ) 290 | 291 | class TestWithArgs: 292 | def test_automation_is_injected_with_args(self, testdir): 293 | testdir.makepyfile( 294 | """ 295 | from appdaemon.plugins.hass.hassapi import Hass 296 | from appdaemontestframework import automation_fixture 297 | 298 | class MockAutomation(Hass): 299 | def initialize(self): 300 | pass 301 | 302 | 303 | @automation_fixture((MockAutomation, "some_arg")) 304 | def mock_automation_with_args(): 305 | pass 306 | 307 | def test_automation_was_injected_with_args(mock_automation_with_args): 308 | automation = mock_automation_with_args[0] 309 | arg = mock_automation_with_args[1] 310 | 311 | assert isinstance(automation, MockAutomation) 312 | assert arg == "some_arg" 313 | """ 314 | ) 315 | 316 | result = testdir.runpytest() 317 | result.assert_outcomes(passed=1) 318 | 319 | def test_multiple_automation_are_injected_with_args(self, testdir): 320 | testdir.makepyfile( 321 | """ 322 | from appdaemon.plugins.hass.hassapi import Hass 323 | from appdaemontestframework import automation_fixture 324 | 325 | class MockAutomation(Hass): 326 | def initialize(self): 327 | pass 328 | 329 | class OtherAutomation(Hass): 330 | def initialize(self): 331 | pass 332 | 333 | 334 | @automation_fixture( 335 | (MockAutomation, "some_arg"), 336 | (OtherAutomation, "other_arg") 337 | ) 338 | def mock_automation_with_args(): 339 | pass 340 | 341 | def test_automation_was_injected_with_args(mock_automation_with_args): 342 | automation = mock_automation_with_args[0] 343 | arg = mock_automation_with_args[1] 344 | 345 | assert isinstance(automation, MockAutomation) or isinstance(automation, OtherAutomation) 346 | assert arg == "some_arg" or arg == "other_arg" 347 | """ 348 | ) 349 | 350 | result = testdir.runpytest() 351 | result.assert_outcomes(passed=2) 352 | -------------------------------------------------------------------------------- /appdaemontestframework/hass_mocks.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import warnings 3 | import mock 4 | import appdaemon 5 | import asyncio 6 | import threading 7 | import datetime 8 | from packaging.version import Version 9 | from appdaemontestframework.appdaemon_mock.appdaemon import MockAppDaemon 10 | from appdaemon.plugins.hass.hassapi import Hass 11 | 12 | _hass_instances = [] 13 | 14 | CURRENT_APPDAEMON_VERSION = Version(appdaemon.utils.__version__) 15 | 16 | 17 | def is_appdaemon_version_at_least(version_as_string): 18 | expected_appdaemon_version = Version(version_as_string) 19 | return CURRENT_APPDAEMON_VERSION >= expected_appdaemon_version 20 | 21 | 22 | class _DeprecatedAndUnsupportedAppdaemonCheck: 23 | already_warned_during_this_test_session = False 24 | min_supported_appdaemon_version = "4.0.0" 25 | min_deprecated_appdaemon_version = "4.0.0" 26 | 27 | @classmethod 28 | def show_warning_only_once(cls): 29 | if cls.already_warned_during_this_test_session is True: 30 | return 31 | cls.already_warned_during_this_test_session = True 32 | 33 | appdaemon_version_unsupported = not is_appdaemon_version_at_least( 34 | cls.min_supported_appdaemon_version 35 | ) 36 | appdaemon_version_deprecated = not is_appdaemon_version_at_least( 37 | cls.min_deprecated_appdaemon_version 38 | ) 39 | 40 | if appdaemon_version_unsupported: 41 | raise Exception( 42 | "Appdaemon-Test-Framework only support Appdemon >={} " 43 | "Your current Appdemon version is {}".format( 44 | cls.min_supported_appdaemon_version, CURRENT_APPDAEMON_VERSION 45 | ) 46 | ) 47 | 48 | if appdaemon_version_deprecated: 49 | warnings.warn( 50 | "Appdaemon-Test-Framework will only support Appdaemon >={} " 51 | "until the next major release. " 52 | "Your current Appdemon version is {}".format( 53 | cls.min_deprecated_appdaemon_version, CURRENT_APPDAEMON_VERSION 54 | ), 55 | DeprecationWarning, 56 | ) 57 | 58 | 59 | class AsyncSpyMockHandler(object): 60 | """ 61 | Mock Handler that provides async spy functionality. When invoked, it will 62 | call our MockScheduler methods instead of the original async methods, 63 | but still provide all Mock-related functionality for tracking calls. 64 | """ 65 | 66 | def __init__(self, object_to_patch, function_name, mock_scheduler_method=None): 67 | self.function_or_field_name = function_name 68 | self.mock_scheduler_method = mock_scheduler_method 69 | 70 | # Create a wrapper that does both: tracks the call AND executes our logic 71 | def async_side_effect(mock_self, *args, **kwargs): 72 | # The first argument is the mock itself when using side_effect 73 | # Let the mock record the call normally first 74 | # We can't use _mock_call directly, so we'll manually track 75 | if not hasattr(mock_self, "call_count"): 76 | mock_self.call_count = 0 77 | if not hasattr(mock_self, "call_args_list"): 78 | mock_self.call_args_list = [] 79 | 80 | mock_self.call_count += 1 81 | call_obj = mock.call(*args, **kwargs) 82 | mock_self.call_args = call_obj 83 | mock_self.call_args_list.append(call_obj) 84 | 85 | # Now execute our custom logic 86 | method_args = args 87 | 88 | # Delegate to our mock scheduler method if provided 89 | if self.mock_scheduler_method: 90 | # Pass None as hass_self since our mock_scheduler_method will find the real instance 91 | result = self.mock_scheduler_method(None, *method_args, **kwargs) 92 | else: 93 | # For methods without custom implementation, return a suitable default 94 | if function_name in [ 95 | "run_in", 96 | "run_at", 97 | "run_daily", 98 | "run_hourly", 99 | "run_minutely", 100 | "run_every", 101 | "run_once", 102 | ]: 103 | # Return a mock handle for scheduler methods 104 | result = f"mock_handle_{function_name}_{id(method_args)}" 105 | else: 106 | result = mock_self.return_value 107 | 108 | return result 109 | 110 | # Set up the patch with our wrapper as side_effect and autospec=True 111 | # so that mock_self is passed as first argument 112 | self.patch = mock.patch.object( 113 | object_to_patch, 114 | function_name, 115 | side_effect=async_side_effect, 116 | autospec=True, 117 | ) 118 | self.mock = self.patch.start() 119 | 120 | 121 | class HassMocks: 122 | def __init__(self): 123 | _DeprecatedAndUnsupportedAppdaemonCheck.show_warning_only_once() 124 | # Mocked out init for Hass class. 125 | self._hass_instances = [] # list of all hass instances 126 | 127 | hass_mocks = self 128 | AD = MockAppDaemon() 129 | self.AD = AD 130 | 131 | def _hass_init_mock(self, _ad, config_model, *_args): 132 | hass_mocks._hass_instances.append(self) 133 | # Store the config_model for new Appdaemon versions 134 | self._config_model = config_model 135 | # In new Appdaemon, name comes from config_model 136 | if hasattr(config_model, "name"): 137 | self._name = config_model.name 138 | else: 139 | # Fallback for old-style string name 140 | self._name = config_model 141 | self.AD = AD 142 | self.logger = logging.getLogger(__name__) 143 | 144 | # Create async-compatible scheduler method implementations 145 | def mock_datetime(hass_self_unused, *args, **kwargs): 146 | """Mock implementation of datetime that uses our MockScheduler""" 147 | # Find the real automation instance 148 | real_automation = None 149 | for instance in hass_mocks._hass_instances: 150 | if hasattr(instance, "AD") and hasattr(instance.AD, "sched"): 151 | real_automation = instance 152 | break 153 | 154 | if real_automation is None: 155 | raise RuntimeError("Could not find real automation instance") 156 | 157 | # Use our mock scheduler to get the current time 158 | current_time = real_automation.AD.sched.get_now_sync() 159 | # Convert to naive datetime as expected by the automation 160 | return real_automation.AD.sched.make_naive(current_time) 161 | 162 | def mock_time(hass_self_unused, *args, **kwargs): 163 | """Mock implementation of time that uses our MockScheduler""" 164 | # Find the real automation instance 165 | real_automation = None 166 | for instance in hass_mocks._hass_instances: 167 | if hasattr(instance, "AD") and hasattr(instance.AD, "sched"): 168 | real_automation = instance 169 | break 170 | 171 | if real_automation is None: 172 | raise RuntimeError("Could not find real automation instance") 173 | 174 | # Use our mock scheduler to get the current time 175 | current_time = real_automation.AD.sched.get_now_sync() 176 | # Convert to naive datetime and return just the time part 177 | naive_datetime = real_automation.AD.sched.make_naive(current_time) 178 | return naive_datetime.time() 179 | 180 | def mock_run_in(hass_self_unused, *args, **kwargs): 181 | """Mock implementation of run_in that uses our MockScheduler""" 182 | # Extract callback and delay from args 183 | # run_in(callback, delay) or run_in(callback, delay, **kwargs) 184 | callback = args[0] if len(args) > 0 else kwargs.get("callback") 185 | delay = ( 186 | args[1] 187 | if len(args) > 1 188 | else kwargs.get("delay", kwargs.get("seconds", 0)) 189 | ) 190 | 191 | # Convert delay to seconds if needed 192 | if hasattr(delay, "total_seconds"): 193 | delay_seconds = delay.total_seconds() 194 | else: 195 | delay_seconds = float(delay) 196 | 197 | # Find the real automation instance from the hass_instances 198 | real_automation = None 199 | for instance in hass_mocks._hass_instances: 200 | if hasattr(instance, "AD") and hasattr(instance.AD, "sched"): 201 | real_automation = instance 202 | break 203 | 204 | if real_automation is None: 205 | raise RuntimeError("Could not find real automation instance") 206 | 207 | # Calculate target time using the real automation's scheduler 208 | current_time = real_automation.AD.sched.get_now_sync() 209 | target_time = current_time + datetime.timedelta(seconds=delay_seconds) 210 | 211 | # Use our mock scheduler 212 | return real_automation.AD.sched.insert_schedule_sync( 213 | name="run_in", 214 | aware_dt=target_time, 215 | callback=callback, 216 | repeat=False, 217 | type_="run_in", 218 | **kwargs, 219 | ) 220 | 221 | def mock_cancel_timer(hass_self_unused, *args, **kwargs): 222 | """Mock implementation of cancel_timer""" 223 | handle = args[0] if len(args) > 0 else kwargs.get("handle") 224 | 225 | # Find the real automation instance 226 | real_automation = None 227 | for instance in hass_mocks._hass_instances: 228 | if hasattr(instance, "AD") and hasattr(instance.AD, "sched"): 229 | real_automation = instance 230 | break 231 | 232 | if real_automation is None: 233 | raise RuntimeError("Could not find real automation instance") 234 | 235 | return real_automation.AD.sched.cancel_timer_sync( 236 | name="cancel_timer", handle=handle 237 | ) 238 | 239 | # This is a list of all mocked out functions. 240 | self._mock_handlers = [ 241 | ### Meta 242 | # Patch the __init__ method to skip Hass initialization. 243 | # Use autospec so we can access the `self` object 244 | MockHandler(Hass, "__init__", side_effect=_hass_init_mock, autospec=True), 245 | ### logging 246 | MockHandler(Hass, "log", side_effect=self._log_log), 247 | MockHandler(Hass, "error", side_effect=self._log_error), 248 | ### Scheduler callback registrations functions - now with async support 249 | AsyncSpyMockHandler(Hass, "run_in", mock_scheduler_method=mock_run_in), 250 | MockHandler(Hass, "run_once"), 251 | MockHandler(Hass, "run_at"), 252 | MockHandler(Hass, "run_daily"), 253 | MockHandler(Hass, "run_hourly"), 254 | MockHandler(Hass, "run_minutely"), 255 | MockHandler(Hass, "run_every"), 256 | AsyncSpyMockHandler( 257 | Hass, "cancel_timer", mock_scheduler_method=mock_cancel_timer 258 | ), 259 | ### Sunrise and sunset functions 260 | MockHandler(Hass, "run_at_sunrise"), 261 | MockHandler(Hass, "run_at_sunset"), 262 | ### Listener callback registrations functions 263 | MockHandler(Hass, "listen_event"), 264 | MockHandler(Hass, "listen_state"), 265 | ### State functions / attr 266 | MockHandler(Hass, "set_state"), 267 | MockHandler(Hass, "get_state"), 268 | AsyncSpyMockHandler(Hass, "time", mock_scheduler_method=mock_time), 269 | AsyncSpyMockHandler(Hass, "datetime", mock_scheduler_method=mock_datetime), 270 | DictMockHandler(Hass, "args"), 271 | ### Interactions functions 272 | MockHandler(Hass, "call_service"), 273 | MockHandler(Hass, "turn_on"), 274 | MockHandler(Hass, "turn_off"), 275 | MockHandler(Hass, "fire_event"), 276 | ### Custom callback functions 277 | MockHandler(Hass, "register_constraint"), 278 | MockHandler(Hass, "now_is_between"), 279 | MockHandler(Hass, "notify"), 280 | ### Miscellaneous Helper Functions 281 | MockHandler(Hass, "entity_exists"), 282 | ] 283 | 284 | # Generate a dictionary of mocked Hass functions for use by older code 285 | # Note: This interface is considered deprecated and should be replaced 286 | # with calls to public methods in the HassMocks object going forward. 287 | self._hass_functions = {} 288 | for mock_handler in self._mock_handlers: 289 | self._hass_functions[mock_handler.function_or_field_name] = ( 290 | mock_handler.mock 291 | ) 292 | 293 | ### Mock handling 294 | def unpatch_mocks(self): 295 | """Stops all mocks this class handles.""" 296 | for mock_handler in self._mock_handlers: 297 | mock_handler.patch.stop() 298 | 299 | ### Access to the deprecated hass_functions dict. 300 | @property 301 | def hass_functions(self): 302 | return self._hass_functions 303 | 304 | ### Logging mocks 305 | @staticmethod 306 | def _log_error(msg, level="ERROR"): 307 | HassMocks._log_log(msg, level) 308 | 309 | @staticmethod 310 | def _log_log(msg, level="INFO"): 311 | # Renamed the function to remove confusion 312 | get_logging_level_from_name = logging.getLevelName 313 | logging.log(get_logging_level_from_name(level), msg) 314 | 315 | 316 | class MockHandler: 317 | """ 318 | A class for generating a mock in an object and holding on to info about it. 319 | :param object_to_patch: The object to patch 320 | :param function_or_field_name: the name of the function to patch in the 321 | object 322 | :param side_effect: side effect method to call. If not set, it will just 323 | return `None` 324 | :param autospec: If `True` will autospec the Mock signature. Useful for 325 | getting `self` in side effects. 326 | """ 327 | 328 | def __init__( 329 | self, object_to_patch, function_or_field_name, side_effect=None, autospec=False 330 | ): 331 | self.function_or_field_name = function_or_field_name 332 | patch_kwargs = self._patch_kwargs(side_effect, autospec) 333 | self.patch = mock.patch.object( 334 | object_to_patch, function_or_field_name, **patch_kwargs 335 | ) 336 | self.mock = self.patch.start() 337 | 338 | def _patch_kwargs(self, side_effect, autospec): 339 | return { 340 | "create": True, 341 | "side_effect": side_effect, 342 | "return_value": None, 343 | "autospec": autospec, 344 | } 345 | 346 | 347 | class DictMockHandler(MockHandler): 348 | class MockDict(dict): 349 | def reset_mock(self): 350 | pass 351 | 352 | def __init__(self, object_to_patch, field_name): 353 | super().__init__(object_to_patch, field_name) 354 | 355 | def _patch_kwargs(self, _side_effect, _autospec): 356 | return {"create": True, "new": self.MockDict()} 357 | 358 | 359 | class SpyMockHandler(MockHandler): 360 | """ 361 | Mock Handler that provides a Spy. That is, when invoke it will call the 362 | original function but still provide all Mock-related functionality 363 | """ 364 | 365 | def __init__(self, object_to_patch, function_name): 366 | original_function = getattr(object_to_patch, function_name) 367 | super().__init__( 368 | object_to_patch, function_name, side_effect=original_function, autospec=True 369 | ) 370 | -------------------------------------------------------------------------------- /doc/full_example/tests/test_bathroom.py: -------------------------------------------------------------------------------- 1 | from apps.bathroom import Bathroom, BATHROOM_VOLUMES, DEFAULT_VOLUMES, FAKE_MUTE_VOLUME 2 | from appdaemon.plugins.hass.hassapi import Hass 3 | from mock import patch, MagicMock 4 | import pytest 5 | from datetime import time 6 | from apps.entity_ids import ID 7 | 8 | MORNING_STEP1_COLOR = 'BLUE' 9 | SHOWER_COLOR = 'GREEN' 10 | MORNING_STEP3_COLOR = 'YELLOW' 11 | DAY_COLOR = 'WHITE' 12 | EVENING_COLOR = 'RED' 13 | EVENING_HOUR = 20 14 | DAY_HOUR = 4 15 | 16 | 17 | @pytest.fixture 18 | def bathroom(given_that): 19 | # Set initial state 20 | speakers = [ 21 | ID['bathroom']['speaker'], 22 | ID['kitchen']['speaker'], 23 | ID['living_room']['soundbar'], 24 | ID['living_room']['controller'], 25 | ID['cast_groups']['entire_flat'] 26 | ] 27 | for speaker in speakers: 28 | given_that.state_of(speaker).is_set_to('off') 29 | 30 | given_that.time_is(time(hour=15)) 31 | 32 | bathroom = Bathroom( 33 | None, None, None, None, None, None, None, None) 34 | bathroom.initialize() 35 | 36 | # Clear calls recorded during initialisation 37 | given_that.mock_functions_are_cleared() 38 | return bathroom 39 | 40 | 41 | @pytest.fixture 42 | def when_new(bathroom): 43 | class WhenNewWrapper: 44 | def time(self, hour=None): 45 | bathroom._time_triggered({'hour': hour}) 46 | 47 | def motion_bathroom(self): 48 | bathroom._new_motion_bathroom(None, None, None) 49 | 50 | def motion_kitchen(self): 51 | bathroom._new_motion_kitchen(None, None, None) 52 | 53 | def motion_living_room(self): 54 | bathroom._new_motion_living_room(None, None, None) 55 | 56 | def no_more_motion_bathroom(self): 57 | bathroom._no_more_motion_bathroom( 58 | None, None, None, None, None) 59 | 60 | def click_bathroom_button(self): 61 | bathroom._new_click_bathroom_button(None, None, None) 62 | 63 | def debug(self): 64 | bathroom.debug(None, {'click_type': 'single'}, None) 65 | return WhenNewWrapper() 66 | 67 | 68 | # Start at different times 69 | class TestInitialize: 70 | 71 | def test_start_during_day(self, given_that, when_new, assert_that, bathroom, assert_day_mode_started): 72 | given_that.time_is(time(hour=13)) 73 | bathroom.initialize() 74 | assert_day_mode_started() 75 | 76 | def test_start_during_evening(self, given_that, when_new, assert_that, bathroom, assert_evening_mode_started): 77 | given_that.time_is(time(hour=20)) 78 | bathroom.initialize() 79 | assert_evening_mode_started() 80 | 81 | def test_callbacks_are_registered(self, bathroom, hass_functions): 82 | # Given: The mocked callback Appdaemon registration functions 83 | listen_event = hass_functions['listen_event'] 84 | listen_state = hass_functions['listen_state'] 85 | run_daily = hass_functions['run_daily'] 86 | 87 | # When: Calling `initialize` 88 | bathroom.initialize() 89 | 90 | # Then: callbacks are registered 91 | listen_event.assert_any_call( 92 | bathroom._new_click_bathroom_button, 93 | 'click', 94 | entity_id=ID['bathroom']['button'], 95 | click_type='single') 96 | 97 | listen_event.assert_any_call( 98 | bathroom._new_motion_bathroom, 99 | 'motion', 100 | entity_id=ID['bathroom']['motion_sensor']) 101 | listen_event.assert_any_call( 102 | bathroom._new_motion_kitchen, 103 | 'motion', 104 | entity_id=ID['kitchen']['motion_sensor']) 105 | listen_event.assert_any_call( 106 | bathroom._new_motion_living_room, 107 | 'motion', 108 | entity_id=ID['living_room']['motion_sensor']) 109 | listen_state.assert_any_call( 110 | bathroom._no_more_motion_bathroom, 111 | ID['bathroom']['motion_sensor'], 112 | new='off') 113 | 114 | run_daily.assert_any_call( 115 | bathroom._time_triggered, 116 | time(hour=DAY_HOUR), 117 | hour=DAY_HOUR) 118 | run_daily.assert_any_call( 119 | bathroom._time_triggered, 120 | time(hour=EVENING_HOUR), 121 | hour=EVENING_HOUR) 122 | 123 | 124 | ################################################################################## 125 | ## For the rest of the tests, Bathroom WAS STARTED DURING THE DAY (at 3PM) ## 126 | ## For the rest of the tests, Bathroom WAS STARTED DURING THE DAY (at 3PM) ## 127 | ## For the rest of the tests, Bathroom WAS STARTED DURING THE DAY (at 3PM) ## 128 | ################################################################################## 129 | 130 | class TestDuringEvening: 131 | @pytest.fixture 132 | def start_evening_mode(self, when_new, given_that): 133 | given_that.mock_functions_are_cleared() 134 | # Provide a trigger to switch to evening mode 135 | return lambda: when_new.time(hour=EVENING_HOUR) 136 | 137 | class TestEnterBathroom: 138 | def test_light_turn_on(self, given_that, when_new, assert_that, start_evening_mode): 139 | start_evening_mode() 140 | when_new.motion_bathroom() 141 | assert_that(ID['bathroom']['led_light'] 142 | ).was.turned_on(color_name=EVENING_COLOR) 143 | 144 | def test__bathroom_playing__unmute(self, given_that, when_new, assert_that, start_evening_mode): 145 | start_evening_mode() 146 | given_that.state_of(ID['bathroom']['speaker']).is_set_to('playing') 147 | when_new.motion_bathroom() 148 | assert_bathroom_was_UNmuted(assert_that) 149 | 150 | def test__entire_flat_playing__unmute(self, given_that, when_new, assert_that, start_evening_mode): 151 | start_evening_mode() 152 | given_that.state_of( 153 | ID['cast_groups']['entire_flat']).is_set_to('playing') 154 | when_new.motion_bathroom() 155 | assert_bathroom_was_UNmuted(assert_that) 156 | 157 | def test__nothing_playing__do_not_unmute(self, given_that, when_new, assert_that, start_evening_mode): 158 | start_evening_mode() 159 | when_new.motion_bathroom() 160 | assert_that('media_player/volume_set').was_not.called_with( 161 | entity_id=ID['bathroom']['speaker'], 162 | volume_level=BATHROOM_VOLUMES['regular']) 163 | 164 | class TestLeaveBathroom: 165 | def test_mute_turn_off_light(self, given_that, when_new, assert_that, start_evening_mode): 166 | scenarios = [ 167 | when_new.motion_kitchen, 168 | when_new.motion_living_room, 169 | when_new.no_more_motion_bathroom 170 | ] 171 | for scenario in scenarios: 172 | given_that.mock_functions_are_cleared(clear_mock_states=True) 173 | start_evening_mode() 174 | scenario() 175 | assert_bathroom_was_muted(assert_that) 176 | assert_that(ID['bathroom']['led_light']).was.turned_off() 177 | 178 | 179 | class TestsDuringDay: 180 | @pytest.fixture 181 | def start_day_mode(self, when_new, given_that): 182 | # Switch to Evening mode and provide 183 | # a trigger to start the Day mode 184 | 185 | # Switch to: Evening mode 186 | when_new.time(hour=EVENING_HOUR) 187 | given_that.mock_functions_are_cleared() 188 | 189 | # Trigger to switch to Day mode 190 | return lambda: when_new.time(hour=DAY_HOUR) 191 | 192 | class TestAtStart: 193 | def test_turn_on_water_heater(self, assert_that, start_day_mode): 194 | start_day_mode() 195 | assert_that(ID['bathroom']['water_heater']).was.turned_on() 196 | 197 | def test_turn_off_bathroom_light(self, assert_that, start_day_mode): 198 | start_day_mode() 199 | assert_that(ID['bathroom']['led_light']).was.turned_off() 200 | 201 | def test_reset_volumes(self, assert_that, start_day_mode): 202 | pass 203 | start_day_mode() 204 | assert_that('media_player/volume_set').was.called_with( 205 | entity_id=ID['bathroom']['speaker'], 206 | volume_level=DEFAULT_VOLUMES['bathroom']) 207 | assert_that('media_player/volume_set').was.called_with( 208 | entity_id=ID['kitchen']['speaker'], 209 | volume_level=DEFAULT_VOLUMES['kitchen']) 210 | assert_that('media_player/volume_set').was.called_with( 211 | entity_id=ID['living_room']['soundbar'], 212 | volume_level=DEFAULT_VOLUMES['living_room_soundbar']) 213 | assert_that('media_player/volume_set').was.called_with( 214 | entity_id=ID['living_room']['controller'], 215 | volume_level=DEFAULT_VOLUMES['living_room_controller']) 216 | 217 | class TestEnterBathroom: 218 | def test_light_turn_on(self, given_that, when_new, assert_that, start_day_mode): 219 | start_day_mode() 220 | when_new.motion_bathroom() 221 | assert_that(ID['bathroom']['led_light'] 222 | ).was.turned_on(color_name=DAY_COLOR) 223 | 224 | def test__bathroom_playing__unmute(self, given_that, when_new, assert_that, start_day_mode): 225 | start_day_mode() 226 | given_that.state_of(ID['bathroom']['speaker']).is_set_to('playing') 227 | when_new.motion_bathroom() 228 | assert_bathroom_was_UNmuted(assert_that) 229 | 230 | def test__entire_flat_playing__unmute(self, given_that, when_new, assert_that, start_day_mode): 231 | start_day_mode() 232 | given_that.state_of( 233 | ID['cast_groups']['entire_flat']).is_set_to('playing') 234 | when_new.motion_bathroom() 235 | assert_bathroom_was_UNmuted(assert_that) 236 | 237 | def test__nothing_playing__do_not_unmute(self, given_that, when_new, assert_that, start_day_mode): 238 | start_day_mode() 239 | when_new.motion_bathroom() 240 | assert_that('media_player/volume_set').was_not.called_with( 241 | entity_id=ID['bathroom']['speaker'], 242 | volume_level=BATHROOM_VOLUMES['regular']) 243 | 244 | class TestLeaveBathroom: 245 | def test__no_more_motion__mute_turn_off_light(self, given_that, when_new, assert_that, start_day_mode): 246 | start_day_mode() 247 | when_new.no_more_motion_bathroom() 248 | assert_bathroom_was_muted(assert_that) 249 | assert_that(ID['bathroom']['led_light']).was.turned_off() 250 | 251 | def test__motion_anywhere_except_bathroom__do_NOT_mute_turn_off_light(self, given_that, when_new, assert_that, start_day_mode): 252 | scenarios = [ 253 | when_new.motion_kitchen, 254 | when_new.motion_living_room 255 | ] 256 | for scenario in scenarios: 257 | start_day_mode() 258 | given_that.mock_functions_are_cleared() 259 | scenario() 260 | assert_bathroom_was_NOT_muted(assert_that) 261 | assert_that(ID['bathroom']['led_light']).was_not.turned_off() 262 | 263 | class TestSwitchToNextState: 264 | def test_click_activate_shower_state(self, start_day_mode, when_new, assert_shower_state_started): 265 | start_day_mode() 266 | when_new.click_bathroom_button() 267 | assert_shower_state_started() 268 | 269 | def test_8pm_activate_evening_state(self, start_day_mode, when_new, assert_evening_mode_started): 270 | start_day_mode() 271 | when_new.time(hour=EVENING_HOUR) 272 | assert_evening_mode_started() 273 | 274 | 275 | class TestDuringShower: 276 | @pytest.fixture 277 | def start_shower_mode(self, when_new, given_that): 278 | # Provide a trigger to start shower mode 279 | given_that.mock_functions_are_cleared() 280 | return lambda: when_new.click_bathroom_button() 281 | 282 | class TestAtStart: 283 | def test_light_indicator(self, given_that, when_new, assert_that, start_shower_mode): 284 | start_shower_mode() 285 | assert_that(ID['bathroom']['led_light']).was.turned_on( 286 | color_name=SHOWER_COLOR) 287 | 288 | def test_notif_sound(self, assert_that, start_shower_mode): 289 | notif_sound_id = 10001 290 | volume = 20 291 | xiaomi_gateway_mac_address = ID['bathroom']['gateway_mac_address'] 292 | start_shower_mode() 293 | assert_that('xiaomi_aqara/play_ringtone').was.called_with( 294 | ringtone_id=notif_sound_id, ringtone_vol=volume, gw_mac=xiaomi_gateway_mac_address) 295 | 296 | def test_mute_all_except_bathroom(self, given_that, assert_that, start_shower_mode): 297 | # Bug with sound bar firmware: Can only increase the volume by 10% at a time 298 | # to prevent this being a problem, we're not muting it 299 | all_speakers_except_bathroom = [ 300 | # ID['living_room']['soundbar'], 301 | ID['kitchen']['speaker'], 302 | ID['living_room']['controller'] 303 | ] 304 | 305 | start_shower_mode() 306 | for speaker in all_speakers_except_bathroom: 307 | assert_that('media_player/volume_set').was.called_with( 308 | entity_id=speaker, 309 | volume_level=FAKE_MUTE_VOLUME) 310 | 311 | def test_set_shower_volume_bathroom(self, given_that, assert_that, start_shower_mode): 312 | start_shower_mode() 313 | assert_that('media_player/volume_set').was.called_with( 314 | entity_id=ID['bathroom']['speaker'], 315 | volume_level=BATHROOM_VOLUMES['shower']) 316 | 317 | class TestClickButton: 318 | def test__activate_after_shower(self, given_that, when_new, assert_that, start_shower_mode): 319 | def assert_after_shower_started(): 320 | assert_that(ID['bathroom']['led_light']).was.turned_on( 321 | color_name=MORNING_STEP3_COLOR) 322 | 323 | start_shower_mode() 324 | when_new.click_bathroom_button() 325 | assert_after_shower_started() 326 | 327 | 328 | class TestDuringAfterShower: 329 | @pytest.fixture 330 | def start_after_shower_mode(self, when_new, given_that): 331 | # Return callback to trigger AfterShower mode 332 | def trigger_after_shower_mode(): 333 | when_new.click_bathroom_button() 334 | given_that.mock_functions_are_cleared() 335 | when_new.click_bathroom_button() 336 | return trigger_after_shower_mode 337 | 338 | class TestAtStart: 339 | def test_light_indicator(self, given_that, when_new, assert_that, start_after_shower_mode): 340 | start_after_shower_mode() 341 | assert_that(ID['bathroom']['led_light']).was.turned_on( 342 | color_name=MORNING_STEP3_COLOR) 343 | 344 | def test_notif_sound(self, assert_that, start_after_shower_mode): 345 | notif_sound_id = 10001 346 | volume = 20 347 | xiaomi_gateway_mac_address = ID['bathroom']['gateway_mac_address'] 348 | start_after_shower_mode() 349 | assert_that('xiaomi_aqara/play_ringtone').was.called_with( 350 | ringtone_id=notif_sound_id, ringtone_vol=volume, gw_mac=xiaomi_gateway_mac_address) 351 | 352 | def test_pause_podcast(self, assert_that, start_after_shower_mode): 353 | start_after_shower_mode() 354 | for playback_device in [ 355 | ID['bathroom']['speaker'], 356 | ID['cast_groups']['entire_flat']]: 357 | assert_that('media_player/media_pause').was.called_with( 358 | entity_id=playback_device) 359 | 360 | def test_mute_bathroom(self, assert_that, start_after_shower_mode): 361 | start_after_shower_mode() 362 | assert_bathroom_was_muted(assert_that) 363 | 364 | def test_turn_off_water_heater(self, assert_that, start_after_shower_mode): 365 | start_after_shower_mode() 366 | assert_that(ID['bathroom']['water_heater']).was.turned_off() 367 | 368 | class TestMotionAnywhereExceptBathroom: 369 | def test_resume_podcast_playback(self, given_that, when_new, assert_that, assert_day_mode_started, start_after_shower_mode): 370 | scenarios = [ 371 | when_new.motion_kitchen, 372 | when_new.motion_living_room, 373 | when_new.no_more_motion_bathroom 374 | ] 375 | for scenario in scenarios: 376 | # Given: In shower mode 377 | start_after_shower_mode() 378 | # When: Motion 379 | scenario() 380 | # Assert: Playback resumed 381 | for playback_device in [ 382 | ID['bathroom']['speaker'], 383 | ID['cast_groups']['entire_flat']]: 384 | assert_that('media_player/media_play').was.called_with( 385 | entity_id=playback_device) 386 | 387 | def test_during_day_activate_day_mode(self, given_that, when_new, assert_that, assert_day_mode_started, start_after_shower_mode): 388 | scenarios = [ 389 | when_new.motion_kitchen, 390 | when_new.motion_living_room, 391 | when_new.no_more_motion_bathroom 392 | ] 393 | for scenario in scenarios: 394 | given_that.time_is(time(hour=14)) 395 | start_after_shower_mode() 396 | scenario() 397 | assert_day_mode_started() 398 | 399 | def test_during_evening_activate_evening_mode(self, given_that, when_new, assert_that, assert_evening_mode_started, start_after_shower_mode): 400 | scenarios = [ 401 | when_new.motion_kitchen, 402 | when_new.motion_living_room, 403 | when_new.no_more_motion_bathroom 404 | ] 405 | for scenario in scenarios: 406 | given_that.time_is(time(hour=20)) 407 | start_after_shower_mode() 408 | scenario() 409 | assert_evening_mode_started() 410 | 411 | def assert_bathroom_was_muted(assert_that): 412 | assert_that('media_player/volume_set').was.called_with( 413 | entity_id=ID['bathroom']['speaker'], 414 | volume_level=FAKE_MUTE_VOLUME) 415 | 416 | 417 | def assert_bathroom_was_NOT_muted(assert_that): 418 | assert_that('media_player/volume_set').was_not.called_with( 419 | entity_id=ID['bathroom']['speaker'], 420 | volume_level=FAKE_MUTE_VOLUME) 421 | 422 | 423 | def assert_bathroom_was_UNmuted(assert_that): 424 | assert_that('media_player/volume_set').was.called_with( 425 | entity_id=ID['bathroom']['speaker'], 426 | volume_level=BATHROOM_VOLUMES['regular']) 427 | 428 | 429 | def assert_bathroom_light_reacts_to_movement_with_color(color, when_new, assert_that): 430 | when_new.motion_bathroom() 431 | assert_that(ID['bathroom']['led_light'] 432 | ).was.turned_on(color_name=color) 433 | 434 | 435 | @pytest.fixture 436 | def assert_day_mode_started(when_new, assert_that): 437 | return lambda: assert_bathroom_light_reacts_to_movement_with_color( 438 | DAY_COLOR, 439 | when_new, 440 | assert_that) 441 | 442 | 443 | @pytest.fixture 444 | def assert_evening_mode_started(when_new, assert_that): 445 | return lambda: assert_bathroom_light_reacts_to_movement_with_color( 446 | EVENING_COLOR, 447 | when_new, 448 | assert_that) 449 | 450 | 451 | @pytest.fixture 452 | def assert_shower_state_started(assert_that): 453 | return lambda: assert_that(ID['bathroom']['led_light']).was.turned_on( 454 | color_name=SHOWER_COLOR) 455 | --------------------------------------------------------------------------------