Procházet zdrojové kódy

Add pytest test suite for coordinator, services, and sensors

ZiB před 4 měsíci
rodič
revize
ebc2343ea3

+ 17 - 0
pyproject.toml

@@ -0,0 +1,17 @@
+[project]
+name = "zlock-slots"
+version = "0.1.0"
+requires-python = ">=3.12"
+
+[dependency-groups]
+dev = [
+  "pytest-homeassistant-custom-component",
+  "zwave-js-server-python==0.72.0",
+]
+
+[tool.uv]
+package = false
+
+[tool.pytest.ini_options]
+asyncio_mode = "auto"
+testpaths = ["tests"]

+ 0 - 0
tests/__init__.py


binární
tests/__pycache__/__init__.cpython-314.pyc


binární
tests/__pycache__/conftest.cpython-314-pytest-9.0.3.pyc


binární
tests/__pycache__/test_coordinator.cpython-314-pytest-9.0.3.pyc


binární
tests/__pycache__/test_sensor.cpython-314-pytest-9.0.3.pyc


binární
tests/__pycache__/test_services.cpython-314-pytest-9.0.3.pyc


+ 54 - 0
tests/conftest.py

@@ -0,0 +1,54 @@
+import pytest
+from unittest.mock import AsyncMock
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+
+from custom_components.zlock_slots.const import DOMAIN, CONF_LOCK_ENTITY_IDS
+
+pytest_plugins = ["pytest_homeassistant_custom_component"]
+
+LOCK_ENTITY_ID = "lock.front_door"
+ALARM_TYPE_ENTITY = "sensor.front_door_alarm_type"
+ALARM_LEVEL_ENTITY = "sensor.front_door_alarm_level"
+
+
+class FakeUserData:
+  def __init__(self, user_id: int, active: bool = True):
+    self.user_id = user_id
+    self.active = active
+
+
+class FakeAccessControl:
+  def __init__(self, users=None):
+    self._users = list(users or [])
+    self.set_user = AsyncMock()
+
+  async def get_users_cached(self):
+    return list(self._users)
+
+
+class FakeNode:
+  def __init__(self, users=None):
+    self.access_control = FakeAccessControl(users)
+
+
+@pytest.fixture(autouse=True)
+def auto_enable_custom_integrations(enable_custom_integrations):
+  yield
+
+
+@pytest.fixture
+def mock_node():
+  return FakeNode(users=[
+    FakeUserData(1, active=True),
+    FakeUserData(2, active=False),
+  ])
+
+
+@pytest.fixture
+def config_entry():
+  return MockConfigEntry(
+    domain=DOMAIN,
+    data={CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID]},
+    options={},
+    entry_id="test_entry",
+  )

+ 281 - 0
tests/test_coordinator.py

@@ -0,0 +1,281 @@
+"""Tests for ZLockCoordinator: slot fetch, alarm event handling, name/event persistence."""
+import pytest
+from unittest.mock import patch, AsyncMock
+
+from custom_components.zlock_slots.coordinator import ZLockCoordinator
+from custom_components.zlock_slots.const import (
+  DOMAIN, CONF_LOCK_ENTITY_IDS, CONF_GROUPS, CONF_GROUP_NAME,
+)
+from tests.conftest import (
+  LOCK_ENTITY_ID, ALARM_TYPE_ENTITY, ALARM_LEVEL_ENTITY,
+  FakeUserData, FakeNode,
+)
+
+_ZWAVE_HELPER = "homeassistant.components.zwave_js.helpers.async_get_node_from_entity_id"
+
+
+@pytest.fixture
+async def coord(hass, config_entry, mock_node):
+  config_entry.add_to_hass(hass)
+  with patch(_ZWAVE_HELPER, return_value=mock_node):
+    c = ZLockCoordinator(hass, config_entry)
+    await c.async_setup()
+    await hass.async_block_till_done()
+    yield c
+  c.async_teardown()
+
+
+# ---------------------------------------------------------------------------
+# Slot fetch
+# ---------------------------------------------------------------------------
+
+async def test_slot_fetch_populates_data(coord):
+  slots = coord.data[LOCK_ENTITY_ID].slots
+  assert set(slots) == {1, 2}
+
+
+async def test_slot_enabled_status(coord):
+  assert coord.data[LOCK_ENTITY_ID].slots[1].status == "enabled"
+  assert coord.data[LOCK_ENTITY_ID].slots[2].status == "disabled"
+
+
+async def test_slot_0_excluded(hass, config_entry):
+  node = FakeNode(users=[FakeUserData(0), FakeUserData(1)])
+  config_entry.add_to_hass(hass)
+  with patch(_ZWAVE_HELPER, return_value=node):
+    c = ZLockCoordinator(hass, config_entry)
+    await c.async_setup()
+  assert 0 not in c.data[LOCK_ENTITY_ID].slots
+  assert 1 in c.data[LOCK_ENTITY_ID].slots
+  c.async_teardown()
+
+
+async def test_slot_name_applied_during_fetch(hass, config_entry, mock_node):
+  config_entry.add_to_hass(hass)
+  with patch(_ZWAVE_HELPER, return_value=mock_node):
+    c = ZLockCoordinator(hass, config_entry)
+    # Pre-seed a name before setup fetches slots.
+    c._names[f"{LOCK_ENTITY_ID}:1"] = "Alice"
+    await c.async_setup()
+  assert c.data[LOCK_ENTITY_ID].slots[1].name == "Alice"
+  assert c.data[LOCK_ENTITY_ID].slots[2].name is None
+  c.async_teardown()
+
+
+async def test_fetch_failure_returns_empty(hass, config_entry):
+  config_entry.add_to_hass(hass)
+  with patch(_ZWAVE_HELPER, side_effect=Exception("zwave unavailable")):
+    c = ZLockCoordinator(hass, config_entry)
+    await c.async_setup()
+  assert c.data[LOCK_ENTITY_ID].slots == {}
+  c.async_teardown()
+
+
+# ---------------------------------------------------------------------------
+# Name persistence
+# ---------------------------------------------------------------------------
+
+async def test_set_name_stores_and_retrieves(coord):
+  await coord.async_set_name(LOCK_ENTITY_ID, 1, "Alice")
+  assert coord.get_name(LOCK_ENTITY_ID, 1) == "Alice"
+
+
+async def test_set_name_empty_clears_entry(coord):
+  await coord.async_set_name(LOCK_ENTITY_ID, 1, "Alice")
+  await coord.async_set_name(LOCK_ENTITY_ID, 1, "")
+  assert coord.get_name(LOCK_ENTITY_ID, 1) is None
+
+
+async def test_set_name_updates_cached_slot(coord):
+  await coord.async_set_name(LOCK_ENTITY_ID, 1, "Bob")
+  assert coord.data[LOCK_ENTITY_ID].slots[1].name == "Bob"
+
+
+async def test_set_name_persists_to_store(hass, config_entry, mock_node):
+  """Names survive a coordinator restart via the Store."""
+  config_entry.add_to_hass(hass)
+  with patch(_ZWAVE_HELPER, return_value=mock_node):
+    c1 = ZLockCoordinator(hass, config_entry)
+    await c1.async_setup()
+    await c1.async_set_name(LOCK_ENTITY_ID, 1, "Persisted")
+    c1.async_teardown()
+
+    c2 = ZLockCoordinator(hass, config_entry)
+    await c2.async_setup()
+
+  assert c2.get_name(LOCK_ENTITY_ID, 1) == "Persisted"
+  c2.async_teardown()
+
+
+# ---------------------------------------------------------------------------
+# Alarm event handling
+# ---------------------------------------------------------------------------
+
+async def _fire_alarm(hass, alarm_type: str, alarm_level: str = "0"):
+  hass.states.async_set(ALARM_LEVEL_ENTITY, alarm_level)
+  hass.states.async_set(ALARM_TYPE_ENTITY, alarm_type)
+  await hass.async_block_till_done()
+
+
+async def test_keypad_lock_sets_action_and_user(hass, coord):
+  hass.states.async_set(ALARM_LEVEL_ENTITY, "1")
+  hass.states.async_set(ALARM_TYPE_ENTITY, "18")
+  await hass.async_block_till_done()
+
+  assert coord.data[LOCK_ENTITY_ID].last_action == "locked"
+  assert coord.data[LOCK_ENTITY_ID].last_user == "1"
+
+
+async def test_keypad_lock_uses_slot_name(hass, coord):
+  await coord.async_set_name(LOCK_ENTITY_ID, 1, "Alice")
+  hass.states.async_set(ALARM_LEVEL_ENTITY, "1")
+  hass.states.async_set(ALARM_TYPE_ENTITY, "18")
+  await hass.async_block_till_done()
+
+  assert coord.data[LOCK_ENTITY_ID].last_user == "Alice"
+
+
+async def test_keypad_unlock(hass, coord):
+  await _fire_alarm(hass, "19", alarm_level="2")
+  assert coord.data[LOCK_ENTITY_ID].last_action == "unlocked"
+  assert coord.data[LOCK_ENTITY_ID].last_user == "2"
+
+
+async def test_manual_lock(hass, coord):
+  await _fire_alarm(hass, "21")
+  assert coord.data[LOCK_ENTITY_ID].last_action == "locked"
+  assert coord.data[LOCK_ENTITY_ID].last_user == "<Manual>"
+
+
+async def test_manual_unlock(hass, coord):
+  await _fire_alarm(hass, "22")
+  assert coord.data[LOCK_ENTITY_ID].last_action == "unlocked"
+  assert coord.data[LOCK_ENTITY_ID].last_user == "<Manual>"
+
+
+async def test_radio_lock(hass, coord):
+  await _fire_alarm(hass, "24")
+  assert coord.data[LOCK_ENTITY_ID].last_action == "locked"
+  assert coord.data[LOCK_ENTITY_ID].last_user == "<Radio>"
+
+
+async def test_radio_unlock(hass, coord):
+  await _fire_alarm(hass, "25")
+  assert coord.data[LOCK_ENTITY_ID].last_action == "unlocked"
+  assert coord.data[LOCK_ENTITY_ID].last_user == "<Radio>"
+
+
+async def test_unknown_alarm_type_ignored(hass, coord):
+  await _fire_alarm(hass, "99")
+  assert coord.data[LOCK_ENTITY_ID].last_action is None
+  assert coord.data[LOCK_ENTITY_ID].last_user is None
+
+
+async def test_non_integer_alarm_state_ignored(hass, coord):
+  hass.states.async_set(ALARM_TYPE_ENTITY, "unavailable")
+  await hass.async_block_till_done()
+  assert coord.data[LOCK_ENTITY_ID].last_action is None
+
+
+# ---------------------------------------------------------------------------
+# last_events persistence and restore
+# ---------------------------------------------------------------------------
+
+async def test_last_events_persisted_after_alarm(hass, config_entry, mock_node):
+  config_entry.add_to_hass(hass)
+  with patch(_ZWAVE_HELPER, return_value=mock_node):
+    c = ZLockCoordinator(hass, config_entry)
+    await c.async_setup()
+
+    hass.states.async_set(ALARM_LEVEL_ENTITY, "1")
+    hass.states.async_set(ALARM_TYPE_ENTITY, "18")
+    await hass.async_block_till_done()
+
+    stored = await c._store.async_load()
+
+  assert stored["last_events"]["locks"][LOCK_ENTITY_ID]["last_action"] == "locked"
+  assert stored["last_events"]["locks"][LOCK_ENTITY_ID]["last_user"] == "1"
+  c.async_teardown()
+
+
+async def test_last_events_restored_on_startup(hass, config_entry, mock_node):
+  """last_action/last_user are non-None immediately after a cold start."""
+  config_entry.add_to_hass(hass)
+  with patch(_ZWAVE_HELPER, return_value=mock_node):
+    c1 = ZLockCoordinator(hass, config_entry)
+    await c1.async_setup()
+    hass.states.async_set(ALARM_LEVEL_ENTITY, "2")
+    hass.states.async_set(ALARM_TYPE_ENTITY, "22")
+    await hass.async_block_till_done()
+    c1.async_teardown()
+
+    c2 = ZLockCoordinator(hass, config_entry)
+    await c2.async_setup()
+
+  assert c2.data[LOCK_ENTITY_ID].last_action == "unlocked"
+  assert c2.data[LOCK_ENTITY_ID].last_user == "<Manual>"
+  c2.async_teardown()
+
+
+async def test_names_still_present_after_alarm_save(hass, config_entry, mock_node):
+  """_async_save merges names + last_events; names must not be clobbered."""
+  config_entry.add_to_hass(hass)
+  with patch(_ZWAVE_HELPER, return_value=mock_node):
+    c = ZLockCoordinator(hass, config_entry)
+    await c.async_setup()
+    await c.async_set_name(LOCK_ENTITY_ID, 1, "Alice")
+    await _fire_alarm(hass, "18", "1")
+
+    stored = await c._store.async_load()
+
+  assert stored["names"][f"{LOCK_ENTITY_ID}:1"] == "Alice"
+  assert stored["last_events"]["locks"][LOCK_ENTITY_ID]["last_action"] == "locked"
+  c.async_teardown()
+
+
+# ---------------------------------------------------------------------------
+# Group state
+# ---------------------------------------------------------------------------
+
+@pytest.fixture
+def group_config_entry():
+  from pytest_homeassistant_custom_component.common import MockConfigEntry
+  return MockConfigEntry(
+    domain=DOMAIN,
+    data={CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID]},
+    options={
+      CONF_GROUPS: [{
+        CONF_GROUP_NAME: "All Locks",
+        CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID],
+      }]
+    },
+    entry_id="test_entry_group",
+  )
+
+
+async def test_group_state_updated_on_alarm(hass, group_config_entry, mock_node):
+  group_config_entry.add_to_hass(hass)
+  with patch(_ZWAVE_HELPER, return_value=mock_node):
+    c = ZLockCoordinator(hass, group_config_entry)
+    await c.async_setup()
+    await _fire_alarm(hass, "19", "1")
+
+  assert c.group_data["All Locks"].last_action == "unlocked"
+  assert c.group_data["All Locks"].last_user == "1"
+  c.async_teardown()
+
+
+async def test_group_last_events_restored(hass, group_config_entry, mock_node):
+  group_config_entry.add_to_hass(hass)
+  with patch(_ZWAVE_HELPER, return_value=mock_node):
+    c1 = ZLockCoordinator(hass, group_config_entry)
+    await c1.async_setup()
+    await _fire_alarm(hass, "24")
+    c1.async_teardown()
+
+    c2 = ZLockCoordinator(hass, group_config_entry)
+    await c2.async_setup()
+
+  assert c2.group_data["All Locks"].last_action == "locked"
+  assert c2.group_data["All Locks"].last_user == "<Radio>"
+  c2.async_teardown()

+ 206 - 0
tests/test_sensor.py

@@ -0,0 +1,206 @@
+"""Tests for sensor entities: native_value and extra_state_attributes."""
+import pytest
+from unittest.mock import patch
+
+from custom_components.zlock_slots.coordinator import ZLockCoordinator, LockState, SlotInfo
+from custom_components.zlock_slots.sensor import (
+  ZLockLastActionSensor,
+  ZLockLastUserSensor,
+  ZLockSlotsSensor,
+  ZLockGroupLastActionSensor,
+  ZLockGroupLastUserSensor,
+)
+from custom_components.zlock_slots.const import CONF_GROUPS, CONF_GROUP_NAME, CONF_LOCK_ENTITY_IDS
+from tests.conftest import LOCK_ENTITY_ID, ALARM_TYPE_ENTITY, ALARM_LEVEL_ENTITY
+
+_ZWAVE_HELPER = "homeassistant.components.zwave_js.helpers.async_get_node_from_entity_id"
+
+
+@pytest.fixture
+async def coord(hass, config_entry, mock_node):
+  config_entry.add_to_hass(hass)
+  with patch(_ZWAVE_HELPER, return_value=mock_node):
+    c = ZLockCoordinator(hass, config_entry)
+    await c.async_setup()
+    await hass.async_block_till_done()
+    yield c
+  c.async_teardown()
+
+
+# ---------------------------------------------------------------------------
+# ZLockLastActionSensor
+# ---------------------------------------------------------------------------
+
+async def test_last_action_none_initially(coord, config_entry):
+  sensor = ZLockLastActionSensor(coord, config_entry, LOCK_ENTITY_ID)
+  assert sensor.native_value is None
+
+
+async def test_last_action_after_event(hass, coord, config_entry):
+  hass.states.async_set(ALARM_LEVEL_ENTITY, "1")
+  hass.states.async_set(ALARM_TYPE_ENTITY, "18")
+  await hass.async_block_till_done()
+
+  sensor = ZLockLastActionSensor(coord, config_entry, LOCK_ENTITY_ID)
+  assert sensor.native_value == "locked"
+
+
+# ---------------------------------------------------------------------------
+# ZLockLastUserSensor
+# ---------------------------------------------------------------------------
+
+async def test_last_user_none_initially(coord, config_entry):
+  sensor = ZLockLastUserSensor(coord, config_entry, LOCK_ENTITY_ID)
+  assert sensor.native_value is None
+
+
+async def test_last_user_slot_number_when_unnamed(hass, coord, config_entry):
+  hass.states.async_set(ALARM_LEVEL_ENTITY, "2")
+  hass.states.async_set(ALARM_TYPE_ENTITY, "19")
+  await hass.async_block_till_done()
+
+  sensor = ZLockLastUserSensor(coord, config_entry, LOCK_ENTITY_ID)
+  assert sensor.native_value == "2"
+
+
+async def test_last_user_name_when_named(hass, coord, config_entry):
+  await coord.async_set_name(LOCK_ENTITY_ID, 1, "Eve")
+  hass.states.async_set(ALARM_LEVEL_ENTITY, "1")
+  hass.states.async_set(ALARM_TYPE_ENTITY, "18")
+  await hass.async_block_till_done()
+
+  sensor = ZLockLastUserSensor(coord, config_entry, LOCK_ENTITY_ID)
+  assert sensor.native_value == "Eve"
+
+
+async def test_last_user_manual(hass, coord, config_entry):
+  hass.states.async_set(ALARM_TYPE_ENTITY, "21")
+  await hass.async_block_till_done()
+
+  sensor = ZLockLastUserSensor(coord, config_entry, LOCK_ENTITY_ID)
+  assert sensor.native_value == "<Manual>"
+
+
+async def test_last_user_radio(hass, coord, config_entry):
+  hass.states.async_set(ALARM_TYPE_ENTITY, "25")
+  await hass.async_block_till_done()
+
+  sensor = ZLockLastUserSensor(coord, config_entry, LOCK_ENTITY_ID)
+  assert sensor.native_value == "<Radio>"
+
+
+# ---------------------------------------------------------------------------
+# ZLockSlotsSensor
+# ---------------------------------------------------------------------------
+
+async def test_slots_sensor_count(coord, config_entry):
+  sensor = ZLockSlotsSensor(coord, config_entry, LOCK_ENTITY_ID)
+  assert sensor.native_value == 2
+
+
+async def test_slots_sensor_zero_when_empty(hass, config_entry):
+  from tests.conftest import FakeNode
+  config_entry.add_to_hass(hass)
+  with patch(_ZWAVE_HELPER, return_value=FakeNode(users=[])):
+    c = ZLockCoordinator(hass, config_entry)
+    await c.async_setup()
+
+  sensor = ZLockSlotsSensor(c, config_entry, LOCK_ENTITY_ID)
+  assert sensor.native_value == 0
+  assert sensor.extra_state_attributes == {"slots": []}
+  c.async_teardown()
+
+
+async def test_slots_sensor_attributes_structure(coord, config_entry):
+  sensor = ZLockSlotsSensor(coord, config_entry, LOCK_ENTITY_ID)
+  attrs = sensor.extra_state_attributes
+  assert "slots" in attrs
+  slots = attrs["slots"]
+  assert len(slots) == 2
+  assert slots[0] == {"slot_num": 1, "status": "enabled", "name": None}
+  assert slots[1] == {"slot_num": 2, "status": "disabled", "name": None}
+
+
+async def test_slots_sensor_attributes_sorted(hass, config_entry):
+  from tests.conftest import FakeNode, FakeUserData
+  node = FakeNode(users=[FakeUserData(5), FakeUserData(1), FakeUserData(3)])
+  config_entry.add_to_hass(hass)
+  with patch(_ZWAVE_HELPER, return_value=node):
+    c = ZLockCoordinator(hass, config_entry)
+    await c.async_setup()
+
+  sensor = ZLockSlotsSensor(c, config_entry, LOCK_ENTITY_ID)
+  slot_nums = [s["slot_num"] for s in sensor.extra_state_attributes["slots"]]
+  assert slot_nums == [1, 3, 5]
+  c.async_teardown()
+
+
+async def test_slots_sensor_attributes_include_names(hass, config_entry, mock_node):
+  config_entry.add_to_hass(hass)
+  with patch(_ZWAVE_HELPER, return_value=mock_node):
+    c = ZLockCoordinator(hass, config_entry)
+    c._names[f"{LOCK_ENTITY_ID}:1"] = "Frank"
+    await c.async_setup()
+
+  sensor = ZLockSlotsSensor(c, config_entry, LOCK_ENTITY_ID)
+  slot1 = sensor.extra_state_attributes["slots"][0]
+  assert slot1["name"] == "Frank"
+  c.async_teardown()
+
+
+# ---------------------------------------------------------------------------
+# Group sensors
+# ---------------------------------------------------------------------------
+
+@pytest.fixture
+def group_config_entry():
+  from pytest_homeassistant_custom_component.common import MockConfigEntry
+  from custom_components.zlock_slots.const import DOMAIN
+  return MockConfigEntry(
+    domain=DOMAIN,
+    data={CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID]},
+    options={
+      CONF_GROUPS: [{
+        CONF_GROUP_NAME: "All Locks",
+        CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID],
+      }]
+    },
+    entry_id="test_group_entry",
+  )
+
+
+async def test_group_last_action_initially_none(hass, group_config_entry, mock_node):
+  group_config_entry.add_to_hass(hass)
+  with patch(_ZWAVE_HELPER, return_value=mock_node):
+    c = ZLockCoordinator(hass, group_config_entry)
+    await c.async_setup()
+
+  sensor = ZLockGroupLastActionSensor(c, group_config_entry, "All Locks")
+  assert sensor.native_value is None
+  c.async_teardown()
+
+
+async def test_group_last_action_after_alarm(hass, group_config_entry, mock_node):
+  group_config_entry.add_to_hass(hass)
+  with patch(_ZWAVE_HELPER, return_value=mock_node):
+    c = ZLockCoordinator(hass, group_config_entry)
+    await c.async_setup()
+    hass.states.async_set(ALARM_TYPE_ENTITY, "24")
+    await hass.async_block_till_done()
+
+  sensor = ZLockGroupLastActionSensor(c, group_config_entry, "All Locks")
+  assert sensor.native_value == "locked"
+  c.async_teardown()
+
+
+async def test_group_last_user_after_alarm(hass, group_config_entry, mock_node):
+  group_config_entry.add_to_hass(hass)
+  with patch(_ZWAVE_HELPER, return_value=mock_node):
+    c = ZLockCoordinator(hass, group_config_entry)
+    await c.async_setup()
+    hass.states.async_set(ALARM_TYPE_ENTITY, "22")
+    await hass.async_block_till_done()
+
+  sensor = ZLockGroupLastUserSensor(c, group_config_entry, "All Locks")
+  assert sensor.native_value == "<Manual>"
+  c.async_teardown()

+ 171 - 0
tests/test_services.py

@@ -0,0 +1,171 @@
+"""Tests for zlock_slots service handlers."""
+import pytest
+from unittest.mock import patch, AsyncMock, call
+
+from homeassistant.exceptions import ServiceValidationError
+
+from custom_components.zlock_slots.coordinator import ZLockCoordinator
+from custom_components.zlock_slots.services import register_services, unregister_services
+from custom_components.zlock_slots.const import DOMAIN, CONF_LOCK_ENTITY_IDS
+from tests.conftest import LOCK_ENTITY_ID, FakeUserData, FakeNode
+
+_ZWAVE_HELPER = "homeassistant.components.zwave_js.helpers.async_get_node_from_entity_id"
+
+
+@pytest.fixture
+async def service_env(hass, config_entry, mock_node):
+  """Coordinator wired into hass.data with services registered and mock zwave_js services ready."""
+  config_entry.add_to_hass(hass)
+  with patch(_ZWAVE_HELPER, return_value=mock_node):
+    coord = ZLockCoordinator(hass, config_entry)
+    await coord.async_setup()
+    hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coord
+
+    set_lock_usercode = AsyncMock()
+    clear_lock_usercode = AsyncMock()
+    hass.services.async_register("zwave_js", "set_lock_usercode", set_lock_usercode)
+    hass.services.async_register("zwave_js", "clear_lock_usercode", clear_lock_usercode)
+
+    register_services(hass)
+    yield coord, set_lock_usercode, clear_lock_usercode
+
+  unregister_services(hass)
+  coord.async_teardown()
+
+
+# ---------------------------------------------------------------------------
+# set_code
+# ---------------------------------------------------------------------------
+
+async def test_set_code_calls_zwave_service(hass, service_env, mock_node):
+  coord, set_lock_usercode, _ = service_env
+  with patch(_ZWAVE_HELPER, return_value=mock_node):
+    await hass.services.async_call(
+      DOMAIN, "set_code",
+      {"lock_entity_id": LOCK_ENTITY_ID, "slot": 5, "code": "1234"},
+      blocking=True,
+    )
+
+  set_lock_usercode.assert_awaited_once()
+  service_data = set_lock_usercode.call_args[0][0].data
+  assert service_data["code_slot"] == 5
+  assert service_data["usercode"] == "1234"
+  assert service_data["entity_id"] == LOCK_ENTITY_ID
+
+
+async def test_set_code_auto_assigns_lowest_free_slot(hass, service_env, mock_node):
+  """Slots 1 and 2 occupied → auto-assign picks 3."""
+  coord, set_lock_usercode, _ = service_env
+  with patch(_ZWAVE_HELPER, return_value=mock_node):
+    await hass.services.async_call(
+      DOMAIN, "set_code",
+      {"lock_entity_id": LOCK_ENTITY_ID, "code": "5678"},
+      blocking=True,
+    )
+
+  service_data = set_lock_usercode.call_args[0][0].data
+  assert service_data["code_slot"] == 3
+
+
+async def test_set_code_auto_assign_skips_occupied(hass, service_env, mock_node):
+  """If slot 3 is also occupied, auto-assign picks 4."""
+  node_with_3 = FakeNode(users=[
+    FakeUserData(1), FakeUserData(2), FakeUserData(3),
+  ])
+  coord, set_lock_usercode, _ = service_env
+  # Refresh to get node_with_3 slots into coordinator state.
+  with patch(_ZWAVE_HELPER, return_value=node_with_3):
+    await coord.async_refresh_lock(LOCK_ENTITY_ID)
+    await hass.services.async_call(
+      DOMAIN, "set_code",
+      {"lock_entity_id": LOCK_ENTITY_ID, "code": "9999"},
+      blocking=True,
+    )
+
+  service_data = set_lock_usercode.call_args[0][0].data
+  assert service_data["code_slot"] == 4
+
+
+async def test_set_code_with_name_saves_name(hass, service_env, mock_node):
+  coord, _, _ = service_env
+  with patch(_ZWAVE_HELPER, return_value=mock_node):
+    await hass.services.async_call(
+      DOMAIN, "set_code",
+      {"lock_entity_id": LOCK_ENTITY_ID, "slot": 5, "code": "4321", "name": "Charlie"},
+      blocking=True,
+    )
+
+  assert coord.get_name(LOCK_ENTITY_ID, 5) == "Charlie"
+
+
+async def test_set_code_unknown_lock_raises(hass, service_env):
+  with pytest.raises(ServiceValidationError):
+    await hass.services.async_call(
+      DOMAIN, "set_code",
+      {"lock_entity_id": "lock.unknown", "slot": 1, "code": "1234"},
+      blocking=True,
+    )
+
+
+# ---------------------------------------------------------------------------
+# clear_code
+# ---------------------------------------------------------------------------
+
+async def test_clear_code_calls_zwave_service(hass, service_env, mock_node):
+  _, _, clear_lock_usercode = service_env
+  with patch(_ZWAVE_HELPER, return_value=mock_node):
+    await hass.services.async_call(
+      DOMAIN, "clear_code",
+      {"lock_entity_id": LOCK_ENTITY_ID, "slot": 2},
+      blocking=True,
+    )
+
+  clear_lock_usercode.assert_awaited_once()
+  service_data = clear_lock_usercode.call_args[0][0].data
+  assert service_data["code_slot"] == 2
+  assert service_data["entity_id"] == LOCK_ENTITY_ID
+
+
+# ---------------------------------------------------------------------------
+# set_name
+# ---------------------------------------------------------------------------
+
+async def test_set_name_updates_coordinator(hass, service_env):
+  coord, _, _ = service_env
+  await hass.services.async_call(
+    DOMAIN, "set_name",
+    {"lock_entity_id": LOCK_ENTITY_ID, "slot": 1, "name": "Diana"},
+    blocking=True,
+  )
+
+  assert coord.get_name(LOCK_ENTITY_ID, 1) == "Diana"
+
+
+# ---------------------------------------------------------------------------
+# set_slot_enabled
+# ---------------------------------------------------------------------------
+
+async def test_set_slot_enabled_calls_node(hass, service_env, mock_node):
+  with patch(_ZWAVE_HELPER, return_value=mock_node):
+    await hass.services.async_call(
+      DOMAIN, "set_slot_enabled",
+      {"lock_entity_id": LOCK_ENTITY_ID, "slot": 1, "enabled": False},
+      blocking=True,
+    )
+
+  mock_node.access_control.set_user.assert_awaited_once()
+  slot_arg, opts_arg = mock_node.access_control.set_user.call_args[0]
+  assert slot_arg == 1
+  assert opts_arg.active is False
+
+
+async def test_set_slot_enabled_true(hass, service_env, mock_node):
+  with patch(_ZWAVE_HELPER, return_value=mock_node):
+    await hass.services.async_call(
+      DOMAIN, "set_slot_enabled",
+      {"lock_entity_id": LOCK_ENTITY_ID, "slot": 2, "enabled": True},
+      blocking=True,
+    )
+
+  _, opts_arg = mock_node.access_control.set_user.call_args[0]
+  assert opts_arg.active is True