Prechádzať zdrojové kódy

Completed tests for all light and lock components

Nik Rolls 5 rokov pred
rodič
commit
4980c8883a

+ 7 - 1
.vscode/tasks.json

@@ -4,7 +4,7 @@
     {
       "label": "Install requirements",
       "type": "shell",
-      "command": "pip3 install pycrypto==2.6.1;pip3 install pytuya==7.0.5",
+      "command": "pip3 install -r requirements-first.txt;pip3 install -r requirements-dev.txt",
       "problemMatcher": []
     },
     {
@@ -36,6 +36,12 @@
       "type": "shell",
       "command": "pytest --cov=. --cov-config=.coveragerc --cov-report xml:coverage.xml",
       "problemMatcher": []
+    },
+    {
+      "label": "Reformat code",
+      "type": "shell",
+      "command": "isort --recursive . &&  black .",
+      "problemMatcher": []
     }
   ]
 }

+ 3 - 4
custom_components/goldair_climate/__init__.py

@@ -9,28 +9,27 @@ import logging
 
 import homeassistant.helpers.config_validation as cv
 import voluptuous as vol
-from homeassistant.config_entries import ConfigEntry, SOURCE_IMPORT
+from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
 from homeassistant.const import CONF_HOST, CONF_NAME
 from homeassistant.core import HomeAssistant
 from homeassistant.helpers.discovery import async_load_platform
 
 from .configuration import individual_config_schema
 from .const import (
-    DOMAIN,
     CONF_CHILD_LOCK,
     CONF_CLIMATE,
     CONF_DEVICE_ID,
     CONF_DISPLAY_LIGHT,
     CONF_LOCAL_KEY,
     CONF_TYPE,
+    CONF_TYPE_AUTO,
     CONF_TYPE_DEHUMIDIFIER,
     CONF_TYPE_FAN,
     CONF_TYPE_GPPH_HEATER,
+    DOMAIN,
     SCAN_INTERVAL,
-    CONF_TYPE_AUTO,
 )
 from .device import GoldairTuyaDevice
-from .config_flow import ConfigFlowHandler
 
 _LOGGER = logging.getLogger(__name__)
 

+ 2 - 2
custom_components/goldair_climate/climate.py

@@ -3,15 +3,15 @@ Setup for different kinds of Goldair climate devices
 """
 from . import DOMAIN
 from .const import (
+    CONF_CLIMATE,
     CONF_DEVICE_ID,
     CONF_TYPE,
+    CONF_TYPE_AUTO,
     CONF_TYPE_DEHUMIDIFIER,
     CONF_TYPE_FAN,
     CONF_TYPE_GECO_HEATER,
     CONF_TYPE_GPCV_HEATER,
     CONF_TYPE_GPPH_HEATER,
-    CONF_CLIMATE,
-    CONF_TYPE_AUTO,
 )
 from .dehumidifier.climate import GoldairDehumidifier
 from .fan.climate import GoldairFan

+ 5 - 4
custom_components/goldair_climate/config_flow.py

@@ -1,11 +1,12 @@
+import logging
+
 import voluptuous as vol
 from homeassistant import config_entries, data_entry_flow
-from homeassistant.const import CONF_NAME, CONF_HOST
-from homeassistant.core import callback, HomeAssistant
+from homeassistant.const import CONF_HOST, CONF_NAME
+from homeassistant.core import HomeAssistant, callback
 
-from . import DOMAIN, individual_config_schema, GoldairTuyaDevice
+from . import DOMAIN, GoldairTuyaDevice, individual_config_schema
 from .const import CONF_DEVICE_ID, CONF_LOCAL_KEY
-import logging
 
 _LOGGER = logging.getLogger(__name__)
 

+ 6 - 6
custom_components/goldair_climate/configuration.py

@@ -1,19 +1,19 @@
 import voluptuous as vol
-from homeassistant.const import CONF_NAME, CONF_HOST
+from homeassistant.const import CONF_HOST, CONF_NAME
 
 from .const import (
+    CONF_CHILD_LOCK,
+    CONF_CLIMATE,
     CONF_DEVICE_ID,
+    CONF_DISPLAY_LIGHT,
     CONF_LOCAL_KEY,
     CONF_TYPE,
-    CONF_TYPE_GPPH_HEATER,
+    CONF_TYPE_AUTO,
     CONF_TYPE_DEHUMIDIFIER,
     CONF_TYPE_FAN,
     CONF_TYPE_GECO_HEATER,
     CONF_TYPE_GPCV_HEATER,
-    CONF_CLIMATE,
-    CONF_DISPLAY_LIGHT,
-    CONF_CHILD_LOCK,
-    CONF_TYPE_AUTO,
+    CONF_TYPE_GPPH_HEATER,
 )
 
 INDIVIDUAL_CONFIG_SCHEMA_TEMPLATE = [

+ 3 - 1
custom_components/goldair_climate/dehumidifier/light.py

@@ -68,7 +68,9 @@ class GoldairDehumidifierLedDisplayLight(LightEntity):
         dps_display_on = self._device.get_property(PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON])
 
         if dps_hvac_mode != HVAC_MODE_TO_DPS_MODE[HVAC_MODE_OFF]:
-            await (self.turn_on() if not dps_display_on else self.turn_off())
+            await (
+                self.async_turn_on() if not dps_display_on else self.async_turn_off()
+            )
 
     async def async_update(self):
         await self._device.async_refresh()

+ 2 - 2
custom_components/goldair_climate/device.py

@@ -5,19 +5,19 @@ API for Goldair Tuya devices.
 import json
 import logging
 from threading import Lock, Timer
-from time import time, sleep
+from time import sleep, time
 
 from homeassistant.const import TEMP_CELSIUS
 from homeassistant.core import HomeAssistant
 
 from .const import (
-    DOMAIN,
     API_PROTOCOL_VERSIONS,
     CONF_TYPE_DEHUMIDIFIER,
     CONF_TYPE_FAN,
     CONF_TYPE_GECO_HEATER,
     CONF_TYPE_GPCV_HEATER,
     CONF_TYPE_GPPH_HEATER,
+    DOMAIN,
 )
 
 _LOGGER = logging.getLogger(__name__)

+ 2 - 2
custom_components/goldair_climate/light.py

@@ -4,14 +4,14 @@ Setup for different kinds of Goldair climate devices
 from . import DOMAIN
 from .const import (
     CONF_DEVICE_ID,
+    CONF_DISPLAY_LIGHT,
     CONF_TYPE,
+    CONF_TYPE_AUTO,
     CONF_TYPE_DEHUMIDIFIER,
     CONF_TYPE_FAN,
     CONF_TYPE_GECO_HEATER,
     CONF_TYPE_GPCV_HEATER,
     CONF_TYPE_GPPH_HEATER,
-    CONF_DISPLAY_LIGHT,
-    CONF_TYPE_AUTO,
 )
 from .dehumidifier.light import GoldairDehumidifierLedDisplayLight
 from .fan.light import GoldairFanLedDisplayLight

+ 5 - 4
custom_components/goldair_climate/lock.py

@@ -1,23 +1,24 @@
 """
 Setup for different kinds of Goldair climate devices
 """
+import logging
+
 from . import DOMAIN
 from .const import (
+    CONF_CHILD_LOCK,
     CONF_DEVICE_ID,
     CONF_TYPE,
+    CONF_TYPE_AUTO,
     CONF_TYPE_DEHUMIDIFIER,
     CONF_TYPE_FAN,
     CONF_TYPE_GECO_HEATER,
     CONF_TYPE_GPCV_HEATER,
     CONF_TYPE_GPPH_HEATER,
-    CONF_CHILD_LOCK,
-    CONF_TYPE_AUTO,
 )
 from .dehumidifier.lock import GoldairDehumidifierChildLock
-from .gpcv_heater.lock import GoldairGPCVHeaterChildLock
 from .geco_heater.lock import GoldairGECOHeaterChildLock
+from .gpcv_heater.lock import GoldairGPCVHeaterChildLock
 from .heater.lock import GoldairHeaterChildLock
-import logging
 
 _LOGGER = logging.getLogger(__name__)
 

+ 0 - 0
tests/dehumidifier/__init__.py


+ 98 - 0
tests/dehumidifier/test_light.py

@@ -0,0 +1,98 @@
+from unittest import IsolatedAsyncioTestCase
+from unittest.mock import AsyncMock, patch
+
+from custom_components.goldair_climate.dehumidifier.const import (
+    ATTR_DISPLAY_ON,
+    ATTR_HVAC_MODE,
+    PROPERTY_TO_DPS_ID,
+)
+from custom_components.goldair_climate.dehumidifier.light import (
+    GoldairDehumidifierLedDisplayLight,
+)
+
+from ..const import DEHUMIDIFIER_PAYLOAD
+from ..helpers import assert_device_properties_set
+
+
+class TestLight(IsolatedAsyncioTestCase):
+    def setUp(self):
+        device_patcher = patch(
+            "custom_components.goldair_climate.dehumidifier.light.GoldairTuyaDevice"
+        )
+        self.addCleanup(device_patcher.stop)
+        self.mock_device = device_patcher.start()
+
+        self.subject = GoldairDehumidifierLedDisplayLight(self.mock_device())
+
+        self.dps = DEHUMIDIFIER_PAYLOAD.copy()
+        self.subject._device.get_property.side_effect = lambda id: self.dps[id]
+
+    def test_should_poll(self):
+        self.assertTrue(self.subject.should_poll)
+
+    def test_name_returns_device_name(self):
+        self.assertEqual(self.subject.name, self.subject._device.name)
+
+    def test_unique_id_returns_device_unique_id(self):
+        self.assertEqual(self.subject.unique_id, self.subject._device.unique_id)
+
+    def test_device_info_returns_device_info_from_device(self):
+        self.assertEqual(self.subject.device_info, self.subject._device.device_info)
+
+    def test_icon(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]] = True
+        self.assertEqual(self.subject.icon, "mdi:led-on")
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]] = False
+        self.assertEqual(self.subject.icon, "mdi:led-off")
+
+    def test_is_on(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]] = True
+        self.assertEqual(self.subject.is_on, True)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]] = False
+        self.assertEqual(self.subject.is_on, False)
+
+    async def test_turn_on(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]: True}
+        ):
+            await self.subject.async_turn_on()
+
+    async def test_turn_off(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]: False}
+        ):
+            await self.subject.async_turn_off()
+
+    async def test_toggle_takes_no_action_when_dehumidifier_off(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = False
+        await self.subject.async_toggle()
+        self.subject._device.async_set_property.assert_not_called
+
+    async def test_toggle_turns_the_light_on_when_it_was_off(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = True
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]] = False
+
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]: True}
+        ):
+            await self.subject.async_toggle()
+
+    async def test_toggle_turns_the_light_off_when_it_was_on(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = True
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]] = True
+
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]: False}
+        ):
+            await self.subject.async_toggle()
+
+    async def test_update(self):
+        result = AsyncMock()
+        self.subject._device.async_refresh.return_value = result()
+
+        await self.subject.async_update()
+
+        self.subject._device.async_refresh.assert_called_once()
+        result.assert_awaited()

+ 68 - 0
tests/dehumidifier/test_lock.py

@@ -0,0 +1,68 @@
+from unittest import IsolatedAsyncioTestCase
+from unittest.mock import AsyncMock, patch
+
+from custom_components.goldair_climate.dehumidifier.const import (
+    ATTR_CHILD_LOCK,
+    ATTR_HVAC_MODE,
+    PROPERTY_TO_DPS_ID,
+)
+from custom_components.goldair_climate.dehumidifier.lock import (
+    GoldairDehumidifierChildLock,
+)
+
+from ..const import DEHUMIDIFIER_PAYLOAD
+from ..helpers import assert_device_properties_set
+
+
+class TestLock(IsolatedAsyncioTestCase):
+    def setUp(self):
+        device_patcher = patch(
+            "custom_components.goldair_climate.dehumidifier.lock.GoldairTuyaDevice"
+        )
+        self.addCleanup(device_patcher.stop)
+        self.mock_device = device_patcher.start()
+
+        self.subject = GoldairDehumidifierChildLock(self.mock_device())
+
+        self.dps = DEHUMIDIFIER_PAYLOAD.copy()
+        self.subject._device.get_property.side_effect = lambda id: self.dps[id]
+
+    def test_should_poll(self):
+        self.assertTrue(self.subject.should_poll)
+
+    def test_name_returns_device_name(self):
+        self.assertEqual(self.subject.name, self.subject._device.name)
+
+    def test_unique_id_returns_device_unique_id(self):
+        self.assertEqual(self.subject.unique_id, self.subject._device.unique_id)
+
+    def test_device_info_returns_device_info_from_device(self):
+        self.assertEqual(self.subject.device_info, self.subject._device.device_info)
+
+    def test_is_locked(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]] = True
+        self.assertEqual(self.subject.is_locked, True)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]] = False
+        self.assertEqual(self.subject.is_locked, False)
+
+    async def test_lock(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]: True}
+        ):
+            await self.subject.async_lock()
+
+    async def test_unlock(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]: False}
+        ):
+            await self.subject.async_unlock()
+
+    async def test_update(self):
+        result = AsyncMock()
+        self.subject._device.async_refresh.return_value = result()
+
+        await self.subject.async_update()
+
+        self.subject._device.async_refresh.assert_called_once()
+        result.assert_awaited()

+ 17 - 34
tests/fan/test_light.py

@@ -9,6 +9,7 @@ from custom_components.goldair_climate.fan.const import (
 from custom_components.goldair_climate.fan.light import GoldairFanLedDisplayLight
 
 from ..const import FAN_PAYLOAD
+from ..helpers import assert_device_properties_set
 
 
 class TestLight(IsolatedAsyncioTestCase):
@@ -51,26 +52,16 @@ class TestLight(IsolatedAsyncioTestCase):
         self.assertEqual(self.subject.is_on, False)
 
     async def test_turn_on(self):
-        result = AsyncMock()
-        self.subject._device.async_set_property.return_value = result()
-
-        await self.subject.async_turn_on()
-
-        self.subject._device.async_set_property.assert_called_once_with(
-            PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON], True
-        )
-        result.assert_awaited()
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]: True}
+        ):
+            await self.subject.async_turn_on()
 
     async def test_turn_off(self):
-        result = AsyncMock()
-        self.subject._device.async_set_property.return_value = result()
-
-        await self.subject.async_turn_off()
-
-        self.subject._device.async_set_property.assert_called_once_with(
-            PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON], False
-        )
-        result.assert_awaited()
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]: False}
+        ):
+            await self.subject.async_turn_off()
 
     async def test_toggle_takes_no_action_when_fan_off(self):
         self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = False
@@ -78,30 +69,22 @@ class TestLight(IsolatedAsyncioTestCase):
         self.subject._device.async_set_property.assert_not_called
 
     async def test_toggle_turns_the_light_on_when_it_was_off(self):
-        result = AsyncMock()
-        self.subject._device.async_set_property.return_value = result()
         self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = True
         self.dps[PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]] = False
 
-        await self.subject.async_toggle()
-
-        self.subject._device.async_set_property.assert_called_once_with(
-            PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON], True
-        )
-        result.assert_awaited()
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]: True}
+        ):
+            await self.subject.async_toggle()
 
     async def test_toggle_turns_the_light_off_when_it_was_on(self):
-        result = AsyncMock()
-        self.subject._device.async_set_property.return_value = result()
         self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = True
         self.dps[PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]] = True
 
-        await self.subject.async_toggle()
-
-        self.subject._device.async_set_property.assert_called_once_with(
-            PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON], False
-        )
-        result.assert_awaited()
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]: False}
+        ):
+            await self.subject.async_toggle()
 
     async def test_update(self):
         result = AsyncMock()

+ 0 - 0
tests/geco_heater/__init__.py


+ 68 - 0
tests/geco_heater/test_lock.py

@@ -0,0 +1,68 @@
+from unittest import IsolatedAsyncioTestCase
+from unittest.mock import AsyncMock, patch
+
+from custom_components.goldair_climate.gpcv_heater.const import (
+    ATTR_CHILD_LOCK,
+    ATTR_HVAC_MODE,
+    PROPERTY_TO_DPS_ID,
+)
+from custom_components.goldair_climate.gpcv_heater.lock import (
+    GoldairGPCVHeaterChildLock,
+)
+
+from ..const import GPCV_HEATER_PAYLOAD
+from ..helpers import assert_device_properties_set
+
+
+class TestLock(IsolatedAsyncioTestCase):
+    def setUp(self):
+        device_patcher = patch(
+            "custom_components.goldair_climate.gpcv_heater.lock.GoldairTuyaDevice"
+        )
+        self.addCleanup(device_patcher.stop)
+        self.mock_device = device_patcher.start()
+
+        self.subject = GoldairGPCVHeaterChildLock(self.mock_device())
+
+        self.dps = GPCV_HEATER_PAYLOAD.copy()
+        self.subject._device.get_property.side_effect = lambda id: self.dps[id]
+
+    def test_should_poll(self):
+        self.assertTrue(self.subject.should_poll)
+
+    def test_name_returns_device_name(self):
+        self.assertEqual(self.subject.name, self.subject._device.name)
+
+    def test_unique_id_returns_device_unique_id(self):
+        self.assertEqual(self.subject.unique_id, self.subject._device.unique_id)
+
+    def test_device_info_returns_device_info_from_device(self):
+        self.assertEqual(self.subject.device_info, self.subject._device.device_info)
+
+    def test_is_locked(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]] = True
+        self.assertEqual(self.subject.is_locked, True)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]] = False
+        self.assertEqual(self.subject.is_locked, False)
+
+    async def test_lock(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]: True}
+        ):
+            await self.subject.async_lock()
+
+    async def test_unlock(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]: False}
+        ):
+            await self.subject.async_unlock()
+
+    async def test_update(self):
+        result = AsyncMock()
+        self.subject._device.async_refresh.return_value = result()
+
+        await self.subject.async_update()
+
+        self.subject._device.async_refresh.assert_called_once()
+        result.assert_awaited()

+ 0 - 0
tests/gpcv_heater/__init__.py


+ 68 - 0
tests/gpcv_heater/test_lock.py

@@ -0,0 +1,68 @@
+from unittest import IsolatedAsyncioTestCase
+from unittest.mock import AsyncMock, patch
+
+from custom_components.goldair_climate.geco_heater.const import (
+    ATTR_CHILD_LOCK,
+    ATTR_HVAC_MODE,
+    PROPERTY_TO_DPS_ID,
+)
+from custom_components.goldair_climate.geco_heater.lock import (
+    GoldairGECOHeaterChildLock,
+)
+
+from ..const import GECO_HEATER_PAYLOAD
+from ..helpers import assert_device_properties_set
+
+
+class TestLock(IsolatedAsyncioTestCase):
+    def setUp(self):
+        device_patcher = patch(
+            "custom_components.goldair_climate.geco_heater.lock.GoldairTuyaDevice"
+        )
+        self.addCleanup(device_patcher.stop)
+        self.mock_device = device_patcher.start()
+
+        self.subject = GoldairGECOHeaterChildLock(self.mock_device())
+
+        self.dps = GECO_HEATER_PAYLOAD.copy()
+        self.subject._device.get_property.side_effect = lambda id: self.dps[id]
+
+    def test_should_poll(self):
+        self.assertTrue(self.subject.should_poll)
+
+    def test_name_returns_device_name(self):
+        self.assertEqual(self.subject.name, self.subject._device.name)
+
+    def test_unique_id_returns_device_unique_id(self):
+        self.assertEqual(self.subject.unique_id, self.subject._device.unique_id)
+
+    def test_device_info_returns_device_info_from_device(self):
+        self.assertEqual(self.subject.device_info, self.subject._device.device_info)
+
+    def test_is_locked(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]] = True
+        self.assertEqual(self.subject.is_locked, True)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]] = False
+        self.assertEqual(self.subject.is_locked, False)
+
+    async def test_lock(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]: True}
+        ):
+            await self.subject.async_lock()
+
+    async def test_unlock(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]: False}
+        ):
+            await self.subject.async_unlock()
+
+    async def test_update(self):
+        result = AsyncMock()
+        self.subject._device.async_refresh.return_value = result()
+
+        await self.subject.async_update()
+
+        self.subject._device.async_refresh.assert_called_once()
+        result.assert_awaited()

+ 0 - 0
tests/heater/__init__.py


+ 96 - 0
tests/heater/test_light.py

@@ -0,0 +1,96 @@
+from unittest import IsolatedAsyncioTestCase
+from unittest.mock import AsyncMock, patch
+
+from custom_components.goldair_climate.heater.const import (
+    ATTR_DISPLAY_ON,
+    ATTR_HVAC_MODE,
+    PROPERTY_TO_DPS_ID,
+)
+from custom_components.goldair_climate.heater.light import GoldairHeaterLedDisplayLight
+
+from ..const import GPPH_HEATER_PAYLOAD
+from ..helpers import assert_device_properties_set
+
+
+class TestLight(IsolatedAsyncioTestCase):
+    def setUp(self):
+        device_patcher = patch(
+            "custom_components.goldair_climate.heater.light.GoldairTuyaDevice"
+        )
+        self.addCleanup(device_patcher.stop)
+        self.mock_device = device_patcher.start()
+
+        self.subject = GoldairHeaterLedDisplayLight(self.mock_device())
+
+        self.dps = GPPH_HEATER_PAYLOAD.copy()
+        self.subject._device.get_property.side_effect = lambda id: self.dps[id]
+
+    def test_should_poll(self):
+        self.assertTrue(self.subject.should_poll)
+
+    def test_name_returns_device_name(self):
+        self.assertEqual(self.subject.name, self.subject._device.name)
+
+    def test_unique_id_returns_device_unique_id(self):
+        self.assertEqual(self.subject.unique_id, self.subject._device.unique_id)
+
+    def test_device_info_returns_device_info_from_device(self):
+        self.assertEqual(self.subject.device_info, self.subject._device.device_info)
+
+    def test_icon(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]] = True
+        self.assertEqual(self.subject.icon, "mdi:led-on")
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]] = False
+        self.assertEqual(self.subject.icon, "mdi:led-off")
+
+    def test_is_on(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]] = True
+        self.assertEqual(self.subject.is_on, True)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]] = False
+        self.assertEqual(self.subject.is_on, False)
+
+    async def test_turn_on(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]: True}
+        ):
+            await self.subject.async_turn_on()
+
+    async def test_turn_off(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]: False}
+        ):
+            await self.subject.async_turn_off()
+
+    async def test_toggle_takes_no_action_when_heater_off(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = False
+        await self.subject.async_toggle()
+        self.subject._device.async_set_property.assert_not_called
+
+    async def test_toggle_turns_the_light_on_when_it_was_off(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = True
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]] = False
+
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]: True}
+        ):
+            await self.subject.async_toggle()
+
+    async def test_toggle_turns_the_light_off_when_it_was_on(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE]] = True
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]] = True
+
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON]: False}
+        ):
+            await self.subject.async_toggle()
+
+    async def test_update(self):
+        result = AsyncMock()
+        self.subject._device.async_refresh.return_value = result()
+
+        await self.subject.async_update()
+
+        self.subject._device.async_refresh.assert_called_once()
+        result.assert_awaited()

+ 66 - 0
tests/heater/test_lock.py

@@ -0,0 +1,66 @@
+from unittest import IsolatedAsyncioTestCase
+from unittest.mock import AsyncMock, patch
+
+from custom_components.goldair_climate.heater.const import (
+    ATTR_CHILD_LOCK,
+    ATTR_HVAC_MODE,
+    PROPERTY_TO_DPS_ID,
+)
+from custom_components.goldair_climate.heater.lock import GoldairHeaterChildLock
+
+from ..const import GPPH_HEATER_PAYLOAD
+from ..helpers import assert_device_properties_set
+
+
+class TestLock(IsolatedAsyncioTestCase):
+    def setUp(self):
+        device_patcher = patch(
+            "custom_components.goldair_climate.heater.lock.GoldairTuyaDevice"
+        )
+        self.addCleanup(device_patcher.stop)
+        self.mock_device = device_patcher.start()
+
+        self.subject = GoldairHeaterChildLock(self.mock_device())
+
+        self.dps = GPPH_HEATER_PAYLOAD.copy()
+        self.subject._device.get_property.side_effect = lambda id: self.dps[id]
+
+    def test_should_poll(self):
+        self.assertTrue(self.subject.should_poll)
+
+    def test_name_returns_device_name(self):
+        self.assertEqual(self.subject.name, self.subject._device.name)
+
+    def test_unique_id_returns_device_unique_id(self):
+        self.assertEqual(self.subject.unique_id, self.subject._device.unique_id)
+
+    def test_device_info_returns_device_info_from_device(self):
+        self.assertEqual(self.subject.device_info, self.subject._device.device_info)
+
+    def test_is_locked(self):
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]] = True
+        self.assertEqual(self.subject.is_locked, True)
+
+        self.dps[PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]] = False
+        self.assertEqual(self.subject.is_locked, False)
+
+    async def test_lock(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]: True}
+        ):
+            await self.subject.async_lock()
+
+    async def test_unlock(self):
+        async with assert_device_properties_set(
+            self.subject._device, {PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK]: False}
+        ):
+            await self.subject.async_unlock()
+
+    async def test_update(self):
+        result = AsyncMock()
+        self.subject._device.async_refresh.return_value = result()
+
+        await self.subject.async_update()
+
+        self.subject._device.async_refresh.assert_called_once()
+        result.assert_awaited()

+ 17 - 0
tests/helpers.py

@@ -0,0 +1,17 @@
+from contextlib import asynccontextmanager
+from unittest.mock import AsyncMock
+
+from custom_components.goldair_climate.device import GoldairTuyaDevice
+
+
+@asynccontextmanager
+async def assert_device_properties_set(device: GoldairTuyaDevice, properties: dict):
+    result = AsyncMock()
+    device.async_set_property.return_value = result()
+
+    try:
+        yield
+    finally:
+        for key in properties.keys():
+            device.async_set_property.assert_called_once_with(key, properties[key])
+        result.assert_awaited()