inital commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Tests for components."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for MySmartBike BLE integration."""
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Fixtures for MySmartBike BLE tests."""
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from homeassistant.const import CONF_ADDRESS
|
||||
|
||||
from custom_components.mysmartbike_ble.const import (
|
||||
CONF_DEVICE_ADDRESS,
|
||||
CONF_DEVICE_NAME,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
|
||||
def _get_bluetooth_service_info() -> MagicMock:
|
||||
"""Return a mock BluetoothServiceInfoBleak."""
|
||||
service_info = MagicMock()
|
||||
service_info.name = "iWoc1A36"
|
||||
service_info.address = "AA:BB:CC:DD:EE:FF"
|
||||
service_info.rssi = -60
|
||||
service_info.manufacturer_data = {}
|
||||
service_info.service_data = {}
|
||||
service_info.service_uuids = []
|
||||
service_info.source = "local"
|
||||
return service_info
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_bluetooth_service_info() -> MagicMock:
|
||||
"""Return mock Bluetooth service info."""
|
||||
return _get_bluetooth_service_info()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry() -> MockConfigEntry:
|
||||
"""Return default mocked config entry."""
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title="iWoc1A36",
|
||||
data={
|
||||
CONF_DEVICE_NAME: "iWoc1A36",
|
||||
CONF_DEVICE_ADDRESS: "AA:BB:CC:DD:EE:FF",
|
||||
},
|
||||
unique_id="AA:BB:CC:DD:EE:FF",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_bleak_client() -> Generator[MagicMock]:
|
||||
"""Return a mocked BleakClient."""
|
||||
with patch(
|
||||
"custom_components.mysmartbike_ble.coordinator.establish_connection",
|
||||
autospec=True,
|
||||
) as mock_client:
|
||||
client = AsyncMock()
|
||||
client.is_connected = True
|
||||
client.write_gatt_char = AsyncMock()
|
||||
client.start_notify = AsyncMock()
|
||||
client.stop_notify = AsyncMock()
|
||||
client.disconnect = AsyncMock()
|
||||
mock_client.return_value = client
|
||||
yield mock_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ble_device() -> Generator[MagicMock]:
|
||||
"""Return a mocked BLE device."""
|
||||
with patch(
|
||||
"custom_components.mysmartbike_ble.config_flow.async_discovered_service_info",
|
||||
autospec=True,
|
||||
) as mock_devices:
|
||||
mock_devices.return_value = [_get_bluetooth_service_info()]
|
||||
yield mock_devices
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def init_integration(
|
||||
hass,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_bleak_client: MagicMock,
|
||||
) -> MockConfigEntry:
|
||||
"""Set up the MySmartBike BLE integration for testing."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.async_ble_device_from_address"
|
||||
) as mock_ble_device_from_address:
|
||||
mock_ble_device_from_address.return_value = _get_bluetooth_service_info()
|
||||
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
return mock_config_entry
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Test the MySmartBike BLE config flow."""
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import CONF_ADDRESS
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from custom_components.mysmartbike_ble.const import CONF_DEVICE_ADDRESS, CONF_DEVICE_NAME, DOMAIN
|
||||
|
||||
from .conftest import _get_bluetooth_service_info
|
||||
|
||||
|
||||
async def test_bluetooth_discovery(hass: HomeAssistant) -> None:
|
||||
"""Test discovery via Bluetooth."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_BLUETOOTH},
|
||||
data=_get_bluetooth_service_info(),
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "bluetooth_confirm"
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.async_ble_device_from_address"
|
||||
) as mock_ble_device:
|
||||
mock_ble_device.return_value = _get_bluetooth_service_info()
|
||||
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={}
|
||||
)
|
||||
|
||||
assert result2["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert result2["title"] == "iWoc1A36"
|
||||
assert result2["data"] == {
|
||||
CONF_DEVICE_NAME: "iWoc1A36",
|
||||
CONF_DEVICE_ADDRESS: "AA:BB:CC:DD:EE:FF",
|
||||
}
|
||||
|
||||
|
||||
async def test_bluetooth_discovery_already_configured(
|
||||
hass: HomeAssistant, mock_config_entry
|
||||
) -> None:
|
||||
"""Test discovery aborts if already configured."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_BLUETOOTH},
|
||||
data=_get_bluetooth_service_info(),
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_user_flow_success(hass: HomeAssistant, mock_ble_device) -> None:
|
||||
"""Test user flow - successful flow."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.async_ble_device_from_address"
|
||||
) as mock_ble_device_from_address:
|
||||
mock_ble_device_from_address.return_value = _get_bluetooth_service_info()
|
||||
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"},
|
||||
)
|
||||
|
||||
assert result2["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert result2["title"] == "iWoc1A36"
|
||||
assert result2["data"] == {
|
||||
CONF_DEVICE_NAME: "iWoc1A36",
|
||||
CONF_DEVICE_ADDRESS: "AA:BB:CC:DD:EE:FF",
|
||||
}
|
||||
|
||||
|
||||
async def test_user_flow_no_devices_found(hass: HomeAssistant) -> None:
|
||||
"""Test user flow - no devices found."""
|
||||
with patch(
|
||||
"custom_components.mysmartbike_ble.config_flow.async_discovered_service_info",
|
||||
return_value=[],
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.ABORT
|
||||
assert result["reason"] == "no_devices_found"
|
||||
|
||||
|
||||
async def test_user_flow_already_configured(
|
||||
hass: HomeAssistant, mock_config_entry, mock_ble_device
|
||||
) -> None:
|
||||
"""Test user flow - device already configured.
|
||||
|
||||
When the only discovered device is already configured,
|
||||
it should abort immediately with no_devices_found.
|
||||
"""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.ABORT
|
||||
assert result["reason"] == "no_devices_found"
|
||||
|
||||
|
||||
async def test_user_flow_device_not_found_after_selection(
|
||||
hass: HomeAssistant, mock_ble_device
|
||||
) -> None:
|
||||
"""Test user flow - device not found after selection."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
# Device disappears after selection (returns None)
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.async_ble_device_from_address",
|
||||
return_value=None,
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"},
|
||||
)
|
||||
|
||||
# This should trigger ConfigEntryNotReady in the actual setup,
|
||||
# but in config_flow it just proceeds to create the entry
|
||||
assert result2["type"] == FlowResultType.CREATE_ENTRY
|
||||
@@ -0,0 +1,316 @@
|
||||
"""Test the MySmartBike BLE parsers with real message data."""
|
||||
import pytest
|
||||
|
||||
from custom_components.mysmartbike_ble.parsers import (
|
||||
BikeDataParser,
|
||||
read16,
|
||||
read24,
|
||||
read32,
|
||||
read_unsigned_byte,
|
||||
)
|
||||
|
||||
|
||||
class TestReadFunctions:
|
||||
"""Test byte reading functions (big-endian)."""
|
||||
|
||||
def test_read16_big_endian(self):
|
||||
"""Test 16-bit big-endian read."""
|
||||
# Big-endian: first byte is most significant
|
||||
data = bytes([0x12, 0x34])
|
||||
assert read16(data, 0) == 0x1234
|
||||
|
||||
def test_read24_big_endian(self):
|
||||
"""Test 24-bit big-endian read."""
|
||||
data = bytes([0x12, 0x34, 0x56])
|
||||
assert read24(data, 0) == 0x123456
|
||||
|
||||
def test_read32_big_endian(self):
|
||||
"""Test 32-bit big-endian read."""
|
||||
data = bytes([0x12, 0x34, 0x56, 0x78])
|
||||
assert read32(data, 0) == 0x12345678
|
||||
|
||||
def test_read_unsigned_byte(self):
|
||||
"""Test unsigned byte read."""
|
||||
assert read_unsigned_byte(0xFF) == 255
|
||||
assert read_unsigned_byte(0x00) == 0
|
||||
assert read_unsigned_byte(0x7F) == 127
|
||||
|
||||
|
||||
class TestEbmParser:
|
||||
"""Test EBM message parsing with real data."""
|
||||
|
||||
# Real EBM message from log: 246a245a230056af68000bef6f00002340
|
||||
# Expected: Odometer ~568.1 km, Range ~78.2 km
|
||||
EBM_MESSAGE = bytes.fromhex("246a245a230056af68000bef6f00002340")
|
||||
|
||||
def test_ebm_message_recognition(self):
|
||||
"""Test that EBM message type is recognized."""
|
||||
parser = BikeDataParser()
|
||||
msg_type = parser.recognize_message_type(self.EBM_MESSAGE)
|
||||
assert msg_type == "ebm"
|
||||
|
||||
def test_ebm_odometry_parsing(self):
|
||||
"""Test odometry value parsing from real EBM message."""
|
||||
parser = BikeDataParser()
|
||||
result = parser.parse_ebm_message(self.EBM_MESSAGE)
|
||||
|
||||
assert result is not None
|
||||
# Odometry: 0x0056af68 = 5681000 / 10000 = 568.1 km
|
||||
assert abs(result["odometry"] - 568.1) < 0.1
|
||||
|
||||
def test_ebm_autonomy_parsing(self):
|
||||
"""Test autonomy/range value parsing from real EBM message."""
|
||||
parser = BikeDataParser()
|
||||
result = parser.parse_ebm_message(self.EBM_MESSAGE)
|
||||
|
||||
assert result is not None
|
||||
# Autonomy: 0x000bef6f = 782191 / 10000 = 78.2 km
|
||||
assert abs(result["autonomy"] - 78.2) < 0.1
|
||||
|
||||
def test_ebm_light_status(self):
|
||||
"""Test light status parsing from real EBM message."""
|
||||
parser = BikeDataParser()
|
||||
result = parser.parse_ebm_message(self.EBM_MESSAGE)
|
||||
|
||||
assert result is not None
|
||||
# Byte 13 = 0x00, so light is off
|
||||
assert result["is_light_on"] is False
|
||||
|
||||
def test_ebm_state_update(self):
|
||||
"""Test that parser state is updated after parsing."""
|
||||
parser = BikeDataParser()
|
||||
parser.parse_ebm_message(self.EBM_MESSAGE)
|
||||
|
||||
assert parser.state["ebm"] is not None
|
||||
assert "odometry" in parser.state["ebm"]
|
||||
assert "autonomy" in parser.state["ebm"]
|
||||
|
||||
|
||||
class TestMotorParser:
|
||||
"""Test motor message parsing with real data."""
|
||||
|
||||
# Real motor message from log: 246d245a230117000000000000004f642340
|
||||
MOTOR_MESSAGE = bytes.fromhex("246d245a230117000000000000004f642340")
|
||||
|
||||
def test_motor_message_recognition(self):
|
||||
"""Test that motor message type is recognized."""
|
||||
parser = BikeDataParser()
|
||||
msg_type = parser.recognize_message_type(self.MOTOR_MESSAGE)
|
||||
assert msg_type == "motor"
|
||||
|
||||
def test_motor_parsing(self):
|
||||
"""Test motor value parsing from real message."""
|
||||
parser = BikeDataParser()
|
||||
result = parser.parse_motor_message(self.MOTOR_MESSAGE)
|
||||
|
||||
assert result is not None
|
||||
# Assist level at byte 5 = 0x01
|
||||
assert result["assist_level"] == 1
|
||||
# Temperature at byte 6 = 0x17 = 23°C
|
||||
assert result["temperature_celsius"] == 23
|
||||
|
||||
|
||||
class TestBatteryParser:
|
||||
"""Test battery message parsing with real data."""
|
||||
|
||||
# Real battery message from log: 2462245a230193541700000877071a27342340
|
||||
BATTERY_MESSAGE = bytes.fromhex("2462245a230193541700000877071a27342340")
|
||||
|
||||
def test_battery_message_recognition(self):
|
||||
"""Test that battery message type is recognized."""
|
||||
parser = BikeDataParser()
|
||||
msg_type = parser.recognize_message_type(self.BATTERY_MESSAGE)
|
||||
assert msg_type == "battery"
|
||||
|
||||
def test_battery_voltage(self):
|
||||
"""Test battery voltage parsing."""
|
||||
parser = BikeDataParser()
|
||||
result = parser.parse_battery_message(self.BATTERY_MESSAGE)
|
||||
|
||||
assert result is not None
|
||||
# Voltage: 0x0193 = 403 / 10 = 40.3 V
|
||||
assert abs(result["voltage"] - 40.3) < 0.1
|
||||
|
||||
def test_battery_soc(self):
|
||||
"""Test battery state of charge parsing."""
|
||||
parser = BikeDataParser()
|
||||
result = parser.parse_battery_message(self.BATTERY_MESSAGE)
|
||||
|
||||
assert result is not None
|
||||
# SoC at byte 7 = 0x54 = 84%
|
||||
assert result["soc"] == 84
|
||||
|
||||
def test_battery_temperature(self):
|
||||
"""Test battery temperature parsing."""
|
||||
parser = BikeDataParser()
|
||||
result = parser.parse_battery_message(self.BATTERY_MESSAGE)
|
||||
|
||||
assert result is not None
|
||||
# Temperature at byte 8 = 0x17 = 23
|
||||
assert result["temperature"] == 23
|
||||
|
||||
def test_battery_current(self):
|
||||
"""Test battery current parsing."""
|
||||
parser = BikeDataParser()
|
||||
result = parser.parse_battery_message(self.BATTERY_MESSAGE)
|
||||
|
||||
assert result is not None
|
||||
# Current: 0x0000 = 0 / 10 = 0.0 A
|
||||
assert result["current"] == 0.0
|
||||
|
||||
def test_battery_capacity(self):
|
||||
"""Test battery nominal capacity parsing."""
|
||||
parser = BikeDataParser()
|
||||
result = parser.parse_battery_message(self.BATTERY_MESSAGE)
|
||||
|
||||
assert result is not None
|
||||
# Nominal capacity: 0x0877 = 2167 / 10 = 216.7 Wh
|
||||
assert abs(result["nominal_capacity"] - 216.7) < 0.1
|
||||
|
||||
def test_battery_remaining(self):
|
||||
"""Test battery remaining energy parsing."""
|
||||
parser = BikeDataParser()
|
||||
result = parser.parse_battery_message(self.BATTERY_MESSAGE)
|
||||
|
||||
assert result is not None
|
||||
# Remaining Wh: 0x071a = 1818 / 10 = 181.8 Wh
|
||||
assert abs(result["remaining_wh"] - 181.8) < 0.1
|
||||
|
||||
def test_battery_cycles(self):
|
||||
"""Test battery cycles parsing."""
|
||||
parser = BikeDataParser()
|
||||
result = parser.parse_battery_message(self.BATTERY_MESSAGE)
|
||||
|
||||
assert result is not None
|
||||
# Cycles: 0x2734 = 10036, cycles = 10036 % 10000 = 36
|
||||
assert result["cycles"] == 36
|
||||
|
||||
def test_battery_number_detection(self):
|
||||
"""Test primary/secondary battery detection."""
|
||||
parser = BikeDataParser()
|
||||
parser.parse_battery_message(self.BATTERY_MESSAGE)
|
||||
|
||||
# Battery number = 10036 / 10000 = 1 (primary)
|
||||
assert parser.state["battery_primary"] is not None
|
||||
assert parser.state["battery_primary"]["cycles"] == 36
|
||||
|
||||
|
||||
class TestVinParser:
|
||||
"""Test VIN/serial number message parsing."""
|
||||
|
||||
# Example VIN message: $s$V#SB000000002207203#@
|
||||
# Hex: 24 73 24 56 23 + serial + 23 40
|
||||
VIN_SERIAL = "SB000000002207203"
|
||||
VIN_MESSAGE = f"$s$V#{VIN_SERIAL}#@".encode("utf-8")
|
||||
|
||||
def test_vin_message_recognition(self):
|
||||
"""Test that VIN message type is recognized."""
|
||||
parser = BikeDataParser()
|
||||
msg_type = parser.recognize_message_type(self.VIN_MESSAGE)
|
||||
assert msg_type == "vin"
|
||||
|
||||
def test_vin_parsing_standard_format(self):
|
||||
"""Test VIN parsing from standard format $s$V#<serial>#@."""
|
||||
parser = BikeDataParser()
|
||||
result = parser.parse_vin_message(self.VIN_MESSAGE)
|
||||
|
||||
assert result == self.VIN_SERIAL
|
||||
assert parser.vin == self.VIN_SERIAL
|
||||
|
||||
def test_vin_parsing_r0_format(self):
|
||||
"""Test VIN parsing from R0 format (20 chars ending with @)."""
|
||||
parser = BikeDataParser()
|
||||
# R0 format: R0<17 char serial>@
|
||||
serial = "AB123456789012345"
|
||||
message = f"R0{serial}@".encode("utf-8")
|
||||
|
||||
result = parser.parse_vin_message(message)
|
||||
|
||||
assert result == serial
|
||||
assert parser.vin == serial
|
||||
|
||||
def test_vin_handle_message_updates_state(self):
|
||||
"""Test that handle_message updates VIN state."""
|
||||
parser = BikeDataParser()
|
||||
parser.handle_message(self.VIN_MESSAGE)
|
||||
|
||||
assert parser.vin == self.VIN_SERIAL
|
||||
|
||||
|
||||
class TestAssistParser:
|
||||
"""Test assist level message parsing with real data."""
|
||||
|
||||
# Real assist message from log: 246d2441233033312340
|
||||
ASSIST_MESSAGE = bytes.fromhex("246d2441233033312340")
|
||||
|
||||
def test_assist_message_recognition(self):
|
||||
"""Test that assist message type is recognized."""
|
||||
parser = BikeDataParser()
|
||||
msg_type = parser.recognize_message_type(self.ASSIST_MESSAGE)
|
||||
assert msg_type == "assist"
|
||||
|
||||
def test_assist_parsing(self):
|
||||
"""Test assist level parsing from real message."""
|
||||
parser = BikeDataParser()
|
||||
result = parser.parse_assist_level_message(self.ASSIST_MESSAGE)
|
||||
|
||||
assert result is not None
|
||||
# Message is "031" which means min=0, max=3, current=1
|
||||
assert result["min"] == 0
|
||||
assert result["max"] == 3
|
||||
assert result["current"] == 1
|
||||
|
||||
|
||||
class TestProtocolParser:
|
||||
"""Test protocol version message parsing."""
|
||||
|
||||
# Protocol message format: $s$P#<version>#@
|
||||
PROTOCOL_MESSAGE_V102 = b"$s$P#1.02#@"
|
||||
PROTOCOL_MESSAGE_V100 = b"$s$P#1.00#@"
|
||||
PROTOCOL_MESSAGE_V300 = b"$s$P#3.00#@"
|
||||
PROTOCOL_MESSAGE_ERROR = b"$s$P#ER#@"
|
||||
|
||||
def test_protocol_message_recognition(self):
|
||||
"""Test that protocol message type is recognized."""
|
||||
parser = BikeDataParser()
|
||||
msg_type = parser.recognize_message_type(self.PROTOCOL_MESSAGE_V102)
|
||||
assert msg_type == "protocol"
|
||||
|
||||
def test_protocol_parsing_v102(self):
|
||||
"""Test protocol version 1.02 parsing."""
|
||||
parser = BikeDataParser()
|
||||
result = parser.parse_protocol_message(self.PROTOCOL_MESSAGE_V102)
|
||||
|
||||
assert result == "1.02"
|
||||
assert parser.protocol_version == "1.02"
|
||||
|
||||
def test_protocol_parsing_v100(self):
|
||||
"""Test protocol version 1.00 parsing."""
|
||||
parser = BikeDataParser()
|
||||
result = parser.parse_protocol_message(self.PROTOCOL_MESSAGE_V100)
|
||||
|
||||
assert result == "1.00"
|
||||
assert parser.protocol_version == "1.00"
|
||||
|
||||
def test_protocol_parsing_v300(self):
|
||||
"""Test protocol version 3.00 parsing."""
|
||||
parser = BikeDataParser()
|
||||
result = parser.parse_protocol_message(self.PROTOCOL_MESSAGE_V300)
|
||||
|
||||
assert result == "3.00"
|
||||
assert parser.protocol_version == "3.00"
|
||||
|
||||
def test_protocol_parsing_error(self):
|
||||
"""Test protocol error response handling."""
|
||||
parser = BikeDataParser()
|
||||
result = parser.parse_protocol_message(self.PROTOCOL_MESSAGE_ERROR)
|
||||
|
||||
assert result is None
|
||||
assert parser.protocol_version is None
|
||||
|
||||
def test_protocol_handle_message_updates_state(self):
|
||||
"""Test that handle_message updates protocol version."""
|
||||
parser = BikeDataParser()
|
||||
parser.handle_message(self.PROTOCOL_MESSAGE_V102)
|
||||
|
||||
assert parser.protocol_version == "1.02"
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Test the MySmartBike BLE switch."""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
|
||||
def get_connection_switch_id(hass: HomeAssistant) -> str:
|
||||
"""Get the connection switch entity ID."""
|
||||
entity_registry = er.async_get(hass)
|
||||
for entity in entity_registry.entities.values():
|
||||
if entity.unique_id.endswith("_connection"):
|
||||
return entity.entity_id
|
||||
raise ValueError("Connection switch not found")
|
||||
|
||||
|
||||
async def test_switch_setup(hass: HomeAssistant, init_integration) -> None:
|
||||
"""Test switch setup."""
|
||||
entity_id = get_connection_switch_id(hass)
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
# Check if the connection switch entity exists
|
||||
entry = entity_registry.async_get(entity_id)
|
||||
assert entry
|
||||
assert entry.unique_id.endswith("_connection")
|
||||
|
||||
|
||||
async def test_switch_initial_state(hass: HomeAssistant, init_integration) -> None:
|
||||
"""Test switch initial state is on (connected)."""
|
||||
entity_id = get_connection_switch_id(hass)
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.state == STATE_ON
|
||||
|
||||
|
||||
async def test_switch_turn_off(hass: HomeAssistant, init_integration) -> None:
|
||||
"""Test turning off the switch disconnects from bike."""
|
||||
entity_id = get_connection_switch_id(hass)
|
||||
coordinator = init_integration.runtime_data
|
||||
|
||||
# Mock the disconnect method
|
||||
with patch.object(coordinator, "async_disconnect", new_callable=AsyncMock) as mock_disconnect:
|
||||
# Turn off the switch
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
"turn_off",
|
||||
{ATTR_ENTITY_ID: entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Verify disconnect was called
|
||||
mock_disconnect.assert_called_once()
|
||||
|
||||
|
||||
async def test_switch_turn_on(hass: HomeAssistant, init_integration) -> None:
|
||||
"""Test turning on the switch reconnects to bike."""
|
||||
entity_id = get_connection_switch_id(hass)
|
||||
coordinator = init_integration.runtime_data
|
||||
|
||||
# Mock the reconnect method
|
||||
with patch.object(coordinator, "async_reconnect", new_callable=AsyncMock) as mock_reconnect:
|
||||
# Turn on the switch
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
"turn_on",
|
||||
{ATTR_ENTITY_ID: entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Verify reconnect was called
|
||||
mock_reconnect.assert_called_once()
|
||||
|
||||
|
||||
async def test_switch_icon(hass: HomeAssistant, init_integration) -> None:
|
||||
"""Test switch icon is correct for connected state."""
|
||||
entity_id = get_connection_switch_id(hass)
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.attributes.get("icon") == "mdi:bluetooth-connect"
|
||||
|
||||
|
||||
async def test_switch_turn_off_error_handling(
|
||||
hass: HomeAssistant, init_integration
|
||||
) -> None:
|
||||
"""Test error handling when turning off switch fails."""
|
||||
entity_id = get_connection_switch_id(hass)
|
||||
coordinator = init_integration.runtime_data
|
||||
|
||||
# Mock disconnect to raise an exception
|
||||
with patch.object(
|
||||
coordinator, "async_disconnect", side_effect=Exception("Disconnect failed")
|
||||
):
|
||||
# Turn off should not raise, but log error
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
"turn_off",
|
||||
{ATTR_ENTITY_ID: entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
# Entity should still exist
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
|
||||
|
||||
async def test_switch_turn_on_error_handling(
|
||||
hass: HomeAssistant, init_integration
|
||||
) -> None:
|
||||
"""Test error handling when turning on switch fails."""
|
||||
entity_id = get_connection_switch_id(hass)
|
||||
coordinator = init_integration.runtime_data
|
||||
|
||||
# Mock reconnect to raise an exception
|
||||
with patch.object(
|
||||
coordinator, "async_reconnect", side_effect=Exception("Reconnect failed")
|
||||
):
|
||||
# Turn on should not raise, but log error
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
"turn_on",
|
||||
{ATTR_ENTITY_ID: entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
# Entity should still exist
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
|
||||
|
||||
async def test_no_auto_reconnect_when_manually_disconnected(
|
||||
hass: HomeAssistant, init_integration
|
||||
) -> None:
|
||||
"""Test that coordinator doesn't auto-reconnect after manual disconnect."""
|
||||
entity_id = get_connection_switch_id(hass)
|
||||
coordinator = init_integration.runtime_data
|
||||
|
||||
# Turn off the switch (manual disconnect)
|
||||
with patch.object(coordinator, "async_disconnect", wraps=coordinator.async_disconnect) as mock_disconnect:
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
"turn_off",
|
||||
{ATTR_ENTITY_ID: entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
mock_disconnect.assert_called_once()
|
||||
|
||||
# Verify manual disconnect flag is set
|
||||
assert coordinator._manual_disconnect is True
|
||||
|
||||
# Trigger an update - should NOT attempt to reconnect
|
||||
with patch.object(coordinator, "_connect", new_callable=AsyncMock) as mock_connect:
|
||||
await coordinator.async_refresh()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# _connect should NOT have been called
|
||||
mock_connect.assert_not_called()
|
||||
Reference in New Issue
Block a user