Skip to content

Commit

Permalink
Add tests, dynamic battery icon and state class to odometer sensor (#86)
Browse files Browse the repository at this point in the history
* Initial testing

* Update test dependencies

* Bugfix

* Remove useless test

* Add state class to odometer

* Change battery icon to reflect charge level
  • Loading branch information
dan-r authored Jan 25, 2025
1 parent 8ad74d6 commit fd00c7e
Show file tree
Hide file tree
Showing 16 changed files with 515 additions and 7 deletions.
18 changes: 17 additions & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,20 @@ jobs:
- name: HACS Action
uses: "hacs/action@main"
with:
category: "integration"
category: "integration"
pytest:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: asdf_install
uses: asdf-vm/actions/install@v3
- name: Install Python modules
run: |
pip install -r requirements.test.txt
- name: Run unit tests
run: |
python -m pytest tests
1 change: 1 addition & 0 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
python 3.11.3
1 change: 0 additions & 1 deletion custom_components/nissan_connect/device_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ async def async_setup_entry(hass, entry, async_add_entities):

async_add_entities(entities, update_before_add=True)

return True

class KamereonDeviceTracker(KamereonEntity, TrackerEntity):
_attr_translation_key = "location"
Expand Down
3 changes: 1 addition & 2 deletions custom_components/nissan_connect/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,9 @@
"@dan-r"
],
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/dan-r/HomeAssistant-NissanConnect/",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/dan-r/HomeAssistant-NissanConnect/issues",
"requirements": ["requests", "requests_oauthlib", "pytz"],
"requirements": ["requests", "requests_oauthlib"],
"version": "1.0.0"
}
13 changes: 11 additions & 2 deletions custom_components/nissan_connect/sensor.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
import math

from homeassistant.components.sensor import (
SensorDeviceClass,
Expand All @@ -7,6 +8,7 @@
)
from homeassistant.core import callback
from homeassistant.const import PERCENTAGE, UnitOfLength, UnitOfTime
from homeassistant.components.sensor import SensorStateClass
from .base import KamereonEntity
from .kamereon import ChargingSpeed, Feature
from .const import DOMAIN, DATA_VEHICLES, DATA_COORDINATOR_FETCH, DATA_COORDINATOR_STATISTICS
Expand Down Expand Up @@ -71,8 +73,14 @@ def state(self):

@property
def icon(self):
"""Icon of the sensor."""
return "mdi:battery"
"""Icon of the sensor. Round up to the nearest 10% icon."""
nearest = math.ceil((self.state or 0) / 10.0) * 10
if nearest == 0:
return "mdi:battery-outline"
elif nearest == 100:
return "mdi:battery"
else:
return "mdi:battery-" + str(nearest)

@property
def device_state_attributes(self):
Expand Down Expand Up @@ -165,6 +173,7 @@ class OdometerSensor(KamereonEntity, SensorEntity):
_attr_translation_key = "odometer"
_attr_device_class = SensorDeviceClass.DISTANCE
_attr_native_unit_of_measurement = UnitOfLength.KILOMETERS
_attr_state_class = SensorStateClass.TOTAL_INCREASING

def __init__(self, coordinator, vehicle, imperial_distance):
if imperial_distance:
Expand Down
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[tool.pytest.ini_options]
asyncio_mode = "auto"
filterwarnings = [
"ignore::RuntimeWarning"
]
7 changes: 7 additions & 0 deletions requirements.test.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
coverage==7.4.3
pytest==8.0.2
pytest-asyncio==0.23.5
pytest-cov==4.1.0
pytest-homeassistant-custom-component==0.13.109
requests
requests_oauthlib
1 change: 0 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
pytz
requests
requests_oauthlib
1 change: 1 addition & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Tests for the NissanConnect integration."""
11 changes: 11 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""Global fixtures for custom integration."""
import pytest
import pytest_socket

@pytest.fixture(autouse=True)
def auto_enable_custom_integrations(enable_custom_integrations):
"""Enable custom integrations defined in the test dir."""
yield

def enable_external_sockets():
pytest_socket.enable_socket()
61 changes: 61 additions & 0 deletions tests/test_binary_sensor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import pytest
from homeassistant.const import STATE_UNKNOWN
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from custom_components.nissan_connect.kamereon import ChargingStatus, PluggedStatus, LockStatus

from custom_components.nissan_connect.binary_sensor import (
ChargingStatusEntity,
PluggedStatusEntity,
LockStatusEntity,
)

@pytest.fixture
def vehicle():
class Vehicle:
def __init__(self):
self.charging = None
self.charging_speed = None
self.battery_status_last_updated = None
self.plugged_in = None
self.plugged_in_time = None
self.unplugged_time = None
self.lock_status = None

return Vehicle()

@pytest.fixture
def coordinator(hass):
async def async_update_data():
return {}

return DataUpdateCoordinator(hass, None, name="test", update_method=async_update_data)

async def test_charging_status_entity(vehicle, coordinator):
entity = ChargingStatusEntity(coordinator, vehicle)
assert entity.is_on == STATE_UNKNOWN

vehicle.charging = ChargingStatus.CHARGING
assert entity.is_on is True

vehicle.charging = ChargingStatus.NOT_CHARGING
assert entity.is_on is False

async def test_plugged_status_entity(vehicle, coordinator):
entity = PluggedStatusEntity(coordinator, vehicle)
assert entity.is_on == STATE_UNKNOWN

vehicle.plugged_in = PluggedStatus.PLUGGED
assert entity.is_on is True

vehicle.plugged_in = PluggedStatus.NOT_PLUGGED
assert entity.is_on is False

async def test_lock_status_entity(vehicle, coordinator):
entity = LockStatusEntity(coordinator, vehicle)
assert entity.is_on is False

vehicle.lock_status = LockStatus.LOCKED
assert entity.is_on is False

vehicle.lock_status = LockStatus.UNLOCKED
assert entity.is_on is True
86 changes: 86 additions & 0 deletions tests/test_button.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import pytest
from unittest.mock import AsyncMock, MagicMock
from homeassistant.helpers import entity_registry as er
from custom_components.nissan_connect.const import DOMAIN, DATA_VEHICLES, DATA_COORDINATOR_POLL, DATA_COORDINATOR_FETCH, DATA_COORDINATOR_STATISTICS
from custom_components.nissan_connect.kamereon.kamereon_const import Feature

from custom_components.nissan_connect.button import (
async_setup_entry,
ForceUpdateButton,
HornLightsButtons,
ChargeControlButtons,
)


@pytest.fixture
def mock_hass():
hass = MagicMock()
hass.data = {
DOMAIN: {
'test_account': {
DATA_VEHICLES: {
'vehicle_1': MagicMock(features=[Feature.HORN_AND_LIGHTS, Feature.CHARGING_START])
},
DATA_COORDINATOR_POLL: MagicMock(),
DATA_COORDINATOR_FETCH: MagicMock(),
DATA_COORDINATOR_STATISTICS: MagicMock(),
}
}
}
return hass


@pytest.fixture
def mock_config():
return MagicMock(data={'email': 'test_account'})


@pytest.fixture
def mock_async_add_entities():
return AsyncMock()


@pytest.mark.asyncio
async def test_async_setup_entry(mock_hass, mock_config, mock_async_add_entities):
await async_setup_entry(mock_hass, mock_config, mock_async_add_entities)
assert mock_async_add_entities.call_count == 1
entities = mock_async_add_entities.call_args[0][0]
assert len(entities) == 4
assert isinstance(entities[0], ForceUpdateButton)
assert isinstance(entities[1], HornLightsButtons)
assert isinstance(entities[2], HornLightsButtons)
assert isinstance(entities[3], ChargeControlButtons)


@pytest.mark.asyncio
async def test_force_update_button():
coordinator = AsyncMock()
vehicle = MagicMock()
hass = AsyncMock()
stats_coordinator = MagicMock()

button = ForceUpdateButton(coordinator, vehicle, hass, stats_coordinator)

await button.async_press()
vehicle.refresh.assert_called_once()
coordinator.async_refresh.assert_called_once()


def test_horn_lights_buttons():
coordinator = MagicMock()
vehicle = MagicMock()
button = HornLightsButtons(
coordinator, vehicle, "flash_lights", "mdi:car-light-high", "lights")

button.press()
vehicle.control_horn_lights.assert_called_once_with('start', "lights")


def test_charge_control_buttons():
coordinator = MagicMock()
vehicle = MagicMock()
button = ChargeControlButtons(
coordinator, vehicle, "charge_start", "mdi:play", "start")

button.press()
vehicle.control_charging.assert_called_once_with("start")
71 changes: 71 additions & 0 deletions tests/test_climate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import pytest
from unittest.mock import AsyncMock, MagicMock
from homeassistant.components.climate.const import HVACMode, HVACAction as HASSHVACAction
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from custom_components.nissan_connect.climate import KamereonClimate
from custom_components.nissan_connect.kamereon.kamereon_const import Feature, HVACAction

@pytest.fixture
def mock_vehicle():
vehicle = MagicMock()
vehicle.features = [Feature.CLIMATE_ON_OFF, Feature.TEMPERATURE]
vehicle.hvac_status = False
vehicle.internal_temperature = 22
return vehicle

@pytest.fixture
def mock_coordinator():
return MagicMock()

@pytest.fixture
def mock_hass():
hass = MagicMock()
hass.async_add_executor_job = AsyncMock()
hass.async_create_task = AsyncMock()
return hass

@pytest.fixture
def climate_entity(mock_coordinator, mock_vehicle, mock_hass):
return KamereonClimate(mock_coordinator, mock_vehicle, mock_hass)

def test_hvac_mode(climate_entity, mock_vehicle):
mock_vehicle.hvac_status = True
assert climate_entity.hvac_mode == HVACMode.HEAT_COOL
mock_vehicle.hvac_status = False
assert climate_entity.hvac_mode == HVACMode.OFF

def test_current_temperature(climate_entity, mock_vehicle):
mock_vehicle.internal_temperature = 22
assert climate_entity.current_temperature == 22
mock_vehicle.internal_temperature = None
assert climate_entity.current_temperature is None

def test_target_temperature(climate_entity):
assert climate_entity.target_temperature == 20
climate_entity.set_temperature(**{ATTR_TEMPERATURE: 25})
assert climate_entity.target_temperature == 25

def test_hvac_action(climate_entity, mock_vehicle):
mock_vehicle.hvac_status = True
mock_vehicle.internal_temperature = 18
climate_entity._target = 20
assert climate_entity.hvac_action == HASSHVACAction.HEATING
mock_vehicle.internal_temperature = 22
assert climate_entity.hvac_action == HASSHVACAction.COOLING
mock_vehicle.hvac_status = False
assert climate_entity.hvac_action == HASSHVACAction.OFF

@pytest.mark.asyncio
async def test_async_set_hvac_mode(climate_entity, mock_hass, mock_vehicle):
await climate_entity.async_set_hvac_mode(HVACMode.OFF)
mock_hass.async_add_executor_job.assert_called_with(mock_vehicle.set_hvac_status, HVACAction.STOP)
mock_hass.async_create_task.assert_called_once()

await climate_entity.async_set_hvac_mode(HVACMode.HEAT_COOL)
mock_hass.async_add_executor_job.assert_called_with(mock_vehicle.set_hvac_status, HVACAction.START, 20)
assert mock_hass.async_create_task.call_count == 2

@pytest.mark.asyncio
async def test_async_turn_off(climate_entity):
await climate_entity.async_turn_off()
assert climate_entity.hvac_mode == HVACMode.OFF
Loading

0 comments on commit fd00c7e

Please sign in to comment.