├── 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 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/profiles_settings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/modules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
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 |
10 |
11 |
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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/__mark_only__tests.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/All_tests_except_w__pytester.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
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 |
--------------------------------------------------------------------------------