Selaa lähdekoodia

Single config entry, staged options flow, debounced saves, dead-code cleanup

- manifest: single_config_entry (Store uses one shared key; a second entry
  would clobber it), documentation/issue_tracker URLs, lovelace dependency
- options flow: stage lock changes on the flow and commit with groups at
  'Save and close'; abandoning the flow no longer leaves entry.data updated
  with entry.options stale. Deep-copy groups — pruning previously mutated
  entry.options dicts in place
- coordinator: debounce alarm-driven Store writes via async_delay_save (1s);
  import the previously-undefined AlarmMapping annotation
- card: escape slot names/title before innerHTML; drop getConfigElement
  (referenced a never-defined editor element)
- remove dead translation keys, unreachable abort string, unused imports
- add config-flow test suite (user step, single-instance abort, group
  add/prune/duplicate, abandoned-flow safety) and a save-debounce test
ZiB 4 kuukautta sitten
vanhempi
sitoutus
550d67d76d

+ 16 - 12
custom_components/zlock_slots/config_flow.py

@@ -1,5 +1,7 @@
 from __future__ import annotations
 
+from copy import deepcopy
+
 import voluptuous as vol
 
 from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
@@ -55,9 +57,14 @@ class ZLockSlotsConfigFlow(ConfigFlow, domain=DOMAIN):
 
 
 class ZLockSlotsOptionsFlow(OptionsFlow):
+  """All changes are staged on the flow and committed together in the finish
+  step, so abandoning the flow mid-way leaves the entry untouched."""
+
   def __init__(self, config_entry) -> None:
     self._entry = config_entry
-    self._groups: list[dict] = list(config_entry.options.get(CONF_GROUPS, []))
+    # Deep copy: pruning mutates group dicts, which must not alias entry.options.
+    self._groups: list[dict] = deepcopy(config_entry.options.get(CONF_GROUPS, []))
+    self._lock_ids: list[str] = list(config_entry.data.get(CONF_LOCK_ENTITY_IDS, []))
 
   async def async_step_init(self, user_input=None) -> ConfigFlowResult:
     return self.async_show_menu(
@@ -67,31 +74,24 @@ class ZLockSlotsOptionsFlow(OptionsFlow):
 
   async def async_step_manage_locks(self, user_input=None) -> ConfigFlowResult:
     errors: dict[str, str] = {}
-    current_lock_ids: list[str] = self._entry.data.get(CONF_LOCK_ENTITY_IDS, [])
 
     if user_input is not None:
       new_lock_ids: list[str] = user_input.get(CONF_LOCK_ENTITY_IDS, [])
       if not new_lock_ids:
         errors[CONF_LOCK_ENTITY_IDS] = "no_locks_selected"
       else:
-        removed = set(current_lock_ids) - set(new_lock_ids)
+        removed = set(self._lock_ids) - set(new_lock_ids)
         if removed:
-          self._groups = [
-            g for g in self._groups
-            if not all(lid in removed for lid in g.get(CONF_LOCK_ENTITY_IDS, []))
-          ]
           for g in self._groups:
             g[CONF_LOCK_ENTITY_IDS] = [
               lid for lid in g.get(CONF_LOCK_ENTITY_IDS, []) if lid not in removed
             ]
           self._groups = [g for g in self._groups if g.get(CONF_LOCK_ENTITY_IDS)]
-        self.hass.config_entries.async_update_entry(
-          self._entry, data={CONF_LOCK_ENTITY_IDS: new_lock_ids}
-        )
+        self._lock_ids = new_lock_ids
         return await self.async_step_init()
 
     schema = vol.Schema({
-      vol.Required(CONF_LOCK_ENTITY_IDS, default=current_lock_ids): selector.EntitySelector(
+      vol.Required(CONF_LOCK_ENTITY_IDS, default=self._lock_ids): selector.EntitySelector(
         selector.EntitySelectorConfig(
           domain="lock",
           integration="zwave_js",
@@ -103,7 +103,7 @@ class ZLockSlotsOptionsFlow(OptionsFlow):
 
   async def async_step_add_group(self, user_input=None) -> ConfigFlowResult:
     errors: dict[str, str] = {}
-    lock_ids: list[str] = self._entry.data[CONF_LOCK_ENTITY_IDS]
+    lock_ids: list[str] = self._lock_ids
 
     if user_input is not None:
       name = user_input.get(CONF_GROUP_NAME, "").strip()
@@ -156,4 +156,8 @@ class ZLockSlotsOptionsFlow(OptionsFlow):
     return self.async_show_form(step_id="remove_group", data_schema=schema)
 
   async def async_step_finish(self, user_input=None) -> ConfigFlowResult:
+    if self._lock_ids != self._entry.data.get(CONF_LOCK_ENTITY_IDS, []):
+      self.hass.config_entries.async_update_entry(
+        self._entry, data={CONF_LOCK_ENTITY_IDS: self._lock_ids}
+      )
     return self.async_create_entry(data={CONF_GROUPS: self._groups})

+ 12 - 4
custom_components/zlock_slots/coordinator.py

@@ -18,6 +18,7 @@ from .const import (
   CONF_GROUPS,
   CONF_GROUP_NAME,
   ALARM_MAP,
+  AlarmMapping,
   UserSource,
 )
 
@@ -208,14 +209,18 @@ class ZLockCoordinator(DataUpdateCoordinator[dict[str, LockState]]):
     for group_name in self._lock_to_groups.get(lock_entity_id, []):
       self.group_data[group_name].last_action = action
       self.group_data[group_name].last_user = user_str
-    self.hass.async_create_task(self._async_save())
+    # Debounced: bursts of lock events coalesce into one Store write.
+    self._store.async_delay_save(self._data_to_save, self._SAVE_DELAY_S)
     self.async_update_listeners()
 
   def get_name(self, lock_entity_id: str, slot_num: int) -> str | None:
     return self._names.get(f"{lock_entity_id}:{slot_num}")
 
-  async def _async_save(self) -> None:
-    await self._store.async_save({
+  _SAVE_DELAY_S: float = 1.0
+
+  @callback
+  def _data_to_save(self) -> dict:
+    return {
       "names": self._names,
       "last_events": {
         "locks": {
@@ -229,7 +234,10 @@ class ZLockCoordinator(DataUpdateCoordinator[dict[str, LockState]]):
           if g.last_action is not None or g.last_user is not None
         },
       },
-    })
+    }
+
+  async def _async_save(self) -> None:
+    await self._store.async_save(self._data_to_save())
 
   async def async_set_name(self, lock_entity_id: str, slot_num: int, name: str) -> None:
     key = f"{lock_entity_id}:{slot_num}"

+ 10 - 12
custom_components/zlock_slots/frontend/zlock-slots-card.js

@@ -9,6 +9,9 @@
 
 const DOMAIN = "zlock_slots";
 
+const ESCAPE_MAP = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
+const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ESCAPE_MAP[c]);
+
 class ZLockSlotsCard extends HTMLElement {
   constructor() {
     super();
@@ -105,7 +108,7 @@ class ZLockSlotsCard extends HTMLElement {
         .pin-edit-row label { font-size: 0.8rem; opacity: 0.7; white-space: nowrap; }
         .name-display { cursor: text; }
       </style>
-      <ha-card${this._config.title ? ` header="${this._config.title}"` : ""}>
+      <ha-card${this._config.title ? ` header="${escapeHtml(this._config.title)}"` : ""}>
         <div class="pending-indicator" id="pending-indicator"><ha-icon icon="mdi:loading"></ha-icon></div>
       <div class="card-content">
         <div class="section">
@@ -118,10 +121,10 @@ class ZLockSlotsCard extends HTMLElement {
                       <td>${s.slot_num}</td>
                       <td>
                         <span id="name-display-${s.slot_num}" class="name-display" data-action="edit-name" data-slot="${s.slot_num}" title="Click to edit name">
-                          ${s.name ?? '<em style="opacity:.4">unnamed</em>'}
+                          ${s.name != null ? escapeHtml(s.name) : '<em style="opacity:.4">unnamed</em>'}
                         </span>
                         <span class="inline-edit" id="name-edit-${s.slot_num}" style="display:none">
-                          <input id="name-input-${s.slot_num}" type="text" value="${s.name ?? ''}" placeholder="Enter name — Enter to save, Esc to cancel" data-slot="${s.slot_num}" />
+                          <input id="name-input-${s.slot_num}" type="text" value="${escapeHtml(s.name ?? '')}" placeholder="Enter name — Enter to save, Esc to cancel" data-slot="${s.slot_num}" />
                         </span>
                       </td>
                       <td class="actions-cell">
@@ -207,10 +210,9 @@ class ZLockSlotsCard extends HTMLElement {
       });
     });
 
-    const closeName = (slot, save) => {
+    const closeName = (slot) => {
       root.getElementById(`name-display-${slot}`).style.display = "";
       root.getElementById(`name-edit-${slot}`).style.display = "none";
-      return save;
     };
 
     const closePin = (slot) => {
@@ -232,16 +234,16 @@ class ZLockSlotsCard extends HTMLElement {
             await this._callService("set_code", { lock_entity_id: lock, slot, code });
           } else {
             const name = input.value.trim();
-            closeName(slot, true);
+            closeName(slot);
             await this._callService("set_name", { lock_entity_id: lock, slot, name });
           }
         } else if (e.key === "Escape") {
           input.removeEventListener("blur", input._blurHandler);
-          isPin ? closePin(slot) : closeName(slot, false);
+          isPin ? closePin(slot) : closeName(slot);
         }
       });
 
-      input._blurHandler = () => isPin ? closePin(slot) : closeName(slot, false);
+      input._blurHandler = () => isPin ? closePin(slot) : closeName(slot);
       input.addEventListener("blur", input._blurHandler);
     });
 
@@ -271,10 +273,6 @@ class ZLockSlotsCard extends HTMLElement {
 
   getCardSize() { return 4; }
 
-  static getConfigElement() {
-    return document.createElement("zlock-slots-card-editor");
-  }
-
   static getStubConfig() {
     return { entity: "sensor.my_lock_slots", lock_entity_id: "lock.my_lock" };
   }

+ 6 - 4
custom_components/zlock_slots/manifest.json

@@ -1,12 +1,14 @@
 {
   "domain": "zlock_slots",
   "name": "zLock Slots",
-  "version": "0.2.0",
   "codeowners": [],
   "config_flow": true,
-  "dependencies": ["zwave_js", "frontend"],
-  "documentation": "",
+  "dependencies": ["zwave_js", "frontend", "lovelace"],
+  "documentation": "https://git.zi3.xyz/code/zlock_slots",
   "integration_type": "hub",
   "iot_class": "local_push",
-  "requirements": []
+  "issue_tracker": "https://git.zi3.xyz/code/zlock_slots/issues",
+  "requirements": [],
+  "single_config_entry": true,
+  "version": "0.2.0"
 }

+ 1 - 6
custom_components/zlock_slots/sensor.py

@@ -2,7 +2,7 @@ from __future__ import annotations
 
 from homeassistant.components.sensor import SensorEntity
 from homeassistant.config_entries import ConfigEntry
-from homeassistant.core import HomeAssistant, callback
+from homeassistant.core import HomeAssistant
 from homeassistant.helpers.entity_platform import AddEntitiesCallback
 from homeassistant.helpers.update_coordinator import CoordinatorEntity
 
@@ -51,7 +51,6 @@ class _ZLockBaseSensor(CoordinatorEntity[ZLockCoordinator], SensorEntity):
 
 
 class ZLockLastActionSensor(_ZLockBaseSensor):
-  _attr_translation_key = "last_action"
 
   def __init__(self, coordinator, entry, lock_entity_id):
     super().__init__(coordinator, entry, lock_entity_id)
@@ -68,7 +67,6 @@ class ZLockLastActionSensor(_ZLockBaseSensor):
 
 
 class ZLockLastUserSensor(_ZLockBaseSensor):
-  _attr_translation_key = "last_user"
 
   def __init__(self, coordinator, entry, lock_entity_id):
     super().__init__(coordinator, entry, lock_entity_id)
@@ -85,7 +83,6 @@ class ZLockLastUserSensor(_ZLockBaseSensor):
 
 
 class ZLockSlotsSensor(_ZLockBaseSensor):
-  _attr_translation_key = "slots"
 
   def __init__(self, coordinator, entry, lock_entity_id):
     super().__init__(coordinator, entry, lock_entity_id)
@@ -136,7 +133,6 @@ class _ZLockGroupBaseSensor(CoordinatorEntity[ZLockCoordinator], SensorEntity):
 
 
 class ZLockGroupLastActionSensor(_ZLockGroupBaseSensor):
-  _attr_translation_key = "last_action"
 
   def __init__(self, coordinator, entry, group_name):
     super().__init__(coordinator, entry, group_name)
@@ -153,7 +149,6 @@ class ZLockGroupLastActionSensor(_ZLockGroupBaseSensor):
 
 
 class ZLockGroupLastUserSensor(_ZLockGroupBaseSensor):
-  _attr_translation_key = "last_user"
 
   def __init__(self, coordinator, entry, group_name):
     super().__init__(coordinator, entry, group_name)

+ 1 - 1
custom_components/zlock_slots/services.py

@@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant, ServiceCall
 from homeassistant.exceptions import ServiceValidationError
 from homeassistant.helpers import config_validation as cv
 
-from .const import DOMAIN, CONF_LOCK_ENTITY_IDS
+from .const import DOMAIN
 from .coordinator import ZLockCoordinator
 
 _LOGGER = logging.getLogger(__name__)

+ 0 - 3
custom_components/zlock_slots/strings.json

@@ -11,9 +11,6 @@
     },
     "error": {
       "no_locks_selected": "Please select at least one lock."
-    },
-    "abort": {
-      "already_configured": "This configuration already exists."
     }
   },
   "options": {

+ 0 - 3
custom_components/zlock_slots/translations/en.json

@@ -11,9 +11,6 @@
     },
     "error": {
       "no_locks_selected": "Please select at least one lock."
-    },
-    "abort": {
-      "already_configured": "This configuration already exists."
     }
   },
   "options": {

+ 201 - 0
tests/test_config_flow.py

@@ -0,0 +1,201 @@
+"""Tests for the config flow and options flow (staged commit semantics)."""
+import pytest
+from unittest.mock import patch
+
+from homeassistant.config_entries import SOURCE_USER
+from homeassistant.data_entry_flow import FlowResultType
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+
+from custom_components.zlock_slots.const import (
+  DOMAIN, CONF_LOCK_ENTITY_IDS, CONF_GROUPS, CONF_GROUP_NAME,
+)
+from tests.conftest import LOCK_ENTITY_ID, FakeNode
+
+_ZWAVE_HELPER = "homeassistant.components.zwave_js.helpers.async_get_node_from_entity_id"
+LOCK_2 = "lock.back_door"
+
+
+@pytest.fixture(autouse=True)
+def mock_zwave_node():
+  with patch(_ZWAVE_HELPER, return_value=FakeNode()):
+    yield
+
+
+@pytest.fixture(autouse=True)
+def bypass_manifest_dependencies():
+  """zwave_js/frontend/lovelace can't be set up in the test harness."""
+  with patch(
+    "homeassistant.setup._async_process_dependencies", return_value=[]
+  ):
+    yield
+
+
+# ---------------------------------------------------------------------------
+# Config flow
+# ---------------------------------------------------------------------------
+
+async def test_user_flow_creates_entry(hass):
+  result = await hass.config_entries.flow.async_init(
+    DOMAIN, context={"source": SOURCE_USER}
+  )
+  assert result["type"] is FlowResultType.FORM
+  assert result["step_id"] == "user"
+
+  result = await hass.config_entries.flow.async_configure(
+    result["flow_id"], {CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID]}
+  )
+  await hass.async_block_till_done()
+
+  assert result["type"] is FlowResultType.CREATE_ENTRY
+  assert result["data"] == {CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID]}
+
+
+async def test_user_flow_no_locks_shows_error(hass):
+  result = await hass.config_entries.flow.async_init(
+    DOMAIN, context={"source": SOURCE_USER}
+  )
+  result = await hass.config_entries.flow.async_configure(
+    result["flow_id"], {CONF_LOCK_ENTITY_IDS: []}
+  )
+  assert result["type"] is FlowResultType.FORM
+  assert result["errors"] == {CONF_LOCK_ENTITY_IDS: "no_locks_selected"}
+
+
+async def test_second_entry_blocked(hass, config_entry):
+  """manifest single_config_entry: a second entry cannot be created."""
+  config_entry.add_to_hass(hass)
+  result = await hass.config_entries.flow.async_init(
+    DOMAIN, context={"source": SOURCE_USER}
+  )
+  assert result["type"] is FlowResultType.ABORT
+  assert result["reason"] == "single_instance_allowed"
+
+
+# ---------------------------------------------------------------------------
+# Options flow
+# ---------------------------------------------------------------------------
+
+def _entry_with_group() -> MockConfigEntry:
+  return MockConfigEntry(
+    domain=DOMAIN,
+    data={CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID, LOCK_2]},
+    options={
+      CONF_GROUPS: [{
+        CONF_GROUP_NAME: "All Locks",
+        CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID, LOCK_2],
+      }]
+    },
+    entry_id="test_entry_options",
+  )
+
+
+async def _setup_entry(hass, entry):
+  entry.add_to_hass(hass)
+  assert await hass.config_entries.async_setup(entry.entry_id)
+  await hass.async_block_till_done()
+
+
+async def _begin_options(hass, entry, step: str):
+  result = await hass.config_entries.options.async_init(entry.entry_id)
+  assert result["type"] is FlowResultType.MENU
+  return await hass.config_entries.options.async_configure(
+    result["flow_id"], {"next_step_id": step}
+  )
+
+
+async def _finish(hass, flow_id):
+  result = await hass.config_entries.options.async_configure(
+    flow_id, {"next_step_id": "finish"}
+  )
+  await hass.async_block_till_done()
+  return result
+
+
+async def test_options_add_group(hass, config_entry):
+  await _setup_entry(hass, config_entry)
+
+  result = await _begin_options(hass, config_entry, "add_group")
+  result = await hass.config_entries.options.async_configure(
+    result["flow_id"],
+    {CONF_GROUP_NAME: "Front", CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID]},
+  )
+  assert result["type"] is FlowResultType.MENU
+  result = await _finish(hass, result["flow_id"])
+
+  assert result["type"] is FlowResultType.CREATE_ENTRY
+  assert config_entry.options[CONF_GROUPS] == [
+    {CONF_GROUP_NAME: "Front", CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID]}
+  ]
+
+
+async def test_options_duplicate_group_name_rejected(hass):
+  entry = _entry_with_group()
+  await _setup_entry(hass, entry)
+
+  result = await _begin_options(hass, entry, "add_group")
+  result = await hass.config_entries.options.async_configure(
+    result["flow_id"],
+    {CONF_GROUP_NAME: "All Locks", CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID]},
+  )
+  assert result["type"] is FlowResultType.FORM
+  assert result["errors"] == {CONF_GROUP_NAME: "group_name_exists"}
+
+
+async def test_options_remove_lock_prunes_it_from_groups(hass):
+  entry = _entry_with_group()
+  await _setup_entry(hass, entry)
+
+  result = await _begin_options(hass, entry, "manage_locks")
+  result = await hass.config_entries.options.async_configure(
+    result["flow_id"], {CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID]}
+  )
+  assert result["type"] is FlowResultType.MENU
+  await _finish(hass, result["flow_id"])
+
+  assert entry.data[CONF_LOCK_ENTITY_IDS] == [LOCK_ENTITY_ID]
+  # Group survives but no longer references the removed lock.
+  assert entry.options[CONF_GROUPS] == [
+    {CONF_GROUP_NAME: "All Locks", CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID]}
+  ]
+
+
+async def test_options_group_dropped_when_all_its_locks_removed(hass):
+  entry = MockConfigEntry(
+    domain=DOMAIN,
+    data={CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID, LOCK_2]},
+    options={
+      CONF_GROUPS: [{
+        CONF_GROUP_NAME: "Back only",
+        CONF_LOCK_ENTITY_IDS: [LOCK_2],
+      }]
+    },
+    entry_id="test_entry_prune",
+  )
+  await _setup_entry(hass, entry)
+
+  result = await _begin_options(hass, entry, "manage_locks")
+  result = await hass.config_entries.options.async_configure(
+    result["flow_id"], {CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID]}
+  )
+  await _finish(hass, result["flow_id"])
+
+  assert entry.data[CONF_LOCK_ENTITY_IDS] == [LOCK_ENTITY_ID]
+  assert entry.options[CONF_GROUPS] == []
+
+
+async def test_options_abandoned_flow_changes_nothing(hass):
+  """Lock changes are staged on the flow; without 'finish' the entry is untouched."""
+  entry = _entry_with_group()
+  await _setup_entry(hass, entry)
+
+  result = await _begin_options(hass, entry, "manage_locks")
+  result = await hass.config_entries.options.async_configure(
+    result["flow_id"], {CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID]}
+  )
+  assert result["type"] is FlowResultType.MENU
+  # No finish step — abandon the flow here.
+
+  assert entry.data[CONF_LOCK_ENTITY_IDS] == [LOCK_ENTITY_ID, LOCK_2]
+  assert entry.options[CONF_GROUPS] == [
+    {CONF_GROUP_NAME: "All Locks", CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID, LOCK_2]}
+  ]

+ 33 - 1
tests/test_coordinator.py

@@ -1,6 +1,8 @@
 """Tests for ZLockCoordinator: slot fetch, alarm event handling, name/event persistence."""
 import pytest
-from unittest.mock import patch, AsyncMock
+from unittest.mock import patch
+
+from pytest_homeassistant_custom_component.common import flush_store
 
 from custom_components.zlock_slots.coordinator import ZLockCoordinator
 from custom_components.zlock_slots.const import (
@@ -117,6 +119,11 @@ async def _fire_alarm(hass, alarm_type: str, alarm_level: str = "0"):
   await hass.async_block_till_done()
 
 
+async def _flush_delayed_save(coord):
+  """Alarm-driven Store writes are debounced; force pending writes to flush."""
+  await flush_store(coord._store)
+
+
 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")
@@ -250,6 +257,7 @@ async def test_last_events_persisted_after_alarm(hass, config_entry, mock_node):
     hass.states.async_set(ALARM_LEVEL_ENTITY, "1")
     hass.states.async_set(ALARM_TYPE_ENTITY, "18")
     await hass.async_block_till_done()
+    await _flush_delayed_save(c)
 
     stored = await c._store.async_load()
 
@@ -267,6 +275,7 @@ async def test_last_events_restored_on_startup(hass, config_entry, mock_node):
     hass.states.async_set(ALARM_LEVEL_ENTITY, "2")
     hass.states.async_set(ALARM_TYPE_ENTITY, "22")
     await hass.async_block_till_done()
+    await _flush_delayed_save(c1)
     c1.async_teardown()
 
     c2 = ZLockCoordinator(hass, config_entry)
@@ -285,6 +294,7 @@ async def test_names_still_present_after_alarm_save(hass, config_entry, mock_nod
     await c.async_setup()
     await c.async_set_name(LOCK_ENTITY_ID, 1, "Alice")
     await _fire_alarm(hass, "18", "1")
+    await _flush_delayed_save(c)
 
     stored = await c._store.async_load()
 
@@ -293,6 +303,27 @@ async def test_names_still_present_after_alarm_save(hass, config_entry, mock_nod
   c.async_teardown()
 
 
+async def test_alarm_saves_are_debounced(hass, coord):
+  """Back-to-back alarms coalesce into a single delayed Store write."""
+  calls = []
+  real_data_to_save = coord._data_to_save
+
+  def counting_data_to_save():
+    calls.append(1)
+    return real_data_to_save()
+
+  coord._data_to_save = counting_data_to_save
+
+  await _fire_alarm(hass, "21")
+  await _fire_alarm(hass, "22")
+  assert not calls  # nothing written yet
+  await _flush_delayed_save(coord)
+  assert len(calls) == 1  # single coalesced write
+
+  stored = await coord._store.async_load()
+  assert stored["last_events"]["locks"][LOCK_ENTITY_ID]["last_action"] == "unlocked"
+
+
 # ---------------------------------------------------------------------------
 # Group state
 # ---------------------------------------------------------------------------
@@ -331,6 +362,7 @@ async def test_group_last_events_restored(hass, group_config_entry, mock_node):
     c1 = ZLockCoordinator(hass, group_config_entry)
     await c1.async_setup()
     await _fire_alarm(hass, "24")
+    await _flush_delayed_save(c1)
     c1.async_teardown()
 
     c2 = ZLockCoordinator(hass, group_config_entry)

+ 1 - 1
tests/test_sensor.py

@@ -2,7 +2,7 @@
 import pytest
 from unittest.mock import patch
 
-from custom_components.zlock_slots.coordinator import ZLockCoordinator, LockState, SlotInfo
+from custom_components.zlock_slots.coordinator import ZLockCoordinator
 from custom_components.zlock_slots.sensor import (
   ZLockLastActionSensor,
   ZLockLastUserSensor,

+ 2 - 2
tests/test_services.py

@@ -1,12 +1,12 @@
 """Tests for zlock_slots service handlers."""
 import pytest
-from unittest.mock import patch, AsyncMock, call
+from unittest.mock import patch, AsyncMock
 
 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 custom_components.zlock_slots.const import DOMAIN
 from tests.conftest import LOCK_ENTITY_ID, FakeUserData, FakeNode
 
 _ZWAVE_HELPER = "homeassistant.components.zwave_js.helpers.async_get_node_from_entity_id"