├── .gitignore ├── .idea ├── appdaemontestframework.iml ├── codeStyles │ └── codeStyleConfig.xml ├── inspectionProfiles │ └── profiles_settings.xml ├── misc.xml ├── modules.xml ├── runConfigurations │ ├── All_tests.xml │ ├── All_tests_except_w__pytester.xml │ └── __mark_only__tests.xml └── vcs.xml ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── Pipfile ├── Pipfile.lock ├── README.md ├── _config.yml ├── appdaemontestframework ├── __init__.py ├── appdaemon_mock │ ├── __init__.py │ ├── appdaemon.py │ └── scheduler.py ├── assert_that.py ├── automation_fixture.py ├── common.py ├── given_that.py ├── hass_mocks.py ├── pytest_conftest.py └── time_travel.py ├── conftest.py ├── doc ├── full_example │ ├── Pipfile │ ├── Pipfile.lock │ ├── apps │ │ ├── __init__.py │ │ ├── apps.yaml │ │ ├── bathroom.py │ │ ├── entity_ids.py │ │ └── kitchen.py │ ├── conftest.py │ ├── pytest.ini │ ├── start_tdd.sh │ └── tests │ │ ├── __init__.py │ │ ├── test_assertions_dsl.py │ │ ├── test_bathroom.py │ │ ├── test_kitchen.py │ │ └── test_vanilla_file.py ├── pytest_example.py └── unittest_example.py ├── pytest.ini ├── setup.py ├── test ├── appdaemon_mock │ └── test_scheduler.py ├── integration_tests │ ├── apps │ │ ├── __init__.py │ │ ├── apps.yaml │ │ ├── bathroom.py │ │ ├── entity_ids.py │ │ └── kitchen.py │ └── tests │ │ ├── __init__.py │ │ ├── test_assertions_dsl.py │ │ ├── test_bathroom.py │ │ ├── test_kitchen.py │ │ └── test_vanilla_file.py ├── test_assert_callback_registration.py ├── test_assert_that.py ├── test_automation_fixture.py ├── test_events.py ├── test_extra_hass_functions.py ├── test_logging.py ├── test_miscellaneous_helper_functions.py ├── test_state.py ├── test_time_travel.py └── test_with_arguments.py └── tox.ini /.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 | -------------------------------------------------------------------------------- /.idea/appdaemontestframework.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 12 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/runConfigurations/All_tests.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 18 | -------------------------------------------------------------------------------- /.idea/runConfigurations/All_tests_except_w__pytester.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 18 | -------------------------------------------------------------------------------- /.idea/runConfigurations/__mark_only__tests.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 18 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | jobs: 3 | include: 4 | 5 | - stage: test 6 | name: "Tests with Python 3.7" 7 | python: '3.7' 8 | env: TOXENV=py37 9 | install: pip install tox-travis 10 | script: tox 11 | 12 | - name: "Tests with Python 3.8" 13 | python: '3.8' 14 | env: TOXENV=py38 15 | install: pip install tox-travis 16 | script: tox 17 | 18 | - stage: deploy 19 | script: skip 20 | python: 21 | - '3.8' 22 | deploy: 23 | provider: pypi 24 | user: FlorianKempenich 25 | password: 26 | secure: yGP3vlxjwuJypHkuaCUBdXxjuZNUwJb/NGyTruIqnaNqNK/R2Q6Z+ICUsBxzhGUNJNWfyYTOHt/BLPHOIyt0/jAGL0JsFLRpQlT+HmwbjzQi/TFF48b1EiP36ivXszrY/aCjw3/yiqcHWxkOzpf93ximt0KEDNVg94g5vqQS7IwDZw39pZsfbrNTX+ycBTcU5D0juv1JXGge0X4upBLBUaPC8HZD5uE/EhiPjjQkI9ti4W1Z4fdk+etpYTWIW8txL/IeNr2eVfQJjl1BSvrlI1eGFVuyj6WqiOmodNjZrQqKUw/MbcvsvnyR2PLy/krsCC7pnArGt61HQuVXRRCWT3iGcVp9/OgKCN7UGOAJJmyYOOp8QfEuG6ft3PFbcs72y4qi7AA+r/B0QFHRvsx1MyueoRVCkQaqXY2WL6O7ZkgaDzCvvAHqKtMvR6qjzGMi7huDqyhUEm90tZ04VvnBwYqFrvbYUCtkmpIAAmy5o5CXHm7wetO19SL+Vpw92+5QWDfoZgr95YJ5ayqkPz3VxIn6Tj+yR76PNfpI4QT8XFPtcb5iVRCTCi1c/RmvO17FupBhPhHlUV1HUkWQQHuS+i/LuAB1Bgk0wdXhN1IsJMcg90vO/Zq+oUYMNaNRJwxONugKnBv0L1tov6CcgkoqeHjDCifDl9YkzwSx+8YDlxo= 27 | on: 28 | tags: true 29 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | 4 | 5 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | appdaemontestframework = {editable=true, path="."} 8 | 9 | [dev-packages] 10 | coverage = "*" 11 | pytest = ">=5.3.0,<5.4.0" 12 | pytest-asyncio = "*" 13 | pylint = "*" 14 | "autopep8" = "*" 15 | tox = "*" 16 | 17 | [requires] 18 | python_version = "3.8" 19 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-cayman -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /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/appdaemon_mock/appdaemon.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import pytz 3 | 4 | class MockAppDaemon: 5 | """Implementation of appdaemon's internal AppDaemon class suitable for testing""" 6 | def __init__(self, **kwargs): 7 | 8 | # 9 | # Import various AppDaemon bits and pieces now to avoid circular import 10 | # 11 | 12 | from appdaemontestframework.appdaemon_mock.scheduler import MockScheduler 13 | 14 | # Use UTC timezone just for testing. 15 | self.tz = pytz.timezone('UTC') 16 | 17 | self.sched = MockScheduler(self) 18 | 19 | -------------------------------------------------------------------------------- /appdaemontestframework/appdaemon_mock/scheduler.py: -------------------------------------------------------------------------------- 1 | 2 | import datetime 3 | import uuid 4 | import pytz 5 | from appdaemontestframework.appdaemon_mock.appdaemon import MockAppDaemon 6 | 7 | class MockScheduler: 8 | """Implement the AppDaemon Scheduler appropriate for testing and provide extra interfaces for adjusting the simulation""" 9 | def __init__(self, AD: MockAppDaemon): 10 | self.AD = AD 11 | self._registered_callbacks = [] 12 | 13 | # Default to Jan 1st, 2000 12:00AM 14 | # internal time is stored as a naive datetime in UTC 15 | self.sim_set_start_time(datetime.datetime(2000, 1, 1, 0, 0)) 16 | 17 | ### Implement the AppDaemon APIs for Scheduler 18 | async def get_now(self): 19 | """Return current localized naive datetime""" 20 | return self.get_now_sync() 21 | 22 | def get_now_sync(self): 23 | """Same as `get_now` but synchronous""" 24 | return pytz.utc.localize(self._now) 25 | 26 | async def get_now_ts(self): 27 | """Retrun the current localized timestamp""" 28 | return (await self.get_now()).timestamp() 29 | 30 | async def get_now_naive(self): 31 | return self.make_naive(await self.get_now()) 32 | 33 | async def insert_schedule(self, name, aware_dt, callback, repeat, type_, **kwargs): 34 | naive_dt = self.make_naive(aware_dt) 35 | return self._queue_calllback(callback, kwargs, naive_dt) 36 | 37 | async def cancel_timer(self, name, handle): 38 | for callback in self._registered_callbacks: 39 | if callback.handle == handle: 40 | self._registered_callbacks.remove(callback) 41 | 42 | def convert_naive(self, dt): 43 | # Is it naive? 44 | result = None 45 | if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None: 46 | # Localize with the configured timezone 47 | result = self.AD.tz.localize(dt) 48 | else: 49 | result = dt 50 | 51 | return result 52 | 53 | def make_naive(self, dt): 54 | local = dt.astimezone(self.AD.tz) 55 | return datetime.datetime( 56 | local.year, local.month, local.day, local.hour, local.minute, local.second, local.microsecond, 57 | ) 58 | 59 | 60 | ### Test framework simulation functions 61 | def sim_set_start_time(self, time): 62 | """Set the absolute start time and set current time to that as well. 63 | if time is a datetime, it goes right to that. 64 | if time is time, it will set to that time with the current date. 65 | All dates/datetimes should be localized naive 66 | 67 | To guarantee consistency, you can not set the start time while any callbacks are scheduled. 68 | """ 69 | if len(self._registered_callbacks) > 0: 70 | raise RuntimeError("You can not set start time while callbacks are scheduled") 71 | 72 | if type(time) == datetime.time: 73 | time = datetime.datetime.combine(self._now.date(), time) 74 | self._start_time = self._now = time 75 | 76 | def sim_get_start_time(self): 77 | """returns localized naive datetime of the start of the simulation""" 78 | return pytz.utc.localize(self._start_time) 79 | 80 | def sim_elapsed_seconds(self): 81 | """Returns number of seconds elapsed since the start of the simulation""" 82 | return (self._now - self._start_time).total_seconds() 83 | 84 | def sim_fast_forward(self, time): 85 | """Fastforward time and invoke callbacks. time can be a timedelta, time, or datetime (all should be localized naive)""" 86 | if type(time) == datetime.timedelta: 87 | target_datetime = self._now + time 88 | elif type(time) == datetime.time: 89 | if time > self._now.time(): 90 | target_datetime = datetime.datetime.combine(self._now.date(), time) 91 | else: 92 | # handle wrap around to next day if time is in the past already 93 | target_date = self._now.date() + datetime.timedelta(days=1) 94 | target_datetime = datetime.datetime.combine(target_date, time) 95 | elif type(time) == datetime.datetime: 96 | target_datetime = time 97 | else: 98 | raise ValueError(f"Unknown time type '{type(time)}' for fast_forward") 99 | 100 | self._run_callbacks_and_advance_time(target_datetime) 101 | 102 | ### Internal functions 103 | def _queue_calllback(self, callback_function, kwargs, run_date_time): 104 | """queue a new callback and return its handle""" 105 | interval = kwargs.get("interval", 0) 106 | new_callback = CallbackInfo(callback_function, kwargs, run_date_time, interval) 107 | 108 | if new_callback.run_date_time < self._now: 109 | raise ValueError("Can not schedule events in the past") 110 | 111 | self._registered_callbacks.append(new_callback) 112 | return new_callback.handle 113 | 114 | def _run_callbacks_and_advance_time(self, target_datetime, run_callbacks=True): 115 | """run all callbacks scheduled between now and target_datetime""" 116 | if target_datetime < self._now: 117 | raise ValueError("You can not fast forward to a time in the past.") 118 | 119 | while True: 120 | callbacks_to_run = [x for x in self._registered_callbacks if x.run_date_time <= target_datetime] 121 | if not callbacks_to_run: 122 | break 123 | # sort so we call them in the order from oldest to newest 124 | callbacks_to_run.sort(key=lambda cb: cb.run_date_time) 125 | # dispatch the oldest callback 126 | callback = callbacks_to_run[0] 127 | self._now = callback.run_date_time 128 | if run_callbacks: 129 | callback() 130 | if callback.interval > 0: 131 | callback.run_date_time += datetime.timedelta(seconds=callback.interval) 132 | else: 133 | self._registered_callbacks.remove(callback) 134 | 135 | self._now = target_datetime 136 | 137 | def __getattr__(self, name: str): 138 | raise RuntimeError( 139 | f"'{name}' has not been mocked in {self.__class__.__name__}" 140 | ) 141 | 142 | 143 | class CallbackInfo: 144 | """Class to hold info about a scheduled callback""" 145 | def __init__(self, callback_function, kwargs, run_date_time, interval): 146 | self.handle = str(uuid.uuid4()) 147 | self.run_date_time = run_date_time 148 | self.callback_function = callback_function 149 | self.kwargs = kwargs 150 | self.interval = interval 151 | 152 | def __call__(self): 153 | self.callback_function(self.kwargs) -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /appdaemontestframework/automation_fixture.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from inspect import isfunction, signature 3 | import pkg_resources 4 | 5 | import pytest 6 | from appdaemon.plugins.hass.hassapi import Hass 7 | 8 | from appdaemontestframework.common import AppdaemonTestFrameworkError 9 | 10 | 11 | class AutomationFixtureError(AppdaemonTestFrameworkError): 12 | pass 13 | 14 | 15 | def _instantiate_and_initialize_automation(function, automation_class, given_that, hass_functions, hass_mocks): 16 | _inject_helpers_and_call_function(function, given_that, hass_functions, hass_mocks) 17 | 18 | automation = automation_class( 19 | None, 20 | automation_class.__name__, 21 | None, 22 | None, 23 | None, 24 | None, 25 | None 26 | ) 27 | automation.initialize() 28 | given_that.mock_functions_are_cleared() 29 | return automation 30 | 31 | 32 | def _inject_helpers_and_call_function(function, given_that, hass_functions, hass_mocks): 33 | injectable_fixtures = { 34 | 'given_that': given_that, 35 | 'hass_functions': hass_functions, 36 | 'hass_mocks': hass_mocks, 37 | } 38 | 39 | def _check_valid(param): 40 | if param not in injectable_fixtures: 41 | raise AutomationFixtureError( 42 | f"'{param}' is not a valid fixture! | The only fixtures injectable in '@automation_fixture' are: {list(injectable_fixtures.keys())}") 43 | 44 | if param == 'hass_functions': 45 | warnings.warn( 46 | """ 47 | Injecting `hass_functions` into automation fixtures is deprecated. 48 | Replace `hass_functions` with `hass_mocks` injections and access hass_functions with `hass_mocks.hass_functions` 49 | """, 50 | DeprecationWarning) 51 | 52 | 53 | args = [] 54 | for param in signature(function).parameters: 55 | _check_valid(param) 56 | args.append(injectable_fixtures.get(param)) 57 | 58 | function(*tuple(args)) 59 | 60 | 61 | def ensure_automation_is_valid(automation_class): 62 | def function_exist_in_automation_class(func_name): 63 | return func_name in dir(automation_class) 64 | 65 | def function_has_arguments_other_than_self(func_name): 66 | func_parameters = signature(getattr(automation_class, func_name)).parameters 67 | return list(func_parameters.keys()) != ["self"] 68 | 69 | def __init___was_overridden(): 70 | return '__init__' in automation_class.__dict__ 71 | 72 | # noinspection PyPep8Naming,SpellCheckingInspection 73 | def not_subclass_of_Hass(): 74 | return not issubclass(automation_class, Hass) 75 | 76 | if not function_exist_in_automation_class('initialize'): 77 | raise AutomationFixtureError( 78 | f"'{automation_class.__name__}' has no 'initialize' function! Make sure you implemented it!") 79 | if function_has_arguments_other_than_self('initialize'): 80 | raise AutomationFixtureError( 81 | f"'{automation_class.__name__}' 'initialize' should have no arguments other than 'self'!") 82 | if __init___was_overridden(): 83 | raise AutomationFixtureError(f"'{automation_class.__name__}' should not override '__init__'") 84 | if not_subclass_of_Hass(): 85 | raise AutomationFixtureError(f"'{automation_class.__name__}' should be a subclass of 'Hass'") 86 | 87 | 88 | class _AutomationFixtureDecoratorWithoutArgs: 89 | def __init__(self, automation_classes): 90 | self.automation_classes = automation_classes 91 | for automation in self.automation_classes: 92 | ensure_automation_is_valid(automation) 93 | 94 | def __call__(self, function): 95 | @pytest.fixture(params=self.automation_classes, ids=self._generate_id) 96 | def automation_fixture_with_initialisation(request, given_that, hass_functions, hass_mocks): 97 | automation_class = request.param 98 | return _instantiate_and_initialize_automation(function, automation_class, given_that, hass_functions, hass_mocks) 99 | 100 | return automation_fixture_with_initialisation 101 | 102 | def _generate_id(self, automation_classes): 103 | return automation_classes.__name__ 104 | 105 | 106 | class _AutomationFixtureDecoratorWithArgs: 107 | def __init__(self, automation_classes_with_args): 108 | self.automation_classes_with_args = automation_classes_with_args 109 | for automation, _args in self.automation_classes_with_args: 110 | ensure_automation_is_valid(automation) 111 | 112 | def __call__(self, function): 113 | @pytest.fixture(params=self.automation_classes_with_args, ids=self._generate_id) 114 | def automation_fixture_with_initialisation(request, given_that, hass_functions, hass_mocks): 115 | automation_class = request.param[0] 116 | automation_args = request.param[1] 117 | automation = _instantiate_and_initialize_automation( 118 | function, automation_class, given_that, hass_functions, hass_mocks) 119 | return (automation, automation_args) 120 | 121 | return automation_fixture_with_initialisation 122 | 123 | def _generate_id(self, automation_classes_with_args): 124 | return automation_classes_with_args[0].__name__ 125 | 126 | 127 | def automation_fixture(*args): 128 | """ 129 | Decorator to seamlessly initialize and inject an automation fixture 130 | 131 | 4 Versions: 132 | - Single Class: @automation_fixture(MyAutomation) 133 | - Multiple Classes: @automation_fixture(MyAutomation, MyOtherAutomation) 134 | - Single Class w/ params: @automation_fixture((upstairs.Bedroom, {'motion': 'binary_sensor.bedroom_motion'})) 135 | - Multiple Classes w/ params: @automation_fixture( 136 | (upstairs.Bedroom, {'motion': 'binary_sensor.bedroom_motion'}), 137 | (upstairs.Bathroom, {'motion': 'binary_sensor.bathroom_motion'}), 138 | ) 139 | 140 | When multiple classes are passed, tests will be generated for each automation. 141 | When using parameters, the injected object will be a tuple: `(Initialized_Automation, params)` 142 | 143 | # Pre-initialization setup 144 | All code in the `@automation_fixture` function will be executed before initializing the `automation_class` 145 | 146 | 3 fixtures are injectable in `@automation_fixture`: 'given_that', 'hass_mocks' and 'hass_functions' 147 | 'hass_functions' is deprecated in favor of 'hass_mocks' 148 | 149 | Examples: 150 | ```python 151 | @automation_fixture(Bathroom) 152 | def bathroom(): 153 | pass 154 | # -> `Bathroom` automation will be initialized and available in tests as `bathroom` 155 | 156 | --- 157 | 158 | @automation_fixture(Bathroom) 159 | def bathroom(given_that): 160 | given_that.time_is(time(hour=13)) 161 | 162 | # -> 1. `given_that.time_is(time(hour=13))` will be called 163 | # -> 2. `Bathroom` automation will be initialized and available in tests as `bathroom` 164 | 165 | ``` 166 | 167 | Do not return anything, any returned object will be ignored 168 | 169 | """ 170 | if not args or isfunction(args[0]): 171 | raise AutomationFixtureError( 172 | 'Do not forget to pass the automation class(es) as argument') 173 | 174 | if type(args[0]) is not tuple: 175 | automation_classes = args 176 | return _AutomationFixtureDecoratorWithoutArgs(automation_classes) 177 | else: 178 | automation_classes_with_args = args 179 | return _AutomationFixtureDecoratorWithArgs(automation_classes_with_args) 180 | -------------------------------------------------------------------------------- /appdaemontestframework/common.py: -------------------------------------------------------------------------------- 1 | class AppdaemonTestFrameworkError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /appdaemontestframework/hass_mocks.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import warnings 3 | 4 | from appdaemontestframework.appdaemon_mock.appdaemon import MockAppDaemon 5 | import appdaemon.utils 6 | import mock 7 | from appdaemon.plugins.hass.hassapi import Hass 8 | from packaging.version import Version 9 | 10 | CURRENT_APPDAEMON_VERSION = Version(appdaemon.utils.__version__) 11 | 12 | 13 | def is_appdaemon_version_at_least(version_as_string): 14 | expected_appdaemon_version = Version(version_as_string) 15 | return CURRENT_APPDAEMON_VERSION >= expected_appdaemon_version 16 | 17 | 18 | class _DeprecatedAndUnsupportedAppdaemonCheck: 19 | already_warned_during_this_test_session = False 20 | min_supported_appdaemon_version = '4.0.0' 21 | min_deprecated_appdaemon_version = '4.0.0' 22 | 23 | @classmethod 24 | def show_warning_only_once(cls): 25 | if cls.already_warned_during_this_test_session is True: 26 | return 27 | cls.already_warned_during_this_test_session = True 28 | 29 | appdaemon_version_unsupported = not is_appdaemon_version_at_least( 30 | cls.min_supported_appdaemon_version 31 | ) 32 | appdaemon_version_deprecated = not is_appdaemon_version_at_least( 33 | cls.min_deprecated_appdaemon_version 34 | ) 35 | 36 | if appdaemon_version_unsupported: 37 | raise Exception("Appdaemon-Test-Framework only support Appdemon >={} " 38 | "Your current Appdemon version is {}".format( 39 | cls.min_supported_appdaemon_version, 40 | CURRENT_APPDAEMON_VERSION)) 41 | 42 | if appdaemon_version_deprecated: 43 | warnings.warn( 44 | "Appdaemon-Test-Framework will only support Appdaemon >={} " 45 | "until the next major release. " 46 | "Your current Appdemon version is {}".format( 47 | cls.min_deprecated_appdaemon_version, 48 | CURRENT_APPDAEMON_VERSION 49 | ), 50 | DeprecationWarning) 51 | 52 | 53 | class HassMocks: 54 | def __init__(self): 55 | _DeprecatedAndUnsupportedAppdaemonCheck.show_warning_only_once() 56 | # Mocked out init for Hass class. 57 | self._hass_instances = [] # list of all hass instances 58 | 59 | hass_mocks = self 60 | AD = MockAppDaemon() 61 | self.AD = AD 62 | 63 | def _hass_init_mock(self, _ad, name, *_args): 64 | hass_mocks._hass_instances.append(self) 65 | self.name = name 66 | self.AD = AD 67 | self.logger = logging.getLogger(__name__) 68 | 69 | # This is a list of all mocked out functions. 70 | self._mock_handlers = [ 71 | ### Meta 72 | # Patch the __init__ method to skip Hass initialization. 73 | # Use autospec so we can access the `self` object 74 | MockHandler(Hass, '__init__', 75 | side_effect=_hass_init_mock, autospec=True), 76 | 77 | ### logging 78 | MockHandler(Hass, 'log', side_effect=self._log_log), 79 | MockHandler(Hass, 'error', side_effect=self._log_error), 80 | 81 | ### Scheduler callback registrations functions 82 | # Wrap all these so we can re-use the AppDaemon code, but check 83 | # if they were called 84 | SpyMockHandler(Hass, 'run_in'), 85 | MockHandler(Hass, 'run_once'), 86 | MockHandler(Hass, 'run_at'), 87 | MockHandler(Hass, 'run_daily'), 88 | MockHandler(Hass, 'run_hourly'), 89 | MockHandler(Hass, 'run_minutely'), 90 | MockHandler(Hass, 'run_every'), 91 | SpyMockHandler(Hass, 'cancel_timer'), 92 | 93 | ### Sunrise and sunset functions 94 | MockHandler(Hass, 'run_at_sunrise'), 95 | MockHandler(Hass, 'run_at_sunset'), 96 | 97 | ### Listener callback registrations functions 98 | MockHandler(Hass, 'listen_event'), 99 | MockHandler(Hass, 'listen_state'), 100 | 101 | ### State functions / attr 102 | MockHandler(Hass, 'set_state'), 103 | MockHandler(Hass, 'get_state'), 104 | SpyMockHandler(Hass, 'time'), 105 | DictMockHandler(Hass, 'args'), 106 | 107 | ### Interactions functions 108 | MockHandler(Hass, 'call_service'), 109 | MockHandler(Hass, 'turn_on'), 110 | MockHandler(Hass, 'turn_off'), 111 | MockHandler(Hass, 'fire_event'), 112 | 113 | ### Custom callback functions 114 | MockHandler(Hass, 'register_constraint'), 115 | MockHandler(Hass, 'now_is_between'), 116 | MockHandler(Hass, 'notify'), 117 | 118 | ### Miscellaneous Helper Functions 119 | MockHandler(Hass, 'entity_exists'), 120 | ] 121 | 122 | # Generate a dictionary of mocked Hass functions for use by older code 123 | # Note: This interface is considered deprecated and should be replaced 124 | # with calls to public methods in the HassMocks object going forward. 125 | self._hass_functions = {} 126 | for mock_handler in self._mock_handlers: 127 | self._hass_functions[ 128 | mock_handler.function_or_field_name] = mock_handler.mock 129 | 130 | ### Mock handling 131 | def unpatch_mocks(self): 132 | """Stops all mocks this class handles.""" 133 | for mock_handler in self._mock_handlers: 134 | mock_handler.patch.stop() 135 | 136 | ### Access to the deprecated hass_functions dict. 137 | @property 138 | def hass_functions(self): 139 | return self._hass_functions 140 | 141 | ### Logging mocks 142 | @staticmethod 143 | def _log_error(msg, level='ERROR'): 144 | HassMocks._log_log(msg, level) 145 | 146 | @staticmethod 147 | def _log_log(msg, level='INFO'): 148 | # Renamed the function to remove confusion 149 | get_logging_level_from_name = logging.getLevelName 150 | logging.log(get_logging_level_from_name(level), msg) 151 | 152 | 153 | class MockHandler: 154 | """ 155 | A class for generating a mock in an object and holding on to info about it. 156 | :param object_to_patch: The object to patch 157 | :param function_or_field_name: the name of the function to patch in the 158 | object 159 | :param side_effect: side effect method to call. If not set, it will just 160 | return `None` 161 | :param autospec: If `True` will autospec the Mock signature. Useful for 162 | getting `self` in side effects. 163 | """ 164 | 165 | def __init__(self, 166 | object_to_patch, 167 | function_or_field_name, 168 | side_effect=None, 169 | autospec=False): 170 | self.function_or_field_name = function_or_field_name 171 | patch_kwargs = self._patch_kwargs(side_effect, autospec) 172 | self.patch = mock.patch.object( 173 | object_to_patch, 174 | function_or_field_name, 175 | **patch_kwargs 176 | ) 177 | self.mock = self.patch.start() 178 | 179 | def _patch_kwargs(self, side_effect, autospec): 180 | return { 181 | 'create': True, 182 | 'side_effect': side_effect, 183 | 'return_value': None, 184 | 'autospec': autospec 185 | } 186 | 187 | 188 | class DictMockHandler(MockHandler): 189 | class MockDict(dict): 190 | def reset_mock(self): 191 | pass 192 | 193 | def __init__(self, object_to_patch, field_name): 194 | super().__init__(object_to_patch, field_name) 195 | 196 | def _patch_kwargs(self, _side_effect, _autospec): 197 | return { 198 | 'create': True, 199 | 'new': self.MockDict() 200 | } 201 | 202 | 203 | class SpyMockHandler(MockHandler): 204 | """ 205 | Mock Handler that provides a Spy. That is, when invoke it will call the 206 | original function but still provide all Mock-related functionality 207 | """ 208 | 209 | def __init__(self, object_to_patch, function_name): 210 | original_function = getattr(object_to_patch, function_name) 211 | super().__init__( 212 | object_to_patch, 213 | function_name, 214 | side_effect=original_function, 215 | autospec=True 216 | ) 217 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /doc/full_example/Pipfile: -------------------------------------------------------------------------------- 1 | # This Pipfile is used to do ANY kind of developpement in the entire project. 2 | # One Pipfile to rule them all :) 3 | 4 | [[source]] 5 | url = "https://pypi.org/simple" 6 | verify_ssl = true 7 | name = "pypi" 8 | 9 | 10 | [packages] 11 | appdaemon = "*" 12 | 13 | 14 | [dev-packages] 15 | appdaemontestframework = "*" 16 | mock = "*" 17 | 18 | pytest = "*" 19 | pytest-watch = "*" 20 | pytest-mock = "*" 21 | pytest-only = "*" 22 | 23 | pylint = "*" 24 | autopep8 = "*" 25 | 26 | 27 | [requires] 28 | python_version = "3.6" 29 | -------------------------------------------------------------------------------- /doc/full_example/apps/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HelloThisIsFlo/Appdaemon-Test-Framework/edb79def7ae1b7d3bdbf3c47a1df406884baf02c/doc/full_example/apps/__init__.py -------------------------------------------------------------------------------- /doc/full_example/apps/apps.yaml: -------------------------------------------------------------------------------- 1 | Kitchen: 2 | module: kitchen 3 | class: Kitchen 4 | 5 | Bathroom: 6 | module: bathroom 7 | class: Bathroom 8 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /doc/full_example/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | -------------------------------------------------------------------------------- /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/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HelloThisIsFlo/Appdaemon-Test-Framework/edb79def7ae1b7d3bdbf3c47a1df406884baf02c/doc/full_example/tests/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --ignore doc 3 | markers = 4 | only 5 | using_pytester 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name='appdaemontestframework', 5 | version='4.0.0b2', 6 | description='Clean, human-readable tests for Appdaemon', 7 | long_description='See: https://floriankempenich.github.io/Appdaemon-Test-Framework/', 8 | keywords='appdaemon homeassistant test tdd clean-code home-automation', 9 | classifiers=[ 10 | 'Environment :: Console', 11 | 'Framework :: Pytest', 12 | 'Intended Audience :: Developers', 13 | 'License :: OSI Approved :: MIT License', 14 | 'Topic :: Utilities', 15 | 'Topic :: Home Automation', 16 | 'Topic :: Software Development :: Testing' 17 | ], 18 | url='https://floriankempenich.github.io/Appdaemon-Test-Framework', 19 | author='Florian Kempenich', 20 | author_email='Flori@nKempenich.com', 21 | packages=find_packages(), 22 | license='MIT', 23 | python_requires=">=3.7", 24 | install_requires=[ 25 | 'appdaemon>=4.0,<5.0', 26 | 'mock>=3.0.5,<4.0', 27 | 'packaging>=20.1,<21.0', 28 | ], 29 | include_package_data=True 30 | ) 31 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/integration_tests/apps/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HelloThisIsFlo/Appdaemon-Test-Framework/edb79def7ae1b7d3bdbf3c47a1df406884baf02c/test/integration_tests/apps/__init__.py -------------------------------------------------------------------------------- /test/integration_tests/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/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/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/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/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HelloThisIsFlo/Appdaemon-Test-Framework/edb79def7ae1b7d3bdbf3c47a1df406884baf02c/test/integration_tests/tests/__init__.py -------------------------------------------------------------------------------- /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/integration_tests/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 | from appdaemontestframework import automation_fixture 8 | 9 | MORNING_STEP1_COLOR = 'BLUE' 10 | SHOWER_COLOR = 'GREEN' 11 | MORNING_STEP3_COLOR = 'YELLOW' 12 | DAY_COLOR = 'WHITE' 13 | EVENING_COLOR = 'RED' 14 | EVENING_HOUR = 20 15 | DAY_HOUR = 4 16 | 17 | 18 | @automation_fixture(Bathroom) 19 | def bathroom(given_that): 20 | # Set initial state 21 | speakers = [ 22 | ID['bathroom']['speaker'], 23 | ID['kitchen']['speaker'], 24 | ID['living_room']['soundbar'], 25 | ID['living_room']['controller'], 26 | ID['cast_groups']['entire_flat'] 27 | ] 28 | for speaker in speakers: 29 | given_that.state_of(speaker).is_set_to('off') 30 | 31 | given_that.time_is(time(hour=15)) 32 | 33 | # Clear calls recorded during initialisation 34 | given_that.mock_functions_are_cleared() 35 | 36 | 37 | @pytest.fixture 38 | def when_new(bathroom): 39 | class WhenNewWrapper: 40 | def time(self, hour=None): 41 | bathroom._time_triggered({'hour': hour}) 42 | 43 | def motion_bathroom(self): 44 | bathroom._new_motion_bathroom(None, None, None) 45 | 46 | def motion_kitchen(self): 47 | bathroom._new_motion_kitchen(None, None, None) 48 | 49 | def motion_living_room(self): 50 | bathroom._new_motion_living_room(None, None, None) 51 | 52 | def no_more_motion_bathroom(self): 53 | bathroom._no_more_motion_bathroom( 54 | None, None, None, None, None) 55 | 56 | def click_bathroom_button(self): 57 | bathroom._new_click_bathroom_button(None, None, None) 58 | 59 | def debug(self): 60 | bathroom.debug(None, {'click_type': 'single'}, None) 61 | return WhenNewWrapper() 62 | 63 | 64 | # Start at different times 65 | class TestInitialize: 66 | 67 | def test_start_during_day(self, given_that, when_new, assert_that, bathroom, assert_day_mode_started): 68 | given_that.time_is(time(hour=13)) 69 | bathroom.initialize() 70 | assert_day_mode_started() 71 | 72 | def test_start_during_evening(self, given_that, when_new, assert_that, bathroom, assert_evening_mode_started): 73 | given_that.time_is(time(hour=20)) 74 | bathroom.initialize() 75 | assert_evening_mode_started() 76 | 77 | def test_callbacks_are_registered(self, bathroom, hass_mocks): 78 | # Given: The mocked callback Appdaemon registration functions 79 | listen_event = hass_mocks.hass_functions['listen_event'] 80 | listen_state = hass_mocks.hass_functions['listen_state'] 81 | run_daily = hass_mocks.hass_functions['run_daily'] 82 | 83 | # When: Calling `initialize` 84 | bathroom.initialize() 85 | 86 | # Then: callbacks are registered 87 | listen_event.assert_any_call( 88 | bathroom._new_click_bathroom_button, 89 | 'click', 90 | entity_id=ID['bathroom']['button'], 91 | click_type='single') 92 | 93 | listen_event.assert_any_call( 94 | bathroom._new_motion_bathroom, 95 | 'motion', 96 | entity_id=ID['bathroom']['motion_sensor']) 97 | listen_event.assert_any_call( 98 | bathroom._new_motion_kitchen, 99 | 'motion', 100 | entity_id=ID['kitchen']['motion_sensor']) 101 | listen_event.assert_any_call( 102 | bathroom._new_motion_living_room, 103 | 'motion', 104 | entity_id=ID['living_room']['motion_sensor']) 105 | listen_state.assert_any_call( 106 | bathroom._no_more_motion_bathroom, 107 | ID['bathroom']['motion_sensor'], 108 | new='off') 109 | 110 | run_daily.assert_any_call( 111 | bathroom._time_triggered, 112 | time(hour=DAY_HOUR), 113 | hour=DAY_HOUR) 114 | run_daily.assert_any_call( 115 | bathroom._time_triggered, 116 | time(hour=EVENING_HOUR), 117 | hour=EVENING_HOUR) 118 | 119 | 120 | ################################################################################## 121 | ## For the rest of the tests, Bathroom WAS STARTED DURING THE DAY (at 3PM) ## 122 | ## For the rest of the tests, Bathroom WAS STARTED DURING THE DAY (at 3PM) ## 123 | ## For the rest of the tests, Bathroom WAS STARTED DURING THE DAY (at 3PM) ## 124 | ################################################################################## 125 | 126 | class TestDuringEvening: 127 | @pytest.fixture 128 | def start_evening_mode(self, when_new, given_that): 129 | given_that.mock_functions_are_cleared() 130 | # Provide a trigger to switch to evening mode 131 | return lambda: when_new.time(hour=EVENING_HOUR) 132 | 133 | class TestEnterBathroom: 134 | def test_light_turn_on(self, given_that, when_new, assert_that, start_evening_mode): 135 | start_evening_mode() 136 | when_new.motion_bathroom() 137 | assert_that(ID['bathroom']['led_light'] 138 | ).was.turned_on(color_name=EVENING_COLOR) 139 | 140 | def test__bathroom_playing__unmute(self, given_that, when_new, assert_that, start_evening_mode): 141 | start_evening_mode() 142 | given_that.state_of(ID['bathroom']['speaker']).is_set_to('playing') 143 | when_new.motion_bathroom() 144 | assert_bathroom_was_UNmuted(assert_that) 145 | 146 | def test__entire_flat_playing__unmute(self, given_that, when_new, assert_that, start_evening_mode): 147 | start_evening_mode() 148 | given_that.state_of( 149 | ID['cast_groups']['entire_flat']).is_set_to('playing') 150 | when_new.motion_bathroom() 151 | assert_bathroom_was_UNmuted(assert_that) 152 | 153 | def test__nothing_playing__do_not_unmute(self, given_that, when_new, assert_that, start_evening_mode): 154 | start_evening_mode() 155 | when_new.motion_bathroom() 156 | assert_that('media_player/volume_set').was_not.called_with( 157 | entity_id=ID['bathroom']['speaker'], 158 | volume_level=BATHROOM_VOLUMES['regular']) 159 | 160 | class TestLeaveBathroom: 161 | def test_mute_turn_off_light(self, given_that, when_new, assert_that, start_evening_mode): 162 | scenarios = [ 163 | when_new.motion_kitchen, 164 | when_new.motion_living_room, 165 | when_new.no_more_motion_bathroom 166 | ] 167 | for scenario in scenarios: 168 | given_that.mock_functions_are_cleared(clear_mock_states=True) 169 | start_evening_mode() 170 | scenario() 171 | assert_bathroom_was_muted(assert_that) 172 | assert_that(ID['bathroom']['led_light']).was.turned_off() 173 | 174 | 175 | class TestsDuringDay: 176 | @pytest.fixture 177 | def start_day_mode(self, when_new, given_that): 178 | # Switch to Evening mode and provide 179 | # a trigger to start the Day mode 180 | 181 | # Switch to: Evening mode 182 | when_new.time(hour=EVENING_HOUR) 183 | given_that.mock_functions_are_cleared() 184 | 185 | # Trigger to switch to Day mode 186 | return lambda: when_new.time(hour=DAY_HOUR) 187 | 188 | class TestAtStart: 189 | def test_turn_on_water_heater(self, assert_that, start_day_mode): 190 | start_day_mode() 191 | assert_that(ID['bathroom']['water_heater']).was.turned_on() 192 | 193 | def test_turn_off_bathroom_light(self, assert_that, start_day_mode): 194 | start_day_mode() 195 | assert_that(ID['bathroom']['led_light']).was.turned_off() 196 | 197 | def test_reset_volumes(self, assert_that, start_day_mode): 198 | pass 199 | start_day_mode() 200 | assert_that('media_player/volume_set').was.called_with( 201 | entity_id=ID['bathroom']['speaker'], 202 | volume_level=DEFAULT_VOLUMES['bathroom']) 203 | assert_that('media_player/volume_set').was.called_with( 204 | entity_id=ID['kitchen']['speaker'], 205 | volume_level=DEFAULT_VOLUMES['kitchen']) 206 | assert_that('media_player/volume_set').was.called_with( 207 | entity_id=ID['living_room']['soundbar'], 208 | volume_level=DEFAULT_VOLUMES['living_room_soundbar']) 209 | assert_that('media_player/volume_set').was.called_with( 210 | entity_id=ID['living_room']['controller'], 211 | volume_level=DEFAULT_VOLUMES['living_room_controller']) 212 | 213 | class TestEnterBathroom: 214 | def test_light_turn_on(self, given_that, when_new, assert_that, start_day_mode): 215 | start_day_mode() 216 | when_new.motion_bathroom() 217 | assert_that(ID['bathroom']['led_light'] 218 | ).was.turned_on(color_name=DAY_COLOR) 219 | 220 | def test__bathroom_playing__unmute(self, given_that, when_new, assert_that, start_day_mode): 221 | start_day_mode() 222 | given_that.state_of(ID['bathroom']['speaker']).is_set_to('playing') 223 | when_new.motion_bathroom() 224 | assert_bathroom_was_UNmuted(assert_that) 225 | 226 | def test__entire_flat_playing__unmute(self, given_that, when_new, assert_that, start_day_mode): 227 | start_day_mode() 228 | given_that.state_of( 229 | ID['cast_groups']['entire_flat']).is_set_to('playing') 230 | when_new.motion_bathroom() 231 | assert_bathroom_was_UNmuted(assert_that) 232 | 233 | def test__nothing_playing__do_not_unmute(self, given_that, when_new, assert_that, start_day_mode): 234 | start_day_mode() 235 | when_new.motion_bathroom() 236 | assert_that('media_player/volume_set').was_not.called_with( 237 | entity_id=ID['bathroom']['speaker'], 238 | volume_level=BATHROOM_VOLUMES['regular']) 239 | 240 | class TestLeaveBathroom: 241 | def test__no_more_motion__mute_turn_off_light(self, given_that, when_new, assert_that, start_day_mode): 242 | start_day_mode() 243 | when_new.no_more_motion_bathroom() 244 | assert_bathroom_was_muted(assert_that) 245 | assert_that(ID['bathroom']['led_light']).was.turned_off() 246 | 247 | def test__motion_anywhere_except_bathroom__do_NOT_mute_turn_off_light(self, given_that, when_new, assert_that, start_day_mode): 248 | scenarios = [ 249 | when_new.motion_kitchen, 250 | when_new.motion_living_room 251 | ] 252 | for scenario in scenarios: 253 | start_day_mode() 254 | given_that.mock_functions_are_cleared() 255 | scenario() 256 | assert_bathroom_was_NOT_muted(assert_that) 257 | assert_that(ID['bathroom']['led_light']).was_not.turned_off() 258 | 259 | class TestSwitchToNextState: 260 | def test_click_activate_shower_state(self, start_day_mode, when_new, assert_shower_state_started): 261 | start_day_mode() 262 | when_new.click_bathroom_button() 263 | assert_shower_state_started() 264 | 265 | def test_8pm_activate_evening_state(self, start_day_mode, when_new, assert_evening_mode_started): 266 | start_day_mode() 267 | when_new.time(hour=EVENING_HOUR) 268 | assert_evening_mode_started() 269 | 270 | 271 | class TestDuringShower: 272 | @pytest.fixture 273 | def start_shower_mode(self, when_new, given_that): 274 | # Provide a trigger to start shower mode 275 | given_that.mock_functions_are_cleared() 276 | return lambda: when_new.click_bathroom_button() 277 | 278 | class TestAtStart: 279 | def test_light_indicator(self, given_that, when_new, assert_that, start_shower_mode): 280 | start_shower_mode() 281 | assert_that(ID['bathroom']['led_light']).was.turned_on( 282 | color_name=SHOWER_COLOR) 283 | 284 | def test_notif_sound(self, assert_that, start_shower_mode): 285 | notif_sound_id = 10001 286 | volume = 20 287 | xiaomi_gateway_mac_address = ID['bathroom']['gateway_mac_address'] 288 | start_shower_mode() 289 | assert_that('xiaomi_aqara/play_ringtone').was.called_with( 290 | ringtone_id=notif_sound_id, ringtone_vol=volume, gw_mac=xiaomi_gateway_mac_address) 291 | 292 | def test_mute_all_except_bathroom(self, given_that, assert_that, start_shower_mode): 293 | # Bug with sound bar firmware: Can only increase the volume by 10% at a time 294 | # to prevent this being a problem, we're not muting it 295 | all_speakers_except_bathroom = [ 296 | # ID['living_room']['soundbar'], 297 | ID['kitchen']['speaker'], 298 | ID['living_room']['controller'] 299 | ] 300 | 301 | start_shower_mode() 302 | for speaker in all_speakers_except_bathroom: 303 | assert_that('media_player/volume_set').was.called_with( 304 | entity_id=speaker, 305 | volume_level=FAKE_MUTE_VOLUME) 306 | 307 | def test_set_shower_volume_bathroom(self, given_that, assert_that, start_shower_mode): 308 | start_shower_mode() 309 | assert_that('media_player/volume_set').was.called_with( 310 | entity_id=ID['bathroom']['speaker'], 311 | volume_level=BATHROOM_VOLUMES['shower']) 312 | 313 | class TestClickButton: 314 | def test__activate_after_shower(self, given_that, when_new, assert_that, start_shower_mode): 315 | def assert_after_shower_started(): 316 | assert_that(ID['bathroom']['led_light']).was.turned_on( 317 | color_name=MORNING_STEP3_COLOR) 318 | 319 | start_shower_mode() 320 | when_new.click_bathroom_button() 321 | assert_after_shower_started() 322 | 323 | 324 | class TestDuringAfterShower: 325 | @pytest.fixture 326 | def start_after_shower_mode(self, when_new, given_that): 327 | # Return callback to trigger AfterShower mode 328 | def trigger_after_shower_mode(): 329 | when_new.click_bathroom_button() 330 | given_that.mock_functions_are_cleared() 331 | when_new.click_bathroom_button() 332 | return trigger_after_shower_mode 333 | 334 | class TestAtStart: 335 | def test_light_indicator(self, given_that, when_new, assert_that, start_after_shower_mode): 336 | start_after_shower_mode() 337 | assert_that(ID['bathroom']['led_light']).was.turned_on( 338 | color_name=MORNING_STEP3_COLOR) 339 | 340 | def test_notif_sound(self, assert_that, start_after_shower_mode): 341 | notif_sound_id = 10001 342 | volume = 20 343 | xiaomi_gateway_mac_address = ID['bathroom']['gateway_mac_address'] 344 | start_after_shower_mode() 345 | assert_that('xiaomi_aqara/play_ringtone').was.called_with( 346 | ringtone_id=notif_sound_id, ringtone_vol=volume, gw_mac=xiaomi_gateway_mac_address) 347 | 348 | def test_pause_podcast(self, assert_that, start_after_shower_mode): 349 | start_after_shower_mode() 350 | for playback_device in [ 351 | ID['bathroom']['speaker'], 352 | ID['cast_groups']['entire_flat']]: 353 | assert_that('media_player/media_pause').was.called_with( 354 | entity_id=playback_device) 355 | 356 | def test_mute_bathroom(self, assert_that, start_after_shower_mode): 357 | start_after_shower_mode() 358 | assert_bathroom_was_muted(assert_that) 359 | 360 | def test_turn_off_water_heater(self, assert_that, start_after_shower_mode): 361 | start_after_shower_mode() 362 | assert_that(ID['bathroom']['water_heater']).was.turned_off() 363 | 364 | class TestMotionAnywhereExceptBathroom: 365 | def test_resume_podcast_playback(self, given_that, when_new, assert_that, assert_day_mode_started, start_after_shower_mode): 366 | scenarios = [ 367 | when_new.motion_kitchen, 368 | when_new.motion_living_room, 369 | when_new.no_more_motion_bathroom 370 | ] 371 | for scenario in scenarios: 372 | # Given: In shower mode 373 | start_after_shower_mode() 374 | # When: Motion 375 | scenario() 376 | # Assert: Playback resumed 377 | for playback_device in [ 378 | ID['bathroom']['speaker'], 379 | ID['cast_groups']['entire_flat']]: 380 | assert_that('media_player/media_play').was.called_with( 381 | entity_id=playback_device) 382 | 383 | def test_during_day_activate_day_mode(self, given_that, when_new, assert_that, assert_day_mode_started, start_after_shower_mode): 384 | scenarios = [ 385 | when_new.motion_kitchen, 386 | when_new.motion_living_room, 387 | when_new.no_more_motion_bathroom 388 | ] 389 | for scenario in scenarios: 390 | given_that.time_is(time(hour=14)) 391 | start_after_shower_mode() 392 | scenario() 393 | assert_day_mode_started() 394 | 395 | def test_during_evening_activate_evening_mode(self, given_that, when_new, assert_that, assert_evening_mode_started, start_after_shower_mode): 396 | scenarios = [ 397 | when_new.motion_kitchen, 398 | when_new.motion_living_room, 399 | when_new.no_more_motion_bathroom 400 | ] 401 | for scenario in scenarios: 402 | given_that.time_is(time(hour=20)) 403 | start_after_shower_mode() 404 | scenario() 405 | assert_evening_mode_started() 406 | 407 | def assert_bathroom_was_muted(assert_that): 408 | assert_that('media_player/volume_set').was.called_with( 409 | entity_id=ID['bathroom']['speaker'], 410 | volume_level=FAKE_MUTE_VOLUME) 411 | 412 | 413 | def assert_bathroom_was_NOT_muted(assert_that): 414 | assert_that('media_player/volume_set').was_not.called_with( 415 | entity_id=ID['bathroom']['speaker'], 416 | volume_level=FAKE_MUTE_VOLUME) 417 | 418 | 419 | def assert_bathroom_was_UNmuted(assert_that): 420 | assert_that('media_player/volume_set').was.called_with( 421 | entity_id=ID['bathroom']['speaker'], 422 | volume_level=BATHROOM_VOLUMES['regular']) 423 | 424 | 425 | def assert_bathroom_light_reacts_to_movement_with_color(color, when_new, assert_that): 426 | when_new.motion_bathroom() 427 | assert_that(ID['bathroom']['led_light'] 428 | ).was.turned_on(color_name=color) 429 | 430 | 431 | @pytest.fixture 432 | def assert_day_mode_started(when_new, assert_that): 433 | return lambda: assert_bathroom_light_reacts_to_movement_with_color( 434 | DAY_COLOR, 435 | when_new, 436 | assert_that) 437 | 438 | 439 | @pytest.fixture 440 | def assert_evening_mode_started(when_new, assert_that): 441 | return lambda: assert_bathroom_light_reacts_to_movement_with_color( 442 | EVENING_COLOR, 443 | when_new, 444 | assert_that) 445 | 446 | 447 | @pytest.fixture 448 | def assert_shower_state_started(assert_that): 449 | return lambda: assert_that(ID['bathroom']['led_light']).was.turned_on( 450 | color_name=SHOWER_COLOR) 451 | -------------------------------------------------------------------------------- /test/integration_tests/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 | from appdaemontestframework import automation_fixture 8 | 9 | PHONE_PUSHBULLET_ID = "device/OnePlus 5T" 10 | 11 | 12 | @automation_fixture(Kitchen) 13 | def kitchen(given_that): 14 | pass 15 | 16 | 17 | @pytest.fixture 18 | def when_new(kitchen): 19 | class WhenNewWrapper: 20 | def motion(self): 21 | kitchen._new_motion(None, None, None) 22 | 23 | def no_more_motion(self): 24 | kitchen._no_more_motion( 25 | None, None, None, None, None) 26 | 27 | def click_button(self, type='single'): 28 | { 29 | 'single': kitchen._new_button_click, 30 | 'double': kitchen._new_button_double_click, 31 | 'long': kitchen._new_button_long_press 32 | }[type](None, None, None) 33 | 34 | return WhenNewWrapper() 35 | 36 | 37 | class TestInitialization: 38 | def test_callbacks_are_registered(self, kitchen, hass_mocks): 39 | # Given: The mocked callback Appdaemon registration functions 40 | listen_event = hass_mocks.hass_functions['listen_event'] 41 | listen_state = hass_mocks.hass_functions['listen_state'] 42 | 43 | # When: Calling `initialize` 44 | kitchen.initialize() 45 | 46 | # Then: callbacks are registered 47 | listen_event.assert_any_call( 48 | kitchen._new_button_click, 49 | 'click', 50 | entity_id=ID['kitchen']['button'], 51 | click_type='single') 52 | 53 | listen_event.assert_any_call( 54 | kitchen._new_button_double_click, 55 | 'click', 56 | entity_id=ID['kitchen']['button'], 57 | click_type='double') 58 | 59 | listen_event.assert_any_call( 60 | kitchen._new_motion, 61 | 'motion', 62 | entity_id=ID['kitchen']['motion_sensor']) 63 | 64 | listen_state.assert_any_call( 65 | kitchen._no_more_motion, 66 | ID['kitchen']['motion_sensor'], 67 | new='off') 68 | 69 | 70 | class TestAutomaticLights: 71 | def test_turn_on(self, when_new, assert_that): 72 | when_new.motion() 73 | assert_that(ID['kitchen']['light']).was.turned_on() 74 | 75 | def test_turn_off(self, when_new, assert_that): 76 | when_new.no_more_motion() 77 | assert_that(ID['kitchen']['light']).was.turned_off() 78 | 79 | 80 | SHORT_DELAY = 10 81 | LONG_DELAY = 30 82 | 83 | 84 | @pytest.fixture 85 | def assert_water_heater_notif_sent(assert_that): 86 | def assert_water_heater_sent_wrapper(message): 87 | assert_that('notify/pushbullet').was.called_with( 88 | title="Water Heater", 89 | message=message, 90 | target=PHONE_PUSHBULLET_ID) 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", 100 | message=message, 101 | target=PHONE_PUSHBULLET_ID) 102 | 103 | return assert_water_heater_NOT_sent_wrapper 104 | 105 | 106 | class TestSingleClickOnButton: 107 | def test_turn_off_water_heater(self, when_new, assert_that): 108 | when_new.click_button() 109 | assert_that(ID['bathroom']['water_heater']).was.turned_off() 110 | 111 | def test_send_notification(self, when_new, assert_water_heater_notif_sent): 112 | when_new.click_button() 113 | assert_water_heater_notif_sent( 114 | f"was turned off for {SHORT_DELAY} minutes") 115 | 116 | class TestAfterDelay: 117 | def test_turn_water_heater_back_on(self, when_new, time_travel, assert_that): 118 | when_new.click_button() 119 | time_travel.fast_forward(SHORT_DELAY).minutes() 120 | assert_that(ID['bathroom']['water_heater']).was.turned_on() 121 | 122 | def test_send_notification(self, when_new, time_travel, assert_water_heater_notif_sent): 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( 136 | f"was turned off for {LONG_DELAY} minutes") 137 | 138 | class TestAfterShortDelay: 139 | def test_DOES_NOT_turn_water_heater_back_on(self, when_new, time_travel, assert_that): 140 | when_new.click_button(type='double') 141 | time_travel.fast_forward(SHORT_DELAY).minutes() 142 | assert_that(ID['bathroom']['water_heater']).was_not.turned_on() 143 | 144 | def test_DOES_NOT_send_notification(self, when_new, time_travel, assert_water_heater_notif_NOT_sent): 145 | when_new.click_button(type='double') 146 | time_travel.fast_forward(SHORT_DELAY).minutes() 147 | assert_water_heater_notif_NOT_sent("was turned back on") 148 | 149 | class TestAfterLongDelay: 150 | def test_turn_water_heater_back_on(self, when_new, time_travel, assert_that): 151 | when_new.click_button(type='double') 152 | time_travel.fast_forward(LONG_DELAY).minutes() 153 | assert_that(ID['bathroom']['water_heater']).was.turned_on() 154 | 155 | def test_send_notification(self, when_new, time_travel, assert_water_heater_notif_sent): 156 | when_new.click_button(type='double') 157 | time_travel.fast_forward(LONG_DELAY).minutes() 158 | assert_water_heater_notif_sent("was turned back on") 159 | 160 | 161 | class TestClickCancellation: 162 | class TestSingleClick: 163 | def test_new_click_cancels_previous_one(self, when_new, time_travel, assert_that): 164 | # T = 0min 165 | # FF = 0min 166 | time_travel.assert_current_time(0).minutes() 167 | when_new.click_button() 168 | 169 | # T = 2min 170 | # FF = 2min 171 | time_travel.fast_forward(2).minutes() 172 | time_travel.assert_current_time(2).minutes() 173 | when_new.click_button() 174 | 175 | # T = SHORT_DELAY 176 | # FF = SHORT_DELAY - 2min 177 | # Do NOT turn water heater back on yet! 178 | time_travel.fast_forward(SHORT_DELAY - 2).minutes() 179 | time_travel.assert_current_time(SHORT_DELAY).minutes() 180 | assert_that(ID['bathroom']['water_heater']).was_not.turned_on() 181 | 182 | # T = SHORT_DELAY + 2min 183 | # FF = SHORT_DELAY + 2min - (2min + 8min) 184 | time_travel.fast_forward(SHORT_DELAY - 8).minutes() 185 | time_travel.assert_current_time(SHORT_DELAY + 2).minutes() 186 | assert_that(ID['bathroom']['water_heater']).was.turned_on() 187 | 188 | def test_multiple_clicks(self, when_new, time_travel, assert_that): 189 | # Given: 3 clicks, every 2 seconds 190 | when_new.click_button() 191 | time_travel.fast_forward(2).minutes() 192 | when_new.click_button() 193 | time_travel.fast_forward(2).minutes() 194 | when_new.click_button() 195 | 196 | time_travel.assert_current_time(4).minutes() 197 | 198 | # When 1/2: 199 | # Fast forwarding up until 1 min before reactivation 200 | # scheduled by last click 201 | time_travel.fast_forward(SHORT_DELAY - 1).minutes() 202 | # Then 1/2: 203 | # Water heater still not turned back on (first clicks ignored) 204 | assert_that(ID['bathroom']['water_heater']).was_not.turned_on() 205 | 206 | # When 2/2: 207 | # Fast forwarding after reactivation 208 | # scheduled by last click 209 | time_travel.fast_forward(SHORT_DELAY - 1).minutes() 210 | # Then 2/2: 211 | # Water heater still now turned back on 212 | assert_that(ID['bathroom']['water_heater']).was.turned_on() 213 | 214 | class TestDoubleClick: 215 | def test_multiple_clicks(self, when_new, time_travel, assert_that): 216 | # Given: 3 clicks, every 2 seconds 217 | when_new.click_button(type='double') 218 | time_travel.fast_forward(2).minutes() 219 | when_new.click_button(type='double') 220 | time_travel.fast_forward(2).minutes() 221 | when_new.click_button(type='double') 222 | 223 | time_travel.assert_current_time(4).minutes() 224 | 225 | # When 1/2: 226 | # Fast forwarding up until 1 min before reactivation 227 | # scheduled by last click 228 | time_travel.fast_forward(LONG_DELAY - 1).minutes() 229 | # Then 1/2: 230 | # Water heater still not turned back on (first clicks ignored) 231 | assert_that(ID['bathroom']['water_heater']).was_not.turned_on() 232 | 233 | # When 2/2: 234 | # Fast forwarding after reactivation 235 | # scheduled by last click 236 | time_travel.fast_forward(LONG_DELAY - 1).minutes() 237 | # Then 2/2: 238 | # Water heater still now turned back on 239 | assert_that(ID['bathroom']['water_heater']).was.turned_on() 240 | 241 | class TestMixedClicks: 242 | def test_short_then_long_keep_latest(self, when_new, time_travel, assert_that): 243 | when_new.click_button() 244 | time_travel.fast_forward(2).minutes() 245 | when_new.click_button(type='double') 246 | 247 | time_travel.fast_forward(LONG_DELAY - 1).minutes() 248 | assert_that(ID['bathroom']['water_heater']).was_not.turned_on() 249 | time_travel.fast_forward(1).minutes() 250 | assert_that(ID['bathroom']['water_heater']).was.turned_on() 251 | 252 | def test_long_then_short_keep_latest(self, when_new, time_travel, assert_that): 253 | when_new.click_button(type='double') 254 | time_travel.fast_forward(2).minutes() 255 | when_new.click_button() 256 | 257 | time_travel.fast_forward(SHORT_DELAY - 1).minutes() 258 | assert_that(ID['bathroom']['water_heater']).was_not.turned_on() 259 | time_travel.fast_forward(1).minutes() 260 | assert_that(ID['bathroom']['water_heater']).was.turned_on() 261 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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_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_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, name, logger, error, args, config, app_config, global_vars): 11 | super().__init__(ad, name, logger, error, args, config, app_config, global_vars) 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 | result = testdir.runpytest() 48 | result.assert_outcomes(passed=1) 49 | 50 | def test_automation_was_initialized(self, testdir): 51 | testdir.makepyfile( 52 | """ 53 | from appdaemon.plugins.hass.hassapi import Hass 54 | from appdaemontestframework import automation_fixture 55 | 56 | class MockAutomation(Hass): 57 | was_initialized: False 58 | 59 | def initialize(self): 60 | self.was_initialized = True 61 | 62 | 63 | @automation_fixture(MockAutomation) 64 | def mock_automation(): 65 | pass 66 | 67 | def test_was_initialized(mock_automation): 68 | assert mock_automation.was_initialized 69 | """) 70 | 71 | result = testdir.runpytest() 72 | result.assert_outcomes(passed=1) 73 | 74 | def test_calls_to_appdaemon_during_initialize_are_cleared_before_entering_test(self, testdir): 75 | testdir.makepyfile( 76 | """ 77 | from appdaemon.plugins.hass.hassapi import Hass 78 | from appdaemontestframework import automation_fixture 79 | 80 | class MockAutomation(Hass): 81 | def initialize(self): 82 | self.turn_on('light.living_room') 83 | 84 | 85 | @automation_fixture(MockAutomation) 86 | def mock_automation(): 87 | pass 88 | 89 | def test_some_test(mock_automation): 90 | assert mock_automation is not None 91 | 92 | """) 93 | 94 | result = testdir.runpytest() 95 | result.assert_outcomes(passed=1) 96 | 97 | def test_multiple_automations(self, testdir): 98 | testdir.makepyfile( 99 | """ 100 | from appdaemon.plugins.hass.hassapi import Hass 101 | from appdaemontestframework import automation_fixture 102 | 103 | class MockAutomation(Hass): 104 | def initialize(self): 105 | pass 106 | 107 | class OtherMockAutomation(Hass): 108 | def initialize(self): 109 | pass 110 | 111 | 112 | @automation_fixture(MockAutomation, OtherMockAutomation) 113 | def mock_automation(): 114 | pass 115 | 116 | def test_some_test(mock_automation): 117 | assert mock_automation is not None 118 | """) 119 | 120 | result = testdir.runpytest() 121 | result.assert_outcomes(passed=2) 122 | 123 | def test_given_that_fixture_is_injectable_in_automation_fixture(self, testdir): 124 | testdir.makepyfile( 125 | """ 126 | from appdaemon.plugins.hass.hassapi import Hass 127 | from appdaemontestframework import automation_fixture 128 | 129 | class MockAutomation(Hass): 130 | def initialize(self): 131 | pass 132 | 133 | def assert_light_on(self): 134 | assert self.get_state('light.bed') == 'on' 135 | 136 | 137 | @automation_fixture(MockAutomation) 138 | def mock_automation(given_that): 139 | given_that.state_of('light.bed').is_set_to('on') 140 | 141 | def test_some_test(mock_automation): 142 | mock_automation.assert_light_on() 143 | """) 144 | 145 | result = testdir.runpytest() 146 | result.assert_outcomes(passed=1) 147 | 148 | def test_decorator_called_without_automation__raise_error(self, testdir): 149 | testdir.makepyfile( 150 | """ 151 | from appdaemon.plugins.hass.hassapi import Hass 152 | from appdaemontestframework import automation_fixture 153 | 154 | class MockAutomation(Hass): 155 | def initialize(self): 156 | pass 157 | 158 | @automation_fixture 159 | def mock_automation(): 160 | pass 161 | 162 | def test_some_test(mock_automation): 163 | assert mock_automation is not None 164 | """) 165 | 166 | result = testdir.runpytest() 167 | result.assert_outcomes(error=1) 168 | assert expected_error_regex_was_found_in_stdout_lines(result, r"AutomationFixtureError.*argument") 169 | 170 | def test_name_attribute_of_hass_object_set_to_automation_class_name(self, testdir): 171 | testdir.makepyfile( 172 | """ 173 | from appdaemon.plugins.hass.hassapi import Hass 174 | from appdaemontestframework import automation_fixture 175 | 176 | class MockAutomation(Hass): 177 | def initialize(self): 178 | pass 179 | 180 | @automation_fixture(MockAutomation) 181 | def mock_automation(): 182 | pass 183 | 184 | def test_name_attribute_of_hass_object_set_to_automation_class_name(mock_automation): 185 | assert mock_automation.name == 'MockAutomation' 186 | """) 187 | 188 | result = testdir.runpytest() 189 | result.assert_outcomes(passed=1) 190 | 191 | class TestInvalidAutomation: 192 | @fixture 193 | def assert_automation_class_fails(self, testdir): 194 | def wrapper(automation_class_src, expected_error_regex): 195 | # Given: Test file with given automation class 196 | testdir.makepyfile(dedent( 197 | """ 198 | from appdaemon.plugins.hass.hassapi import Hass 199 | from appdaemontestframework import automation_fixture 200 | 201 | %s 202 | 203 | @automation_fixture(MockAutomation) 204 | def mock_automation(): 205 | pass 206 | 207 | def test_some_test(mock_automation): 208 | assert mock_automation is not None 209 | """) % dedent(automation_class_src)) 210 | 211 | # When: Running 'pytest' 212 | result = testdir.runpytest() 213 | 214 | # Then: Found 1 error & stdout has a line with expected error 215 | result.assert_outcomes(error=1) 216 | 217 | if not expected_error_regex_was_found_in_stdout_lines(result, expected_error_regex): 218 | pytest.fail(f"Couldn't fine line matching error: '{expected_error_regex}'") 219 | 220 | return wrapper 221 | 222 | def test_automation_has_no_initialize_function(self, assert_automation_class_fails): 223 | assert_automation_class_fails( 224 | automation_class_src=""" 225 | class MockAutomation(Hass): 226 | def some_other_function(self): 227 | self.turn_on('light.living_room') 228 | """, 229 | expected_error_regex=r"AutomationFixtureError: 'MockAutomation' .* no 'initialize' function") 230 | 231 | def test_initialize_function_has_arguments_other_than_self(self, assert_automation_class_fails): 232 | assert_automation_class_fails( 233 | automation_class_src=""" 234 | class MockAutomation(Hass): 235 | def initialize(self, some_arg): 236 | self.turn_on('light.living_room') 237 | """, 238 | expected_error_regex=r"AutomationFixtureError: 'MockAutomation'.*" 239 | r"'initialize' should have no arguments other than 'self'") 240 | 241 | def test___init___was_overridden(self, assert_automation_class_fails): 242 | assert_automation_class_fails( 243 | automation_class_src=""" 244 | class MockAutomation(Hass): 245 | def __init__(self, ad, name, logger, error, args, config, app_config, global_vars): 246 | super().__init__(ad, name, logger, error, args, config, app_config, global_vars) 247 | self.log("do some things in '__init__'") 248 | 249 | def initialize(self): 250 | self.turn_on('light.living_room') 251 | """, 252 | expected_error_regex=r"AutomationFixtureError: 'MockAutomation'.*should not override '__init__'") 253 | 254 | # noinspection PyPep8Naming,SpellCheckingInspection 255 | def test_not_a_subclass_of_Hass(self, assert_automation_class_fails): 256 | assert_automation_class_fails( 257 | automation_class_src=""" 258 | class MockAutomation: 259 | def initialize(self): 260 | pass 261 | """, 262 | expected_error_regex=r"AutomationFixtureError: 'MockAutomation'.*should be a subclass of 'Hass'") 263 | 264 | class TestWithArgs: 265 | def test_automation_is_injected_with_args(self, testdir): 266 | testdir.makepyfile( 267 | """ 268 | from appdaemon.plugins.hass.hassapi import Hass 269 | from appdaemontestframework import automation_fixture 270 | 271 | class MockAutomation(Hass): 272 | def initialize(self): 273 | pass 274 | 275 | 276 | @automation_fixture((MockAutomation, "some_arg")) 277 | def mock_automation_with_args(): 278 | pass 279 | 280 | def test_automation_was_injected_with_args(mock_automation_with_args): 281 | automation = mock_automation_with_args[0] 282 | arg = mock_automation_with_args[1] 283 | 284 | assert isinstance(automation, MockAutomation) 285 | assert arg == "some_arg" 286 | """) 287 | 288 | result = testdir.runpytest() 289 | result.assert_outcomes(passed=1) 290 | 291 | def test_multiple_automation_are_injected_with_args(self, testdir): 292 | testdir.makepyfile( 293 | """ 294 | from appdaemon.plugins.hass.hassapi import Hass 295 | from appdaemontestframework import automation_fixture 296 | 297 | class MockAutomation(Hass): 298 | def initialize(self): 299 | pass 300 | 301 | class OtherAutomation(Hass): 302 | def initialize(self): 303 | pass 304 | 305 | 306 | @automation_fixture( 307 | (MockAutomation, "some_arg"), 308 | (OtherAutomation, "other_arg") 309 | ) 310 | def mock_automation_with_args(): 311 | pass 312 | 313 | def test_automation_was_injected_with_args(mock_automation_with_args): 314 | automation = mock_automation_with_args[0] 315 | arg = mock_automation_with_args[1] 316 | 317 | assert isinstance(automation, MockAutomation) or isinstance(automation, OtherAutomation) 318 | assert arg == "some_arg" or arg == "other_arg" 319 | """) 320 | 321 | result = testdir.runpytest() 322 | result.assert_outcomes(passed=2) 323 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/test_logging.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | 3 | import pytest 4 | from pytest import mark 5 | 6 | 7 | @mark.using_pytester 8 | class TestLearningTest: 9 | def test_logging_failure(self, testdir): 10 | testdir.makepyfile( 11 | """ 12 | import logging 13 | 14 | def test_log_failure(caplog): 15 | caplog.set_level(logging.INFO) 16 | logging.info("logging failure") 17 | assert 1 == 2 18 | """) 19 | result = testdir.runpytest() 20 | result.stdout.re_match_lines_random(r'.*logging failure.*') 21 | 22 | def test_not_logging_success(self, testdir): 23 | testdir.makepyfile( 24 | """ 25 | import logging 26 | 27 | def test_log_success(caplog): 28 | caplog.set_level(logging.INFO) 29 | logging.info("logging success") 30 | assert 1 == 1 31 | """) 32 | result = testdir.runpytest() 33 | with pytest.raises(ValueError): 34 | result.stdout.re_match_lines_random(r'.*logging success.*') 35 | 36 | 37 | def inject_mock_automation_and_run_test(testdir, test_src): 38 | testdir.makepyfile(dedent( 39 | """ 40 | from appdaemon.plugins.hass.hassapi import Hass 41 | from appdaemontestframework import automation_fixture 42 | 43 | class MockAutomation(Hass): 44 | def initialize(self): 45 | pass 46 | 47 | def log_error(self, msg, level=None): 48 | if level: 49 | self.error(msg, level) 50 | else: 51 | self.error(msg) 52 | 53 | def log_log(self, msg, level=None): 54 | if level: 55 | self.log(msg, level) 56 | else: 57 | self.log(msg) 58 | 59 | @automation_fixture(MockAutomation) 60 | def mock_automation(): 61 | pass 62 | 63 | %s 64 | 65 | """) % dedent(test_src)) 66 | 67 | return testdir.runpytest() 68 | 69 | 70 | @mark.using_pytester 71 | @mark.usefixtures('configure_appdaemontestframework_for_pytester') 72 | class TestLogging: 73 | def test_error(self, testdir): 74 | result = inject_mock_automation_and_run_test( 75 | testdir, 76 | """ 77 | def test_failing_test_with_log_error(mock_automation): 78 | mock_automation.log_error("logging some error") 79 | assert 1 == 2 80 | """) 81 | result.stdout.fnmatch_lines_random('*ERROR*logging some error*') 82 | 83 | def test_error_with_level(self, testdir): 84 | result = inject_mock_automation_and_run_test( 85 | testdir, 86 | """ 87 | def test_log_level_not_set__info(mock_automation): 88 | mock_automation.log_error("should not show", 'INFO') 89 | assert 1 == 2 90 | 91 | def test_log_level_not_set__warning(mock_automation): 92 | mock_automation.log_error("should show", 'WARNING') 93 | assert 1 == 2 94 | 95 | def test_log_level_set_to_info(mock_automation, caplog): 96 | import logging 97 | caplog.set_level(logging.INFO) 98 | mock_automation.log_error("should show", 'INFO') 99 | assert 1 == 2 100 | """) 101 | with pytest.raises(ValueError): 102 | result.stdout.fnmatch_lines_random('*INFO*should not show*') 103 | result.stdout.fnmatch_lines_random('*INFO*should show*') 104 | 105 | def test_log(self, testdir): 106 | result = inject_mock_automation_and_run_test( 107 | testdir, 108 | """ 109 | def test_log_level_not_set(mock_automation): 110 | mock_automation.log_log("should not show") 111 | assert 1 == 2 112 | 113 | def test_log_level_set_to_info(mock_automation, caplog): 114 | import logging 115 | caplog.set_level(logging.INFO) 116 | mock_automation.log_log("should show") 117 | assert 1 == 2 118 | """) 119 | 120 | with pytest.raises(ValueError): 121 | result.stdout.fnmatch_lines_random('*INFO*should not show*') 122 | result.stdout.fnmatch_lines_random('*INFO*should show*') 123 | 124 | def test_log_with_level(self, testdir): 125 | result = inject_mock_automation_and_run_test( 126 | testdir, 127 | """ 128 | def test_log_level_not_set__info(mock_automation): 129 | mock_automation.log_log("should not show", 'INFO') 130 | assert 1 == 2 131 | 132 | def test_log_level_not_set__warning(mock_automation): 133 | mock_automation.log_log("should show", 'WARNING') 134 | assert 1 == 2 135 | 136 | def test_log_level_set_to_info(mock_automation, caplog): 137 | import logging 138 | caplog.set_level(logging.INFO) 139 | mock_automation.log_log("should show", 'INFO') 140 | assert 1 == 2 141 | """) 142 | with pytest.raises(ValueError): 143 | result.stdout.fnmatch_lines_random('*INFO*should not show*') 144 | result.stdout.fnmatch_lines_random('*INFO*should show*') 145 | result.stdout.fnmatch_lines_random('*WARNING*should show*') 146 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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_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 | def test_callback_not_called_before_timeout(time_travel, automation): 19 | foo = mock.Mock() 20 | automation.run_in(foo, 10) 21 | time_travel.fast_forward(5).seconds() 22 | foo.assert_not_called() 23 | 24 | 25 | def test_callback_called_after_timeout(time_travel, automation): 26 | foo = mock.Mock() 27 | automation.run_in(foo, 10) 28 | time_travel.fast_forward(20).seconds() 29 | foo.assert_called() 30 | 31 | 32 | def test_canceled_timer_does_not_run_callback(time_travel, automation): 33 | foo = mock.Mock() 34 | handle = automation.run_in(foo, 10) 35 | time_travel.fast_forward(5).seconds() 36 | automation.cancel_timer(handle) 37 | time_travel.fast_forward(10).seconds() 38 | foo.assert_not_called() 39 | 40 | 41 | class Test_fast_forward: 42 | @staticmethod 43 | @pytest.fixture 44 | def automation_at_noon(automation, time_travel, given_that): 45 | given_that.time_is(datetime.datetime(2020, 1, 1, 12, 0)) 46 | return automation 47 | 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 = [mock.call.first_mock({}), mock.call.second_mock({}), mock.call.third_mock({})] 74 | assert manager.mock_calls == expected_call_order 75 | 76 | def test_callback_not_called_before_timeout(self, time_travel, automation): 77 | callback_mock = mock.Mock() 78 | automation.run_in(callback_mock, 10) 79 | time_travel.fast_forward(5).seconds() 80 | callback_mock.assert_not_called() 81 | 82 | def test_callback_called_after_timeout(self, time_travel, automation): 83 | scheduled_callback = mock.Mock(name="Scheduled Callback") 84 | automation.run_in(scheduled_callback, 10) 85 | time_travel.fast_forward(20).seconds() 86 | scheduled_callback.assert_called() 87 | 88 | def test_canceled_timer_does_not_run_callback(self, time_travel, automation): 89 | callback_mock = mock.Mock() 90 | handle = automation.run_in(callback_mock, 10) 91 | time_travel.fast_forward(5).seconds() 92 | automation.cancel_timer(handle) 93 | time_travel.fast_forward(10).seconds() 94 | callback_mock.assert_not_called() 95 | 96 | def test_time_is_correct_when_callback_it_run(self, time_travel, given_that, automation): 97 | given_that.time_is(datetime.datetime(2020, 1, 1, 12, 0)) 98 | 99 | time_when_called = [] 100 | def callback(kwargs): 101 | nonlocal time_when_called 102 | time_when_called.append(automation.datetime()) 103 | 104 | automation.run_in(callback, 1) 105 | automation.run_in(callback, 15) 106 | automation.run_in(callback, 65) 107 | time_travel.fast_forward(90).seconds() 108 | 109 | expected_call_times = [ 110 | datetime.datetime(2020, 1, 1, 12, 0, 1), 111 | datetime.datetime(2020, 1, 1, 12, 0, 15), 112 | datetime.datetime(2020, 1, 1, 12, 1, 5), 113 | ] 114 | assert expected_call_times == time_when_called 115 | 116 | def test_callback_called_with_correct_args(self, time_travel, automation): 117 | callback_mock = mock.Mock() 118 | automation.run_in(callback_mock, 1, arg1='asdf', arg2='qwerty') 119 | time_travel.fast_forward(10).seconds() 120 | callback_mock.assert_called_once_with({'arg1': 'asdf', 'arg2': 'qwerty'}) -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py37, py38 3 | 4 | [testenv] 5 | deps = 6 | pytest >=5.3.0,<5.4.0 7 | pytest-asyncio ==0.10.0 8 | commands = 9 | pytest 10 | --------------------------------------------------------------------------------