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